gateway/modules/datamodels/datamodelSubscription.py
ValueOn AG 75484c0f73 BREAKING CHANGE
API and persisted records use PowerOnModel system fields:
- sysCreatedAt, sysCreatedBy, sysModifiedAt, sysModifiedBy
Removed legacy JSON/DB field names:
- _createdAt, _createdBy, _modifiedAt, _modifiedBy
Frontend (frontend_nyla) and gateway call sites were updated accordingly.
Database:
- Bootstrap runs idempotent backfill (_migrateSystemFieldColumns) from old
  underscore columns and selected business duplicates into sys* where sys* IS NULL.
- Re-run app bootstrap against each PostgreSQL database after deploy.
- Optional: DROP INDEX IF EXISTS "idx_invitation_createdby" if an old index remains;
  new index: idx_invitation_syscreatedby on Invitation(sysCreatedBy).
Tests:
- RBAC integration tests aligned with current GROUP mandate filter and UserMandate-based
  UserConnection GROUP clause; buildRbacWhereClause(..., mandateId=...) must be passed
  explicitly (same as production request context).
2026-03-28 18:12:37 +01:00

242 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.datamodels.datamodelBase import PowerOnModel
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(PowerOnModel):
"""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]