Add subcription feature
This commit is contained in:
@@ -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"):
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -307,7 +307,7 @@ class ExternalServiceClient(BaseServiceClient):
|
||||
"POST",
|
||||
"external/location-context",
|
||||
tenant_id=tenant_id,
|
||||
json=payload,
|
||||
data=payload,
|
||||
timeout=10.0
|
||||
)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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:
|
||||
"""
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user