# Copyright (c) 2025 Patrick Motsch # All rights reserved. """Subscription models: SubscriptionPlan (catalog), MandateSubscription (instance per mandate), StripePlanPrice (persisted Stripe IDs per plan). State Machine: see wiki/concepts/Subscription-State-Machine.md """ from typing import Dict, List, Optional from enum import Enum from datetime import datetime, timezone from pydantic import BaseModel, Field from modules.datamodels.datamodelBase import PowerOnModel from modules.shared.attributeUtils import registerModelLabels import uuid class SubscriptionStatusEnum(str, Enum): """Lifecycle status of a mandate subscription. See wiki/concepts/Subscription-State-Machine.md for transition rules.""" PENDING = "PENDING" SCHEDULED = "SCHEDULED" TRIALING = "TRIALING" ACTIVE = "ACTIVE" PAST_DUE = "PAST_DUE" EXPIRED = "EXPIRED" TERMINAL_STATUSES = {SubscriptionStatusEnum.EXPIRED} OPERATIVE_STATUSES = {SubscriptionStatusEnum.ACTIVE, SubscriptionStatusEnum.TRIALING, SubscriptionStatusEnum.PAST_DUE} ALLOWED_TRANSITIONS = { (SubscriptionStatusEnum.PENDING, SubscriptionStatusEnum.ACTIVE), (SubscriptionStatusEnum.PENDING, SubscriptionStatusEnum.TRIALING), (SubscriptionStatusEnum.PENDING, SubscriptionStatusEnum.SCHEDULED), (SubscriptionStatusEnum.PENDING, SubscriptionStatusEnum.EXPIRED), (SubscriptionStatusEnum.SCHEDULED, SubscriptionStatusEnum.ACTIVE), (SubscriptionStatusEnum.SCHEDULED, SubscriptionStatusEnum.EXPIRED), (SubscriptionStatusEnum.TRIALING, SubscriptionStatusEnum.EXPIRED), (SubscriptionStatusEnum.ACTIVE, SubscriptionStatusEnum.PAST_DUE), (SubscriptionStatusEnum.ACTIVE, SubscriptionStatusEnum.EXPIRED), (SubscriptionStatusEnum.PAST_DUE, SubscriptionStatusEnum.ACTIVE), (SubscriptionStatusEnum.PAST_DUE, SubscriptionStatusEnum.EXPIRED), } class BillingPeriodEnum(str, Enum): """Recurring billing interval.""" MONTHLY = "MONTHLY" YEARLY = "YEARLY" NONE = "NONE" # ============================================================================ # Catalog: SubscriptionPlan (static, in-memory) # ============================================================================ class SubscriptionPlan(BaseModel): """Plan definition (catalog entry). Not stored per mandate — static.""" planKey: str = Field(..., description="Unique plan identifier") selectableByUser: bool = Field(default=True, description="Whether users can choose this plan in the UI") title: Dict[str, str] = Field(default_factory=dict, description="Multilingual title (en/de/fr)") description: Dict[str, str] = Field(default_factory=dict, description="Multilingual description") currency: str = Field(default="CHF", description="Billing currency") billingPeriod: BillingPeriodEnum = Field(default=BillingPeriodEnum.MONTHLY, description="Recurring interval") pricePerUserCHF: float = Field(default=0.0, description="Price per active user per period") pricePerFeatureInstanceCHF: float = Field(default=0.0, description="Price per active feature instance per period") autoRenew: bool = Field(default=True, description="Stripe renews automatically at period end") maxUsers: Optional[int] = Field(None, description="Hard cap on active users (None = unlimited)") maxFeatureInstances: Optional[int] = Field(None, description="Hard cap on active feature instances (None = unlimited)") trialDays: Optional[int] = Field(None, description="Trial duration in days (only for trial plans)") maxDataVolumeMB: Optional[int] = Field(None, description="Soft-limit for data volume in MB per mandate (None = unlimited)") budgetAiCHF: float = Field(default=0.0, description="AI budget (CHF) included in subscription price per billing period") successorPlanKey: Optional[str] = Field(None, description="Plan to transition to when trial ends") registerModelLabels( "SubscriptionPlan", {"en": "Subscription Plan", "de": "Abonnement-Plan", "fr": "Plan d'abonnement"}, { "planKey": {"en": "Plan", "de": "Plan", "fr": "Plan"}, "selectableByUser": {"en": "Selectable", "de": "Wählbar", "fr": "Sélectionnable"}, "billingPeriod": {"en": "Billing Period", "de": "Abrechnungszeitraum", "fr": "Période de facturation"}, "pricePerUserCHF": {"en": "Price per User (CHF)", "de": "Preis pro User (CHF)"}, "pricePerFeatureInstanceCHF": {"en": "Price per Instance (CHF)", "de": "Preis pro Instanz (CHF)"}, "maxUsers": {"en": "Max Users", "de": "Max. Benutzer", "fr": "Max. utilisateurs"}, "maxFeatureInstances": {"en": "Max Instances", "de": "Max. Instanzen", "fr": "Max. instances"}, "maxDataVolumeMB": {"en": "Data Volume (MB)", "de": "Datenvolumen (MB)"}, "budgetAiCHF": {"en": "AI Budget (CHF)", "de": "AI-Budget (CHF)"}, }, ) # ============================================================================ # Stripe Price mapping (persisted in DB, auto-created at bootstrap) # ============================================================================ class StripePlanPrice(BaseModel): """Persisted mapping from planKey to Stripe Product/Price IDs. Auto-created at startup — no manual configuration needed. Uses separate Stripe Products for users and instances for clear invoice labels.""" id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key") planKey: str = Field(..., description="Reference to SubscriptionPlan.planKey") stripeProductId: str = Field("", description="Legacy single-product ID (unused)") stripeProductIdUsers: Optional[str] = Field(None, description="Stripe Product ID for user licenses") stripeProductIdInstances: Optional[str] = Field(None, description="Stripe Product ID for feature instances") stripePriceIdUsers: Optional[str] = Field(None, description="Stripe Price ID for user-seat line item") stripePriceIdInstances: Optional[str] = Field(None, description="Stripe Price ID for instance line item") registerModelLabels( "StripePlanPrice", {"en": "Stripe Plan Prices", "de": "Stripe-Planpreise"}, { "planKey": {"en": "Plan", "de": "Plan"}, "stripeProductIdUsers": {"en": "Product (Users)", "de": "Produkt (User)"}, "stripeProductIdInstances": {"en": "Product (Instances)", "de": "Produkt (Instanzen)"}, "stripePriceIdUsers": {"en": "Price ID (Users)", "de": "Preis-ID (User)"}, "stripePriceIdInstances": {"en": "Price ID (Instances)", "de": "Preis-ID (Instanzen)"}, }, ) # ============================================================================ # Instance: MandateSubscription # ============================================================================ class MandateSubscription(PowerOnModel): """A subscription instance bound to a specific mandate. See wiki/concepts/Subscription-State-Machine.md for state transitions.""" id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key") mandateId: str = Field(..., description="Foreign key to Mandate") planKey: str = Field(..., description="Reference to SubscriptionPlan.planKey") status: SubscriptionStatusEnum = Field(default=SubscriptionStatusEnum.PENDING, description="Current lifecycle status") recurring: bool = Field(default=True, description="True: auto-renews at period end. False: expires at period end (gekuendigt).") startedAt: datetime = Field(default_factory=lambda: datetime.now(timezone.utc), description="Record creation timestamp") effectiveFrom: Optional[datetime] = Field(None, description="When this subscription becomes operative. None = immediate. Set for SCHEDULED subs.") endedAt: Optional[datetime] = Field(None, description="When subscription ended (terminal)") currentPeriodStart: Optional[datetime] = Field(None, description="Current billing period start (synced from Stripe)") currentPeriodEnd: Optional[datetime] = Field(None, description="Current billing period end (synced from Stripe)") trialEndsAt: Optional[datetime] = Field(None, description="Trial expiry timestamp") snapshotPricePerUserCHF: float = Field(default=0.0, description="Price snapshot at activation (for invoice history)") snapshotPricePerInstanceCHF: float = Field(default=0.0, description="Price snapshot at activation") stripeSubscriptionId: Optional[str] = Field(None, description="Stripe Subscription ID (sub_xxx)") stripeItemIdUsers: Optional[str] = Field(None, description="Stripe Subscription Item ID for user seats") stripeItemIdInstances: Optional[str] = Field(None, description="Stripe Subscription Item ID for feature instances") registerModelLabels( "MandateSubscription", {"en": "Mandate Subscription", "de": "Mandanten-Abonnement", "fr": "Abonnement du mandat"}, { "id": {"en": "ID", "de": "ID"}, "mandateId": {"en": "Mandate ID", "de": "Mandanten-ID"}, "planKey": {"en": "Plan", "de": "Plan"}, "status": {"en": "Status", "de": "Status"}, "recurring": {"en": "Recurring", "de": "Wiederkehrend"}, "startedAt": {"en": "Started", "de": "Gestartet"}, "effectiveFrom": {"en": "Effective From", "de": "Wirksam ab"}, "endedAt": {"en": "Ended", "de": "Beendet"}, "currentPeriodStart": {"en": "Period Start", "de": "Periodenbeginn"}, "currentPeriodEnd": {"en": "Period End", "de": "Periodenende"}, "trialEndsAt": {"en": "Trial Ends", "de": "Trial endet"}, "snapshotPricePerUserCHF": {"en": "Price/User (CHF)", "de": "Preis/User (CHF)"}, "snapshotPricePerInstanceCHF": {"en": "Price/Instance (CHF)", "de": "Preis/Instanz (CHF)"}, }, ) # ============================================================================ # Built-in plan catalog (static, no env dependency) # ============================================================================ BUILTIN_PLANS: Dict[str, SubscriptionPlan] = { "ROOT": SubscriptionPlan( planKey="ROOT", selectableByUser=False, title={"en": "Root (System)", "de": "Root (System)", "fr": "Root (Système)"}, description={"en": "Internal system plan — no billing.", "de": "Interner Systemplan — keine Verrechnung."}, billingPeriod=BillingPeriodEnum.NONE, autoRenew=False, maxUsers=None, maxFeatureInstances=None, maxDataVolumeMB=None, budgetAiCHF=0.0, ), "TRIAL_7D": SubscriptionPlan( planKey="TRIAL_7D", selectableByUser=False, title={"en": "Free Trial (7 days)", "de": "Gratis-Testphase (7 Tage)", "fr": "Essai gratuit (7 jours)"}, description={ "en": "Try the platform for 7 days — 1 user, up to 3 feature instances, 5 CHF AI budget included.", "de": "Plattform 7 Tage testen — 1 User, bis zu 3 Feature-Instanzen, 5 CHF AI-Budget inklusive.", }, billingPeriod=BillingPeriodEnum.NONE, autoRenew=False, maxUsers=1, maxFeatureInstances=3, trialDays=7, maxDataVolumeMB=500, budgetAiCHF=5.0, successorPlanKey="STANDARD_MONTHLY", ), "STANDARD_MONTHLY": SubscriptionPlan( planKey="STANDARD_MONTHLY", selectableByUser=True, title={"en": "Standard (Monthly)", "de": "Standard (Monatlich)", "fr": "Standard (Mensuel)"}, description={ "en": "Usage-based billing per active user and feature instance, billed monthly. Includes 10 CHF AI budget.", "de": "Nutzungsbasierte Abrechnung pro aktivem User und Feature-Instanz, monatlich. Inkl. 10 CHF AI-Budget.", }, billingPeriod=BillingPeriodEnum.MONTHLY, pricePerUserCHF=19.0, pricePerFeatureInstanceCHF=29.0, maxDataVolumeMB=1024, budgetAiCHF=10.0, ), "STANDARD_YEARLY": SubscriptionPlan( planKey="STANDARD_YEARLY", selectableByUser=True, title={"en": "Standard (Yearly)", "de": "Standard (Jährlich)", "fr": "Standard (Annuel)"}, description={ "en": "Usage-based billing per active user and feature instance, billed yearly. Includes 120 CHF AI budget.", "de": "Nutzungsbasierte Abrechnung pro aktivem User und Feature-Instanz, jährlich. Inkl. 120 CHF AI-Budget.", }, billingPeriod=BillingPeriodEnum.YEARLY, pricePerUserCHF=228.0, pricePerFeatureInstanceCHF=348.0, maxDataVolumeMB=1024, budgetAiCHF=120.0, ), } def _getPlan(planKey: str) -> Optional[SubscriptionPlan]: """Resolve a plan by key from the built-in catalog.""" return BUILTIN_PLANS.get(planKey) def _getSelectablePlans() -> List[SubscriptionPlan]: """Return plans that users can choose in the UI.""" return [p for p in BUILTIN_PLANS.values() if p.selectableByUser]