Add subcription feature 3

This commit is contained in:
Urtzi Alfaro
2026-01-15 20:45:49 +01:00
parent a4c3b7da3f
commit b674708a4c
83 changed files with 9451 additions and 6828 deletions

View File

@@ -0,0 +1,160 @@
"""
Payment Provider Interface
Abstract base class for payment provider implementations
Allows easy swapping of payment SDKs (Stripe, PayPal, etc.)
"""
from abc import ABC, abstractmethod
from typing import Dict, Any, Optional
class PaymentProvider(ABC):
"""
Abstract Payment Provider Interface
Define all required methods for payment processing
"""
@abstractmethod
async def create_customer(
self,
email: str,
name: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None
) -> Dict[str, Any]:
"""Create a customer in the payment provider"""
pass
@abstractmethod
async def attach_payment_method(
self,
payment_method_id: str,
customer_id: str
) -> Dict[str, Any]:
"""Attach a payment method to a customer"""
pass
@abstractmethod
async def set_default_payment_method(
self,
customer_id: str,
payment_method_id: str
) -> Dict[str, Any]:
"""Set the default payment method for a customer"""
pass
@abstractmethod
async def create_setup_intent_for_verification(
self,
customer_id: str,
payment_method_id: str,
metadata: Optional[Dict[str, Any]] = None
) -> Dict[str, Any]:
"""Create a SetupIntent for payment method verification (3DS support)"""
pass
@abstractmethod
async def verify_setup_intent_status(
self,
setup_intent_id: str
) -> Dict[str, Any]:
"""Verify the status of a SetupIntent"""
pass
@abstractmethod
async def create_subscription_with_verified_payment(
self,
customer_id: str,
price_id: str,
payment_method_id: str,
trial_period_days: Optional[int] = None,
billing_cycle_anchor: Optional[Any] = None,
metadata: Optional[Dict[str, Any]] = None
) -> Dict[str, Any]:
"""Create a subscription with a verified payment method
Args:
billing_cycle_anchor: Can be int (Unix timestamp), "now", or "unchanged"
"""
pass
@abstractmethod
async def create_setup_intent(self) -> Dict[str, Any]:
"""Create a basic SetupIntent"""
pass
@abstractmethod
async def get_setup_intent(
self,
setup_intent_id: str
) -> Any:
"""Get SetupIntent details"""
pass
@abstractmethod
async def create_payment_intent(
self,
amount: float,
currency: str,
customer_id: str,
payment_method_id: str
) -> Dict[str, Any]:
"""Create a PaymentIntent for one-time payments"""
pass
@abstractmethod
async def complete_subscription_after_setup_intent(
self,
setup_intent_id: str
) -> Dict[str, Any]:
"""Complete subscription creation after SetupIntent verification"""
pass
@abstractmethod
async def cancel_subscription(
self,
subscription_id: str
) -> Dict[str, Any]:
"""Cancel a subscription"""
pass
@abstractmethod
async def update_payment_method(
self,
customer_id: str,
payment_method_id: str
) -> Dict[str, Any]:
"""Update customer's payment method"""
pass
@abstractmethod
async def update_subscription(
self,
subscription_id: str,
new_price_id: str
) -> Dict[str, Any]:
"""Update subscription price"""
pass
@abstractmethod
async def get_subscription(
self,
subscription_id: str
) -> Dict[str, Any]:
"""Get subscription details"""
pass
@abstractmethod
async def get_customer_payment_method(
self,
customer_id: str
) -> Dict[str, Any]:
"""Get customer's payment method"""
pass
@abstractmethod
async def get_invoices(
self,
customer_id: str
) -> Dict[str, Any]:
"""Get customer invoices"""
pass

File diff suppressed because it is too large Load Diff

View File

