decision subscription

This commit is contained in:
ValueOn AG 2026-04-10 22:44:08 +02:00
parent be9e47caad
commit 87e2e6d401
30 changed files with 441 additions and 330 deletions

View file

@ -103,10 +103,10 @@ class ChatWorkflow(PowerOnModel):
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key", json_schema_extra={"label": "ID", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False}) id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key", json_schema_extra={"label": "ID", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
featureInstanceId: Optional[str] = Field(None, description="Feature instance ID for multi-tenancy isolation", json_schema_extra={"label": "Feature-Instanz-ID", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False}) featureInstanceId: Optional[str] = Field(None, description="Feature instance ID for multi-tenancy isolation", json_schema_extra={"label": "Feature-Instanz-ID", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
status: str = Field(default="running", description="Current status of the workflow", json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": False, "frontend_options": [ status: str = Field(default="running", description="Current status of the workflow", json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": False, "frontend_options": [
{"value": "running", "label": {"en": "Running", "fr": "En cours"}}, {"value": "running", "label": "Running"},
{"value": "completed", "label": {"en": "Completed", "fr": "Terminé"}}, {"value": "completed", "label": "Completed"},
{"value": "stopped", "label": {"en": "Stopped", "fr": "Arrêté"}}, {"value": "stopped", "label": "Stopped"},
{"value": "error", "label": {"en": "Error", "fr": "Erreur"}}, {"value": "error", "label": "Error"},
]}) ]})
name: Optional[str] = Field(None, description="Name of the workflow", json_schema_extra={"label": "Name", "frontend_type": "text", "frontend_readonly": False, "frontend_required": True}) name: Optional[str] = Field(None, description="Name of the workflow", json_schema_extra={"label": "Name", "frontend_type": "text", "frontend_readonly": False, "frontend_required": True})
currentRound: int = Field(default=0, description="Current round number", json_schema_extra={"label": "Aktuelle Runde", "frontend_type": "integer", "frontend_readonly": True, "frontend_required": False}) currentRound: int = Field(default=0, description="Current round number", json_schema_extra={"label": "Aktuelle Runde", "frontend_type": "integer", "frontend_readonly": True, "frontend_required": False})
@ -122,15 +122,15 @@ class ChatWorkflow(PowerOnModel):
workflowMode: WorkflowModeEnum = Field(default=WorkflowModeEnum.WORKFLOW_DYNAMIC, description="Workflow mode selector", json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": False, "frontend_options": [ workflowMode: WorkflowModeEnum = Field(default=WorkflowModeEnum.WORKFLOW_DYNAMIC, description="Workflow mode selector", json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": False, "frontend_options": [
{ {
"value": WorkflowModeEnum.WORKFLOW_DYNAMIC.value, "value": WorkflowModeEnum.WORKFLOW_DYNAMIC.value,
"label": {"en": "Dynamic", "fr": "Dynamique"}, "label": "Dynamic",
}, },
{ {
"value": WorkflowModeEnum.WORKFLOW_AUTOMATION.value, "value": WorkflowModeEnum.WORKFLOW_AUTOMATION.value,
"label": {"en": "Automation", "fr": "Automatisation"}, "label": "Automation",
}, },
{ {
"value": WorkflowModeEnum.WORKFLOW_CHATBOT.value, "value": WorkflowModeEnum.WORKFLOW_CHATBOT.value,
"label": {"en": "Chatbot", "fr": "Chatbot"}, "label": "Chatbot",
}, },
]}) ]})
maxSteps: int = Field(default=10, description="Maximum number of iterations in dynamic mode", json_schema_extra={"label": "Max. Schritte", "frontend_type": "integer", "frontend_readonly": False, "frontend_required": False}) maxSteps: int = Field(default=10, description="Maximum number of iterations in dynamic mode", json_schema_extra={"label": "Max. Schritte", "frontend_type": "integer", "frontend_readonly": False, "frontend_required": False})

View file

@ -71,10 +71,10 @@ class DataSource(PowerOnModel):
default="personal", default="personal",
description="Data visibility scope: personal, featureInstance, mandate, global", description="Data visibility scope: personal, featureInstance, mandate, global",
json_schema_extra={"label": "Sichtbarkeit", "frontend_type": "select", "frontend_readonly": False, "frontend_required": False, "frontend_options": [ json_schema_extra={"label": "Sichtbarkeit", "frontend_type": "select", "frontend_readonly": False, "frontend_required": False, "frontend_options": [
{"value": "personal", "label": {"en": "Personal", "de": "Persönlich"}}, {"value": "personal", "label": "Persönlich"},
{"value": "featureInstance", "label": {"en": "Feature Instance", "de": "Feature-Instanz"}}, {"value": "featureInstance", "label": "Feature-Instanz"},
{"value": "mandate", "label": {"en": "Mandate", "de": "Mandant"}}, {"value": "mandate", "label": "Mandant"},
{"value": "global", "label": {"en": "Global", "de": "Global"}}, {"value": "global", "label": "Global"},
]}, ]},
) )
neutralize: bool = Field( neutralize: bool = Field(

View file

@ -59,10 +59,10 @@ class FeatureDataSource(PowerOnModel):
default="personal", default="personal",
description="Data visibility scope: personal, featureInstance, mandate, global", description="Data visibility scope: personal, featureInstance, mandate, global",
json_schema_extra={"label": "Sichtbarkeit", "frontend_type": "select", "frontend_readonly": False, "frontend_required": False, "frontend_options": [ json_schema_extra={"label": "Sichtbarkeit", "frontend_type": "select", "frontend_readonly": False, "frontend_required": False, "frontend_options": [
{"value": "personal", "label": {"en": "Personal", "de": "Persönlich"}}, {"value": "personal", "label": "Persönlich"},
{"value": "featureInstance", "label": {"en": "Feature Instance", "de": "Feature-Instanz"}}, {"value": "featureInstance", "label": "Feature-Instanz"},
{"value": "mandate", "label": {"en": "Mandate", "de": "Mandant"}}, {"value": "mandate", "label": "Mandant"},
{"value": "global", "label": {"en": "Global", "de": "Global"}}, {"value": "global", "label": "Global"},
]}, ]},
) )
neutralize: bool = Field( neutralize: bool = Field(

View file

@ -68,10 +68,10 @@ class FileItem(PowerOnModel):
default="personal", default="personal",
description="Data visibility scope: personal, featureInstance, mandate, global", description="Data visibility scope: personal, featureInstance, mandate, global",
json_schema_extra={"label": "Sichtbarkeit", "frontend_type": "select", "frontend_readonly": False, "frontend_required": False, "frontend_options": [ json_schema_extra={"label": "Sichtbarkeit", "frontend_type": "select", "frontend_readonly": False, "frontend_required": False, "frontend_options": [
{"value": "personal", "label": {"en": "Personal", "de": "Persönlich"}}, {"value": "personal", "label": "Persönlich"},
{"value": "featureInstance", "label": {"en": "Feature Instance", "de": "Feature-Instanz"}}, {"value": "featureInstance", "label": "Feature-Instanz"},
{"value": "mandate", "label": {"en": "Mandate", "de": "Mandant"}}, {"value": "mandate", "label": "Mandant"},
{"value": "global", "label": {"en": "Global", "de": "Global"}}, {"value": "global", "label": "Global"},
]}, ]},
) )
neutralize: bool = Field( neutralize: bool = Field(

View file

@ -165,10 +165,10 @@ class MessagingSubscriptionRegistration(BaseModel):
"frontend_readonly": False, "frontend_readonly": False,
"frontend_required": True, "frontend_required": True,
"frontend_options": [ "frontend_options": [
{"value": "email", "label": {"en": "Email", "fr": "Email"}}, {"value": "email", "label": "Email"},
{"value": "sms", "label": {"en": "SMS", "fr": "SMS"}}, {"value": "sms", "label": "SMS"},
{"value": "whatsapp", "label": {"en": "WhatsApp", "fr": "WhatsApp"}}, {"value": "whatsapp", "label": "WhatsApp"},
{"value": "teams_chat", "label": {"en": "Teams Chat", "fr": "Chat Teams"}}, {"value": "teams_chat", "label": "Teams Chat"},
], ],
"label": "Kanal", "label": "Kanal",
}, },
@ -253,10 +253,10 @@ class MessagingDelivery(BaseModel):
"frontend_readonly": True, "frontend_readonly": True,
"frontend_required": False, "frontend_required": False,
"frontend_options": [ "frontend_options": [
{"value": "email", "label": {"en": "Email", "fr": "Email"}}, {"value": "email", "label": "Email"},
{"value": "sms", "label": {"en": "SMS", "fr": "SMS"}}, {"value": "sms", "label": "SMS"},
{"value": "whatsapp", "label": {"en": "WhatsApp", "fr": "WhatsApp"}}, {"value": "whatsapp", "label": "WhatsApp"},
{"value": "teams_chat", "label": {"en": "Teams Chat", "fr": "Chat Teams"}}, {"value": "teams_chat", "label": "Teams Chat"},
], ],
"label": "Kanal", "label": "Kanal",
}, },
@ -269,9 +269,9 @@ class MessagingDelivery(BaseModel):
"frontend_readonly": True, "frontend_readonly": True,
"frontend_required": False, "frontend_required": False,
"frontend_options": [ "frontend_options": [
{"value": "pending", "label": {"en": "Pending", "fr": "En attente"}}, {"value": "pending", "label": "Pending"},
{"value": "sent", "label": {"en": "Sent", "fr": "Envoyé"}}, {"value": "sent", "label": "Sent"},
{"value": "failed", "label": {"en": "Failed", "fr": "Échoué"}}, {"value": "failed", "label": "Failed"},
], ],
"label": "Status", "label": "Status",
}, },

View file

