From 87e2e6d401549033c65db8e9d96e1b9f78a5c093 Mon Sep 17 00:00:00 2001
From: ValueOn AG
Date: Fri, 10 Apr 2026 22:44:08 +0200
Subject: [PATCH] decision subscription
---
modules/datamodels/datamodelChat.py | 14 +-
modules/datamodels/datamodelDataSource.py | 8 +-
.../datamodels/datamodelFeatureDataSource.py | 8 +-
modules/datamodels/datamodelFiles.py | 8 +-
modules/datamodels/datamodelMessaging.py | 22 +--
modules/datamodels/datamodelNotification.py | 16 +-
modules/datamodels/datamodelRbac.py | 38 ++--
modules/datamodels/datamodelSubscription.py | 169 ++++++++++++------
modules/datamodels/datamodelUam.py | 14 +-
modules/datamodels/datamodelUiLanguage.py | 6 +-
.../datamodelFeatureNeutralizer.py | 8 +-
.../connectors/accountingConnectorAbacus.py | 10 +-
.../connectors/accountingConnectorBexio.py | 8 +-
.../connectors/accountingConnectorRma.py | 8 +-
.../trustee/datamodelFeatureTrustee.py | 26 +--
modules/interfaces/interfaceBootstrap.py | 2 +-
modules/interfaces/interfaceDbApp.py | 24 ++-
modules/interfaces/interfaceDbBilling.py | 83 ++++++++-
modules/interfaces/interfaceDbSubscription.py | 8 +-
modules/migration/migrateRootUsers.py | 2 +-
modules/routes/routeAdminFeatures.py | 13 +-
modules/routes/routeDataMandates.py | 2 +-
modules/routes/routeI18n.py | 66 ++++---
modules/routes/routeSecurityLocal.py | 12 +-
modules/routes/routeStore.py | 36 ++--
modules/routes/routeSubscription.py | 6 +-
.../mainServiceSubscription.py | 20 ++-
.../serviceSubscription/stripeBootstrap.py | 12 +-
modules/shared/frontendTypes.py | 111 +-----------
tests/test_phase123_basic.py | 11 +-
30 files changed, 441 insertions(+), 330 deletions(-)
diff --git a/modules/datamodels/datamodelChat.py b/modules/datamodels/datamodelChat.py
index f1dc720b..80b4455d 100644
--- a/modules/datamodels/datamodelChat.py
+++ b/modules/datamodels/datamodelChat.py
@@ -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})
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": [
- {"value": "running", "label": {"en": "Running", "fr": "En cours"}},
- {"value": "completed", "label": {"en": "Completed", "fr": "Terminé"}},
- {"value": "stopped", "label": {"en": "Stopped", "fr": "Arrêté"}},
- {"value": "error", "label": {"en": "Error", "fr": "Erreur"}},
+ {"value": "running", "label": "Running"},
+ {"value": "completed", "label": "Completed"},
+ {"value": "stopped", "label": "Stopped"},
+ {"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})
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": [
{
"value": WorkflowModeEnum.WORKFLOW_DYNAMIC.value,
- "label": {"en": "Dynamic", "fr": "Dynamique"},
+ "label": "Dynamic",
},
{
"value": WorkflowModeEnum.WORKFLOW_AUTOMATION.value,
- "label": {"en": "Automation", "fr": "Automatisation"},
+ "label": "Automation",
},
{
"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})
diff --git a/modules/datamodels/datamodelDataSource.py b/modules/datamodels/datamodelDataSource.py
index 441d7e7d..0e0a7d16 100644
--- a/modules/datamodels/datamodelDataSource.py
+++ b/modules/datamodels/datamodelDataSource.py
@@ -71,10 +71,10 @@ class DataSource(PowerOnModel):
default="personal",
description="Data visibility scope: personal, featureInstance, mandate, global",
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": "featureInstance", "label": {"en": "Feature Instance", "de": "Feature-Instanz"}},
- {"value": "mandate", "label": {"en": "Mandate", "de": "Mandant"}},
- {"value": "global", "label": {"en": "Global", "de": "Global"}},
+ {"value": "personal", "label": "Persönlich"},
+ {"value": "featureInstance", "label": "Feature-Instanz"},
+ {"value": "mandate", "label": "Mandant"},
+ {"value": "global", "label": "Global"},
]},
)
neutralize: bool = Field(
diff --git a/modules/datamodels/datamodelFeatureDataSource.py b/modules/datamodels/datamodelFeatureDataSource.py
index 39d03367..3199a054 100644
--- a/modules/datamodels/datamodelFeatureDataSource.py
+++ b/modules/datamodels/datamodelFeatureDataSource.py
@@ -59,10 +59,10 @@ class FeatureDataSource(PowerOnModel):
default="personal",
description="Data visibility scope: personal, featureInstance, mandate, global",
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": "featureInstance", "label": {"en": "Feature Instance", "de": "Feature-Instanz"}},
- {"value": "mandate", "label": {"en": "Mandate", "de": "Mandant"}},
- {"value": "global", "label": {"en": "Global", "de": "Global"}},
+ {"value": "personal", "label": "Persönlich"},
+ {"value": "featureInstance", "label": "Feature-Instanz"},
+ {"value": "mandate", "label": "Mandant"},
+ {"value": "global", "label": "Global"},
]},
)
neutralize: bool = Field(
diff --git a/modules/datamodels/datamodelFiles.py b/modules/datamodels/datamodelFiles.py
index 333120d1..4f843eac 100644
--- a/modules/datamodels/datamodelFiles.py
+++ b/modules/datamodels/datamodelFiles.py
@@ -68,10 +68,10 @@ class FileItem(PowerOnModel):
default="personal",
description="Data visibility scope: personal, featureInstance, mandate, global",
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": "featureInstance", "label": {"en": "Feature Instance", "de": "Feature-Instanz"}},
- {"value": "mandate", "label": {"en": "Mandate", "de": "Mandant"}},
- {"value": "global", "label": {"en": "Global", "de": "Global"}},
+ {"value": "personal", "label": "Persönlich"},
+ {"value": "featureInstance", "label": "Feature-Instanz"},
+ {"value": "mandate", "label": "Mandant"},
+ {"value": "global", "label": "Global"},
]},
)
neutralize: bool = Field(
diff --git a/modules/datamodels/datamodelMessaging.py b/modules/datamodels/datamodelMessaging.py
index d7671da1..1a32a09e 100644
--- a/modules/datamodels/datamodelMessaging.py
+++ b/modules/datamodels/datamodelMessaging.py
@@ -165,10 +165,10 @@ class MessagingSubscriptionRegistration(BaseModel):
"frontend_readonly": False,
"frontend_required": True,
"frontend_options": [
- {"value": "email", "label": {"en": "Email", "fr": "Email"}},
- {"value": "sms", "label": {"en": "SMS", "fr": "SMS"}},
- {"value": "whatsapp", "label": {"en": "WhatsApp", "fr": "WhatsApp"}},
- {"value": "teams_chat", "label": {"en": "Teams Chat", "fr": "Chat Teams"}},
+ {"value": "email", "label": "Email"},
+ {"value": "sms", "label": "SMS"},
+ {"value": "whatsapp", "label": "WhatsApp"},
+ {"value": "teams_chat", "label": "Teams Chat"},
],
"label": "Kanal",
},
@@ -253,10 +253,10 @@ class MessagingDelivery(BaseModel):
"frontend_readonly": True,
"frontend_required": False,
"frontend_options": [
- {"value": "email", "label": {"en": "Email", "fr": "Email"}},
- {"value": "sms", "label": {"en": "SMS", "fr": "SMS"}},
- {"value": "whatsapp", "label": {"en": "WhatsApp", "fr": "WhatsApp"}},
- {"value": "teams_chat", "label": {"en": "Teams Chat", "fr": "Chat Teams"}},
+ {"value": "email", "label": "Email"},
+ {"value": "sms", "label": "SMS"},
+ {"value": "whatsapp", "label": "WhatsApp"},
+ {"value": "teams_chat", "label": "Teams Chat"},
],
"label": "Kanal",
},
@@ -269,9 +269,9 @@ class MessagingDelivery(BaseModel):
"frontend_readonly": True,
"frontend_required": False,
"frontend_options": [
- {"value": "pending", "label": {"en": "Pending", "fr": "En attente"}},
- {"value": "sent", "label": {"en": "Sent", "fr": "Envoyé"}},
- {"value": "failed", "label": {"en": "Failed", "fr": "Échoué"}},
+ {"value": "pending", "label": "Pending"},
+ {"value": "sent", "label": "Sent"},
+ {"value": "failed", "label": "Failed"},
],
"label": "Status",
},
diff --git a/modules/datamodels/datamodelNotification.py b/modules/datamodels/datamodelNotification.py
index 9dfa2b7e..6ff7b52e 100644
--- a/modules/datamodels/datamodelNotification.py
+++ b/modules/datamodels/datamodelNotification.py
@@ -72,10 +72,10 @@ class UserNotification(PowerOnModel):
"frontend_readonly": True,
"frontend_required": True,
"frontend_options": [
- {"value": "invitation", "label": {"en": "Invitation", "de": "Einladung"}},
- {"value": "system", "label": {"en": "System", "de": "System"}},
- {"value": "workflow", "label": {"en": "Workflow", "de": "Workflow"}},
- {"value": "mention", "label": {"en": "Mention", "de": "Erwähnung"}}
+ {"value": "invitation", "label": "Einladung"},
+ {"value": "system", "label": "System"},
+ {"value": "workflow", "label": "Workflow"},
+ {"value": "mention", "label": "Erwähnung"}
]
}
)
@@ -88,10 +88,10 @@ class UserNotification(PowerOnModel):
"frontend_readonly": True,
"frontend_required": False,
"frontend_options": [
- {"value": "unread", "label": {"en": "Unread", "de": "Ungelesen"}},
- {"value": "read", "label": {"en": "Read", "de": "Gelesen"}},
- {"value": "actioned", "label": {"en": "Actioned", "de": "Bearbeitet"}},
- {"value": "dismissed", "label": {"en": "Dismissed", "de": "Verworfen"}}
+ {"value": "unread", "label": "Ungelesen"},
+ {"value": "read", "label": "Gelesen"},
+ {"value": "actioned", "label": "Bearbeitet"},
+ {"value": "dismissed", "label": "Verworfen"}
]
}
)
diff --git a/modules/datamodels/datamodelRbac.py b/modules/datamodels/datamodelRbac.py
index c9829458..1dd69dae 100644
--- a/modules/datamodels/datamodelRbac.py
+++ b/modules/datamodels/datamodelRbac.py
@@ -97,9 +97,9 @@ class AccessRule(PowerOnModel):
context: AccessRuleContext = Field(
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": [
- {"value": "DATA", "label": {"en": "Data", "de": "Daten", "fr": "Données"}},
- {"value": "UI", "label": {"en": "UI", "de": "Oberfläche", "fr": "Interface"}},
- {"value": "RESOURCE", "label": {"en": "Resource", "de": "Ressource", "fr": "Ressource"}}
+ {"value": "DATA", "label": "Daten"},
+ {"value": "UI", "label": "Oberfläche"},
+ {"value": "RESOURCE", "label": "Ressource"}
]}
)
item: Optional[str] = Field(
@@ -116,40 +116,40 @@ class AccessRule(PowerOnModel):
default=None,
description="Read permission level (only for DATA context)",
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": "m", "label": {"en": "My Records", "de": "Meine Datensätze", "fr": "Mes enregistrements"}},
- {"value": "g", "label": {"en": "Group Records", "de": "Gruppen-Datensätze", "fr": "Enregistrements du groupe"}},
- {"value": "n", "label": {"en": "No Access", "de": "Kein Zugriff", "fr": "Aucun accès"}}
+ {"value": "a", "label": "Alle Datensätze"},
+ {"value": "m", "label": "Meine Datensätze"},
+ {"value": "g", "label": "Gruppen-Datensätze"},
+ {"value": "n", "label": "Kein Zugriff"}
]}
)
create: Optional[AccessLevel] = Field(
default=None,
description="Create permission level (only for DATA context)",
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": "m", "label": {"en": "My Records", "de": "Meine Datensätze", "fr": "Mes enregistrements"}},
- {"value": "g", "label": {"en": "Group Records", "de": "Gruppen-Datensätze", "fr": "Enregistrements du groupe"}},
- {"value": "n", "label": {"en": "No Access", "de": "Kein Zugriff", "fr": "Aucun accès"}}
+ {"value": "a", "label": "Alle Datensätze"},
+ {"value": "m", "label": "Meine Datensätze"},
+ {"value": "g", "label": "Gruppen-Datensätze"},
+ {"value": "n", "label": "Kein Zugriff"}
]}
)
update: Optional[AccessLevel] = Field(
default=None,
description="Update permission level (only for DATA context)",
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": "m", "label": {"en": "My Records", "de": "Meine Datensätze", "fr": "Mes enregistrements"}},
- {"value": "g", "label": {"en": "Group Records", "de": "Gruppen-Datensätze", "fr": "Enregistrements du groupe"}},
- {"value": "n", "label": {"en": "No Access", "de": "Kein Zugriff", "fr": "Aucun accès"}}
+ {"value": "a", "label": "Alle Datensätze"},
+ {"value": "m", "label": "Meine Datensätze"},
+ {"value": "g", "label": "Gruppen-Datensätze"},
+ {"value": "n", "label": "Kein Zugriff"}
]}
)
delete: Optional[AccessLevel] = Field(
default=None,
description="Delete permission level (only for DATA context)",
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": "m", "label": {"en": "My Records", "de": "Meine Datensätze", "fr": "Mes enregistrements"}},
- {"value": "g", "label": {"en": "Group Records", "de": "Gruppen-Datensätze", "fr": "Enregistrements du groupe"}},
- {"value": "n", "label": {"en": "No Access", "de": "Kein Zugriff", "fr": "Aucun accès"}}
+ {"value": "a", "label": "Alle Datensätze"},
+ {"value": "m", "label": "Meine Datensätze"},
+ {"value": "g", "label": "Gruppen-Datensätze"},
+ {"value": "n", "label": "Kein Zugriff"}
]}
)
diff --git a/modules/datamodels/datamodelSubscription.py b/modules/datamodels/datamodelSubscription.py
index 16f6789d..73eca60f 100644
--- a/modules/datamodels/datamodelSubscription.py
+++ b/modules/datamodels/datamodelSubscription.py
@@ -69,14 +69,14 @@ class SubscriptionPlan(BaseModel):
json_schema_extra={"label": "Waehlbar"},
)
- title: Dict[str, str] = Field(
- default_factory=dict,
- description="Multilingual title (en/de/fr)",
+ title: str = Field(
+ default="",
+ description="Plan title (i18n key)",
json_schema_extra={"label": "Titel"},
)
- description: Dict[str, str] = Field(
- default_factory=dict,
- description="Multilingual description",
+ description: str = Field(
+ default="",
+ description="Plan description (i18n key)",
json_schema_extra={"label": "Beschreibung"},
)
@@ -97,8 +97,8 @@ class SubscriptionPlan(BaseModel):
)
pricePerFeatureInstanceCHF: float = Field(
default=0.0,
- description="Price per active feature instance per period",
- json_schema_extra={"label": "Preis pro Instanz (CHF)"},
+ description="Price per additional module beyond included (monthly, CHF)",
+ json_schema_extra={"label": "Preis pro Modul (CHF)"},
)
autoRenew: bool = Field(
default=True,
@@ -113,8 +113,13 @@ class SubscriptionPlan(BaseModel):
)
maxFeatureInstances: Optional[int] = Field(
None,
- description="Hard cap on active feature instances (None = unlimited)",
- json_schema_extra={"label": "Max. Instanzen"},
+ description="Hard cap on active modules (None = unlimited)",
+ json_schema_extra={"label": "Max. Module"},
+ )
+ includedModules: int = Field(
+ default=0,
+ description="Number of modules included in plan at no extra charge",
+ json_schema_extra={"label": "Inkl. Module"},
)
trialDays: Optional[int] = Field(
None,
@@ -128,9 +133,14 @@ class SubscriptionPlan(BaseModel):
)
budgetAiCHF: float = Field(
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)"},
)
+ budgetAiPerUserCHF: float = Field(
+ default=0.0,
+ description="AI budget per user per month (CHF). Total = users * this value.",
+ json_schema_extra={"label": "AI-Budget pro User (CHF)"},
+ )
successorPlanKey: Optional[str] = Field(
None,
description="Plan to transition to when trial ends",
@@ -167,8 +177,8 @@ class StripePlanPrice(BaseModel):
)
stripeProductIdInstances: Optional[str] = Field(
None,
- description="Stripe Product ID for feature instances",
- json_schema_extra={"label": "Produkt (Instanzen)"},
+ description="Stripe Product ID for modules",
+ json_schema_extra={"label": "Produkt (Module)"},
)
stripePriceIdUsers: Optional[str] = Field(
None,
@@ -177,8 +187,8 @@ class StripePlanPrice(BaseModel):
)
stripePriceIdInstances: Optional[str] = Field(
None,
- description="Stripe Price ID for instance line item",
- json_schema_extra={"label": "Preis-ID (Instanzen)"},
+ description="Stripe Price ID for module line item",
+ json_schema_extra={"label": "Preis-ID (Module)"},
)
@@ -254,8 +264,8 @@ class MandateSubscription(PowerOnModel):
)
snapshotPricePerInstanceCHF: float = Field(
default=0.0,
- description="Price snapshot at activation",
- json_schema_extra={"label": "Preis/Instanz (CHF)"},
+ description="Price snapshot at activation (per additional module)",
+ json_schema_extra={"label": "Preis/Modul (CHF)"},
)
stripeSubscriptionId: Optional[str] = Field(
@@ -283,59 +293,116 @@ 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."},
+ title="Root (System)",
+ description="Interner Systemplan — keine Verrechnung.",
billingPeriod=BillingPeriodEnum.NONE,
autoRenew=False,
maxUsers=None,
maxFeatureInstances=None,
+ includedModules=0,
maxDataVolumeMB=None,
budgetAiCHF=0.0,
+ budgetAiPerUserCHF=0.0,
),
- "TRIAL_7D": SubscriptionPlan(
- planKey="TRIAL_7D",
+ "TRIAL_14D": SubscriptionPlan(
+ planKey="TRIAL_14D",
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, 5 CHF AI budget included.",
- "de": "Plattform 7 Tage testen — 1 User, bis zu 3 Feature-Instanzen, 5 CHF AI-Budget inklusive.",
- },
+ title="Gratis-Testphase (14 Tage)",
+ description="14 Tage kostenlos testen — 1 User, 2 Module inklusive, CHF 25 AI-Budget.",
billingPeriod=BillingPeriodEnum.NONE,
autoRenew=False,
maxUsers=1,
- maxFeatureInstances=3,
- trialDays=7,
- maxDataVolumeMB=500,
- budgetAiCHF=5.0,
- successorPlanKey="STANDARD_MONTHLY",
+ maxFeatureInstances=2,
+ includedModules=2,
+ trialDays=14,
+ maxDataVolumeMB=1024,
+ budgetAiCHF=25.0,
+ budgetAiPerUserCHF=25.0,
+ successorPlanKey="STARTER_MONTHLY",
),
- "STANDARD_MONTHLY": SubscriptionPlan(
- planKey="STANDARD_MONTHLY",
+ "STARTER_MONTHLY": SubscriptionPlan(
+ planKey="STARTER_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. Includes 10 CHF AI budget.",
- "de": "Nutzungsbasierte Abrechnung pro aktivem User und Feature-Instanz, monatlich. Inkl. 10 CHF AI-Budget.",
- },
+ title="Starter (Monatlich)",
+ description="CHF 69 pro User/Monat. 2 Module inklusive, CHF 25 AI-Budget pro User.",
billingPeriod=BillingPeriodEnum.MONTHLY,
- pricePerUserCHF=79.0,
- pricePerFeatureInstanceCHF=119.0,
+ pricePerUserCHF=69.0,
+ pricePerFeatureInstanceCHF=39.0,
+ maxUsers=None,
+ includedModules=2,
maxDataVolumeMB=1024,
- budgetAiCHF=10.0,
+ budgetAiCHF=0.0,
+ budgetAiPerUserCHF=25.0,
),
- "STANDARD_YEARLY": SubscriptionPlan(
- planKey="STANDARD_YEARLY",
+ "STARTER_YEARLY": SubscriptionPlan(
+ planKey="STARTER_YEARLY",
selectableByUser=True,
- title={"en": "Standard (Yearly)", "de": "Standard (Jaehrlich)", "fr": "Standard (Annuel)"},
- description={
- "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.",
- },
+ title="Starter (Jaehrlich)",
+ description="CHF 690 pro User/Jahr (-17%). 2 Module inklusive, CHF 25 AI-Budget pro User/Monat.",
billingPeriod=BillingPeriodEnum.YEARLY,
- pricePerUserCHF=948.0,
- pricePerFeatureInstanceCHF=1428.0,
+ pricePerUserCHF=690.0,
+ pricePerFeatureInstanceCHF=39.0,
+ maxUsers=None,
+ includedModules=2,
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,
),
}
diff --git a/modules/datamodels/datamodelUam.py b/modules/datamodels/datamodelUam.py
index e33bf7d8..9597eb2f 100644
--- a/modules/datamodels/datamodelUam.py
+++ b/modules/datamodels/datamodelUam.py
@@ -173,9 +173,9 @@ class UserConnection(PowerOnModel):
"frontend_readonly": True,
"frontend_required": False,
"frontend_options": [
- {"value": "active", "label": {"en": "Active", "fr": "Actif"}},
- {"value": "expired", "label": {"en": "Expired", "fr": "Expiré"}},
- {"value": "none", "label": {"en": "None", "fr": "Aucun"}},
+ {"value": "active", "label": "Active"},
+ {"value": "expired", "label": "Expired"},
+ {"value": "none", "label": "None"},
],
"label": "Verbindungsstatus",
},
@@ -249,10 +249,10 @@ class User(PowerOnModel):
"frontend_readonly": False,
"frontend_required": True,
"frontend_options": [
- {"value": "de", "label": {"en": "Deutsch", "de": "Deutsch", "fr": "Allemand"}},
- {"value": "en", "label": {"en": "English", "de": "Englisch", "fr": "Anglais"}},
- {"value": "fr", "label": {"en": "Français", "de": "Französisch", "fr": "Français"}},
- {"value": "it", "label": {"en": "Italiano", "de": "Italienisch", "fr": "Italien"}},
+ {"value": "de", "label": "Deutsch"},
+ {"value": "en", "label": "Englisch"},
+ {"value": "fr", "label": "Französisch"},
+ {"value": "it", "label": "Italienisch"},
],
"label": "Sprache",
},
diff --git a/modules/datamodels/datamodelUiLanguage.py b/modules/datamodels/datamodelUiLanguage.py
index 8154f735..4c589bb3 100644
--- a/modules/datamodels/datamodelUiLanguage.py
+++ b/modules/datamodels/datamodelUiLanguage.py
@@ -80,9 +80,9 @@ class UiLanguageSet(PowerOnModel):
"frontend_readonly": False,
"frontend_required": True,
"frontend_options": [
- {"value": "complete", "label": {"de": "Vollständig", "en": "Complete"}},
- {"value": "incomplete", "label": {"de": "Unvollständig", "en": "Incomplete"}},
- {"value": "generating", "label": {"de": "Wird erzeugt", "en": "Generating"}},
+ {"value": "complete", "label": "Vollständig"},
+ {"value": "incomplete", "label": "Unvollständig"},
+ {"value": "generating", "label": "Wird erzeugt"},
],
},
)
diff --git a/modules/features/neutralization/datamodelFeatureNeutralizer.py b/modules/features/neutralization/datamodelFeatureNeutralizer.py
index 0c353072..cbaae3c4 100644
--- a/modules/features/neutralization/datamodelFeatureNeutralizer.py
+++ b/modules/features/neutralization/datamodelFeatureNeutralizer.py
@@ -46,10 +46,10 @@ class DataNeutraliserConfig(PowerOnModel):
default="personal",
description="Data visibility scope: personal, featureInstance, mandate, global",
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": "featureInstance", "label": {"en": "Feature Instance", "de": "Feature-Instanz"}},
- {"value": "mandate", "label": {"en": "Mandate", "de": "Mandant"}},
- {"value": "global", "label": {"en": "Global", "de": "Global"}},
+ {"value": "personal", "label": "Persönlich"},
+ {"value": "featureInstance", "label": "Feature-Instanz"},
+ {"value": "mandate", "label": "Mandant"},
+ {"value": "global", "label": "Global"},
]},
)
neutralizationStatus: str = Field(
diff --git a/modules/features/trustee/accounting/connectors/accountingConnectorAbacus.py b/modules/features/trustee/accounting/connectors/accountingConnectorAbacus.py
index eec3fef0..51403f70 100644
--- a/modules/features/trustee/accounting/connectors/accountingConnectorAbacus.py
+++ b/modules/features/trustee/accounting/connectors/accountingConnectorAbacus.py
@@ -35,33 +35,33 @@ class AccountingConnectorAbacus(BaseAccountingConnector):
return "abacus"
def getConnectorLabel(self) -> Dict[str, str]:
- return {"en": "Abacus ERP", "de": "Abacus ERP", "fr": "Abacus ERP"}
+ return "Abacus ERP"
def getRequiredConfigFields(self) -> List[ConnectorConfigField]:
return [
ConnectorConfigField(
key="apiBaseUrl",
- label={"en": "API Base URL", "de": "API Base URL", "fr": "URL de base API"},
+ label="API Base URL",
fieldType="text",
secret=False,
placeholder="e.g. https://abacus.meinefirma.ch/api/entity/v1/",
),
ConnectorConfigField(
key="clientName",
- label={"en": "Client Name", "de": "Mandantenname", "fr": "Nom du client"},
+ label="Mandantenname",
fieldType="text",
secret=False,
placeholder="e.g. 7777",
),
ConnectorConfigField(
key="clientId",
- label={"en": "Client ID", "de": "Client-ID", "fr": "ID Client"},
+ label="Client-ID",
fieldType="text",
secret=False,
),
ConnectorConfigField(
key="clientSecret",
- label={"en": "Client Secret", "de": "Client-Secret", "fr": "Secret Client"},
+ label="Client-Secret",
fieldType="password",
secret=True,
),
diff --git a/modules/features/trustee/accounting/connectors/accountingConnectorBexio.py b/modules/features/trustee/accounting/connectors/accountingConnectorBexio.py
index a1e588d6..eadb7d74 100644
--- a/modules/features/trustee/accounting/connectors/accountingConnectorBexio.py
+++ b/modules/features/trustee/accounting/connectors/accountingConnectorBexio.py
@@ -36,27 +36,27 @@ class AccountingConnectorBexio(BaseAccountingConnector):
return "bexio"
def getConnectorLabel(self) -> Dict[str, str]:
- return {"en": "Bexio", "de": "Bexio", "fr": "Bexio"}
+ return "Bexio"
def getRequiredConfigFields(self) -> List[ConnectorConfigField]:
return [
ConnectorConfigField(
key="apiBaseUrl",
- label={"en": "API Base URL", "de": "API Base URL", "fr": "URL de base API"},
+ label="API Base URL",
fieldType="text",
secret=False,
placeholder="https://api.bexio.com/",
),
ConnectorConfigField(
key="clientName",
- label={"en": "Client Name", "de": "Mandantenname", "fr": "Nom du client"},
+ label="Mandantenname",
fieldType="text",
secret=False,
placeholder="e.g. poweronag",
),
ConnectorConfigField(
key="accessToken",
- label={"en": "Personal Access Token", "de": "Persönlicher Zugriffstoken", "fr": "Jeton d'accès personnel"},
+ label="Persönlicher Zugriffstoken",
fieldType="password",
secret=True,
placeholder="PAT from developer.bexio.com",
diff --git a/modules/features/trustee/accounting/connectors/accountingConnectorRma.py b/modules/features/trustee/accounting/connectors/accountingConnectorRma.py
index 15aa7ca9..f3b0ac87 100644
--- a/modules/features/trustee/accounting/connectors/accountingConnectorRma.py
+++ b/modules/features/trustee/accounting/connectors/accountingConnectorRma.py
@@ -36,27 +36,27 @@ class AccountingConnectorRma(BaseAccountingConnector):
return "rma"
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]:
return [
ConnectorConfigField(
key="apiBaseUrl",
- label={"en": "API Base URL", "de": "API Base URL", "fr": "URL de base API"},
+ label="API Base URL",
fieldType="text",
secret=False,
placeholder="https://service.runmyaccounts.com/api/latest/clients/",
),
ConnectorConfigField(
key="clientName",
- label={"en": "Client Name", "de": "Mandantenname", "fr": "Nom du client"},
+ label="Mandantenname",
fieldType="text",
secret=False,
placeholder="e.g. meinefirma",
),
ConnectorConfigField(
key="apiKey",
- label={"en": "API Key", "de": "API-Schlüssel", "fr": "Clé API"},
+ label="API-Schlüssel",
fieldType="password",
secret=True,
),
diff --git a/modules/features/trustee/datamodelFeatureTrustee.py b/modules/features/trustee/datamodelFeatureTrustee.py
index 8d0ed00c..ccb5a407 100644
--- a/modules/features/trustee/datamodelFeatureTrustee.py
+++ b/modules/features/trustee/datamodelFeatureTrustee.py
@@ -470,10 +470,10 @@ class TrusteePosition(PowerOnModel):
"frontend_readonly": False,
"frontend_required": True,
"frontend_options": [
- {"value": "CHF", "label": {"en": "CHF", "fr": "CHF", "de": "CHF"}},
- {"value": "EUR", "label": {"en": "EUR", "fr": "EUR", "de": "EUR"}},
- {"value": "USD", "label": {"en": "USD", "fr": "USD", "de": "USD"}},
- {"value": "GBP", "label": {"en": "GBP", "fr": "GBP", "de": "GBP"}},
+ {"value": "CHF", "label": "CHF"},
+ {"value": "EUR", "label": "EUR"},
+ {"value": "USD", "label": "USD"},
+ {"value": "GBP", "label": "GBP"},
]
}
)
@@ -495,10 +495,10 @@ class TrusteePosition(PowerOnModel):
"frontend_readonly": False,
"frontend_required": True,
"frontend_options": [
- {"value": "CHF", "label": {"en": "CHF", "fr": "CHF", "de": "CHF"}},
- {"value": "EUR", "label": {"en": "EUR", "fr": "EUR", "de": "EUR"}},
- {"value": "USD", "label": {"en": "USD", "fr": "USD", "de": "USD"}},
- {"value": "GBP", "label": {"en": "GBP", "fr": "GBP", "de": "GBP"}},
+ {"value": "CHF", "label": "CHF"},
+ {"value": "EUR", "label": "EUR"},
+ {"value": "USD", "label": "USD"},
+ {"value": "GBP", "label": "GBP"},
]
}
)
@@ -590,11 +590,11 @@ class TrusteePosition(PowerOnModel):
"frontend_readonly": True,
"frontend_required": False,
"frontend_options": [
- {"value": "invoice", "label": {"en": "Invoice", "fr": "Facture", "de": "Rechnung"}},
- {"value": "expense_receipt", "label": {"en": "Expense Receipt", "fr": "Reçu", "de": "Beleg"}},
- {"value": "bank_document", "label": {"en": "Bank Statement", "fr": "Relevé bancaire", "de": "Bankauszug"}},
- {"value": "contract", "label": {"en": "Contract", "fr": "Contrat", "de": "Vertrag"}},
- {"value": "unknown", "label": {"en": "Other", "fr": "Autre", "de": "Sonstige"}},
+ {"value": "invoice", "label": "Rechnung"},
+ {"value": "expense_receipt", "label": "Beleg"},
+ {"value": "bank_document", "label": "Bankauszug"},
+ {"value": "contract", "label": "Vertrag"},
+ {"value": "unknown", "label": "Sonstige"},
]
}
)
diff --git a/modules/interfaces/interfaceBootstrap.py b/modules/interfaces/interfaceBootstrap.py
index b323cb92..ee03ae01 100644
--- a/modules/interfaces/interfaceBootstrap.py
+++ b/modules/interfaces/interfaceBootstrap.py
@@ -556,7 +556,7 @@ def initRoles(db: DatabaseConnector) -> None:
),
Role(
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
featureInstanceId=None,
featureCode=None,
diff --git a/modules/interfaces/interfaceDbApp.py b/modules/interfaces/interfaceDbApp.py
index f025b27c..8fb91fbe 100644
--- a/modules/interfaces/interfaceDbApp.py
+++ b/modules/interfaces/interfaceDbApp.py
@@ -1997,6 +1997,8 @@ class AppObjects:
self._ensureUserBillingAccount(userId, mandateId)
self._syncSubscriptionQuantity(mandateId)
+ if not skipCapacityCheck:
+ self._adjustAiBudgetForUserChange(mandateId, delta=+1)
cleanedRecord = dict(createdRecord)
return UserMandate(**cleanedRecord)
@@ -2060,6 +2062,23 @@ class AppObjects:
raise
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:
"""
Delete a UserMandate record (remove user from mandate).
@@ -2097,7 +2116,10 @@ class AppObjects:
if 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:
logger.error(f"Error deleting UserMandate: {e}")
raise ValueError(f"Failed to delete UserMandate: {e}")
diff --git a/modules/interfaces/interfaceDbBilling.py b/modules/interfaces/interfaceDbBilling.py
index e3229c08..28c9848f 100644
--- a/modules/interfaces/interfaceDbBilling.py
+++ b/modules/interfaces/interfaceDbBilling.py
@@ -964,33 +964,39 @@ class BillingObjects:
# =========================================================================
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).
Returns the created CREDIT transaction or None if budget is 0."""
from modules.datamodels.datamodelSubscription import _getPlan
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
+ from modules.interfaces.interfaceDbSubscription import _getRootInterface as _getSubRoot
+ subRoot = _getSubRoot()
+ activeUsers = max(subRoot.countActiveUsers(mandateId), 1)
+ amount = plan.budgetAiPerUserCHF * activeUsers
+
poolAccount = self.getOrCreateMandateAccount(mandateId)
- description = f"AI-Budget ({planKey})"
+ description = f"AI-Budget ({planKey}, {activeUsers} User)"
if periodLabel:
description += f" – {periodLabel}"
transaction = BillingTransaction(
accountId=poolAccount["id"],
transactionType=TransactionTypeEnum.CREDIT,
- amount=plan.budgetAiCHF,
+ amount=amount,
description=description,
referenceType=ReferenceTypeEnum.SUBSCRIPTION,
referenceId=mandateId,
)
created = self.createTransaction(transaction)
logger.info(
- "AI-Budget credited mandate=%s plan=%s amount=%.2f CHF",
- mandateId, planKey, plan.budgetAiCHF,
+ "AI-Budget credited mandate=%s plan=%s users=%d amount=%.2f CHF",
+ mandateId, planKey, activeUsers, amount,
)
return created
@@ -1013,6 +1019,71 @@ class BillingObjects:
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
# =========================================================================
diff --git a/modules/interfaces/interfaceDbSubscription.py b/modules/interfaces/interfaceDbSubscription.py
index f1d7ccf7..d3943d4b 100644
--- a/modules/interfaces/interfaceDbSubscription.py
+++ b/modules/interfaces/interfaceDbSubscription.py
@@ -375,12 +375,16 @@ class SubscriptionObjects:
itemIdUsers = sub.get("stripeItemIdUsers")
itemIdInstances = sub.get("stripeItemIdInstances")
+ plan = self.getPlan(sub.get("planKey", ""))
+ includedModules = plan.includedModules if plan else 0
+
try:
from modules.shared.stripeClient import getStripeClient
stripe = getStripeClient()
activeUsers = self.countActiveUsers(mandateId)
activeInstances = self.countActiveFeatureInstances(mandateId)
+ billableModules = max(0, activeInstances - includedModules)
if itemIdUsers:
stripe.SubscriptionItem.modify(
@@ -388,10 +392,10 @@ class SubscriptionObjects:
)
if itemIdInstances:
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:
logger.error("syncQuantityToStripe(%s) failed: %s", subscriptionId, e)
if raiseOnError:
diff --git a/modules/migration/migrateRootUsers.py b/modules/migration/migrateRootUsers.py
index 11424987..ebcb1a3e 100644
--- a/modules/migration/migrateRootUsers.py
+++ b/modules/migration/migrateRootUsers.py
@@ -242,7 +242,7 @@ def migrateRootUsers(db, dryRun: bool = False) -> dict:
result = rootInterface._provisionMandateForUser(
userId=userId,
mandateName=f"Home {username}",
- planKey="TRIAL_7D",
+ planKey="TRIAL_14D",
)
targetMandateId = result["mandateId"]
stats["mandatesCreated"] += 1
diff --git a/modules/routes/routeAdminFeatures.py b/modules/routes/routeAdminFeatures.py
index 855a0f80..ddd73ad7 100644
--- a/modules/routes/routeAdminFeatures.py
+++ b/modules/routes/routeAdminFeatures.py
@@ -11,7 +11,7 @@ Multi-Tenant Design:
"""
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
import logging
import json
@@ -33,6 +33,15 @@ routeApiMsg = apiRouteContext("routeAdminFeatures")
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:
if instance is None:
return ""
@@ -185,7 +194,7 @@ def get_my_feature_instances(
featureDef = catalogService.getFeatureDefinition(instance.featureCode)
featuresMap[featureKey] = {
"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",
"instances": [],
"_mandateId": mandateId # Temporary for grouping
diff --git a/modules/routes/routeDataMandates.py b/modules/routes/routeDataMandates.py
index 91b5b1b6..623627c2 100644
--- a/modules/routes/routeDataMandates.py
+++ b/modules/routes/routeDataMandates.py
@@ -329,7 +329,7 @@ def create_mandate(
from modules.interfaces.interfaceDbSubscription import _getRootInterface as _getSubRoot
from datetime import datetime, timezone, timedelta
- planKey = mandateData.get("planKey", "TRIAL_7D")
+ planKey = mandateData.get("planKey", "TRIAL_14D")
plan = BUILTIN_PLANS.get(planKey)
if plan:
now = datetime.now(timezone.utc)
diff --git a/modules/routes/routeI18n.py b/modules/routes/routeI18n.py
index 31813798..1b5fdba3 100644
--- a/modules/routes/routeI18n.py
+++ b/modules/routes/routeI18n.py
@@ -53,6 +53,8 @@ router = APIRouter(
_MIN_AI_BILLING_ESTIMATE_CHF = 0.01
_TRANSLATE_BATCH_SIZE = 80
+_TRANSLATE_BATCH_PAUSE_S = 2.0
+_TRANSLATE_RATE_LIMIT_MAX_RETRIES = 3
_PROTECTED_CODES = frozenset({"xx"})
@@ -236,7 +238,7 @@ async def _translateBatch(
f"Kein Markdown, kein Kommentar."
)
- request = AiCallRequest(
+ aiRequest = AiCallRequest(
prompt=f"Übersetze diese UI-Labels:\n{jsonPayload}",
context=systemPrompt,
options=AiCallOptions(
@@ -252,26 +254,50 @@ async def _translateBatch(
if billingCallback:
aiObjects.billingCallback = billingCallback
- try:
- response = await aiObjects.callWithTextContext(request)
- if response and response.content:
- raw = response.content.strip()
- if raw.startswith("```"):
- raw = re.sub(r"^```[a-z]*\n?", "", raw)
- raw = re.sub(r"\n?```$", "", raw)
- parsed = json.loads(raw)
- if isinstance(parsed, dict):
- result.update(parsed)
+ batchDone = False
+ for retryAttempt in range(_TRANSLATE_RATE_LIMIT_MAX_RETRIES):
+ try:
+ response = await aiObjects.callWithTextContext(aiRequest)
+ if response and response.content:
+ raw = response.content.strip()
+ if raw.startswith("```"):
+ raw = re.sub(r"^```[a-z]*\n?", "", raw)
+ raw = re.sub(r"\n?```$", "", raw)
+ 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:
- logger.warning("i18n AI batch %d/%d returned non-dict", batchIdx + 1, totalBatches)
- else:
- logger.warning("i18n AI batch %d/%d empty response", batchIdx + 1, totalBatches)
- except json.JSONDecodeError as je:
- logger.error("i18n AI batch %d/%d JSON parse error: %s", batchIdx + 1, totalBatches, je)
- except Exception as e:
- logger.error("i18n AI batch %d/%d failed: %s", batchIdx + 1, totalBatches, e)
- finally:
- aiObjects.billingCallback = None
+ logger.warning("i18n AI batch %d/%d empty response", batchIdx + 1, totalBatches)
+ batchDone = True
+ break
+ except json.JSONDecodeError as je:
+ logger.error("i18n AI batch %d/%d JSON parse error: %s", batchIdx + 1, totalBatches, je)
+ batchDone = True
+ break
+ except Exception as e:
+ 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)
return result
diff --git a/modules/routes/routeSecurityLocal.py b/modules/routes/routeSecurityLocal.py
index daa128e0..fa68b5b9 100644
--- a/modules/routes/routeSecurityLocal.py
+++ b/modules/routes/routeSecurityLocal.py
@@ -214,7 +214,7 @@ def _ensureHomeMandate(rootInterface, user) -> None:
rootInterface._provisionMandateForUser(
userId=userId,
mandateName=homeMandateName,
- planKey="TRIAL_7D",
+ planKey="TRIAL_14D",
)
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
(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:
userData: User data (username, email, fullName, language)
@@ -468,7 +468,7 @@ def register_user(
provisionResult = appInterface._provisionMandateForUser(
userId=str(user.id),
mandateName=homeMandateName,
- planKey="TRIAL_7D",
+ planKey="TRIAL_14D",
)
logger.info(f"Provisioned Home mandate for user {user.id}: {provisionResult}")
except Exception as provErr:
@@ -836,7 +836,7 @@ def onboarding_provision(
request: Request,
currentUser: User = Depends(getCurrentUser),
companyName: str = Body(None, embed=True),
- planKey: str = Body("TRIAL_7D", embed=True),
+ planKey: str = Body("TRIAL_14D", embed=True),
) -> Dict[str, Any]:
"""Post-login onboarding: create a mandate for the user.
@@ -884,8 +884,8 @@ def onboarding_provision(
mandateName = (companyName.strip() if companyName and companyName.strip()
else f"Home {currentUser.username}")
- if planKey not in ("TRIAL_7D", "STANDARD_MONTHLY", "STANDARD_YEARLY"):
- planKey = "TRIAL_7D"
+ if planKey not in ("TRIAL_14D", "STARTER_MONTHLY", "STARTER_YEARLY", "PROFESSIONAL_MONTHLY", "PROFESSIONAL_YEARLY", "MAX_MONTHLY", "MAX_YEARLY"):
+ planKey = "TRIAL_14D"
result = appInterface._provisionMandateForUser(
userId=userId,
diff --git a/modules/routes/routeStore.py b/modules/routes/routeStore.py
index e8ffac79..286eed8b 100644
--- a/modules/routes/routeStore.py
+++ b/modules/routes/routeStore.py
@@ -7,7 +7,7 @@ in the user's explicit mandate. Supports Orphan Control.
"""
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
import logging
from pydantic import BaseModel, Field
@@ -28,6 +28,15 @@ routeApiMsg = apiRouteContext("routeStore")
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(
prefix="/api/store",
tags=["Store"],
@@ -51,9 +60,9 @@ class StoreDeactivateRequest(BaseModel):
class StoreFeatureResponse(BaseModel):
"""Response model for a store feature."""
featureCode: str
- label: Dict[str, str]
+ label: str
icon: str
- description: Dict[str, str] = {}
+ description: str = ""
instances: List[Dict[str, Any]] = []
canActivate: bool
@@ -244,7 +253,9 @@ def getSubscriptionInfo(
"operative": operative is not None,
"maxDataVolumeMB": plan.maxDataVolumeMB 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,
+ "budgetAiPerUserCHF": plan.budgetAiPerUserCHF if plan else None,
"currentFeatureInstances": len(currentInstances),
"trialEndsAt": sub.get("trialEndsAt"),
}
@@ -287,8 +298,9 @@ def listStoreFeatures(
instances = _getUserInstancesForFeature(db, userId, featureCode, userMandateIds)
result.append(StoreFeatureResponse(
featureCode=featureCode,
- label=featureDef.get("label", {}),
+ label=_storeLabelText(featureDef.get("label"), featureCode),
icon=featureDef.get("icon", "mdi-puzzle"),
+ description=_storeLabelText(featureDef.get("description"), ""),
instances=instances,
canActivate=True,
))
@@ -361,7 +373,9 @@ def activateStoreFeature(
planKey = operative.get("planKey", "")
plan = BUILTIN_PLANS.get(planKey)
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 ───────────────────────────────────────────
if plan and plan.maxFeatureInstances is not None:
@@ -369,12 +383,12 @@ def activateStoreFeature(
if len(currentInstances) >= plan.maxFeatureInstances:
raise HTTPException(
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 ───────────────────────────────────────
featureInterface = getFeatureInterface(db)
- featureLabel = featureDef.get("label", {}).get("en", featureCode)
+ featureLabel = _storeLabelText(featureDef.get("label"), featureCode)
instance = featureInterface.createFeatureInstance(
featureCode=featureCode,
mandateId=mandateId,
@@ -400,7 +414,7 @@ def activateStoreFeature(
_rollbackInstance(db, instanceId)
raise HTTPException(
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])
@@ -535,7 +549,7 @@ def _notifyFeatureActivation(
priceLine = f"Kosten: CHF {plan.pricePerFeatureInstanceCHF:.2f} / {plan.billingPeriod.value} (anteilig via Stripe-Proration)."
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:
bodyParagraphs.append(priceLine)
@@ -543,8 +557,8 @@ def _notifyFeatureActivation(
notifyMandateAdmins(
mandateId=mandateId,
- subject=f"Feature aktiviert: {featureLabel}",
- headline="Neue Feature-Instanz aktiviert",
+ subject=f"Modul aktiviert: {featureLabel}",
+ headline="Neues Modul aktiviert",
bodyParagraphs=bodyParagraphs,
)
except Exception as e:
diff --git a/modules/routes/routeSubscription.py b/modules/routes/routeSubscription.py
index 2583316d..d2881741 100644
--- a/modules/routes/routeSubscription.py
+++ b/modules/routes/routeSubscription.py
@@ -394,14 +394,16 @@ def _buildEnrichedSubscriptions() -> List[Dict[str, Any]]:
plan = BUILTIN_PLANS.get(planKey)
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:
userPrice = sub.get("snapshotPricePerUserCHF", 0) or 0
instPrice = sub.get("snapshotPricePerInstanceCHF", 0) or 0
userCount = userCountMap.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["activeInstances"] = instanceCount
else:
diff --git a/modules/serviceCenter/services/serviceSubscription/mainServiceSubscription.py b/modules/serviceCenter/services/serviceSubscription/mainServiceSubscription.py
index 89e20112..681070b0 100644
--- a/modules/serviceCenter/services/serviceSubscription/mainServiceSubscription.py
+++ b/modules/serviceCenter/services/serviceSubscription/mainServiceSubscription.py
@@ -248,12 +248,13 @@ class SubscriptionService:
activeUsers = self._interface.countActiveUsers(mandateId)
activeInstances = self._interface.countActiveFeatureInstances(mandateId)
+ billableModules = max(0, activeInstances - plan.includedModules)
lineItems = []
if priceMapping.stripePriceIdUsers:
lineItems.append({"price": priceMapping.stripePriceIdUsers, "quantity": max(activeUsers, 1)})
- if priceMapping.stripePriceIdInstances and activeInstances > 0:
- lineItems.append({"price": priceMapping.stripePriceIdInstances, "quantity": activeInstances})
+ if priceMapping.stripePriceIdInstances and billableModules > 0:
+ lineItems.append({"price": priceMapping.stripePriceIdInstances, "quantity": billableModules})
if not returnUrl:
raise ValueError("returnUrl is required for paid subscription checkout")
@@ -546,7 +547,7 @@ def _notifySubscriptionChange(
try:
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 ""
rawHtmlBlock: Optional[str] = None
@@ -641,11 +642,12 @@ def _buildInvoiceSummaryHtml(
subInterface = getSubRootInterface()
userCount = subInterface.countActiveUsers(mandateId)
instanceCount = subInterface.countActiveFeatureInstances(mandateId)
+ billableModules = max(0, instanceCount - plan.includedModules)
userPrice = plan.pricePerUserCHF
instancePrice = plan.pricePerFeatureInstanceCHF
userTotal = userCount * userPrice
- instanceTotal = instanceCount * instancePrice
+ instanceTotal = billableModules * instancePrice
netTotal = userTotal + instanceTotal
periodLabel = {"MONTHLY": "Monatlich", "YEARLY": "Jährlich"}.get(plan.billingPeriod, plan.billingPeriod)
@@ -660,10 +662,10 @@ def _buildInvoiceSummaryHtml(
f'{userCount} × {_chf(userPrice)} | '
f'{_chf(userTotal)} | \n'
)
- if instancePrice > 0:
+ if instancePrice > 0 and billableModules > 0:
rows += (
- f'| Feature-Instanzen | '
- f'{instanceCount} × {_chf(instancePrice)} | '
+ f'
| Module ({instanceCount} total, {plan.includedModules} inkl.) | '
+ f'{billableModules} × {_chf(instancePrice)} | '
f'{_chf(instanceTotal)} |
\n'
)
@@ -807,8 +809,8 @@ class SubscriptionCapacityException(Exception):
) + _SUBSCRIPTION_LIMITS_UI_HINT_DE
elif resourceType == "featureInstances":
self.message = (
- f"Es sind höchstens {maxAllowed} aktive Feature-Instanzen erlaubt (derzeit {currentCount}). "
- f"Bitte Abonnement erweitern oder eine Instanz entfernen."
+ f"Es sind höchstens {maxAllowed} aktive Module erlaubt (derzeit {currentCount}). "
+ f"Bitte Abonnement erweitern oder ein Modul entfernen."
) + _SUBSCRIPTION_LIMITS_UI_HINT_DE
elif resourceType == "dataVolumeMB":
self.message = (
diff --git a/modules/serviceCenter/services/serviceSubscription/stripeBootstrap.py b/modules/serviceCenter/services/serviceSubscription/stripeBootstrap.py
index 869ab52f..d26ef50e 100644
--- a/modules/serviceCenter/services/serviceSubscription/stripeBootstrap.py
+++ b/modules/serviceCenter/services/serviceSubscription/stripeBootstrap.py
@@ -3,10 +3,10 @@
"""
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:
- "Benutzer-Lizenzen"
- - "Feature-Instanzen"
+ - "Module"
Idempotent — safe to call on every startup.
@@ -279,7 +279,7 @@ def bootstrapStripePrices() -> None:
reconciledInstances = _reconcilePrice(
stripe, mapping.stripeProductIdInstances, mapping.stripePriceIdInstances,
- plan.pricePerFeatureInstanceCHF, interval, f"{planKey} — Feature-Instanz",
+ plan.pricePerFeatureInstanceCHF, interval, f"{planKey} — Modul",
intervalCount,
)
if reconciledInstances != mapping.stripePriceIdInstances:
@@ -320,7 +320,7 @@ def bootstrapStripePrices() -> None:
productIdUsers = _findStripeProduct(stripe, planKey, "users")
if not productIdUsers:
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",
)
userCents = int(round(plan.pricePerUserCHF * 100))
@@ -338,7 +338,7 @@ def bootstrapStripePrices() -> None:
productIdInstances = _findStripeProduct(stripe, planKey, "instances")
if not productIdInstances:
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",
)
instCents = int(round(plan.pricePerFeatureInstanceCHF * 100))
@@ -348,7 +348,7 @@ def bootstrapStripePrices() -> None:
if not priceIdInstances:
priceIdInstances = _createStripePrice(
stripe, productIdInstances, plan.pricePerFeatureInstanceCHF, interval,
- f"{planKey} — Feature-Instanz",
+ f"{planKey} — Modul",
intervalCount,
)
_archiveOtherRecurringPrices(
diff --git a/modules/shared/frontendTypes.py b/modules/shared/frontendTypes.py
index 1a80aa40..9d73ee03 100644
--- a/modules/shared/frontendTypes.py
+++ b/modules/shared/frontendTypes.py
@@ -10,7 +10,7 @@ Custom types support dynamic option loading via API endpoints.
"""
from enum import Enum
-from typing import Dict, Any, Optional
+from typing import Dict, Optional
class FrontendType(str, Enum):
@@ -100,81 +100,6 @@ CUSTOM_TYPE_OPTIONS_API: Dict[FrontendType, str] = {
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]:
"""
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
-
-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
-
diff --git a/tests/test_phase123_basic.py b/tests/test_phase123_basic.py
index d13c4271..49e52abb 100644
--- a/tests/test_phase123_basic.py
+++ b/tests/test_phase123_basic.py
@@ -38,10 +38,13 @@ except Exception as e:
try:
from modules.datamodels.datamodelSubscription import SubscriptionStatusEnum, BUILTIN_PLANS, SubscriptionPlan
_check("PENDING status exists", hasattr(SubscriptionStatusEnum, "PENDING"))
- _check("BUILTIN_PLANS has TRIAL_7D", "TRIAL_7D" in BUILTIN_PLANS)
- trial = BUILTIN_PLANS["TRIAL_7D"]
- _check("TRIAL_7D has maxDataVolumeMB", hasattr(trial, "maxDataVolumeMB"))
- _check("TRIAL_7D maxDataVolumeMB=500", trial.maxDataVolumeMB == 500)
+ _check("BUILTIN_PLANS has TRIAL_14D", "TRIAL_14D" in BUILTIN_PLANS)
+ trial = BUILTIN_PLANS["TRIAL_14D"]
+ _check("TRIAL_14D has maxDataVolumeMB", hasattr(trial, "maxDataVolumeMB"))
+ _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:
errors.append(f"Phase 1 Subscription: {e}")
print(f" [FAIL] Phase 1 Subscription: {e}")