# app/modules/billing/schemas/billing.py """ Pydantic schemas for billing and subscription operations. Used for admin subscription management and merchant-level billing. """ from datetime import datetime from pydantic import BaseModel, ConfigDict, Field, field_validator # ============================================================================ # Subscription Tier Schemas # ============================================================================ class TierFeatureLimitEntry(BaseModel): """Feature limit entry for tier management.""" feature_code: str limit_value: int | None = Field(None, description="None = unlimited for quantitative, ignored for binary") enabled: bool = True class SubscriptionTierBase(BaseModel): """Base schema for subscription tier.""" code: str = Field(..., min_length=1, max_length=30) name: str = Field(..., min_length=1, max_length=100) description: str | None = None price_monthly_cents: int = Field(..., ge=0) price_annual_cents: int | None = Field(None, ge=0) stripe_product_id: str | None = None stripe_price_monthly_id: str | None = None stripe_price_annual_id: str | None = None display_order: int = 0 is_active: bool = True is_public: bool = True platform_id: int | None = None class SubscriptionTierCreate(SubscriptionTierBase): """Schema for creating a subscription tier.""" feature_limits: list[TierFeatureLimitEntry] = Field(default_factory=list) class SubscriptionTierUpdate(BaseModel): """Schema for updating a subscription tier.""" name: str | None = None description: str | None = None price_monthly_cents: int | None = Field(None, ge=0) price_annual_cents: int | None = Field(None, ge=0) stripe_product_id: str | None = None stripe_price_monthly_id: str | None = None stripe_price_annual_id: str | None = None display_order: int | None = None is_active: bool | None = None is_public: bool | None = None feature_limits: list[TierFeatureLimitEntry] | None = None class SubscriptionTierResponse(BaseModel): """Schema for subscription tier response.""" model_config = ConfigDict(from_attributes=True) id: int code: str name: str description: str | None = None price_monthly_cents: int price_annual_cents: int | None = None platform_id: int | None = None platform_name: str | None = None stripe_product_id: str | None = None stripe_price_monthly_id: str | None = None stripe_price_annual_id: str | None = None display_order: int is_active: bool is_public: bool feature_codes: list[str] = Field(default_factory=list) created_at: datetime updated_at: datetime @property def price_monthly_display(self) -> str: """Format monthly price for display.""" return f"€{self.price_monthly_cents / 100:.2f}" @property def price_annual_display(self) -> str | None: """Format annual price for display.""" if self.price_annual_cents is None: return None return f"€{self.price_annual_cents / 100:.2f}" class SubscriptionTierListResponse(BaseModel): """Response for listing subscription tiers.""" tiers: list[SubscriptionTierResponse] total: int # ============================================================================ # Merchant Subscription Schemas (Admin View) # ============================================================================ class MerchantSubscriptionAdminResponse(BaseModel): """Merchant subscription response for admin views.""" model_config = ConfigDict(from_attributes=True) id: int merchant_id: int platform_id: int tier: str | None = None status: str is_annual: bool period_start: datetime period_end: datetime trial_ends_at: datetime | None = None stripe_customer_id: str | None = None stripe_subscription_id: str | None = None cancelled_at: datetime | None = None cancellation_reason: str | None = None payment_retry_count: int = 0 last_payment_error: str | None = None created_at: datetime updated_at: datetime @field_validator("tier", mode="before") @classmethod def extract_tier_code(cls, v): """Convert SubscriptionTier ORM object to its code string.""" if v is not None and hasattr(v, "code"): return v.code return v class MerchantSubscriptionWithMerchant(MerchantSubscriptionAdminResponse): """Subscription response with merchant info.""" merchant_name: str = "" platform_name: str = "" tier_name: str | None = None class MerchantSubscriptionListResponse(BaseModel): """Response for listing merchant subscriptions.""" subscriptions: list[MerchantSubscriptionWithMerchant] total: int page: int per_page: int pages: int class MerchantSubscriptionAdminCreate(BaseModel): """Schema for admin creating a merchant subscription.""" merchant_id: int platform_id: int tier_code: str = "essential" status: str = "trial" trial_days: int = 14 is_annual: bool = False class MerchantSubscriptionAdminUpdate(BaseModel): """Schema for admin updating a merchant subscription.""" tier_code: str | None = None status: str | None = None trial_ends_at: datetime | None = None cancellation_reason: str | None = None # ============================================================================ # Merchant Feature Override Schemas # ============================================================================ class MerchantFeatureOverrideEntry(BaseModel): """Feature override for a specific merchant.""" feature_code: str limit_value: int | None = None is_enabled: bool = True reason: str | None = None class MerchantFeatureOverrideResponse(BaseModel): """Response for merchant feature override.""" model_config = ConfigDict(from_attributes=True) id: int merchant_id: int platform_id: int feature_code: str limit_value: int | None = None is_enabled: bool reason: str | None = None created_at: datetime updated_at: datetime # ============================================================================ # Billing History Schemas # ============================================================================ class BillingHistoryResponse(BaseModel): """Schema for billing history entry.""" model_config = ConfigDict(from_attributes=True) id: int store_id: int | None = None merchant_id: int | None = None stripe_invoice_id: str | None = None invoice_number: str | None = None invoice_date: datetime due_date: datetime | None = None # Amounts subtotal_cents: int tax_cents: int total_cents: int amount_paid_cents: int currency: str = "EUR" # Status status: str # URLs invoice_pdf_url: str | None = None hosted_invoice_url: str | None = None # Description description: str | None = None # Timestamps created_at: datetime @property def total_display(self) -> str: """Format total for display.""" return f"€{self.total_cents / 100:.2f}" class BillingHistoryWithMerchant(BillingHistoryResponse): """Billing history with merchant info.""" merchant_name: str = "" class BillingHistoryListResponse(BaseModel): """Response for listing billing history.""" invoices: list[BillingHistoryResponse] total: int page: int per_page: int pages: int # ============================================================================ # Checkout & Portal Schemas # ============================================================================ class CheckoutRequest(BaseModel): """Request for creating checkout session.""" tier_code: str is_annual: bool = False class CheckoutResponse(BaseModel): """Response with checkout session URL.""" checkout_url: str session_id: str class PortalSessionResponse(BaseModel): """Response with customer portal URL.""" portal_url: str # ============================================================================ # Subscription Stats Schemas # ============================================================================ class SubscriptionStatsResponse(BaseModel): """Subscription statistics for admin dashboard.""" total_subscriptions: int active_count: int trial_count: int past_due_count: int cancelled_count: int expired_count: int # By tier tier_distribution: dict[str, int] # Revenue mrr_cents: int # Monthly recurring revenue arr_cents: int # Annual recurring revenue @property def mrr_display(self) -> str: """Format MRR for display.""" return f"€{self.mrr_cents / 100:,.2f}" @property def arr_display(self) -> str: """Format ARR for display.""" return f"€{self.arr_cents / 100:,.2f}" # ============================================================================ # Feature Catalog Schemas # ============================================================================ class FeatureDeclarationResponse(BaseModel): """Feature declaration for admin display.""" code: str name_key: str description_key: str category: str feature_type: str scope: str default_limit: int | None = None unit_key: str | None = None is_per_period: bool = False ui_icon: str | None = None display_order: int = 0 class FeatureCatalogResponse(BaseModel): """All discovered features grouped by category.""" features: dict[str, list[FeatureDeclarationResponse]] total_count: int # ============================================================================ # Store Checkout Schemas # ============================================================================ class PortalResponse(BaseModel): """Customer portal session response.""" portal_url: str class CancelRequest(BaseModel): """Request to cancel subscription.""" reason: str | None = None immediately: bool = False class CancelResponse(BaseModel): """Cancellation response.""" message: str effective_date: str class UpcomingInvoiceResponse(BaseModel): """Upcoming invoice preview.""" amount_due_cents: int currency: str next_payment_date: str | None = None line_items: list[dict] = [] class ChangeTierRequest(BaseModel): """Request to change subscription tier.""" tier_code: str is_annual: bool = False class ChangeTierResponse(BaseModel): """Response for tier change.""" message: str new_tier: str effective_immediately: bool # ============================================================================ # Store Subscription Schemas # ============================================================================ class SubscriptionStatusResponse(BaseModel): """Current subscription status.""" tier_code: str tier_name: str status: str is_trial: bool trial_ends_at: str | None = None period_start: str | None = None period_end: str | None = None cancelled_at: str | None = None cancellation_reason: str | None = None has_payment_method: bool last_payment_error: str | None = None feature_codes: list[str] = [] class Config: from_attributes = True class TierResponse(BaseModel): """Subscription tier information.""" code: str name: str description: str | None = None price_monthly_cents: int price_annual_cents: int | None = None feature_codes: list[str] = [] is_current: bool = False can_upgrade: bool = False can_downgrade: bool = False class TierListResponse(BaseModel): """List of available tiers.""" tiers: list[TierResponse] current_tier: str class InvoiceResponse(BaseModel): """Invoice information.""" id: int invoice_number: str | None = None invoice_date: str due_date: str | None = None total_cents: int amount_paid_cents: int currency: str status: str pdf_url: str | None = None hosted_url: str | None = None class InvoiceListResponse(BaseModel): """List of invoices.""" invoices: list[InvoiceResponse] total: int # ============================================================================ # Store Feature Schemas # ============================================================================ class FeatureCodeListResponse(BaseModel): """Simple list of available feature codes for quick checks.""" features: list[str] tier_code: str tier_name: str class FeatureResponse(BaseModel): """Full feature information.""" code: str name: str description: str | None = None category: str feature_type: str | None = None ui_icon: str | None = None is_available: bool class FeatureListResponse(BaseModel): """List of features with metadata.""" features: list[FeatureResponse] available_count: int total_count: int tier_code: str tier_name: str class FeatureDetailResponse(BaseModel): """Single feature detail with upgrade info.""" code: str name: str description: str | None = None category: str feature_type: str | None = None ui_icon: str | None = None is_available: bool # Upgrade info (only if not available) upgrade_tier_code: str | None = None upgrade_tier_name: str | None = None upgrade_tier_price_monthly_cents: int | None = None class CategoryListResponse(BaseModel): """List of feature categories.""" categories: list[str] class FeatureGroupedResponse(BaseModel): """Features grouped by category.""" categories: dict[str, list[FeatureResponse]] available_count: int total_count: int class StoreFeatureCheckResponse(BaseModel): """Quick feature availability check response.""" has_feature: bool feature_code: str