# 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.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.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)") 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)"}, }, ) # ============================================================================ # 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(BaseModel): """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, ), "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.", "de": "Plattform 7 Tage testen — 1 User, bis zu 3 Feature-Instanzen.", }, billingPeriod=BillingPeriodEnum.NONE, autoRenew=False, maxUsers=1, maxFeatureInstances=3, trialDays=7, maxDataVolumeMB=500, 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.", "de": "Nutzungsbasierte Abrechnung pro aktivem User und Feature-Instanz, monatlich.", }, billingPeriod=BillingPeriodEnum.MONTHLY, pricePerUserCHF=90.0, pricePerFeatureInstanceCHF=150.0, maxDataVolumeMB=10240, ), "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.", "de": "Nutzungsbasierte Abrechnung pro aktivem User und Feature-Instanz, jährlich.", }, billingPeriod=BillingPeriodEnum.YEARLY, pricePerUserCHF=1080.0, pricePerFeatureInstanceCHF=1800.0, maxDataVolumeMB=10240, ), } 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]