@@ -57,7 +57,7 @@ class SubscriptionServiceClient:
'features': subscription.features or {}
}
except Exception as e:
logger.error("Failed to get subscription", tenant_id=tenant_id, error=str(e))
logger.error(f"Failed to get subscription, tenant_id={tenant_id}, error={str(e)}")
raise
async def update_subscription_plan(self, tenant_id: str, new_plan: str) -> Dict[str, Any]:
@@ -93,7 +93,7 @@ class SubscriptionServiceClient:
'status': updated_subscription.status
}
except Exception as e:
logger.error("Failed to update subscription plan", tenant_id=tenant_id, new_plan=new_plan, error=str(e))
logger.error(f"Failed to update subscription plan, tenant_id={tenant_id}, new_plan={new_plan}, error={str(e)}")
raise
async def create_child_subscription(self, child_tenant_id: str, parent_tenant_id: str) -> Dict[str, Any]:

View File

@@ -292,7 +292,7 @@ class TenantServiceClient(BaseServiceClient):
count=len(result) if isinstance(result, list) else 0)
return result if result else []
except Exception as e:
logger.error("Error getting active tenants", error=str(e))
logger.error(f"Error getting active tenants: {str(e)}")
return []
# ================================================================
@@ -417,72 +417,55 @@ class TenantServiceClient(BaseServiceClient):
result = await self.get("../health") # Health endpoint is not tenant-scoped
return result is not None
except Exception as e:
logger.error("Tenant service health check failed", error=str(e))
logger.error(f"Tenant service health check failed: {str(e)}")
return False
async def get_subscription_status(self, tenant_id: str) -> Optional[Dict[str, Any]]:
"""
Get subscription status for a tenant
Args:
tenant_id: Tenant ID
Returns:
Dictionary with subscription status information
"""
try:
result = await self.get(f"tenants/{tenant_id}/subscription/status")
if result:
logger.info("Retrieved subscription status from tenant service",
tenant_id=tenant_id, status=result.get('status'))
return result
except Exception as e:
logger.error("Error getting subscription status",
error=str(e), tenant_id=tenant_id)
return None
async def get_subscription_details(self, tenant_id: str) -> Optional[Dict[str, Any]]:
"""
Get detailed subscription information for a tenant
Args:
tenant_id: Tenant ID
Returns:
Dictionary with subscription details
"""
try:
result = await self.get(f"tenants/{tenant_id}/subscription")
if result:
logger.info("Retrieved subscription details from tenant service",
tenant_id=tenant_id, plan=result.get('plan'))
return result
except Exception as e:
logger.error("Error getting subscription details",
error=str(e), tenant_id=tenant_id)
return None
# ================================================================
# 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],
@@ -537,7 +520,7 @@ class TenantServiceClient(BaseServiceClient):
}
# Call tenant service endpoint
result = await self.post("/subscriptions/create-for-registration", subscription_data)
result = await self.post("tenants/subscriptions/create-for-registration", subscription_data)
if result and result.get("success"):
data = result.get("data", {})
@@ -598,7 +581,7 @@ class TenantServiceClient(BaseServiceClient):
# Call tenant service endpoint
result = await self.post(
f"/tenants/{tenant_id}/link-subscription",
f"tenants/{tenant_id}/link-subscription",
linking_data
)
@@ -648,7 +631,7 @@ class TenantServiceClient(BaseServiceClient):
# Call tenant service endpoint
result = await self.post(
"/payment-customers/create",
"tenants/payment-customers/create",
{
"user_data": user_data,
"payment_method_id": payment_method_id
@@ -696,7 +679,7 @@ class TenantServiceClient(BaseServiceClient):
# Call tenant service orchestration endpoint
result = await self.post(
"/payment-customers/create",
"registration-payment-setup",
user_data
)
@@ -740,7 +723,7 @@ class TenantServiceClient(BaseServiceClient):
# Call tenant service orchestration endpoint
result = await self.get(
f"/setup-intents/{setup_intent_id}/verify"
f"setup-intents/{setup_intent_id}/verify"
)
if result:
@@ -779,7 +762,7 @@ class TenantServiceClient(BaseServiceClient):
# Call tenant service endpoint
result = await self.get(
f"/setup-intents/{setup_intent_id}/verify"
f"tenants/setup-intents/{setup_intent_id}/verify"
)
if result:
@@ -799,6 +782,67 @@ class TenantServiceClient(BaseServiceClient):
error=str(e))
raise
async def verify_and_complete_registration(
self,
setup_intent_id: str,
user_data: Dict[str, Any]
) -> Dict[str, Any]:
"""
Verify SetupIntent and complete registration via tenant service orchestration
This method calls the tenant service's orchestration endpoint to verify
SetupIntent status and complete the registration process by creating
the user record after successful payment verification.
Args:
setup_intent_id: SetupIntent ID to verify
user_data: User data for registration (email, full_name, etc.)
Returns:
Dictionary with registration completion result including user details
and subscription information
Raises:
Exception: If verification or registration completion fails
"""
try:
logger.info("Verifying SetupIntent and completing registration via tenant service orchestration",
setup_intent_id=setup_intent_id,
email=user_data.get('email'))
# Prepare data for tenant service orchestration
registration_data = {
"setup_intent_id": setup_intent_id,
"user_data": user_data
}
# Call tenant service orchestration endpoint
result = await self.post(
"verify-and-complete-registration",
registration_data
)
if result and result.get("success"):
logger.info("Registration completed successfully via tenant service orchestration",
setup_intent_id=setup_intent_id,
user_id=result.get('user_id'),
email=user_data.get('email'))
return result
else:
logger.error("Registration completion failed via tenant service orchestration",
setup_intent_id=setup_intent_id,
email=user_data.get('email'),
error=result.get('detail') if result else 'No detail provided')
raise Exception("Registration completion failed: " +
(result.get('detail') if result else 'Unknown error'))
except Exception as e:
logger.error("Failed to complete registration via tenant service orchestration",
setup_intent_id=setup_intent_id,
email=user_data.get('email'),
error=str(e))
raise
# Factory function for dependency injection
def create_tenant_client(config: BaseServiceSettings) -> TenantServiceClient:

View File

@@ -310,6 +310,7 @@ class BaseServiceSettings(BaseSettings):
STRIPE_PUBLISHABLE_KEY: str = os.getenv("STRIPE_PUBLISHABLE_KEY", "")
STRIPE_SECRET_KEY: str = os.getenv("STRIPE_SECRET_KEY", "")
STRIPE_WEBHOOK_SECRET: str = os.getenv("STRIPE_WEBHOOK_SECRET", "")
STRIPE_API_VERSION: str = os.getenv("STRIPE_API_VERSION", "") # Empty = use SDK default
# ================================================================
# ML & AI CONFIGURATION

View File

@@ -46,7 +46,8 @@
"cancelled_at": null,
"cancellation_effective_date": null,
"created_at": "BASE_TS-90d",
"updated_at": "BASE_TS-1d"
"updated_at": "BASE_TS-1d",
"is_tenant_linked": true
},
"children": [
{

View File

@@ -44,6 +44,7 @@
"cancelled_at": null,
"cancellation_effective_date": null,
"created_at": "BASE_TS-30d",
"updated_at": "BASE_TS-30d"
"updated_at": "BASE_TS-30d",
"is_tenant_linked": true
}
}

