Add subcription feature

This commit is contained in:
Urtzi Alfaro
2026-01-13 22:22:38 +01:00
parent b931a5c45e
commit 6ddf608d37
61 changed files with 7915 additions and 1238 deletions

View File

@@ -65,7 +65,7 @@ class AlertProcessorClient(BaseServiceClient):
result = await self.post(
f"tenants/{tenant_id}/alerts/acknowledge-by-metadata",
tenant_id=str(tenant_id),
json=payload
data=payload
)
if result and result.get("success"):
@@ -127,7 +127,7 @@ class AlertProcessorClient(BaseServiceClient):
result = await self.post(
f"tenants/{tenant_id}/alerts/resolve-by-metadata",
tenant_id=str(tenant_id),
json=payload
data=payload
)
if result and result.get("success"):

View File

@@ -182,4 +182,82 @@ class AuthServiceClient(BaseServiceClient):
email=user_data.get("email"),
error=str(e)
)
raise
raise
async def get_user_details(self, user_id: str) -> Optional[Dict[str, Any]]:
"""
Get detailed user information including payment details
Args:
user_id: User ID to fetch details for
Returns:
Dict with user details including:
- id, email, full_name, is_active, is_verified
- phone, language, timezone, role
- payment_customer_id, default_payment_method_id
- created_at, last_login, etc.
Returns None if user not found or request fails
"""
try:
logger.info("Fetching user details from auth service",
user_id=user_id)
result = await self.get(f"/users/{user_id}")
if result and result.get("id"):
logger.info("Successfully retrieved user details",
user_id=user_id,
email=result.get("email"),
has_payment_info="payment_customer_id" in result)
return result
else:
logger.warning("No user details found",
user_id=user_id)
return None
except Exception as e:
logger.error("Failed to get user details from auth service",
user_id=user_id,
error=str(e))
return None
async def update_user_tenant_id(self, user_id: str, tenant_id: str) -> bool:
"""
Update the user's tenant_id after tenant registration
Args:
user_id: User ID to update
tenant_id: Tenant ID to link to the user
Returns:
True if successful, False otherwise
"""
try:
logger.info("Updating user tenant_id",
user_id=user_id,
tenant_id=tenant_id)
result = await self.patch(
f"/users/{user_id}/tenant",
{"tenant_id": tenant_id}
)
if result:
logger.info("Successfully updated user tenant_id",
user_id=user_id,
tenant_id=tenant_id)
return True
else:
logger.warning("Failed to update user tenant_id",
user_id=user_id,
tenant_id=tenant_id)
return False
except Exception as e:
logger.error("Error updating user tenant_id",
user_id=user_id,
tenant_id=tenant_id,
error=str(e))
return False

View File

@@ -428,7 +428,11 @@ class BaseServiceClient(ABC):
async def put(self, endpoint: str, data: Dict[str, Any], tenant_id: Optional[str] = None) -> Optional[Dict[str, Any]]:
"""Make a PUT request"""
return await self._make_request("PUT", endpoint, tenant_id=tenant_id, data=data)
async def patch(self, endpoint: str, data: Dict[str, Any], tenant_id: Optional[str] = None) -> Optional[Dict[str, Any]]:
"""Make a PATCH request"""
return await self._make_request("PATCH", endpoint, tenant_id=tenant_id, data=data)
async def delete(self, endpoint: str, tenant_id: Optional[str] = None) -> Optional[Dict[str, Any]]:
"""Make a DELETE request"""
return await self._make_request("DELETE", endpoint, tenant_id=tenant_id)

View File

@@ -307,7 +307,7 @@ class ExternalServiceClient(BaseServiceClient):
"POST",
"external/location-context",
tenant_id=tenant_id,
json=payload,
data=payload,
timeout=10.0
)

View File

@@ -860,9 +860,9 @@ class InventoryServiceClient(BaseServiceClient):
# Factory function for dependency injection
def create_inventory_client(config: BaseServiceSettings) -> InventoryServiceClient:
def create_inventory_client(config: BaseServiceSettings, service_name: str = "unknown") -> InventoryServiceClient:
"""Create inventory service client instance"""
return InventoryServiceClient(config)
return InventoryServiceClient(config, calling_service_name=service_name)
# Convenience function for quick access (requires config to be passed)

