decision subscription
This commit is contained in:
parent
be9e47caad
commit
87e2e6d401
30 changed files with 441 additions and 330 deletions
|
|
@ -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})
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
]
|
||||
}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
]}
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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"},
|
||||
],
|
||||
},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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"},
|
||||
]
|
||||
}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
# =========================================================================
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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 = (
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
|
|
|||
Loading…
Reference in a new issue