@ -72,10 +72,10 @@ class UserNotification(PowerOnModel):
"frontend_readonly": True, "frontend_readonly": True,
"frontend_required": True, "frontend_required": True,
"frontend_options": [ "frontend_options": [
{"value": "invitation", "label": {"en": "Invitation", "de": "Einladung"}}, {"value": "invitation", "label": "Einladung"},
{"value": "system", "label": {"en": "System", "de": "System"}}, {"value": "system", "label": "System"},
{"value": "workflow", "label": {"en": "Workflow", "de": "Workflow"}}, {"value": "workflow", "label": "Workflow"},
{"value": "mention", "label": {"en": "Mention", "de": "Erwähnung"}} {"value": "mention", "label": "Erwähnung"}
] ]
} }
) )
@ -88,10 +88,10 @@ class UserNotification(PowerOnModel):
"frontend_readonly": True, "frontend_readonly": True,
"frontend_required": False, "frontend_required": False,
"frontend_options": [ "frontend_options": [
{"value": "unread", "label": {"en": "Unread", "de": "Ungelesen"}}, {"value": "unread", "label": "Ungelesen"},
{"value": "read", "label": {"en": "Read", "de": "Gelesen"}}, {"value": "read", "label": "Gelesen"},
{"value": "actioned", "label": {"en": "Actioned", "de": "Bearbeitet"}}, {"value": "actioned", "label": "Bearbeitet"},
{"value": "dismissed", "label": {"en": "Dismissed", "de": "Verworfen"}} {"value": "dismissed", "label": "Verworfen"}
] ]
} }
) )

View file

@ -97,9 +97,9 @@ class AccessRule(PowerOnModel):
context: AccessRuleContext = Field( context: AccessRuleContext = Field(
description="Context type: DATA (database), UI (interface), RESOURCE (system resources). IMMUTABLE!", description="Context type: DATA (database), UI (interface), RESOURCE (system resources). IMMUTABLE!",
json_schema_extra={"label": "Kontext", "frontend_type": "select", "frontend_readonly": True, "frontend_required": True, "frontend_options": [ json_schema_extra={"label": "Kontext", "frontend_type": "select", "frontend_readonly": True, "frontend_required": True, "frontend_options": [
{"value": "DATA", "label": {"en": "Data", "de": "Daten", "fr": "Données"}}, {"value": "DATA", "label": "Daten"},
{"value": "UI", "label": {"en": "UI", "de": "Oberfläche", "fr": "Interface"}}, {"value": "UI", "label": "Oberfläche"},
{"value": "RESOURCE", "label": {"en": "Resource", "de": "Ressource", "fr": "Ressource"}} {"value": "RESOURCE", "label": "Ressource"}
]} ]}
) )
item: Optional[str] = Field( item: Optional[str] = Field(
@ -116,40 +116,40 @@ class AccessRule(PowerOnModel):
default=None, default=None,
description="Read permission level (only for DATA context)", description="Read permission level (only for DATA context)",
json_schema_extra={"label": "Lesen", "frontend_type": "select", "frontend_readonly": False, "frontend_required": False, "frontend_options": [ json_schema_extra={"label": "Lesen", "frontend_type": "select", "frontend_readonly": False, "frontend_required": False, "frontend_options": [
{"value": "a", "label": {"en": "All Records", "de": "Alle Datensätze", "fr": "Tous les enregistrements"}}, {"value": "a", "label": "Alle Datensätze"},
{"value": "m", "label": {"en": "My Records", "de": "Meine Datensätze", "fr": "Mes enregistrements"}}, {"value": "m", "label": "Meine Datensätze"},
{"value": "g", "label": {"en": "Group Records", "de": "Gruppen-Datensätze", "fr": "Enregistrements du groupe"}}, {"value": "g", "label": "Gruppen-Datensätze"},
{"value": "n", "label": {"en": "No Access", "de": "Kein Zugriff", "fr": "Aucun accès"}} {"value": "n", "label": "Kein Zugriff"}
]} ]}
) )
create: Optional[AccessLevel] = Field( create: Optional[AccessLevel] = Field(
default=None, default=None,
description="Create permission level (only for DATA context)", description="Create permission level (only for DATA context)",
json_schema_extra={"label": "Erstellen", "frontend_type": "select", "frontend_readonly": False, "frontend_required": False, "frontend_options": [ json_schema_extra={"label": "Erstellen", "frontend_type": "select", "frontend_readonly": False, "frontend_required": False, "frontend_options": [
{"value": "a", "label": {"en": "All Records", "de": "Alle Datensätze", "fr": "Tous les enregistrements"}}, {"value": "a", "label": "Alle Datensätze"},
{"value": "m", "label": {"en": "My Records", "de": "Meine Datensätze", "fr": "Mes enregistrements"}}, {"value": "m", "label": "Meine Datensätze"},
{"value": "g", "label": {"en": "Group Records", "de": "Gruppen-Datensätze", "fr": "Enregistrements du groupe"}}, {"value": "g", "label": "Gruppen-Datensätze"},
{"value": "n", "label": {"en": "No Access", "de": "Kein Zugriff", "fr": "Aucun accès"}} {"value": "n", "label": "Kein Zugriff"}
]} ]}
) )
update: Optional[AccessLevel] = Field( update: Optional[AccessLevel] = Field(
default=None, default=None,
description="Update permission level (only for DATA context)", description="Update permission level (only for DATA context)",
json_schema_extra={"label": "Aktualisieren", "frontend_type": "select", "frontend_readonly": False, "frontend_required": False, "frontend_options": [ json_schema_extra={"label": "Aktualisieren", "frontend_type": "select", "frontend_readonly": False, "frontend_required": False, "frontend_options": [
{"value": "a", "label": {"en": "All Records", "de": "Alle Datensätze", "fr": "Tous les enregistrements"}}, {"value": "a", "label": "Alle Datensätze"},
{"value": "m", "label": {"en": "My Records", "de": "Meine Datensätze", "fr": "Mes enregistrements"}}, {"value": "m", "label": "Meine Datensätze"},
{"value": "g", "label": {"en": "Group Records", "de": "Gruppen-Datensätze", "fr": "Enregistrements du groupe"}}, {"value": "g", "label": "Gruppen-Datensätze"},
{"value": "n", "label": {"en": "No Access", "de": "Kein Zugriff", "fr": "Aucun accès"}} {"value": "n", "label": "Kein Zugriff"}
]} ]}
) )
delete: Optional[AccessLevel] = Field( delete: Optional[AccessLevel] = Field(
default=None, default=None,
description="Delete permission level (only for DATA context)", description="Delete permission level (only for DATA context)",
json_schema_extra={"label": "Loeschen", "frontend_type": "select", "frontend_readonly": False, "frontend_required": False, "frontend_options": [ json_schema_extra={"label": "Loeschen", "frontend_type": "select", "frontend_readonly": False, "frontend_required": False, "frontend_options": [
{"value": "a", "label": {"en": "All Records", "de": "Alle Datensätze", "fr": "Tous les enregistrements"}}, {"value": "a", "label": "Alle Datensätze"},
{"value": "m", "label": {"en": "My Records", "de": "Meine Datensätze", "fr": "Mes enregistrements"}}, {"value": "m", "label": "Meine Datensätze"},
{"value": "g", "label": {"en": "Group Records", "de": "Gruppen-Datensätze", "fr": "Enregistrements du groupe"}}, {"value": "g", "label": "Gruppen-Datensätze"},
{"value": "n", "label": {"en": "No Access", "de": "Kein Zugriff", "fr": "Aucun accès"}} {"value": "n", "label": "Kein Zugriff"}
]} ]}
) )

View file