View File

@@ -36,6 +36,8 @@ class Subscription:
current_period_start: datetime
current_period_end: datetime
created_at: datetime
billing_cycle_anchor: Optional[datetime] = None
cancel_at_period_end: Optional[bool] = None
@dataclass
@@ -81,9 +83,17 @@ class PaymentProvider(abc.ABC):
pass
@abc.abstractmethod
async def cancel_subscription(self, subscription_id: str) -> Subscription:
async def cancel_subscription(
self,
subscription_id: str,
cancel_at_period_end: bool = True
) -> Subscription:
"""
Cancel a subscription
Args:
subscription_id: Subscription ID to cancel
cancel_at_period_end: If True, cancel at end of billing period. Default True.
"""
pass

View File

@@ -289,6 +289,6 @@ class RecipesServiceClient(BaseServiceClient):
# Factory function for dependency injection
def create_recipes_client(config: BaseServiceSettings) -> RecipesServiceClient:
def create_recipes_client(config: BaseServiceSettings, service_name: str = "unknown") -> RecipesServiceClient:
"""Create recipes service client instance"""
return RecipesServiceClient(config)
return RecipesServiceClient(config, calling_service_name=service_name)

View File

@@ -76,16 +76,24 @@ class StripeProvider(PaymentProvider):
plan_id=plan_id,
payment_method_id=payment_method_id)
# Attach payment method to customer with idempotency
stripe.PaymentMethod.attach(
payment_method_id,
customer=customer_id,
idempotency_key=payment_method_idempotency_key
)
logger.info("Payment method attached to customer",
customer_id=customer_id,
payment_method_id=payment_method_id)
# Attach payment method to customer with idempotency and error handling
try:
stripe.PaymentMethod.attach(
payment_method_id,
customer=customer_id,
idempotency_key=payment_method_idempotency_key
)
logger.info("Payment method attached to customer",
customer_id=customer_id,
payment_method_id=payment_method_id)
except stripe.error.InvalidRequestError as e:
# Payment method may already be attached
if 'already been attached' in str(e):
logger.warning("Payment method already attached to customer",
customer_id=customer_id,
payment_method_id=payment_method_id)
else:
raise
# Set customer's default payment method with idempotency
stripe.Customer.modify(
@@ -114,19 +122,36 @@ class StripeProvider(PaymentProvider):
trial_period_days=trial_period_days)
stripe_subscription = stripe.Subscription.create(**subscription_params)
logger.info("Stripe subscription created successfully",
subscription_id=stripe_subscription.id,
status=stripe_subscription.status,
current_period_end=stripe_subscription.current_period_end)
# Handle period dates for trial vs active subscriptions
# During trial: current_period_* fields are only in subscription items, not root
# After trial: current_period_* fields are at root level
if stripe_subscription.status == 'trialing' and stripe_subscription.items and stripe_subscription.items.data:
# For trial subscriptions, get period from first subscription item
first_item = stripe_subscription.items.data[0]
current_period_start = first_item.current_period_start
current_period_end = first_item.current_period_end
logger.info("Stripe trial subscription created successfully",
subscription_id=stripe_subscription.id,
status=stripe_subscription.status,
trial_end=stripe_subscription.trial_end,
current_period_end=current_period_end)
else:
# For active subscriptions, get period from root level
current_period_start = stripe_subscription.current_period_start
current_period_end = stripe_subscription.current_period_end
logger.info("Stripe subscription created successfully",
subscription_id=stripe_subscription.id,
status=stripe_subscription.status,
current_period_end=current_period_end)
return Subscription(
id=stripe_subscription.id,
customer_id=stripe_subscription.customer,
plan_id=plan_id, # Using the price ID as plan_id
status=stripe_subscription.status,
current_period_start=datetime.fromtimestamp(stripe_subscription.current_period_start),
current_period_end=datetime.fromtimestamp(stripe_subscription.current_period_end),
current_period_start=datetime.fromtimestamp(current_period_start),
current_period_end=datetime.fromtimestamp(current_period_end),
created_at=datetime.fromtimestamp(stripe_subscription.created)
)
except stripe.error.CardError as e:
@@ -155,12 +180,24 @@ class StripeProvider(PaymentProvider):
Update the payment method for a customer in Stripe
"""
try:
# Attach payment method to customer
stripe.PaymentMethod.attach(
payment_method_id,
customer=customer_id,
)
# Attach payment method to customer with error handling
try:
stripe.PaymentMethod.attach(
payment_method_id,
customer=customer_id,
)
logger.info("Payment method attached for update",
customer_id=customer_id,
payment_method_id=payment_method_id)
except stripe.error.InvalidRequestError as e:
# Payment method may already be attached
if 'already been attached' in str(e):
logger.warning("Payment method already attached, skipping attach",
customer_id=customer_id,
payment_method_id=payment_method_id)
else:
raise
# Set as default payment method
stripe.Customer.modify(
customer_id,
@@ -183,20 +220,54 @@ class StripeProvider(PaymentProvider):
logger.error("Failed to update Stripe payment method", error=str(e))
raise e
async def cancel_subscription(self, subscription_id: str) -> Subscription:
async def cancel_subscription(
self,
subscription_id: str,
cancel_at_period_end: bool = True
) -> Subscription:
"""
Cancel a subscription in Stripe
Args:
subscription_id: Stripe subscription ID
cancel_at_period_end: If True, subscription continues until end of billing period.
If False, cancels immediately.
Returns:
Updated Subscription object
"""
try:
stripe_subscription = stripe.Subscription.delete(subscription_id)
if cancel_at_period_end:
# Cancel at end of billing period (graceful cancellation)
stripe_subscription = stripe.Subscription.modify(
subscription_id,
cancel_at_period_end=True
)
logger.info("Subscription set to cancel at period end",
subscription_id=subscription_id,
cancel_at=stripe_subscription.trial_end if stripe_subscription.status == 'trialing' else stripe_subscription.current_period_end)
else:
# Cancel immediately
stripe_subscription = stripe.Subscription.delete(subscription_id)
logger.info("Subscription cancelled immediately",
subscription_id=subscription_id)
# Handle period dates for trial vs active subscriptions
if stripe_subscription.status == 'trialing' and stripe_subscription.items and stripe_subscription.items.data:
first_item = stripe_subscription.items.data[0]
current_period_start = first_item.current_period_start
current_period_end = first_item.current_period_end
else:
current_period_start = stripe_subscription.current_period_start
current_period_end = stripe_subscription.current_period_end
return Subscription(
id=stripe_subscription.id,
customer_id=stripe_subscription.customer,
plan_id=subscription_id, # This would need to be retrieved differently in practice
plan_id=subscription_id,
status=stripe_subscription.status,
current_period_start=datetime.fromtimestamp(stripe_subscription.current_period_start),
current_period_end=datetime.fromtimestamp(stripe_subscription.current_period_end),
current_period_start=datetime.fromtimestamp(current_period_start),
current_period_end=datetime.fromtimestamp(current_period_end),
created_at=datetime.fromtimestamp(stripe_subscription.created)
)
except stripe.error.StripeError as e:
@@ -242,19 +313,291 @@ class StripeProvider(PaymentProvider):
"""
try:
stripe_subscription = stripe.Subscription.retrieve(subscription_id)
# Get the actual plan ID from the subscription items
plan_id = subscription_id # Default fallback
if stripe_subscription.items and stripe_subscription.items.data:
plan_id = stripe_subscription.items.data[0].price.id
# Handle period dates for trial vs active subscriptions
# During trial: current_period_* fields are only in subscription items, not root
# After trial: current_period_* fields are at root level
if stripe_subscription.status == 'trialing' and stripe_subscription.items and stripe_subscription.items.data:
# For trial subscriptions, get period from first subscription item
first_item = stripe_subscription.items.data[0]
current_period_start = first_item.current_period_start
current_period_end = first_item.current_period_end
else:
# For active subscriptions, get period from root level
current_period_start = stripe_subscription.current_period_start
current_period_end = stripe_subscription.current_period_end
return Subscription(
id=stripe_subscription.id,
customer_id=stripe_subscription.customer,
plan_id=subscription_id, # This would need to be retrieved differently in practice
plan_id=plan_id,
status=stripe_subscription.status,
current_period_start=datetime.fromtimestamp(stripe_subscription.current_period_start),
current_period_end=datetime.fromtimestamp(stripe_subscription.current_period_end),
created_at=datetime.fromtimestamp(stripe_subscription.created)
current_period_start=datetime.fromtimestamp(current_period_start),
current_period_end=datetime.fromtimestamp(current_period_end),
created_at=datetime.fromtimestamp(stripe_subscription.created),
billing_cycle_anchor=datetime.fromtimestamp(stripe_subscription.billing_cycle_anchor) if stripe_subscription.billing_cycle_anchor else None,
cancel_at_period_end=stripe_subscription.cancel_at_period_end
)
except stripe.error.StripeError as e:
logger.error("Failed to retrieve Stripe subscription", error=str(e))
raise e
async def update_subscription(
self,
subscription_id: str,
new_price_id: str,
proration_behavior: str = "create_prorations",
billing_cycle_anchor: str = "unchanged",
payment_behavior: str = "error_if_incomplete",
immediate_change: bool = False
) -> Subscription:
"""
Update a subscription in Stripe with proration support
Args:
subscription_id: Stripe subscription ID
new_price_id: New Stripe price ID to switch to
proration_behavior: How to handle prorations ('create_prorations', 'none', 'always_invoice')
billing_cycle_anchor: When to apply changes ('unchanged', 'now')
payment_behavior: Payment behavior ('error_if_incomplete', 'allow_incomplete')
immediate_change: Whether to apply changes immediately or at period end
Returns:
Updated Subscription object
"""
try:
logger.info("Updating Stripe subscription",
subscription_id=subscription_id,
new_price_id=new_price_id,
proration_behavior=proration_behavior,
immediate_change=immediate_change)
# Get current subscription to preserve settings
current_subscription = stripe.Subscription.retrieve(subscription_id)
# Build update parameters
update_params = {
'items': [{
'id': current_subscription.items.data[0].id,
'price': new_price_id,
}],
'proration_behavior': proration_behavior,
'billing_cycle_anchor': billing_cycle_anchor,
'payment_behavior': payment_behavior,
'expand': ['latest_invoice.payment_intent']
}
# If not immediate change, set cancel_at_period_end to False
# and let Stripe handle the transition
if not immediate_change:
update_params['cancel_at_period_end'] = False
update_params['proration_behavior'] = 'none' # No proration for end-of-period changes
# Update the subscription
updated_subscription = stripe.Subscription.modify(
subscription_id,
**update_params
)
logger.info("Stripe subscription updated successfully",
subscription_id=updated_subscription.id,
new_price_id=new_price_id,
status=updated_subscription.status)
# Get the actual plan ID from the subscription items
plan_id = new_price_id
if updated_subscription.items and updated_subscription.items.data:
plan_id = updated_subscription.items.data[0].price.id
# Handle period dates for trial vs active subscriptions
if updated_subscription.status == 'trialing' and updated_subscription.items and updated_subscription.items.data:
first_item = updated_subscription.items.data[0]
current_period_start = first_item.current_period_start
current_period_end = first_item.current_period_end
else:
current_period_start = updated_subscription.current_period_start
current_period_end = updated_subscription.current_period_end
return Subscription(
id=updated_subscription.id,
customer_id=updated_subscription.customer,
plan_id=plan_id,
status=updated_subscription.status,
current_period_start=datetime.fromtimestamp(current_period_start),
current_period_end=datetime.fromtimestamp(current_period_end),
created_at=datetime.fromtimestamp(updated_subscription.created),
billing_cycle_anchor=datetime.fromtimestamp(updated_subscription.billing_cycle_anchor) if updated_subscription.billing_cycle_anchor else None,
cancel_at_period_end=updated_subscription.cancel_at_period_end
)
except stripe.error.StripeError as e:
logger.error("Failed to update Stripe subscription",
error=str(e),
subscription_id=subscription_id,
new_price_id=new_price_id)
raise e
async def calculate_proration(
self,
subscription_id: str,
new_price_id: str,
proration_behavior: str = "create_prorations"
) -> Dict[str, Any]:
"""
Calculate proration amounts for a subscription change
Args:
subscription_id: Stripe subscription ID
new_price_id: New Stripe price ID
proration_behavior: Proration behavior to use
Returns:
Dictionary with proration details including amount, currency, and description
"""
try:
logger.info("Calculating proration for subscription change",
subscription_id=subscription_id,
new_price_id=new_price_id)
# Get current subscription
current_subscription = stripe.Subscription.retrieve(subscription_id)
current_price_id = current_subscription.items.data[0].price.id
# Get current and new prices
current_price = stripe.Price.retrieve(current_price_id)
new_price = stripe.Price.retrieve(new_price_id)
# Calculate time remaining in current billing period
current_period_end = datetime.fromtimestamp(current_subscription.current_period_end)
current_period_start = datetime.fromtimestamp(current_subscription.current_period_start)
now = datetime.now(timezone.utc)
total_period_days = (current_period_end - current_period_start).days
remaining_days = (current_period_end - now).days
used_days = (now - current_period_start).days
# Calculate prorated amounts
current_price_amount = current_price.unit_amount / 100.0 # Convert from cents
new_price_amount = new_price.unit_amount / 100.0
# Calculate daily rates
current_daily_rate = current_price_amount / total_period_days
new_daily_rate = new_price_amount / total_period_days
# Calculate proration based on behavior
if proration_behavior == "create_prorations":
# Calculate credit for unused time on current plan
unused_current_amount = current_daily_rate * remaining_days
# Calculate charge for remaining time on new plan
prorated_new_amount = new_daily_rate * remaining_days
# Net amount (could be positive or negative)
net_amount = prorated_new_amount - unused_current_amount
return {
"current_price_amount": current_price_amount,
"new_price_amount": new_price_amount,
"unused_current_amount": unused_current_amount,
"prorated_new_amount": prorated_new_amount,
"net_amount": net_amount,
"currency": current_price.currency.upper(),
"remaining_days": remaining_days,
"used_days": used_days,
"total_period_days": total_period_days,
"description": f"Proration for changing from {current_price_id} to {new_price_id}",
"is_credit": net_amount < 0
}
elif proration_behavior == "none":
return {
"current_price_amount": current_price_amount,
"new_price_amount": new_price_amount,
"net_amount": 0,
"currency": current_price.currency.upper(),
"description": "No proration - changes apply at period end",
"is_credit": False
}
else:
return {
"current_price_amount": current_price_amount,
"new_price_amount": new_price_amount,
"net_amount": new_price_amount - current_price_amount,
"currency": current_price.currency.upper(),
"description": "Full amount difference - immediate billing",
"is_credit": False
}
except stripe.error.StripeError as e:
logger.error("Failed to calculate proration",
error=str(e),
subscription_id=subscription_id,
new_price_id=new_price_id)
raise e
async def change_billing_cycle(
self,
subscription_id: str,
new_billing_cycle: str,
proration_behavior: str = "create_prorations"
) -> Subscription:
"""
Change billing cycle (monthly ↔ yearly) for a subscription
Args:
subscription_id: Stripe subscription ID
new_billing_cycle: New billing cycle ('monthly' or 'yearly')
proration_behavior: Proration behavior to use
Returns:
Updated Subscription object
"""
try:
logger.info("Changing billing cycle for subscription",
subscription_id=subscription_id,
new_billing_cycle=new_billing_cycle)
# Get current subscription
current_subscription = stripe.Subscription.retrieve(subscription_id)
current_price_id = current_subscription.items.data[0].price.id
# Get current price to determine the plan
current_price = stripe.Price.retrieve(current_price_id)
product_id = current_price.product
# Find the corresponding price for the new billing cycle
# This assumes you have price IDs set up for both monthly and yearly
# You would need to map this based on your product catalog
prices = stripe.Price.list(product=product_id, active=True)
new_price_id = None
for price in prices:
if price.recurring and price.recurring.interval == new_billing_cycle:
new_price_id = price.id
break
if not new_price_id:
raise ValueError(f"No {new_billing_cycle} price found for product {product_id}")
# Update the subscription with the new price
return await self.update_subscription(
subscription_id,
new_price_id,
proration_behavior=proration_behavior,
billing_cycle_anchor="now",
immediate_change=True
)
except stripe.error.StripeError as e:
logger.error("Failed to change billing cycle",
error=str(e),
subscription_id=subscription_id,
new_billing_cycle=new_billing_cycle)
raise e
async def get_customer(self, customer_id: str) -> PaymentCustomer:
"""

View File

@@ -291,6 +291,6 @@ class SuppliersServiceClient(BaseServiceClient):
# Factory function for dependency injection
def create_suppliers_client(config: BaseServiceSettings) -> SuppliersServiceClient:
def create_suppliers_client(config: BaseServiceSettings, service_name: str = "unknown") -> SuppliersServiceClient:
"""Create suppliers service client instance"""
return SuppliersServiceClient(config)
return SuppliersServiceClient(config, calling_service_name=service_name)

View File

@@ -420,6 +420,207 @@ class TenantServiceClient(BaseServiceClient):
logger.error("Tenant service health check failed", error=str(e))
return False
# ================================================================
# PAYMENT CUSTOMER MANAGEMENT
# ================================================================
async def create_payment_customer(
self,
user_data: Dict[str, Any],
payment_method_id: Optional[str] = None
) -> Optional[Dict[str, Any]]:
"""
Create a payment customer for a user
This method creates a payment customer record in the tenant service
during user registration or onboarding. It handles the integration
with payment providers and returns the payment customer details.
Args:
user_data: User data including:
- user_id: User ID (required)
- email: User email (required)
- full_name: User full name (required)
- name: User name (optional, defaults to full_name)
payment_method_id: Optional payment method ID to attach to the customer
Returns:
Dict with payment customer details including:
- success: boolean
- payment_customer_id: string
- payment_method: dict with payment method details
- customer: dict with customer details
Returns None if creation fails
"""
try:
logger.info("Creating payment customer via tenant service",
user_id=user_data.get('user_id'),
email=user_data.get('email'))
# Prepare data for tenant service
tenant_data = {
"user_data": user_data,
"payment_method_id": payment_method_id
}
# Call tenant service endpoint
result = await self.post("/payment-customers/create", tenant_data)
if result and result.get("success"):
logger.info("Payment customer created successfully via tenant service",
user_id=user_data.get('user_id'),
payment_customer_id=result.get('payment_customer_id'))
return result
else:
logger.error("Payment customer creation failed via tenant service",
user_id=user_data.get('user_id'),
error=result.get('detail') if result else 'No detail provided')
return None
except Exception as e:
logger.error("Failed to create payment customer via tenant service",
user_id=user_data.get('user_id'),
error=str(e))
return None
async def create_subscription_for_registration(
self,
user_data: Dict[str, Any],
plan_id: str,
payment_method_id: str,
billing_cycle: str = "monthly",
coupon_code: Optional[str] = None
) -> Optional[Dict[str, Any]]:
"""
Create a tenant-independent subscription during user registration
This method creates a subscription that is not linked to any tenant yet.
The subscription will be linked to a tenant during the onboarding flow
when the user creates their bakery/tenant.
Args:
user_data: User data including:
- user_id: User ID (required)
- email: User email (required)
- full_name: User full name (required)
- name: User name (optional, defaults to full_name)
plan_id: Subscription plan ID (starter, professional, enterprise)
payment_method_id: Stripe payment method ID
billing_cycle: Billing cycle (monthly or yearly), defaults to monthly
coupon_code: Optional coupon code for discounts/trials
Returns:
Dict with subscription creation results including:
- success: boolean
- subscription_id: string (Stripe subscription ID)
- customer_id: string (Stripe customer ID)
- status: string (subscription status)
- plan: string (plan name)
- billing_cycle: string (billing interval)
- trial_period_days: int (if trial applied)
- coupon_applied: boolean
Returns None if creation fails
"""
try:
logger.info("Creating tenant-independent subscription for registration",
user_id=user_data.get('user_id'),
plan_id=plan_id,
billing_cycle=billing_cycle)
# Prepare data for tenant service
subscription_data = {
"user_data": user_data,
"plan_id": plan_id,
"payment_method_id": payment_method_id,
"billing_interval": billing_cycle,
"coupon_code": coupon_code
}
# Call tenant service endpoint
result = await self.post("/subscriptions/create-for-registration", subscription_data)
if result and result.get("success"):
data = result.get("data", {})
logger.info("Tenant-independent subscription created successfully",
user_id=user_data.get('user_id'),
subscription_id=data.get('subscription_id'),
plan=data.get('plan'))
return data
else:
logger.error("Subscription creation failed via tenant service",
user_id=user_data.get('user_id'),
error=result.get('detail') if result else 'No detail provided')
return None
except Exception as e:
logger.error("Failed to create subscription for registration via tenant service",
user_id=user_data.get('user_id'),
plan_id=plan_id,
error=str(e))
return None
async def link_subscription_to_tenant(
self,
tenant_id: str,
subscription_id: str,
user_id: str
) -> Optional[Dict[str, Any]]:
"""
Link a pending subscription to a tenant
This completes the registration flow by associating the subscription
created during registration with the tenant created during onboarding.
Args:
tenant_id: Tenant ID to link subscription to
subscription_id: Subscription ID (from registration)
user_id: User ID performing the linking (for validation)
Returns:
Dict with linking results:
- success: boolean
- tenant_id: string
- subscription_id: string
- status: string
Returns None if linking fails
"""
try:
logger.info("Linking subscription to tenant",
tenant_id=tenant_id,
subscription_id=subscription_id,
user_id=user_id)
# Prepare data for tenant service
linking_data = {
"subscription_id": subscription_id,
"user_id": user_id
}
# Call tenant service endpoint
result = await self.post(
f"/tenants/{tenant_id}/link-subscription",
linking_data
)
if result and result.get("success"):
logger.info("Subscription linked to tenant successfully",
tenant_id=tenant_id,
subscription_id=subscription_id)
return result
else:
logger.error("Subscription linking failed via tenant service",
tenant_id=tenant_id,
subscription_id=subscription_id,
error=result.get('detail') if result else 'No detail provided')
return None
except Exception as e:
logger.error("Failed to link subscription to tenant via tenant service",
tenant_id=tenant_id,
subscription_id=subscription_id,
error=str(e))
return None
# Factory function for dependency injection
def create_tenant_client(config: BaseServiceSettings) -> TenantServiceClient:

View File

@@ -3,7 +3,7 @@ Coupon system for subscription discounts and promotions.
Supports trial extensions, percentage discounts, and fixed amount discounts.
"""
from dataclasses import dataclass
from datetime import datetime
from datetime import datetime, timezone
from enum import Enum
from typing import Optional
@@ -32,7 +32,7 @@ class Coupon:
def is_valid(self) -> bool:
"""Check if coupon is currently valid"""
now = datetime.utcnow()
now = datetime.now(timezone.utc)
# Check if active
if not self.active:
@@ -60,7 +60,7 @@ class Coupon:
if not self.active:
return False, "Coupon is inactive"
now = datetime.utcnow()
now = datetime.now(timezone.utc)
if now < self.valid_from:
return False, "Coupon is not yet valid"
@@ -98,7 +98,7 @@ def calculate_trial_end_date(base_trial_days: int, extension_days: int) -> datet
"""Calculate trial end date with coupon extension"""
from datetime import timedelta
total_days = base_trial_days + extension_days
return datetime.utcnow() + timedelta(days=total_days)
return datetime.now(timezone.utc) + timedelta(days=total_days)
def format_discount_description(coupon: Coupon) -> str: