gateway/modules/datamodels/datamodelSubscription.py
2026-04-12 14:04:49 +02:00

417 lines
15 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, 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"},
)
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]