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