@ -69,14 +69,14 @@ class SubscriptionPlan(BaseModel):
json_schema_extra={"label": "Waehlbar"}, json_schema_extra={"label": "Waehlbar"},
) )
title: Dict[str, str] = Field( title: str = Field(
default_factory=dict, default="",
description="Multilingual title (en/de/fr)", description="Plan title (i18n key)",
json_schema_extra={"label": "Titel"}, json_schema_extra={"label": "Titel"},
) )
description: Dict[str, str] = Field( description: str = Field(
default_factory=dict, default="",
description="Multilingual description", description="Plan description (i18n key)",
json_schema_extra={"label": "Beschreibung"}, json_schema_extra={"label": "Beschreibung"},
) )
@ -97,8 +97,8 @@ class SubscriptionPlan(BaseModel):
) )
pricePerFeatureInstanceCHF: float = Field( pricePerFeatureInstanceCHF: float = Field(
default=0.0, default=0.0,
description="Price per active feature instance per period", description="Price per additional module beyond included (monthly, CHF)",
json_schema_extra={"label": "Preis pro Instanz (CHF)"}, json_schema_extra={"label": "Preis pro Modul (CHF)"},
) )
autoRenew: bool = Field( autoRenew: bool = Field(
default=True, default=True,
@ -113,8 +113,13 @@ class SubscriptionPlan(BaseModel):
) )
maxFeatureInstances: Optional[int] = Field( maxFeatureInstances: Optional[int] = Field(
None, None,
description="Hard cap on active feature instances (None = unlimited)", description="Hard cap on active modules (None = unlimited)",
json_schema_extra={"label": "Max. Instanzen"}, 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( trialDays: Optional[int] = Field(
None, None,
@ -128,9 +133,14 @@ class SubscriptionPlan(BaseModel):
) )
budgetAiCHF: float = Field( budgetAiCHF: float = Field(
default=0.0, default=0.0,
description="AI budget (CHF) included in subscription price per billing period", description="AI budget (CHF) total per billing period (users * budgetAiPerUserCHF at activation)",
json_schema_extra={"label": "AI-Budget (CHF)"}, 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( successorPlanKey: Optional[str] = Field(
None, None,
description="Plan to transition to when trial ends", description="Plan to transition to when trial ends",
@ -167,8 +177,8 @@ class StripePlanPrice(BaseModel):
) )
stripeProductIdInstances: Optional[str] = Field( stripeProductIdInstances: Optional[str] = Field(
None, None,
description="Stripe Product ID for feature instances", description="Stripe Product ID for modules",
json_schema_extra={"label": "Produkt (Instanzen)"}, json_schema_extra={"label": "Produkt (Module)"},
) )
stripePriceIdUsers: Optional[str] = Field( stripePriceIdUsers: Optional[str] = Field(
None, None,
@ -177,8 +187,8 @@ class StripePlanPrice(BaseModel):
) )
stripePriceIdInstances: Optional[str] = Field( stripePriceIdInstances: Optional[str] = Field(
None, None,
description="Stripe Price ID for instance line item", description="Stripe Price ID for module line item",
json_schema_extra={"label": "Preis-ID (Instanzen)"}, json_schema_extra={"label": "Preis-ID (Module)"},
) )
@ -254,8 +264,8 @@ class MandateSubscription(PowerOnModel):
) )
snapshotPricePerInstanceCHF: float = Field( snapshotPricePerInstanceCHF: float = Field(
default=0.0, default=0.0,
description="Price snapshot at activation", description="Price snapshot at activation (per additional module)",
json_schema_extra={"label": "Preis/Instanz (CHF)"}, json_schema_extra={"label": "Preis/Modul (CHF)"},
) )
stripeSubscriptionId: Optional[str] = Field( stripeSubscriptionId: Optional[str] = Field(
@ -283,59 +293,116 @@ BUILTIN_PLANS: Dict[str, SubscriptionPlan] = {
"ROOT": SubscriptionPlan( "ROOT": SubscriptionPlan(
planKey="ROOT", planKey="ROOT",
selectableByUser=False, selectableByUser=False,
title={"en": "Root (System)", "de": "Root (System)", "fr": "Root (Système)"}, title="Root (System)",
description={"en": "Internal system plan — no billing.", "de": "Interner Systemplan — keine Verrechnung."}, description="Interner Systemplan — keine Verrechnung.",
billingPeriod=BillingPeriodEnum.NONE, billingPeriod=BillingPeriodEnum.NONE,
autoRenew=False, autoRenew=False,
maxUsers=None, maxUsers=None,
maxFeatureInstances=None, maxFeatureInstances=None,
includedModules=0,
maxDataVolumeMB=None, maxDataVolumeMB=None,
budgetAiCHF=0.0, budgetAiCHF=0.0,
budgetAiPerUserCHF=0.0,
), ),
"TRIAL_7D": SubscriptionPlan( "TRIAL_14D": SubscriptionPlan(
planKey="TRIAL_7D", planKey="TRIAL_14D",
selectableByUser=False, selectableByUser=False,
title={"en": "Free Trial (7 days)", "de": "Gratis-Testphase (7 Tage)", "fr": "Essai gratuit (7 jours)"}, title="Gratis-Testphase (14 Tage)",
description={ description="14 Tage kostenlos testen — 1 User, 2 Module inklusive, CHF 25 AI-Budget.",
"en": "Try the platform for 7 days — 1 user, up to 3 feature instances, 5 CHF AI budget included.",
"de": "Plattform 7 Tage testen — 1 User, bis zu 3 Feature-Instanzen, 5 CHF AI-Budget inklusive.",
},
billingPeriod=BillingPeriodEnum.NONE, billingPeriod=BillingPeriodEnum.NONE,
autoRenew=False, autoRenew=False,
maxUsers=1, maxUsers=1,
maxFeatureInstances=3, maxFeatureInstances=2,
trialDays=7, includedModules=2,
maxDataVolumeMB=500, trialDays=14,
budgetAiCHF=5.0, maxDataVolumeMB=1024,
successorPlanKey="STANDARD_MONTHLY", budgetAiCHF=25.0,
budgetAiPerUserCHF=25.0,
successorPlanKey="STARTER_MONTHLY",
), ),
"STANDARD_MONTHLY": SubscriptionPlan( "STARTER_MONTHLY": SubscriptionPlan(
planKey="STANDARD_MONTHLY", planKey="STARTER_MONTHLY",
selectableByUser=True, selectableByUser=True,
title={"en": "Standard (Monthly)", "de": "Standard (Monatlich)", "fr": "Standard (Mensuel)"}, title="Starter (Monatlich)",
description={ description="CHF 69 pro User/Monat. 2 Module inklusive, CHF 25 AI-Budget pro User.",
"en": "Usage-based billing per active user and feature instance, billed monthly. Includes 10 CHF AI budget.",
"de": "Nutzungsbasierte Abrechnung pro aktivem User und Feature-Instanz, monatlich. Inkl. 10 CHF AI-Budget.",
},
billingPeriod=BillingPeriodEnum.MONTHLY, billingPeriod=BillingPeriodEnum.MONTHLY,
pricePerUserCHF=79.0, pricePerUserCHF=69.0,
pricePerFeatureInstanceCHF=119.0, pricePerFeatureInstanceCHF=39.0,
maxUsers=None,
includedModules=2,
maxDataVolumeMB=1024, maxDataVolumeMB=1024,
budgetAiCHF=10.0, budgetAiCHF=0.0,
budgetAiPerUserCHF=25.0,
), ),
"STANDARD_YEARLY": SubscriptionPlan( "STARTER_YEARLY": SubscriptionPlan(
planKey="STANDARD_YEARLY", planKey="STARTER_YEARLY",
selectableByUser=True, selectableByUser=True,
title={"en": "Standard (Yearly)", "de": "Standard (Jaehrlich)", "fr": "Standard (Annuel)"}, title="Starter (Jaehrlich)",
description={ description="CHF 690 pro User/Jahr (-17%). 2 Module inklusive, CHF 25 AI-Budget pro User/Monat.",
"en": "Usage-based billing per active user and feature instance, billed yearly. Includes 120 CHF AI budget.",
"de": "Nutzungsbasierte Abrechnung pro aktivem User und Feature-Instanz, jaehrlich. Inkl. 120 CHF AI-Budget.",
},
billingPeriod=BillingPeriodEnum.YEARLY, billingPeriod=BillingPeriodEnum.YEARLY,
pricePerUserCHF=948.0, pricePerUserCHF=690.0,
pricePerFeatureInstanceCHF=1428.0, pricePerFeatureInstanceCHF=39.0,
maxUsers=None,
includedModules=2,
maxDataVolumeMB=1024, maxDataVolumeMB=1024,
budgetAiCHF=120.0, 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,
), ),
} }

View file

@ -173,9 +173,9 @@ class UserConnection(PowerOnModel):
"frontend_readonly": True, "frontend_readonly": True,
"frontend_required": False, "frontend_required": False,
"frontend_options": [ "frontend_options": [
{"value": "active", "label": {"en": "Active", "fr": "Actif"}}, {"value": "active", "label": "Active"},
{"value": "expired", "label": {"en": "Expired", "fr": "Expiré"}}, {"value": "expired", "label": "Expired"},
{"value": "none", "label": {"en": "None", "fr": "Aucun"}}, {"value": "none", "label": "None"},
], ],
"label": "Verbindungsstatus", "label": "Verbindungsstatus",
}, },
@ -249,10 +249,10 @@ class User(PowerOnModel):
"frontend_readonly": False, "frontend_readonly": False,
"frontend_required": True, "frontend_required": True,
"frontend_options": [ "frontend_options": [
{"value": "de", "label": {"en": "Deutsch", "de": "Deutsch", "fr": "Allemand"}}, {"value": "de", "label": "Deutsch"},
{"value": "en", "label": {"en": "English", "de": "Englisch", "fr": "Anglais"}}, {"value": "en", "label": "Englisch"},
{"value": "fr", "label": {"en": "Français", "de": "Französisch", "fr": "Français"}}, {"value": "fr", "label": "Französisch"},
{"value": "it", "label": {"en": "Italiano", "de": "Italienisch", "fr": "Italien"}}, {"value": "it", "label": "Italienisch"},
], ],
"label": "Sprache", "label": "Sprache",
}, },

View file

@ -80,9 +80,9 @@ class UiLanguageSet(PowerOnModel):
"frontend_readonly": False, "frontend_readonly": False,
"frontend_required": True, "frontend_required": True,
"frontend_options": [ "frontend_options": [
{"value": "complete", "label": {"de": "Vollständig", "en": "Complete"}}, {"value": "complete", "label": "Vollständig"},
{"value": "incomplete", "label": {"de": "Unvollständig", "en": "Incomplete"}}, {"value": "incomplete", "label": "Unvollständig"},
{"value": "generating", "label": {"de": "Wird erzeugt", "en": "Generating"}}, {"value": "generating", "label": "Wird erzeugt"},
], ],
}, },
) )

View file

