249 lines
12 KiB
Python
249 lines
12 KiB
Python
# 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=79.0,
|
|
pricePerFeatureInstanceCHF=119.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=948.0,
|
|
pricePerFeatureInstanceCHF=1428.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]
|