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})
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})

View file

@ -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(

View file

@ -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(

View file

@ -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(

View file

@ -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",
},

View file

@ -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"}
]
}
)

View file

@ -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"}
]}
)

View file

@ -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,
),
}

View file

@ -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",
},

View file

@ -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"},
],
},
)

View file

@ -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(

View file

@ -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,
),

View file

@ -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",

View file

@ -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,
),

View file

@ -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"},
]
}
)

View file

@ -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,

View file

@ -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}")

View file

@ -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
# =========================================================================

View file

@ -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:

View file

@ -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

View file

@ -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

View file

@ -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)

View file

@ -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,8 +254,10 @@ async def _translateBatch(
if billingCallback:
aiObjects.billingCallback = billingCallback
batchDone = False
for retryAttempt in range(_TRANSLATE_RATE_LIMIT_MAX_RETRIES):
try:
response = await aiObjects.callWithTextContext(request)
response = await aiObjects.callWithTextContext(aiRequest)
if response and response.content:
raw = response.content.strip()
if raw.startswith("```"):
@ -266,13 +270,35 @@ async def _translateBatch(
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)
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

View file

@ -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,

View file

@ -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:

View file

@ -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:

View file

@ -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'<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'
)
if instancePrice > 0:
if instancePrice > 0 and billableModules > 0:
rows += (
f'<tr><td style="padding:6px 0;color:#333;">Feature-Instanzen</td>'
f'<td style="padding:6px 8px;color:#555;text-align:right;">{instanceCount} × {_chf(instancePrice)}</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;">{billableModules} × {_chf(instancePrice)}</td>'
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
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 = (

View file

@ -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(

View file

@ -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

View file

@ -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}")