gateway/modules/datamodels/datamodelSubscription.py
2026-04-10 22:44:08 +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
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="Root (System)",
description="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="Gratis-Testphase (14 Tage)",
description="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="Starter (Monatlich)",
description="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="Starter (Jaehrlich)",
description="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="Professional (Monatlich)",
description="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="Professional (Jaehrlich)",
description="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="Max (Monatlich)",
description="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="Max (Jaehrlich)",
description="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]