View File

@@ -0,0 +1,18 @@
{
"delivery_routes": [],
"vehicles": [],
"drivers": [],
"delivery_schedules": [],
"route_optimization_config": {
"enabled": false,
"algorithm": "basic",
"constraints": {
"max_distance_km": 50,
"max_duration_minutes": 180,
"vehicle_capacity_kg": 500
}
},
"tenant_id": "a1b2c3d4-e5f6-47a8-b9c0-d1e2f3a4b5c6",
"is_active": false,
"description": "Professional tier demo - basic distribution setup for single location bakery"
}

View File

@@ -0,0 +1,8 @@
"""
Exceptions package for the bakery platform
"""
# Import all exceptions for easy access
from .payment_exceptions import *
from .auth_exceptions import *
from .registration_exceptions import *

View File

@@ -0,0 +1,43 @@
"""
Authentication-related exceptions for the auth system
"""
class AuthServiceError(Exception):
"""Base exception for authentication service errors"""
pass
class UserCreationError(Exception):
"""Exception for user creation failures"""
pass
class RegistrationError(Exception):
"""Exception for registration failures"""
pass
class PaymentOrchestrationError(Exception):
"""Exception for payment orchestration failures"""
pass
class LoginError(Exception):
"""Exception for login failures"""
pass
class TokenError(Exception):
"""Exception for token-related errors"""
pass
class PermissionError(Exception):
"""Exception for permission-related errors"""
pass
class UserNotFoundError(Exception):
"""Exception for user not found errors"""
pass
class EmailVerificationError(Exception):
"""Exception for email verification failures"""
pass
class PasswordResetError(Exception):
"""Exception for password reset failures"""
pass