@ -46,10 +46,10 @@ class DataNeutraliserConfig(PowerOnModel):
default="personal", default="personal",
description="Data visibility scope: personal, featureInstance, mandate, global", description="Data visibility scope: personal, featureInstance, mandate, global",
json_schema_extra={"label": "Sichtbarkeit", "frontend_type": "select", "frontend_readonly": False, "frontend_required": False, "frontend_options": [ json_schema_extra={"label": "Sichtbarkeit", "frontend_type": "select", "frontend_readonly": False, "frontend_required": False, "frontend_options": [
{"value": "personal", "label": {"en": "Personal", "de": "Persönlich"}}, {"value": "personal", "label": "Persönlich"},
{"value": "featureInstance", "label": {"en": "Feature Instance", "de": "Feature-Instanz"}}, {"value": "featureInstance", "label": "Feature-Instanz"},
{"value": "mandate", "label": {"en": "Mandate", "de": "Mandant"}}, {"value": "mandate", "label": "Mandant"},
{"value": "global", "label": {"en": "Global", "de": "Global"}}, {"value": "global", "label": "Global"},
]}, ]},
) )
neutralizationStatus: str = Field( neutralizationStatus: str = Field(

View file

@ -35,33 +35,33 @@ class AccountingConnectorAbacus(BaseAccountingConnector):
return "abacus" return "abacus"
def getConnectorLabel(self) -> Dict[str, str]: def getConnectorLabel(self) -> Dict[str, str]:
return {"en": "Abacus ERP", "de": "Abacus ERP", "fr": "Abacus ERP"} return "Abacus ERP"
def getRequiredConfigFields(self) -> List[ConnectorConfigField]: def getRequiredConfigFields(self) -> List[ConnectorConfigField]:
return [ return [
ConnectorConfigField( ConnectorConfigField(
key="apiBaseUrl", key="apiBaseUrl",
label={"en": "API Base URL", "de": "API Base URL", "fr": "URL de base API"}, label="API Base URL",
fieldType="text", fieldType="text",
secret=False, secret=False,
placeholder="e.g. https://abacus.meinefirma.ch/api/entity/v1/", placeholder="e.g. https://abacus.meinefirma.ch/api/entity/v1/",
), ),
ConnectorConfigField( ConnectorConfigField(
key="clientName", key="clientName",
label={"en": "Client Name", "de": "Mandantenname", "fr": "Nom du client"}, label="Mandantenname",
fieldType="text", fieldType="text",
secret=False, secret=False,
placeholder="e.g. 7777", placeholder="e.g. 7777",
), ),
ConnectorConfigField( ConnectorConfigField(
key="clientId", key="clientId",
label={"en": "Client ID", "de": "Client-ID", "fr": "ID Client"}, label="Client-ID",
fieldType="text", fieldType="text",
secret=False, secret=False,
), ),
ConnectorConfigField( ConnectorConfigField(
key="clientSecret", key="clientSecret",
label={"en": "Client Secret", "de": "Client-Secret", "fr": "Secret Client"}, label="Client-Secret",
fieldType="password", fieldType="password",
secret=True, secret=True,
), ),

View file

@ -36,27 +36,27 @@ class AccountingConnectorBexio(BaseAccountingConnector):
return "bexio" return "bexio"
def getConnectorLabel(self) -> Dict[str, str]: def getConnectorLabel(self) -> Dict[str, str]:
return {"en": "Bexio", "de": "Bexio", "fr": "Bexio"} return "Bexio"
def getRequiredConfigFields(self) -> List[ConnectorConfigField]: def getRequiredConfigFields(self) -> List[ConnectorConfigField]:
return [ return [
ConnectorConfigField( ConnectorConfigField(
key="apiBaseUrl", key="apiBaseUrl",
label={"en": "API Base URL", "de": "API Base URL", "fr": "URL de base API"}, label="API Base URL",
fieldType="text", fieldType="text",
secret=False, secret=False,
placeholder="https://api.bexio.com/", placeholder="https://api.bexio.com/",
), ),
ConnectorConfigField( ConnectorConfigField(
key="clientName", key="clientName",
label={"en": "Client Name", "de": "Mandantenname", "fr": "Nom du client"}, label="Mandantenname",
fieldType="text", fieldType="text",
secret=False, secret=False,
placeholder="e.g. poweronag", placeholder="e.g. poweronag",
), ),
ConnectorConfigField( ConnectorConfigField(
key="accessToken", key="accessToken",
label={"en": "Personal Access Token", "de": "Persönlicher Zugriffstoken", "fr": "Jeton d'accès personnel"}, label="Persönlicher Zugriffstoken",
fieldType="password", fieldType="password",
secret=True, secret=True,
placeholder="PAT from developer.bexio.com", placeholder="PAT from developer.bexio.com",

View file

@ -36,27 +36,27 @@ class AccountingConnectorRma(BaseAccountingConnector):
return "rma" return "rma"
def getConnectorLabel(self) -> Dict[str, str]: def getConnectorLabel(self) -> Dict[str, str]:
return {"en": "Run My Accounts", "de": "Run My Accounts", "fr": "Run My Accounts"} return "Run My Accounts"
def getRequiredConfigFields(self) -> List[ConnectorConfigField]: def getRequiredConfigFields(self) -> List[ConnectorConfigField]:
return [ return [
ConnectorConfigField( ConnectorConfigField(
key="apiBaseUrl", key="apiBaseUrl",
label={"en": "API Base URL", "de": "API Base URL", "fr": "URL de base API"}, label="API Base URL",
fieldType="text", fieldType="text",
secret=False, secret=False,
placeholder="https://service.runmyaccounts.com/api/latest/clients/", placeholder="https://service.runmyaccounts.com/api/latest/clients/",
), ),
ConnectorConfigField( ConnectorConfigField(
key="clientName", key="clientName",
label={"en": "Client Name", "de": "Mandantenname", "fr": "Nom du client"}, label="Mandantenname",
fieldType="text", fieldType="text",
secret=False, secret=False,
placeholder="e.g. meinefirma", placeholder="e.g. meinefirma",
), ),
ConnectorConfigField( ConnectorConfigField(
key="apiKey", key="apiKey",
label={"en": "API Key", "de": "API-Schlüssel", "fr": "Clé API"}, label="API-Schlüssel",
fieldType="password", fieldType="password",
secret=True, secret=True,
), ),

View file

@ -470,10 +470,10 @@ class TrusteePosition(PowerOnModel):
"frontend_readonly": False, "frontend_readonly": False,
"frontend_required": True, "frontend_required": True,
"frontend_options": [ "frontend_options": [
{"value": "CHF", "label": {"en": "CHF", "fr": "CHF", "de": "CHF"}}, {"value": "CHF", "label": "CHF"},
{"value": "EUR", "label": {"en": "EUR", "fr": "EUR", "de": "EUR"}}, {"value": "EUR", "label": "EUR"},
{"value": "USD", "label": {"en": "USD", "fr": "USD", "de": "USD"}}, {"value": "USD", "label": "USD"},
{"value": "GBP", "label": {"en": "GBP", "fr": "GBP", "de": "GBP"}}, {"value": "GBP", "label": "GBP"},
] ]
} }
) )
@ -495,10 +495,10 @@ class TrusteePosition(PowerOnModel):
"frontend_readonly": False, "frontend_readonly": False,
"frontend_required": True, "frontend_required": True,
"frontend_options": [ "frontend_options": [
{"value": "CHF", "label": {"en": "CHF", "fr": "CHF", "de": "CHF"}}, {"value": "CHF", "label": "CHF"},
{"value": "EUR", "label": {"en": "EUR", "fr": "EUR", "de": "EUR"}}, {"value": "EUR", "label": "EUR"},
{"value": "USD", "label": {"en": "USD", "fr": "USD", "de": "USD"}}, {"value": "USD", "label": "USD"},
{"value": "GBP", "label": {"en": "GBP", "fr": "GBP", "de": "GBP"}}, {"value": "GBP", "label": "GBP"},
] ]
} }
) )
@ -590,11 +590,11 @@ class TrusteePosition(PowerOnModel):
"frontend_readonly": True, "frontend_readonly": True,
"frontend_required": False, "frontend_required": False,
"frontend_options": [ "frontend_options": [
{"value": "invoice", "label": {"en": "Invoice", "fr": "Facture", "de": "Rechnung"}}, {"value": "invoice", "label": "Rechnung"},
{"value": "expense_receipt", "label": {"en": "Expense Receipt", "fr": "Reçu", "de": "Beleg"}}, {"value": "expense_receipt", "label": "Beleg"},
{"value": "bank_document", "label": {"en": "Bank Statement", "fr": "Relevé bancaire", "de": "Bankauszug"}}, {"value": "bank_document", "label": "Bankauszug"},
{"value": "contract", "label": {"en": "Contract", "fr": "Contrat", "de": "Vertrag"}}, {"value": "contract", "label": "Vertrag"},
{"value": "unknown", "label": {"en": "Other", "fr": "Autre", "de": "Sonstige"}}, {"value": "unknown", "label": "Sonstige"},
] ]
} }
) )

View file

