417 lines
15 KiB
Python
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", "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]
|