View File

@@ -0,0 +1,70 @@
"""
Payment-related exceptions for the atomic payment architecture
"""
class PaymentException(Exception):
"""Base class for payment-related exceptions"""
pass
class PaymentVerificationRequired(PaymentException):
"""Exception raised when payment verification is required before proceeding"""
def __init__(self, message: str = "Payment verification required", setup_intent_id: str = None, client_secret: str = None):
self.message = message
self.setup_intent_id = setup_intent_id
self.client_secret = client_secret
super().__init__(message)
class ThreeDSAuthenticationRequired(PaymentException):
"""Exception raised when 3DS authentication is required"""
def __init__(self, setup_intent_id: str, client_secret: str, action_type: str, **kwargs):
self.setup_intent_id = setup_intent_id
self.client_secret = client_secret
self.action_type = action_type
self.extra_data = kwargs
message = f"3DS authentication required (action: {action_type})"
super().__init__(message)
class PaymentMethodInvalid(PaymentException):
"""Exception raised when payment method is invalid"""
def __init__(self, message: str = "Payment method is invalid"):
super().__init__(message)
class PaymentFailed(PaymentException):
"""Exception raised when payment fails"""
def __init__(self, message: str = "Payment failed"):
super().__init__(message)
class SubscriptionCreationFailed(PaymentException):
"""Exception raised when subscription creation fails"""
def __init__(self, message: str = "Subscription creation failed"):
super().__init__(message)
class PaymentProviderError(PaymentException):
"""Exception raised when there's an error with the payment provider"""
def __init__(self, message: str = "Payment provider error"):
super().__init__(message)
class PaymentVerificationError(PaymentException):
"""Exception raised when payment verification fails"""
def __init__(self, message: str = "Payment verification failed"):
super().__init__(message)
class SetupIntentError(PaymentException):
"""Exception raised when SetupIntent operations fail"""
def __init__(self, message: str = "SetupIntent operation failed"):
super().__init__(message)
class SubscriptionUpdateFailed(PaymentException):
"""Exception raised when subscription update fails"""
def __init__(self, message: str = "Subscription update failed"):
super().__init__(message)
class PaymentServiceError(PaymentException):
"""General payment service error"""
def __init__(self, message: str = "Payment service error"):
super().__init__(message)

View File

@@ -0,0 +1,55 @@
"""
Registration-related exceptions for the atomic registration architecture
"""
class RegistrationException(Exception):
"""Base class for registration-related exceptions"""
pass
class RegistrationPaymentFailed(RegistrationException):
"""Exception raised when registration payment fails"""
def __init__(self, message: str = "Registration payment failed"):
super().__init__(message)
class RegistrationValidationFailed(RegistrationException):
"""Exception raised when registration validation fails"""
def __init__(self, message: str = "Registration validation failed", errors: dict = None):
self.errors = errors or {}
super().__init__(message)
class UserCreationFailed(RegistrationException):
"""Exception raised when user creation fails"""
def __init__(self, message: str = "User creation failed"):
super().__init__(message)
class TenantCreationFailed(RegistrationException):
"""Exception raised when tenant creation fails"""
def __init__(self, message: str = "Tenant creation failed"):
super().__init__(message)
class RegistrationFlowInterrupted(RegistrationException):
"""Exception raised when registration flow is interrupted"""
def __init__(self, message: str = "Registration flow interrupted"):
super().__init__(message)
class EmailAlreadyRegistered(RegistrationException):
"""Exception raised when email is already registered"""
def __init__(self, message: str = "Email already registered"):
super().__init__(message)
class RegistrationStateInvalid(RegistrationException):
"""Exception raised when registration state is invalid"""
def __init__(self, message: str = "Registration state is invalid"):
super().__init__(message)
class RegistrationStateError(RegistrationException):
"""Exception raised when registration state operations fail"""
def __init__(self, message: str = "Registration state error"):
super().__init__(message)
class InvalidStateTransitionError(RegistrationException):
"""Exception raised when an invalid state transition is attempted"""
def __init__(self, message: str = "Invalid state transition"):
super().__init__(message)