@ -556,7 +556,7 @@ def initRoles(db: DatabaseConnector) -> None:
), ),
Role( Role(
roleLabel="user", roleLabel="user",
description="Benutzer - Standard-Benutzer mit Zugriff auf eigene Datensätze", description=coerce_text_multilingual("Benutzer - Standard-Benutzer mit Zugriff auf eigene Datensätze"),
mandateId=None, # Global template role mandateId=None, # Global template role
featureInstanceId=None, featureInstanceId=None,
featureCode=None, featureCode=None,

View file

@ -1997,6 +1997,8 @@ class AppObjects:
self._ensureUserBillingAccount(userId, mandateId) self._ensureUserBillingAccount(userId, mandateId)
self._syncSubscriptionQuantity(mandateId) self._syncSubscriptionQuantity(mandateId)
if not skipCapacityCheck:
self._adjustAiBudgetForUserChange(mandateId, delta=+1)
cleanedRecord = dict(createdRecord) cleanedRecord = dict(createdRecord)
return UserMandate(**cleanedRecord) return UserMandate(**cleanedRecord)
@ -2060,6 +2062,23 @@ class AppObjects:
raise raise
logger.debug(f"Subscription quantity sync skipped: {e}") logger.debug(f"Subscription quantity sync skipped: {e}")
def _adjustAiBudgetForUserChange(self, mandateId: str, delta: int) -> None:
"""Pro-rata AI budget credit/debit when a user is added or removed mid-cycle."""
try:
from modules.interfaces.interfaceDbSubscription import getInterface as getSubInterface
from modules.interfaces.interfaceDbBilling import getInterface as getBillingInterface
from modules.security.rootAccess import getRootUser
rootUser = getRootUser()
subIf = getSubInterface(rootUser, mandateId)
operative = subIf.getOperativeForMandate(mandateId)
if not operative:
return
planKey = operative.get("planKey", "")
billingIf = getBillingInterface(rootUser)
billingIf.adjustAiBudgetForUserChange(mandateId, planKey, delta)
except Exception as e:
logger.debug(f"AI budget adjustment skipped: {e}")
def deleteUserMandate(self, userId: str, mandateId: str) -> bool: def deleteUserMandate(self, userId: str, mandateId: str) -> bool:
""" """
Delete a UserMandate record (remove user from mandate). Delete a UserMandate record (remove user from mandate).
@ -2097,7 +2116,10 @@ class AppObjects:
if accId: if accId:
self.db.recordDelete(FeatureAccess, accId) self.db.recordDelete(FeatureAccess, accId)
return self.db.recordDelete(UserMandate, existing.id) result = self.db.recordDelete(UserMandate, existing.id)
self._syncSubscriptionQuantity(mandateId)
self._adjustAiBudgetForUserChange(mandateId, delta=-1)
return result
except Exception as e: except Exception as e:
logger.error(f"Error deleting UserMandate: {e}") logger.error(f"Error deleting UserMandate: {e}")
raise ValueError(f"Failed to delete UserMandate: {e}") raise ValueError(f"Failed to delete UserMandate: {e}")

View file

@ -964,33 +964,39 @@ class BillingObjects:
# ========================================================================= # =========================================================================
def creditSubscriptionBudget(self, mandateId: str, planKey: str, periodLabel: str = "") -> Optional[Dict[str, Any]]: def creditSubscriptionBudget(self, mandateId: str, planKey: str, periodLabel: str = "") -> Optional[Dict[str, Any]]:
"""Credit the plan's budgetAiCHF to the mandate pool account. """Credit AI budget to the mandate pool account.
Amount = budgetAiPerUserCHF * activeUsers (dynamic, not the static plan.budgetAiCHF).
Should be called once per billing period (initial activation + each invoice.paid). Should be called once per billing period (initial activation + each invoice.paid).
Returns the created CREDIT transaction or None if budget is 0.""" Returns the created CREDIT transaction or None if budget is 0."""
from modules.datamodels.datamodelSubscription import _getPlan from modules.datamodels.datamodelSubscription import _getPlan
plan = _getPlan(planKey) plan = _getPlan(planKey)
if not plan or not plan.budgetAiCHF or plan.budgetAiCHF <= 0: if not plan or not plan.budgetAiPerUserCHF or plan.budgetAiPerUserCHF <= 0:
return None return None
from modules.interfaces.interfaceDbSubscription import _getRootInterface as _getSubRoot
subRoot = _getSubRoot()
activeUsers = max(subRoot.countActiveUsers(mandateId), 1)
amount = plan.budgetAiPerUserCHF * activeUsers
poolAccount = self.getOrCreateMandateAccount(mandateId) poolAccount = self.getOrCreateMandateAccount(mandateId)
description = f"AI-Budget ({planKey})" description = f"AI-Budget ({planKey}, {activeUsers} User)"
if periodLabel: if periodLabel:
description += f" {periodLabel}" description += f" {periodLabel}"
transaction = BillingTransaction( transaction = BillingTransaction(
accountId=poolAccount["id"], accountId=poolAccount["id"],
transactionType=TransactionTypeEnum.CREDIT, transactionType=TransactionTypeEnum.CREDIT,
amount=plan.budgetAiCHF, amount=amount,
description=description, description=description,
referenceType=ReferenceTypeEnum.SUBSCRIPTION, referenceType=ReferenceTypeEnum.SUBSCRIPTION,
referenceId=mandateId, referenceId=mandateId,
) )
created = self.createTransaction(transaction) created = self.createTransaction(transaction)
logger.info( logger.info(
"AI-Budget credited mandate=%s plan=%s amount=%.2f CHF", "AI-Budget credited mandate=%s plan=%s users=%d amount=%.2f CHF",
mandateId, planKey, plan.budgetAiCHF, mandateId, planKey, activeUsers, amount,
) )
return created return created
@ -1013,6 +1019,71 @@ class BillingObjects:
return self.creditSubscriptionBudget(mandateId, planKey, periodLabel="Erstaktivierung") return self.creditSubscriptionBudget(mandateId, planKey, periodLabel="Erstaktivierung")
def adjustAiBudgetForUserChange(self, mandateId: str, planKey: str, delta: int) -> Optional[Dict[str, Any]]:
"""Pro-rata AI budget adjustment when users are added/removed mid-cycle.
delta > 0: user added -> CREDIT pro-rata portion
delta < 0: user removed -> DEBIT pro-rata portion
"""
from modules.datamodels.datamodelSubscription import _getPlan
plan = _getPlan(planKey)
if not plan or not plan.budgetAiPerUserCHF or plan.budgetAiPerUserCHF <= 0:
return None
from modules.interfaces.interfaceDbSubscription import _getRootInterface as _getSubRoot
subRoot = _getSubRoot()
operative = subRoot.getOperativeForMandate(mandateId)
if not operative:
return None
periodStart = operative.get("currentPeriodStart")
periodEnd = operative.get("currentPeriodEnd")
if not periodStart or not periodEnd:
return None
if isinstance(periodStart, str):
periodStart = datetime.fromisoformat(periodStart)
if isinstance(periodEnd, str):
periodEnd = datetime.fromisoformat(periodEnd)
if periodStart.tzinfo is None:
periodStart = periodStart.replace(tzinfo=timezone.utc)
if periodEnd.tzinfo is None:
periodEnd = periodEnd.replace(tzinfo=timezone.utc)
now = datetime.now(timezone.utc)
totalSeconds = (periodEnd - periodStart).total_seconds()
remainingSeconds = max((periodEnd - now).total_seconds(), 0)
proRataFraction = remainingSeconds / totalSeconds if totalSeconds > 0 else 0
amount = round(abs(delta) * plan.budgetAiPerUserCHF * proRataFraction, 2)
if amount <= 0:
return None
poolAccount = self.getOrCreateMandateAccount(mandateId)
if delta > 0:
txType = TransactionTypeEnum.CREDIT
label = f"AI-Budget pro-rata +{abs(delta)} User ({planKey})"
else:
txType = TransactionTypeEnum.DEBIT
label = f"AI-Budget pro-rata -{abs(delta)} User ({planKey})"
transaction = BillingTransaction(
accountId=poolAccount["id"],
transactionType=txType,
amount=amount,
description=label,
referenceType=ReferenceTypeEnum.SUBSCRIPTION,
referenceId=mandateId,
)
created = self.createTransaction(transaction)
logger.info(
"AI-Budget pro-rata %s mandate=%s delta=%+d amount=%.2f CHF (fraction=%.4f)",
txType.value, mandateId, delta, amount, proRataFraction,
)
return created
# ========================================================================= # =========================================================================
# Workflow Cost Query # Workflow Cost Query
# ========================================================================= # =========================================================================

View file

@ -375,12 +375,16 @@ class SubscriptionObjects:
itemIdUsers = sub.get("stripeItemIdUsers") itemIdUsers = sub.get("stripeItemIdUsers")
itemIdInstances = sub.get("stripeItemIdInstances") itemIdInstances = sub.get("stripeItemIdInstances")
plan = self.getPlan(sub.get("planKey", ""))
includedModules = plan.includedModules if plan else 0
try: try:
from modules.shared.stripeClient import getStripeClient from modules.shared.stripeClient import getStripeClient
stripe = getStripeClient() stripe = getStripeClient()
activeUsers = self.countActiveUsers(mandateId) activeUsers = self.countActiveUsers(mandateId)
activeInstances = self.countActiveFeatureInstances(mandateId) activeInstances = self.countActiveFeatureInstances(mandateId)
billableModules = max(0, activeInstances - includedModules)
if itemIdUsers: if itemIdUsers:
stripe.SubscriptionItem.modify( stripe.SubscriptionItem.modify(
@ -388,10 +392,10 @@ class SubscriptionObjects:
) )
if itemIdInstances: if itemIdInstances:
stripe.SubscriptionItem.modify( stripe.SubscriptionItem.modify(
itemIdInstances, quantity=max(activeInstances, 0), proration_behavior="create_prorations", itemIdInstances, quantity=billableModules, proration_behavior="create_prorations",
) )
logger.info("Stripe quantity synced for sub %s: users=%d, instances=%d", subscriptionId, activeUsers, activeInstances) logger.info("Stripe quantity synced for sub %s: users=%d, modules=%d (total=%d, included=%d)", subscriptionId, activeUsers, billableModules, activeInstances, includedModules)
except Exception as e: except Exception as e:
logger.error("syncQuantityToStripe(%s) failed: %s", subscriptionId, e) logger.error("syncQuantityToStripe(%s) failed: %s", subscriptionId, e)
if raiseOnError: if raiseOnError:

View file

@ -242,7 +242,7 @@ def migrateRootUsers(db, dryRun: bool = False) -> dict:
result = rootInterface._provisionMandateForUser( result = rootInterface._provisionMandateForUser(
userId=userId, userId=userId,
mandateName=f"Home {username}", mandateName=f"Home {username}",
planKey="TRIAL_7D", planKey="TRIAL_14D",
) )
targetMandateId = result["mandateId"] targetMandateId = result["mandateId"]
stats["mandatesCreated"] += 1 stats["mandatesCreated"] += 1

View file

@ -11,7 +11,7 @@ Multi-Tenant Design:
""" """
from fastapi import APIRouter, HTTPException, Depends, Request, Query from fastapi import APIRouter, HTTPException, Depends, Request, Query
from typing import List, Dict, Any, Optional from typing import List, Dict, Any, Optional, Union
from fastapi import status from fastapi import status
import logging import logging
import json import json
@ -33,6 +33,15 @@ routeApiMsg = apiRouteContext("routeAdminFeatures")
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def _featureLabelPlain(label: Union[str, Dict[str, str], None], fallback: str) -> str:
"""Catalog feature label as German i18n key string."""
if isinstance(label, str) and label.strip():
return label
if isinstance(label, dict):
return label.get("de") or label.get("en") or fallback
return fallback
def _feature_instance_display_name(instance: Any) -> str: def _feature_instance_display_name(instance: Any) -> str:
if instance is None: if instance is None:
return "" return ""
@ -185,7 +194,7 @@ def get_my_feature_instances(
featureDef = catalogService.getFeatureDefinition(instance.featureCode) featureDef = catalogService.getFeatureDefinition(instance.featureCode)
featuresMap[featureKey] = { featuresMap[featureKey] = {
"code": instance.featureCode, "code": instance.featureCode,
"label": featureDef.get("label", {"de": instance.featureCode, "en": instance.featureCode}) if featureDef else {"de": instance.featureCode, "en": instance.featureCode}, "label": _featureLabelPlain(featureDef.get("label") if featureDef else None, instance.featureCode),
"icon": featureDef.get("icon", "folder") if featureDef else "folder", "icon": featureDef.get("icon", "folder") if featureDef else "folder",
"instances": [], "instances": [],
"_mandateId": mandateId # Temporary for grouping "_mandateId": mandateId # Temporary for grouping

View file

@ -329,7 +329,7 @@ def create_mandate(
from modules.interfaces.interfaceDbSubscription import _getRootInterface as _getSubRoot from modules.interfaces.interfaceDbSubscription import _getRootInterface as _getSubRoot
from datetime import datetime, timezone, timedelta from datetime import datetime, timezone, timedelta
planKey = mandateData.get("planKey", "TRIAL_7D") planKey = mandateData.get("planKey", "TRIAL_14D")
plan = BUILTIN_PLANS.get(planKey) plan = BUILTIN_PLANS.get(planKey)
if plan: if plan:
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)

View file

@ -53,6 +53,8 @@ router = APIRouter(
_MIN_AI_BILLING_ESTIMATE_CHF = 0.01 _MIN_AI_BILLING_ESTIMATE_CHF = 0.01
_TRANSLATE_BATCH_SIZE = 80 _TRANSLATE_BATCH_SIZE = 80
_TRANSLATE_BATCH_PAUSE_S = 2.0
_TRANSLATE_RATE_LIMIT_MAX_RETRIES = 3
_PROTECTED_CODES = frozenset({"xx"}) _PROTECTED_CODES = frozenset({"xx"})
@ -236,7 +238,7 @@ async def _translateBatch(
f"Kein Markdown, kein Kommentar." f"Kein Markdown, kein Kommentar."
) )
request = AiCallRequest( aiRequest = AiCallRequest(
prompt=f"Übersetze diese UI-Labels:\n{jsonPayload}", prompt=f"Übersetze diese UI-Labels:\n{jsonPayload}",
context=systemPrompt, context=systemPrompt,
options=AiCallOptions( options=AiCallOptions(
@ -252,26 +254,50 @@ async def _translateBatch(
if billingCallback: if billingCallback:
aiObjects.billingCallback = billingCallback aiObjects.billingCallback = billingCallback
try: batchDone = False
response = await aiObjects.callWithTextContext(request) for retryAttempt in range(_TRANSLATE_RATE_LIMIT_MAX_RETRIES):
if response and response.content: try:
raw = response.content.strip() response = await aiObjects.callWithTextContext(aiRequest)
if raw.startswith("```"): if response and response.content:
raw = re.sub(r"^```[a-z]*\n?", "", raw) raw = response.content.strip()
raw = re.sub(r"\n?```$", "", raw) if raw.startswith("```"):
parsed = json.loads(raw) raw = re.sub(r"^```[a-z]*\n?", "", raw)
if isinstance(parsed, dict): raw = re.sub(r"\n?```$", "", raw)
result.update(parsed) parsed = json.loads(raw)
if isinstance(parsed, dict):
result.update(parsed)
else:
logger.warning("i18n AI batch %d/%d returned non-dict", batchIdx + 1, totalBatches)
else: else:
logger.warning("i18n AI batch %d/%d returned non-dict", batchIdx + 1, totalBatches) logger.warning("i18n AI batch %d/%d empty response", batchIdx + 1, totalBatches)
else: batchDone = True
logger.warning("i18n AI batch %d/%d empty response", batchIdx + 1, totalBatches) break
except json.JSONDecodeError as je: except json.JSONDecodeError as je:
logger.error("i18n AI batch %d/%d JSON parse error: %s", batchIdx + 1, totalBatches, je) logger.error("i18n AI batch %d/%d JSON parse error: %s", batchIdx + 1, totalBatches, je)
except Exception as e: batchDone = True
logger.error("i18n AI batch %d/%d failed: %s", batchIdx + 1, totalBatches, e) break
finally: except Exception as e:
aiObjects.billingCallback = None errStr = str(e)
if "rate_limit" in errStr.lower() or "429" in errStr or "Rate limit" in errStr:
waitSec = _TRANSLATE_BATCH_PAUSE_S * (2 ** retryAttempt)
logger.warning(
"i18n AI batch %d/%d rate-limited (attempt %d/%d), waiting %.1fs",
batchIdx + 1, totalBatches, retryAttempt + 1,
_TRANSLATE_RATE_LIMIT_MAX_RETRIES, waitSec,
)
await asyncio.sleep(waitSec)
continue
logger.error("i18n AI batch %d/%d failed: %s", batchIdx + 1, totalBatches, e)
batchDone = True
break
finally:
aiObjects.billingCallback = None
if not batchDone:
logger.error("i18n AI batch %d/%d exhausted rate-limit retries", batchIdx + 1, totalBatches)
if batchIdx < totalBatches - 1:
await asyncio.sleep(_TRANSLATE_BATCH_PAUSE_S)
_matchCapitalization(keysToTranslate, result) _matchCapitalization(keysToTranslate, result)
return result return result

View file

@ -214,7 +214,7 @@ def _ensureHomeMandate(rootInterface, user) -> None:
rootInterface._provisionMandateForUser( rootInterface._provisionMandateForUser(
userId=userId, userId=userId,
mandateName=homeMandateName, mandateName=homeMandateName,
planKey="TRIAL_7D", planKey="TRIAL_14D",
) )
logger.info(f"Created Home mandate '{homeMandateName}' for user {user.username}") logger.info(f"Created Home mandate '{homeMandateName}' for user {user.username}")
@ -401,7 +401,7 @@ def register_user(
Unified registration path: invited users skip Home mandate provisioning Unified registration path: invited users skip Home mandate provisioning
(they join the inviting mandate instead). Non-invited users get a Home (they join the inviting mandate instead). Non-invited users get a Home
mandate with TRIAL_7D. Company mandate creation is deferred to onboarding. mandate with TRIAL_14D. Company mandate creation is deferred to onboarding.
Args: Args:
userData: User data (username, email, fullName, language) userData: User data (username, email, fullName, language)
@ -468,7 +468,7 @@ def register_user(
provisionResult = appInterface._provisionMandateForUser( provisionResult = appInterface._provisionMandateForUser(
userId=str(user.id), userId=str(user.id),
mandateName=homeMandateName, mandateName=homeMandateName,
planKey="TRIAL_7D", planKey="TRIAL_14D",
) )
logger.info(f"Provisioned Home mandate for user {user.id}: {provisionResult}") logger.info(f"Provisioned Home mandate for user {user.id}: {provisionResult}")
except Exception as provErr: except Exception as provErr:
@ -836,7 +836,7 @@ def onboarding_provision(
request: Request, request: Request,
currentUser: User = Depends(getCurrentUser), currentUser: User = Depends(getCurrentUser),
companyName: str = Body(None, embed=True), companyName: str = Body(None, embed=True),
planKey: str = Body("TRIAL_7D", embed=True), planKey: str = Body("TRIAL_14D", embed=True),
) -> Dict[str, Any]: ) -> Dict[str, Any]:
"""Post-login onboarding: create a mandate for the user. """Post-login onboarding: create a mandate for the user.
@ -884,8 +884,8 @@ def onboarding_provision(
mandateName = (companyName.strip() if companyName and companyName.strip() mandateName = (companyName.strip() if companyName and companyName.strip()
else f"Home {currentUser.username}") else f"Home {currentUser.username}")
if planKey not in ("TRIAL_7D", "STANDARD_MONTHLY", "STANDARD_YEARLY"): if planKey not in ("TRIAL_14D", "STARTER_MONTHLY", "STARTER_YEARLY", "PROFESSIONAL_MONTHLY", "PROFESSIONAL_YEARLY", "MAX_MONTHLY", "MAX_YEARLY"):
planKey = "TRIAL_7D" planKey = "TRIAL_14D"
result = appInterface._provisionMandateForUser( result = appInterface._provisionMandateForUser(
userId=userId, userId=userId,

View file

@ -7,7 +7,7 @@ in the user's explicit mandate. Supports Orphan Control.
""" """
from fastapi import APIRouter, HTTPException, Depends, Request from fastapi import APIRouter, HTTPException, Depends, Request
from typing import List, Dict, Any, Optional from typing import List, Dict, Any, Optional, Union
from fastapi import status from fastapi import status
import logging import logging
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
@ -28,6 +28,15 @@ routeApiMsg = apiRouteContext("routeStore")
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def _storeLabelText(label: Union[str, Dict[str, str], None], fallback: str) -> str:
"""Normalize catalog label to German i18n key string."""
if isinstance(label, str) and label.strip():
return label
if isinstance(label, dict):
return label.get("de") or label.get("en") or fallback
return fallback
router = APIRouter( router = APIRouter(
prefix="/api/store", prefix="/api/store",
tags=["Store"], tags=["Store"],
@ -51,9 +60,9 @@ class StoreDeactivateRequest(BaseModel):
class StoreFeatureResponse(BaseModel): class StoreFeatureResponse(BaseModel):
"""Response model for a store feature.""" """Response model for a store feature."""
featureCode: str featureCode: str
label: Dict[str, str] label: str
icon: str icon: str
description: Dict[str, str] = {} description: str = ""
instances: List[Dict[str, Any]] = [] instances: List[Dict[str, Any]] = []
canActivate: bool canActivate: bool
@ -244,7 +253,9 @@ def getSubscriptionInfo(
"operative": operative is not None, "operative": operative is not None,
"maxDataVolumeMB": plan.maxDataVolumeMB if plan else None, "maxDataVolumeMB": plan.maxDataVolumeMB if plan else None,
"maxFeatureInstances": plan.maxFeatureInstances if plan else None, "maxFeatureInstances": plan.maxFeatureInstances if plan else None,
"includedModules": plan.includedModules if plan else 0,
"budgetAiCHF": plan.budgetAiCHF if plan else None, "budgetAiCHF": plan.budgetAiCHF if plan else None,
"budgetAiPerUserCHF": plan.budgetAiPerUserCHF if plan else None,
"currentFeatureInstances": len(currentInstances), "currentFeatureInstances": len(currentInstances),
"trialEndsAt": sub.get("trialEndsAt"), "trialEndsAt": sub.get("trialEndsAt"),
} }
@ -287,8 +298,9 @@ def listStoreFeatures(
instances = _getUserInstancesForFeature(db, userId, featureCode, userMandateIds) instances = _getUserInstancesForFeature(db, userId, featureCode, userMandateIds)
result.append(StoreFeatureResponse( result.append(StoreFeatureResponse(
featureCode=featureCode, featureCode=featureCode,
label=featureDef.get("label", {}), label=_storeLabelText(featureDef.get("label"), featureCode),
icon=featureDef.get("icon", "mdi-puzzle"), icon=featureDef.get("icon", "mdi-puzzle"),
description=_storeLabelText(featureDef.get("description"), ""),
instances=instances, instances=instances,
canActivate=True, canActivate=True,
)) ))
@ -361,7 +373,9 @@ def activateStoreFeature(
planKey = operative.get("planKey", "") planKey = operative.get("planKey", "")
plan = BUILTIN_PLANS.get(planKey) plan = BUILTIN_PLANS.get(planKey)
hasStripeIds = bool(operative.get("stripeSubscriptionId") and operative.get("stripeItemIdInstances")) hasStripeIds = bool(operative.get("stripeSubscriptionId") and operative.get("stripeItemIdInstances"))
isBillable = hasStripeIds and plan is not None and (plan.pricePerFeatureInstanceCHF or 0) > 0 currentInstances = db.getRecordset(FeatureInstance, recordFilter={"mandateId": mandateId})
willExceedIncluded = len(currentInstances) >= (plan.includedModules if plan else 0)
isBillable = hasStripeIds and plan is not None and (plan.pricePerFeatureInstanceCHF or 0) > 0 and willExceedIncluded
# ── 2. Capacity check ─────────────────────────────────────────── # ── 2. Capacity check ───────────────────────────────────────────
if plan and plan.maxFeatureInstances is not None: if plan and plan.maxFeatureInstances is not None:
@ -369,12 +383,12 @@ def activateStoreFeature(
if len(currentInstances) >= plan.maxFeatureInstances: if len(currentInstances) >= plan.maxFeatureInstances:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_402_PAYMENT_REQUIRED, status_code=status.HTTP_402_PAYMENT_REQUIRED,
detail=f"Feature-Instanz-Limit erreicht ({plan.maxFeatureInstances}). Bitte Plan upgraden.", detail=f"Modul-Limit erreicht ({plan.maxFeatureInstances}). Bitte Plan upgraden.",
) )
# ── 3. Provision instance ─────────────────────────────────────── # ── 3. Provision instance ───────────────────────────────────────
featureInterface = getFeatureInterface(db) featureInterface = getFeatureInterface(db)
featureLabel = featureDef.get("label", {}).get("en", featureCode) featureLabel = _storeLabelText(featureDef.get("label"), featureCode)
instance = featureInterface.createFeatureInstance( instance = featureInterface.createFeatureInstance(
featureCode=featureCode, featureCode=featureCode,
mandateId=mandateId, mandateId=mandateId,
@ -400,7 +414,7 @@ def activateStoreFeature(
_rollbackInstance(db, instanceId) _rollbackInstance(db, instanceId)
raise HTTPException( raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Keine Feature-Admin-Rolle für {featureCode} gefunden — Rollback.", detail=f"Keine Admin-Rolle für Modul {featureCode} gefunden — Rollback.",
) )
rootInterface.createFeatureAccess(userId, instanceId, roleIds=[adminRoleId]) rootInterface.createFeatureAccess(userId, instanceId, roleIds=[adminRoleId])
@ -535,7 +549,7 @@ def _notifyFeatureActivation(
priceLine = f"Kosten: CHF {plan.pricePerFeatureInstanceCHF:.2f} / {plan.billingPeriod.value} (anteilig via Stripe-Proration)." priceLine = f"Kosten: CHF {plan.pricePerFeatureInstanceCHF:.2f} / {plan.billingPeriod.value} (anteilig via Stripe-Proration)."
bodyParagraphs = [ bodyParagraphs = [
f"Die Feature-Instanz «{featureLabel}» ({featureCode}) wurde soeben für Ihren Mandanten aktiviert.", f"Das Modul «{featureLabel}» ({featureCode}) wurde soeben für Ihren Mandanten aktiviert.",
] ]
if priceLine: if priceLine:
bodyParagraphs.append(priceLine) bodyParagraphs.append(priceLine)
@ -543,8 +557,8 @@ def _notifyFeatureActivation(
notifyMandateAdmins( notifyMandateAdmins(
mandateId=mandateId, mandateId=mandateId,
subject=f"Feature aktiviert: {featureLabel}", subject=f"Modul aktiviert: {featureLabel}",
headline="Neue Feature-Instanz aktiviert", headline="Neues Modul aktiviert",
bodyParagraphs=bodyParagraphs, bodyParagraphs=bodyParagraphs,
) )
except Exception as e: except Exception as e:

View file

@ -394,14 +394,16 @@ def _buildEnrichedSubscriptions() -> List[Dict[str, Any]]:
plan = BUILTIN_PLANS.get(planKey) plan = BUILTIN_PLANS.get(planKey)
sub["mandateName"] = mandateNames.get(mid, mid[:8]) sub["mandateName"] = mandateNames.get(mid, mid[:8])
sub["planTitle"] = (plan.title.get("de") or plan.title.get("en") or planKey) if plan else planKey sub["planTitle"] = (plan.title or planKey) if plan else planKey
if sub.get("status") in operativeValues: if sub.get("status") in operativeValues:
userPrice = sub.get("snapshotPricePerUserCHF", 0) or 0 userPrice = sub.get("snapshotPricePerUserCHF", 0) or 0
instPrice = sub.get("snapshotPricePerInstanceCHF", 0) or 0 instPrice = sub.get("snapshotPricePerInstanceCHF", 0) or 0
userCount = userCountMap.get(mid, 0) userCount = userCountMap.get(mid, 0)
instanceCount = instanceCountMap.get(mid, 0) instanceCount = instanceCountMap.get(mid, 0)
sub["monthlyRevenueCHF"] = round(userPrice * userCount + instPrice * instanceCount, 2) includedModules = plan.includedModules if plan else 0
billableModules = max(0, instanceCount - includedModules)
sub["monthlyRevenueCHF"] = round(userPrice * userCount + instPrice * billableModules, 2)
sub["activeUsers"] = userCount sub["activeUsers"] = userCount
sub["activeInstances"] = instanceCount sub["activeInstances"] = instanceCount
else: else:

View file

@ -248,12 +248,13 @@ class SubscriptionService:
activeUsers = self._interface.countActiveUsers(mandateId) activeUsers = self._interface.countActiveUsers(mandateId)
activeInstances = self._interface.countActiveFeatureInstances(mandateId) activeInstances = self._interface.countActiveFeatureInstances(mandateId)
billableModules = max(0, activeInstances - plan.includedModules)
lineItems = [] lineItems = []
if priceMapping.stripePriceIdUsers: if priceMapping.stripePriceIdUsers:
lineItems.append({"price": priceMapping.stripePriceIdUsers, "quantity": max(activeUsers, 1)}) lineItems.append({"price": priceMapping.stripePriceIdUsers, "quantity": max(activeUsers, 1)})
if priceMapping.stripePriceIdInstances and activeInstances > 0: if priceMapping.stripePriceIdInstances and billableModules > 0:
lineItems.append({"price": priceMapping.stripePriceIdInstances, "quantity": activeInstances}) lineItems.append({"price": priceMapping.stripePriceIdInstances, "quantity": billableModules})
if not returnUrl: if not returnUrl:
raise ValueError("returnUrl is required for paid subscription checkout") raise ValueError("returnUrl is required for paid subscription checkout")
@ -546,7 +547,7 @@ def _notifySubscriptionChange(
try: try:
from modules.shared.notifyMandateAdmins import notifyMandateAdmins from modules.shared.notifyMandateAdmins import notifyMandateAdmins
planLabel = (plan.title.get("de") or plan.title.get("en") or plan.planKey) if plan else "" planLabel = (plan.title or plan.planKey) if plan else "\u2014"
platformHint = f"Plattform: {platformUrl}" if platformUrl else "" platformHint = f"Plattform: {platformUrl}" if platformUrl else ""
rawHtmlBlock: Optional[str] = None rawHtmlBlock: Optional[str] = None
@ -641,11 +642,12 @@ def _buildInvoiceSummaryHtml(
subInterface = getSubRootInterface() subInterface = getSubRootInterface()
userCount = subInterface.countActiveUsers(mandateId) userCount = subInterface.countActiveUsers(mandateId)
instanceCount = subInterface.countActiveFeatureInstances(mandateId) instanceCount = subInterface.countActiveFeatureInstances(mandateId)
billableModules = max(0, instanceCount - plan.includedModules)
userPrice = plan.pricePerUserCHF userPrice = plan.pricePerUserCHF
instancePrice = plan.pricePerFeatureInstanceCHF instancePrice = plan.pricePerFeatureInstanceCHF
userTotal = userCount * userPrice userTotal = userCount * userPrice
instanceTotal = instanceCount * instancePrice instanceTotal = billableModules * instancePrice
netTotal = userTotal + instanceTotal netTotal = userTotal + instanceTotal
periodLabel = {"MONTHLY": "Monatlich", "YEARLY": "Jährlich"}.get(plan.billingPeriod, plan.billingPeriod) periodLabel = {"MONTHLY": "Monatlich", "YEARLY": "Jährlich"}.get(plan.billingPeriod, plan.billingPeriod)
@ -660,10 +662,10 @@ def _buildInvoiceSummaryHtml(
f'<td style="padding:6px 8px;color:#555;text-align:right;">{userCount} × {_chf(userPrice)}</td>' f'<td style="padding:6px 8px;color:#555;text-align:right;">{userCount} × {_chf(userPrice)}</td>'
f'<td style="padding:6px 0;color:#333;text-align:right;font-weight:600;">{_chf(userTotal)}</td></tr>\n' f'<td style="padding:6px 0;color:#333;text-align:right;font-weight:600;">{_chf(userTotal)}</td></tr>\n'
) )
if instancePrice > 0: if instancePrice > 0 and billableModules > 0:
rows += ( rows += (
f'<tr><td style="padding:6px 0;color:#333;">Feature-Instanzen</td>' f'<tr><td style="padding:6px 0;color:#333;">Module ({instanceCount} total, {plan.includedModules} inkl.)</td>'
f'<td style="padding:6px 8px;color:#555;text-align:right;">{instanceCount} × {_chf(instancePrice)}</td>' f'<td style="padding:6px 8px;color:#555;text-align:right;">{billableModules} × {_chf(instancePrice)}</td>'
f'<td style="padding:6px 0;color:#333;text-align:right;font-weight:600;">{_chf(instanceTotal)}</td></tr>\n' f'<td style="padding:6px 0;color:#333;text-align:right;font-weight:600;">{_chf(instanceTotal)}</td></tr>\n'
) )
@ -807,8 +809,8 @@ class SubscriptionCapacityException(Exception):
) + _SUBSCRIPTION_LIMITS_UI_HINT_DE ) + _SUBSCRIPTION_LIMITS_UI_HINT_DE
elif resourceType == "featureInstances": elif resourceType == "featureInstances":
self.message = ( self.message = (
f"Es sind höchstens {maxAllowed} aktive Feature-Instanzen erlaubt (derzeit {currentCount}). " f"Es sind höchstens {maxAllowed} aktive Module erlaubt (derzeit {currentCount}). "
f"Bitte Abonnement erweitern oder eine Instanz entfernen." f"Bitte Abonnement erweitern oder ein Modul entfernen."
) + _SUBSCRIPTION_LIMITS_UI_HINT_DE ) + _SUBSCRIPTION_LIMITS_UI_HINT_DE
elif resourceType == "dataVolumeMB": elif resourceType == "dataVolumeMB":
self.message = ( self.message = (

View file

@ -3,10 +3,10 @@
""" """
Auto-provision Stripe Products and Prices from the built-in plan catalog. Auto-provision Stripe Products and Prices from the built-in plan catalog.
Creates separate Stripe Products for user licenses and feature instances Creates separate Stripe Products for user licenses and modules
so that invoice line items show clear, descriptive names: so that invoice line items show clear, descriptive names:
- "Benutzer-Lizenzen" - "Benutzer-Lizenzen"
- "Feature-Instanzen" - "Module"
Idempotent safe to call on every startup. Idempotent safe to call on every startup.
@ -279,7 +279,7 @@ def bootstrapStripePrices() -> None:
reconciledInstances = _reconcilePrice( reconciledInstances = _reconcilePrice(
stripe, mapping.stripeProductIdInstances, mapping.stripePriceIdInstances, stripe, mapping.stripeProductIdInstances, mapping.stripePriceIdInstances,
plan.pricePerFeatureInstanceCHF, interval, f"{planKey}Feature-Instanz", plan.pricePerFeatureInstanceCHF, interval, f"{planKey}Modul",
intervalCount, intervalCount,
) )
if reconciledInstances != mapping.stripePriceIdInstances: if reconciledInstances != mapping.stripePriceIdInstances:
@ -320,7 +320,7 @@ def bootstrapStripePrices() -> None:
productIdUsers = _findStripeProduct(stripe, planKey, "users") productIdUsers = _findStripeProduct(stripe, planKey, "users")
if not productIdUsers: if not productIdUsers:
productIdUsers = _createStripeProduct( productIdUsers = _createStripeProduct(
stripe, "Benutzer-Lizenzen", f"Benutzer-Lizenzen für {plan.title.get('de', planKey)}", stripe, "Benutzer-Lizenzen", f"Benutzer-Lizenzen für {plan.title or planKey}",
planKey, "users", planKey, "users",
) )
userCents = int(round(plan.pricePerUserCHF * 100)) userCents = int(round(plan.pricePerUserCHF * 100))
@ -338,7 +338,7 @@ def bootstrapStripePrices() -> None:
productIdInstances = _findStripeProduct(stripe, planKey, "instances") productIdInstances = _findStripeProduct(stripe, planKey, "instances")
if not productIdInstances: if not productIdInstances:
productIdInstances = _createStripeProduct( productIdInstances = _createStripeProduct(
stripe, "Feature-Instanzen", f"Feature-Instanzen für {plan.title.get('de', planKey)}", stripe, "Module", f"Module für {plan.title or planKey}",
planKey, "instances", planKey, "instances",
) )
instCents = int(round(plan.pricePerFeatureInstanceCHF * 100)) instCents = int(round(plan.pricePerFeatureInstanceCHF * 100))
@ -348,7 +348,7 @@ def bootstrapStripePrices() -> None:
if not priceIdInstances: if not priceIdInstances:
priceIdInstances = _createStripePrice( priceIdInstances = _createStripePrice(
stripe, productIdInstances, plan.pricePerFeatureInstanceCHF, interval, stripe, productIdInstances, plan.pricePerFeatureInstanceCHF, interval,
f"{planKey}Feature-Instanz", f"{planKey}Modul",
intervalCount, intervalCount,
) )
_archiveOtherRecurringPrices( _archiveOtherRecurringPrices(

View file

@ -10,7 +10,7 @@ Custom types support dynamic option loading via API endpoints.
""" """
from enum import Enum from enum import Enum
from typing import Dict, Any, Optional from typing import Dict, Optional
class FrontendType(str, Enum): class FrontendType(str, Enum):
@ -100,81 +100,6 @@ CUSTOM_TYPE_OPTIONS_API: Dict[FrontendType, str] = {
FrontendType.CLICKUP_TASK: "clickup.task", FrontendType.CLICKUP_TASK: "clickup.task",
} }
# Mapping of custom types to their description
CUSTOM_TYPE_DESCRIPTIONS: Dict[FrontendType, Dict[str, str]] = {
FrontendType.USER_CONNECTION: {
"en": "User Connection",
"fr": "Connexion utilisateur",
"de": "Benutzerverbindung"
},
FrontendType.DOCUMENT_REFERENCE: {
"en": "Document Reference",
"fr": "Référence de document",
"de": "Dokumentreferenz"
},
FrontendType.WORKFLOW_ACTION: {
"en": "Workflow Action",
"fr": "Action de workflow",
"de": "Workflow-Aktion"
},
FrontendType.SHAREPOINT_FOLDER: {
"en": "SharePoint Folder",
"fr": "Dossier SharePoint",
"de": "SharePoint-Ordner"
},
FrontendType.SHAREPOINT_FILE: {
"en": "SharePoint File",
"fr": "Fichier SharePoint",
"de": "SharePoint-Datei"
},
FrontendType.CLICKUP_LIST: {
"en": "ClickUp List",
"fr": "Liste ClickUp",
"de": "ClickUp-Liste"
},
FrontendType.CLICKUP_TASK: {
"en": "ClickUp Task",
"fr": "Tâche ClickUp",
"de": "ClickUp-Aufgabe"
},
FrontendType.CASE_LIST: {
"en": "Case List",
"fr": "Liste de cas",
"de": "Fallunterscheidung"
},
FrontendType.FIELD_BUILDER: {
"en": "Field Builder",
"fr": "Constructeur de champs",
"de": "Feld-Editor"
},
FrontendType.KEY_VALUE_ROWS: {
"en": "Key-Value Rows",
"fr": "Lignes clé-valeur",
"de": "Schlüssel-Wert-Zeilen"
},
FrontendType.CRON: {
"en": "Cron Expression",
"fr": "Expression cron",
"de": "Cron-Ausdruck"
},
FrontendType.CONDITION: {
"en": "Condition",
"fr": "Condition",
"de": "Bedingung"
},
FrontendType.MAPPING_TABLE: {
"en": "Mapping Table",
"fr": "Table de correspondance",
"de": "Zuordnungstabelle"
},
FrontendType.FILTER_EXPRESSION: {
"en": "Filter Expression",
"fr": "Expression de filtre",
"de": "Filterausdruck"
},
}
def getOptionsApiEndpoint(frontendType: FrontendType) -> Optional[str]: def getOptionsApiEndpoint(frontendType: FrontendType) -> Optional[str]:
""" """
Get the API endpoint for fetching dynamic options for a custom frontend type. Get the API endpoint for fetching dynamic options for a custom frontend type.
@ -200,37 +125,3 @@ def isCustomType(frontendType: FrontendType) -> bool:
""" """
return frontendType in CUSTOM_TYPE_OPTIONS_API return frontendType in CUSTOM_TYPE_OPTIONS_API
def getCustomTypeDescription(frontendType: FrontendType, language: str = "en") -> Optional[str]:
"""
Get the description for a custom frontend type.
Args:
frontendType: The frontend type to get description for
language: Language code (default: "en")
Returns:
Description string or None if not a custom type
"""
descriptions = CUSTOM_TYPE_DESCRIPTIONS.get(frontendType)
if not descriptions:
return None
return descriptions.get(language, descriptions.get("en"))
def registerCustomType(
frontendType: FrontendType,
optionsApiEndpoint: str,
description: Dict[str, str]
) -> None:
"""
Register a new custom frontend type.
Args:
frontendType: The frontend type enum value
optionsApiEndpoint: API endpoint for fetching options (e.g., "custom.type")
description: Multilingual description dict (e.g., {"en": "Description", "fr": "Description"})
"""
CUSTOM_TYPE_OPTIONS_API[frontendType] = optionsApiEndpoint
CUSTOM_TYPE_DESCRIPTIONS[frontendType] = description

View file

@ -38,10 +38,13 @@ except Exception as e:
try: try:
from modules.datamodels.datamodelSubscription import SubscriptionStatusEnum, BUILTIN_PLANS, SubscriptionPlan from modules.datamodels.datamodelSubscription import SubscriptionStatusEnum, BUILTIN_PLANS, SubscriptionPlan
_check("PENDING status exists", hasattr(SubscriptionStatusEnum, "PENDING")) _check("PENDING status exists", hasattr(SubscriptionStatusEnum, "PENDING"))
_check("BUILTIN_PLANS has TRIAL_7D", "TRIAL_7D" in BUILTIN_PLANS) _check("BUILTIN_PLANS has TRIAL_14D", "TRIAL_14D" in BUILTIN_PLANS)
trial = BUILTIN_PLANS["TRIAL_7D"] trial = BUILTIN_PLANS["TRIAL_14D"]
_check("TRIAL_7D has maxDataVolumeMB", hasattr(trial, "maxDataVolumeMB")) _check("TRIAL_14D has maxDataVolumeMB", hasattr(trial, "maxDataVolumeMB"))
_check("TRIAL_7D maxDataVolumeMB=500", trial.maxDataVolumeMB == 500) _check("TRIAL_14D maxDataVolumeMB=1024", trial.maxDataVolumeMB == 1024)
_check("TRIAL_14D has includedModules", hasattr(trial, "includedModules"))
_check("TRIAL_14D includedModules=2", trial.includedModules == 2)
_check("TRIAL_14D trialDays=14", trial.trialDays == 14)
except Exception as e: except Exception as e:
errors.append(f"Phase 1 Subscription: {e}") errors.append(f"Phase 1 Subscription: {e}")
print(f" [FAIL] Phase 1 Subscription: {e}") print(f" [FAIL] Phase 1 Subscription: {e}")