350 lines
13 KiB
Python
350 lines
13 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.i18nRegistry import i18nModel
|
|
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: Dict[str, str] = Field(
|
|
default_factory=dict,
|
|
description="Multilingual title (en/de/fr)",
|
|
json_schema_extra={"label": "Titel"},
|
|
)
|
|
description: Dict[str, str] = Field(
|
|
default_factory=dict,
|
|
description="Multilingual description",
|
|
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 active feature instance per period",
|
|
json_schema_extra={"label": "Preis pro Instanz (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 feature instances (None = unlimited)",
|
|
json_schema_extra={"label": "Max. Instanzen"},
|
|
)
|
|
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) included in subscription price per billing period",
|
|
json_schema_extra={"label": "AI-Budget (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 feature instances",
|
|
json_schema_extra={"label": "Produkt (Instanzen)"},
|
|
)
|
|
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 instance line item",
|
|
json_schema_extra={"label": "Preis-ID (Instanzen)"},
|
|
)
|
|
|
|
|
|
# ============================================================================
|
|
# 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"},
|
|
)
|
|
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",
|
|
json_schema_extra={"label": "Preis/Instanz (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={"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 (Jaehrlich)", "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, jaehrlich. 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]
|