gateway/modules/datamodels/datamodelSubscription.py
2026-03-24 14:16:46 +01:00

241 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.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.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)")
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)"},
},
)
# ============================================================================
# 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(BaseModel):
"""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,
),
"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.",
"de": "Plattform 7 Tage testen — 1 User, bis zu 3 Feature-Instanzen.",
},
billingPeriod=BillingPeriodEnum.NONE,
autoRenew=False,
maxUsers=1,
maxFeatureInstances=3,
trialDays=7,
maxDataVolumeMB=500,
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.",
"de": "Nutzungsbasierte Abrechnung pro aktivem User und Feature-Instanz, monatlich.",
},
billingPeriod=BillingPeriodEnum.MONTHLY,
pricePerUserCHF=90.0,
pricePerFeatureInstanceCHF=150.0,
maxDataVolumeMB=10240,
),
"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.",
"de": "Nutzungsbasierte Abrechnung pro aktivem User und Feature-Instanz, jährlich.",
},
billingPeriod=BillingPeriodEnum.YEARLY,
pricePerUserCHF=1080.0,
pricePerFeatureInstanceCHF=1800.0,
maxDataVolumeMB=10240,
),
}
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]