View File

@@ -0,0 +1,22 @@
"""
Subscription-related exceptions
"""
class SubscriptionException(Exception):
"""Base class for subscription-related exceptions"""
pass
class SubscriptionNotFound(SubscriptionException):
"""Exception raised when subscription is not found"""
def __init__(self, message: str = "Subscription not found"):
super().__init__(message)
class SubscriptionAlreadyExists(SubscriptionException):
"""Exception raised when subscription already exists"""
def __init__(self, message: str = "Subscription already exists"):
super().__init__(message)
class SubscriptionUpdateFailed(SubscriptionException):
"""Exception raised when subscription update fails"""
def __init__(self, message: str = "Subscription update failed"):
super().__init__(message)

View File

@@ -320,18 +320,20 @@ class BaseFastAPIService:
@self.app.exception_handler(Exception)
async def general_exception_handler(request: Request, exc: Exception):
"""Handle general exceptions"""
self.logger.error(
print(f"DEBUG_PRINT: Base service caught unhandled exception: {str(exc)} on path {request.url.path}")
self.logger.critical(
"Unhandled exception",
error=str(exc),
path=request.url.path,
method=request.method
method=request.method,
exc_info=True
)
return JSONResponse(
status_code=500,
content={
"error": "Internal Server Error",
"detail": "An unexpected error occurred",
"detail": f"An unexpected error occurred: {str(exc)}",
"type": "internal_error"
}
)

64
shared/utils/retry.py Normal file
View File

@@ -0,0 +1,64 @@
"""
Retry utilities for shared use across services
"""
import asyncio
import random
from typing import Callable, Any, Tuple, Type
import logging
logger = logging.getLogger(__name__)
async def retry_with_backoff(
func,
max_retries: int = 3,
exceptions: Tuple[Type[Exception], ...] = (Exception,),
base_delay: float = 1.0,
max_delay: float = 60.0,
backoff_factor: float = 2.0
):
"""
Retry a function with exponential backoff.
Args:
func: The function to retry (can be sync or async)
max_retries: Maximum number of retry attempts
exceptions: Tuple of exception types to catch and retry
base_delay: Initial delay in seconds
max_delay: Maximum delay between retries
backoff_factor: Factor by which delay increases after each retry
Returns:
Result of the function call
Raises:
The original exception if all retries are exhausted
"""
for attempt in range(max_retries + 1): # +1 because first attempt doesn't count as retry
try:
result = func()
# Handle both async functions and lambdas that return coroutines
if asyncio.iscoroutine(result):
result = await result
return result
except exceptions as e:
if attempt == max_retries:
# Exhausted all retries, re-raise the exception
raise e
# Calculate delay with exponential backoff and jitter
delay = min(base_delay * (backoff_factor ** attempt), max_delay)
# Add jitter to prevent thundering herd
delay = delay * (0.5 + random.random() * 0.5)
logger.warning(
f"Attempt {attempt + 1} failed, retrying in {delay:.2f}s: {str(e)}",
extra={
"attempt": attempt + 1,
"max_retries": max_retries,
"exception": str(e)
}
)
await asyncio.sleep(delay)