# 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.i18nRegistry import i18nModel, t 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) # ============================================================================ @i18nModel("Abonnement-Plan") class SubscriptionPlan(BaseModel): """Plan-Definition (Katalog). Nicht pro Mandat gespeichert — statisch.""" planKey: str = Field( ..., description="Unique plan identifier", json_schema_extra={"label": "Plan"}, ) selectableByUser: bool = Field( default=True, description="Whether users can choose this plan in the UI", json_schema_extra={"label": "Waehlbar"}, ) title: str = Field( default="", description="Plan title (i18n key)", json_schema_extra={"label": "Titel"}, ) description: str = Field( default="", description="Plan description (i18n key)", json_schema_extra={"label": "Beschreibung"}, ) currency: str = Field( default="CHF", description="Billing currency", json_schema_extra={"label": "Waehrung"}, ) billingPeriod: BillingPeriodEnum = Field( default=BillingPeriodEnum.MONTHLY, description="Recurring interval", json_schema_extra={"label": "Abrechnungszeitraum"}, ) pricePerUserCHF: float = Field( default=0.0, description="Price per active user per period", json_schema_extra={"label": "Preis pro User (CHF)"}, ) pricePerFeatureInstanceCHF: float = Field( default=0.0, description="Price per additional module beyond included (monthly, CHF)", json_schema_extra={"label": "Preis pro Modul (CHF)"}, ) autoRenew: bool = Field( default=True, description="Stripe renews automatically at period end", json_schema_extra={"label": "Auto-Verlaengerung"}, ) maxUsers: Optional[int] = Field( None, description="Hard cap on active users (None = unlimited)", json_schema_extra={"label": "Max. Benutzer"}, ) maxFeatureInstances: Optional[int] = Field( None, description="Hard cap on active modules (None = unlimited)", json_schema_extra={"label": "Max. Module"}, ) includedModules: int = Field( default=0, description="Number of modules included in plan at no extra charge", json_schema_extra={"label": "Inkl. Module"}, ) trialDays: Optional[int] = Field( None, description="Trial duration in days (only for trial plans)", json_schema_extra={"label": "Probentage"}, ) maxDataVolumeMB: Optional[int] = Field( None, description="Soft-limit for data volume in MB per mandate (None = unlimited)", json_schema_extra={"label": "Datenvolumen (MB)"}, ) budgetAiCHF: float = Field( default=0.0, description="AI budget (CHF) total per billing period (users * budgetAiPerUserCHF at activation)", json_schema_extra={"label": "AI-Budget (CHF)"}, ) budgetAiPerUserCHF: float = Field( default=0.0, description="AI budget per user per month (CHF). Total = users * this value.", json_schema_extra={"label": "AI-Budget pro User (CHF)"}, ) successorPlanKey: Optional[str] = Field( None, description="Plan to transition to when trial ends", json_schema_extra={"label": "Nachfolge-Plan"}, ) # ============================================================================ # Stripe Price mapping (persisted in DB, auto-created at bootstrap) # ============================================================================ @i18nModel("Stripe-Planpreise") class StripePlanPrice(BaseModel): """Persistierte Zuordnung planKey zu Stripe Product/Price IDs.""" id: str = Field( default_factory=lambda: str(uuid.uuid4()), description="Primary key", json_schema_extra={"label": "ID"}, ) planKey: str = Field( ..., description="Reference to SubscriptionPlan.planKey", json_schema_extra={"label": "Plan"}, ) stripeProductId: str = Field( "", description="Legacy single-product ID (unused)", json_schema_extra={"label": "Stripe-Produkt-ID (Legacy)"}, ) stripeProductIdUsers: Optional[str] = Field( None, description="Stripe Product ID for user licenses", json_schema_extra={"label": "Produkt (User)"}, ) stripeProductIdInstances: Optional[str] = Field( None, description="Stripe Product ID for modules", json_schema_extra={"label": "Produkt (Module)"}, ) stripePriceIdUsers: Optional[str] = Field( None, description="Stripe Price ID for user-seat line item", json_schema_extra={"label": "Preis-ID (User)"}, ) stripePriceIdInstances: Optional[str] = Field( None, description="Stripe Price ID for module line item", json_schema_extra={"label": "Preis-ID (Module)"}, ) # ============================================================================ # Instance: MandateSubscription # ============================================================================ @i18nModel("Mandanten-Abonnement") class MandateSubscription(PowerOnModel): """Abonnement-Instanz gebunden an einen Mandanten.""" id: str = Field( default_factory=lambda: str(uuid.uuid4()), description="Primary key", json_schema_extra={"label": "ID"}, ) mandateId: str = Field( ..., description="Foreign key to Mandate", json_schema_extra={"label": "Mandanten-ID", "fk_target": {"db": "poweron_app", "table": "Mandate"}}, ) planKey: str = Field( ..., description="Reference to SubscriptionPlan.planKey", json_schema_extra={"label": "Plan"}, ) status: SubscriptionStatusEnum = Field( default=SubscriptionStatusEnum.PENDING, description="Current lifecycle status", json_schema_extra={"label": "Status"}, ) recurring: bool = Field( default=True, description="True: auto-renews at period end. False: expires at period end (gekuendigt).", json_schema_extra={"label": "Wiederkehrend"}, ) startedAt: datetime = Field( default_factory=lambda: datetime.now(timezone.utc), description="Record creation timestamp", json_schema_extra={"label": "Gestartet"}, ) effectiveFrom: Optional[datetime] = Field( None, description="When this subscription becomes operative. None = immediate. Set for SCHEDULED subs.", json_schema_extra={"label": "Wirksam ab"}, ) endedAt: Optional[datetime] = Field( None, description="When subscription ended (terminal)", json_schema_extra={"label": "Beendet"}, ) currentPeriodStart: Optional[datetime] = Field( None, description="Current billing period start (synced from Stripe)", json_schema_extra={"label": "Periodenbeginn"}, ) currentPeriodEnd: Optional[datetime] = Field( None, description="Current billing period end (synced from Stripe)", json_schema_extra={"label": "Periodenende"}, ) trialEndsAt: Optional[datetime] = Field( None, description="Trial expiry timestamp", json_schema_extra={"label": "Trial endet"}, ) snapshotPricePerUserCHF: float = Field( default=0.0, description="Price snapshot at activation (for invoice history)", json_schema_extra={"label": "Preis/User (CHF)"}, ) snapshotPricePerInstanceCHF: float = Field( default=0.0, description="Price snapshot at activation (per additional module)", json_schema_extra={"label": "Preis/Modul (CHF)"}, ) stripeSubscriptionId: Optional[str] = Field( None, description="Stripe Subscription ID (sub_xxx)", json_schema_extra={"label": "Stripe-Abonnement-ID"}, ) stripeItemIdUsers: Optional[str] = Field( None, description="Stripe Subscription Item ID for user seats", json_schema_extra={"label": "Stripe-Item (User)"}, ) stripeItemIdInstances: Optional[str] = Field( None, description="Stripe Subscription Item ID for feature instances", json_schema_extra={"label": "Stripe-Item (Instanzen)"}, ) # ============================================================================ # Built-in plan catalog (static, no env dependency) # ============================================================================ BUILTIN_PLANS: Dict[str, SubscriptionPlan] = { "ROOT": SubscriptionPlan( planKey="ROOT", selectableByUser=False, title=t("Root (System)"), description=t("Interner Systemplan — keine Verrechnung."), billingPeriod=BillingPeriodEnum.NONE, autoRenew=False, maxUsers=None, maxFeatureInstances=None, includedModules=0, maxDataVolumeMB=None, budgetAiCHF=0.0, budgetAiPerUserCHF=0.0, ), "TRIAL_14D": SubscriptionPlan( planKey="TRIAL_14D", selectableByUser=False, title=t("Gratis-Testphase (14 Tage)"), description=t("14 Tage kostenlos testen — 1 User, 2 Module inklusive, CHF 25 AI-Budget."), billingPeriod=BillingPeriodEnum.NONE, autoRenew=False, maxUsers=1, maxFeatureInstances=2, includedModules=2, trialDays=14, maxDataVolumeMB=1024, budgetAiCHF=25.0, budgetAiPerUserCHF=25.0, successorPlanKey="STARTER_MONTHLY", ), "STARTER_MONTHLY": SubscriptionPlan( planKey="STARTER_MONTHLY", selectableByUser=True, title=t("Starter (Monatlich)"), description=t("CHF 69 pro User/Monat. 2 Module inklusive, CHF 25 AI-Budget pro User."), billingPeriod=BillingPeriodEnum.MONTHLY, pricePerUserCHF=69.0, pricePerFeatureInstanceCHF=39.0, maxUsers=None, includedModules=2, maxDataVolumeMB=1024, budgetAiCHF=0.0, budgetAiPerUserCHF=25.0, ), "STARTER_YEARLY": SubscriptionPlan( planKey="STARTER_YEARLY", selectableByUser=True, title=t("Starter (Jaehrlich)"), description=t("CHF 690 pro User/Jahr (-17%). 2 Module inklusive, CHF 25 AI-Budget pro User/Monat."), billingPeriod=BillingPeriodEnum.YEARLY, pricePerUserCHF=690.0, pricePerFeatureInstanceCHF=39.0, maxUsers=None, includedModules=2, maxDataVolumeMB=1024, budgetAiCHF=0.0, budgetAiPerUserCHF=25.0, ), "PROFESSIONAL_MONTHLY": SubscriptionPlan( planKey="PROFESSIONAL_MONTHLY", selectableByUser=True, title=t("Professional (Monatlich)"), description=t("CHF 99 pro User/Monat. 5 Module inklusive, CHF 50 AI-Budget pro User."), billingPeriod=BillingPeriodEnum.MONTHLY, pricePerUserCHF=99.0, pricePerFeatureInstanceCHF=29.0, maxUsers=None, includedModules=5, maxDataVolumeMB=5120, budgetAiCHF=0.0, budgetAiPerUserCHF=50.0, ), "PROFESSIONAL_YEARLY": SubscriptionPlan( planKey="PROFESSIONAL_YEARLY", selectableByUser=True, title=t("Professional (Jaehrlich)"), description=t("CHF 990 pro User/Jahr (-17%). 5 Module inklusive, CHF 50 AI-Budget pro User/Monat."), billingPeriod=BillingPeriodEnum.YEARLY, pricePerUserCHF=990.0, pricePerFeatureInstanceCHF=29.0, maxUsers=None, includedModules=5, maxDataVolumeMB=5120, budgetAiCHF=0.0, budgetAiPerUserCHF=50.0, ), "MAX_MONTHLY": SubscriptionPlan( planKey="MAX_MONTHLY", selectableByUser=True, title=t("Max (Monatlich)"), description=t("CHF 145 pro User/Monat. 15 Module inklusive, CHF 100 AI-Budget pro User."), billingPeriod=BillingPeriodEnum.MONTHLY, pricePerUserCHF=145.0, pricePerFeatureInstanceCHF=19.0, maxUsers=None, includedModules=15, maxDataVolumeMB=25600, budgetAiCHF=0.0, budgetAiPerUserCHF=100.0, ), "MAX_YEARLY": SubscriptionPlan( planKey="MAX_YEARLY", selectableByUser=True, title=t("Max (Jaehrlich)"), description=t("CHF 1450 pro User/Jahr (-17%). 15 Module inklusive, CHF 100 AI-Budget pro User/Monat."), billingPeriod=BillingPeriodEnum.YEARLY, pricePerUserCHF=1450.0, pricePerFeatureInstanceCHF=19.0, maxUsers=None, includedModules=15, maxDataVolumeMB=25600, budgetAiCHF=0.0, budgetAiPerUserCHF=100.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]