From 1fdf238aafb1140b5fc06c9ec8c26898a2fec3a2 Mon Sep 17 00:00:00 2001
From: ValueOn AG
Date: Sat, 28 Mar 2026 23:54:11 +0100
Subject: [PATCH] cleaned mandate and unified mandate to be standard type
---
app.py | 4 +-
modules/datamodels/datamodelBilling.py | 64 +---
modules/datamodels/datamodelSubscription.py | 18 +-
modules/datamodels/datamodelUam.py | 29 --
modules/features/chatbot/service.py | 26 +-
.../workspace/routeFeatureWorkspace.py | 50 +++
modules/interfaces/interfaceBootstrap.py | 46 +--
modules/interfaces/interfaceDbApp.py | 83 ++---
modules/interfaces/interfaceDbBilling.py | 309 +++---------------
modules/interfaces/interfaceDbChat.py | 26 ++
modules/interfaces/interfaceDbSubscription.py | 34 ++
modules/migration/migrateRootUsers.py | 3 +-
modules/routes/routeBilling.py | 102 +-----
modules/routes/routeSecurityLocal.py | 108 +++---
modules/routes/routeStore.py | 5 +-
modules/routes/routeSubscription.py | 15 +-
modules/serviceCenter/context.py | 1 +
.../services/serviceAgent/mainServiceAgent.py | 6 +
.../services/serviceAi/mainServiceAi.py | 18 +-
.../serviceBilling/mainServiceBilling.py | 71 +---
.../services/serviceBilling/stripeCheckout.py | 2 +-
tests/test_phase123_basic.py | 11 +-
22 files changed, 366 insertions(+), 665 deletions(-)
diff --git a/app.py b/app.py
index 63a18f94..80a9505c 100644
--- a/app.py
+++ b/app.py
@@ -374,7 +374,7 @@ async def lifespan(app: FastAPI):
if settingsCreated > 0:
logger.info(f"Billing startup: Created {settingsCreated} missing mandate billing settings")
- # Step 2: Ensure all users have billing accounts (for PREPAY_USER mandates)
+ # Step 2: Ensure all users have billing audit accounts
accountsCreated = billingInterface.ensureAllUserAccountsExist()
if accountsCreated > 0:
logger.info(f"Billing startup: Created {accountsCreated} missing user accounts")
@@ -500,7 +500,7 @@ app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
async def _insufficientBalanceHandler(request: Request, exc: Exception):
- """HTTP 402 with structured billing hint (PREPAY_USER vs PREPAY_MANDATE)."""
+ """HTTP 402 with structured billing hint."""
payload = exc.toClientDict() if hasattr(exc, "toClientDict") else {"error": "INSUFFICIENT_BALANCE", "message": str(exc)}
return JSONResponse(status_code=402, content={"detail": payload})
diff --git a/modules/datamodels/datamodelBilling.py b/modules/datamodels/datamodelBilling.py
index a61faa59..a0bb4f88 100644
--- a/modules/datamodels/datamodelBilling.py
+++ b/modules/datamodels/datamodelBilling.py
@@ -11,22 +11,6 @@ from modules.shared.attributeUtils import registerModelLabels
import uuid
-class BillingModelEnum(str, Enum):
- """Billing model types (prepaid only; legacy UNLIMITED in DB maps to PREPAY_MANDATE)."""
- PREPAY_MANDATE = "PREPAY_MANDATE" # Prepaid budget shared by all users in mandate
- PREPAY_USER = "PREPAY_USER" # Prepaid budget per user within mandate
-
-
-# Nur fuer initRootMandateBilling (Root-Mandant PREPAY_USER + Startguthaben in Settings).
-DEFAULT_USER_CREDIT_CHF = 5.0
-
-
-class AccountTypeEnum(str, Enum):
- """Account type for billing accounts."""
- MANDATE = "MANDATE" # Account for entire mandate
- USER = "USER" # Account for specific user within mandate
-
-
class TransactionTypeEnum(str, Enum):
"""Transaction types for billing."""
CREDIT = "CREDIT" # Credit/top-up (positive)
@@ -55,8 +39,7 @@ class BillingAccount(PowerOnModel):
default_factory=lambda: str(uuid.uuid4()), description="Primary key"
)
mandateId: str = Field(..., description="Foreign key to Mandate")
- userId: Optional[str] = Field(None, description="Foreign key to User (only for PREPAY_USER)")
- accountType: AccountTypeEnum = Field(..., description="Account type: MANDATE or USER")
+ userId: Optional[str] = Field(None, description="Foreign key to User (None = mandate pool account, set = user audit account)")
balance: float = Field(default=0.0, description="Current balance in CHF")
warningThreshold: float = Field(default=0.0, description="Warning threshold in CHF")
lastWarningAt: Optional[datetime] = Field(None, description="Last warning sent timestamp")
@@ -70,7 +53,6 @@ registerModelLabels(
"id": {"en": "ID", "de": "ID"},
"mandateId": {"en": "Mandate ID", "de": "Mandanten-ID"},
"userId": {"en": "User ID", "de": "Benutzer-ID"},
- "accountType": {"en": "Account Type", "de": "Kontotyp"},
"balance": {"en": "Balance (CHF)", "de": "Guthaben (CHF)"},
"warningThreshold": {"en": "Warning Threshold (CHF)", "de": "Warnschwelle (CHF)"},
"lastWarningAt": {"en": "Last Warning", "de": "Letzte Warnung"},
@@ -130,27 +112,28 @@ registerModelLabels(
class BillingSettings(BaseModel):
- """Billing settings per mandate."""
+ """Billing settings per mandate. Only PREPAY_MANDATE model."""
id: str = Field(
default_factory=lambda: str(uuid.uuid4()), description="Primary key"
)
mandateId: str = Field(..., description="Foreign key to Mandate (UNIQUE)")
- billingModel: BillingModelEnum = Field(..., description="Billing model")
-
- # Configuration
- defaultUserCredit: float = Field(
- default=0.0,
- description="Automatic initial credit (CHF) for PREPAY_USER only when a user is newly added to the root mandate; other mandates use 0 on join.",
- )
+
warningThresholdPercent: float = Field(default=10.0, description="Warning threshold as percentage")
# Stripe
stripeCustomerId: Optional[str] = Field(None, description="Stripe Customer ID (cus_xxx) — one per mandate")
- # Notifications (e.g. mandate owner / finance — also used when PREPAY_MANDATE pool is exhausted)
+ # Auto-Recharge for AI budget
+ autoRechargeEnabled: bool = Field(default=False, description="Auto-buy AI budget when low")
+ rechargeAmountCHF: float = Field(default=10.0, description="Amount per auto-recharge (CHF, prepaid via Stripe)")
+ rechargeMaxPerMonth: int = Field(default=3, description="Max auto-recharges per month")
+ rechargesThisMonth: int = Field(default=0, description="Counter: auto-recharges used this month")
+ monthResetAt: Optional[datetime] = Field(None, description="When rechargesThisMonth was last reset")
+
+ # Notifications
notifyEmails: List[str] = Field(
default_factory=list,
- description="Email addresses for billing alerts (mandate pool exhausted, warnings, etc.)",
+ description="Email addresses for billing alerts (pool exhausted, warnings, etc.)",
)
notifyOnWarning: bool = Field(default=True, description="Send email when warning threshold is reached")
@@ -161,16 +144,14 @@ registerModelLabels(
{
"id": {"en": "ID", "de": "ID"},
"mandateId": {"en": "Mandate ID", "de": "Mandanten-ID"},
- "billingModel": {"en": "Billing Model", "de": "Abrechnungsmodell"},
- "defaultUserCredit": {
- "en": "Root start credit (CHF)",
- "de": "Startguthaben nur Root-Mandant (CHF)",
- },
"warningThresholdPercent": {"en": "Warning Threshold (%)", "de": "Warnschwelle (%)"},
"stripeCustomerId": {"en": "Stripe Customer ID", "de": "Stripe-Kunden-ID"},
+ "autoRechargeEnabled": {"en": "Auto-Recharge", "de": "Auto-Nachladung"},
+ "rechargeAmountCHF": {"en": "Recharge Amount (CHF)", "de": "Nachladebetrag (CHF)"},
+ "rechargeMaxPerMonth": {"en": "Max Recharges/Month", "de": "Max. Nachladungen/Monat"},
"notifyEmails": {
"en": "Billing notification emails (owner / admin)",
- "de": "E-Mails für Billing-Alerts (Inhaber/Admin)",
+ "de": "E-Mails fuer Billing-Alerts (Inhaber/Admin)",
},
"notifyOnWarning": {"en": "Notify on Warning", "de": "Bei Warnung benachrichtigen"},
},
@@ -239,7 +220,6 @@ class BillingBalanceResponse(BaseModel):
"""Response model for balance endpoint."""
mandateId: str
mandateName: str
- billingModel: BillingModelEnum
balance: float
currency: str = "CHF"
warningThreshold: float
@@ -270,20 +250,8 @@ class BillingCheckResult(BaseModel):
reason: Optional[str] = None
currentBalance: Optional[float] = None
requiredAmount: Optional[float] = None
- billingModel: Optional[BillingModelEnum] = None
upgradeRequired: Optional[bool] = None
subscriptionUiPath: Optional[str] = None
userAction: Optional[str] = None
-def parseBillingModelFromStoredValue(raw: Optional[str]) -> BillingModelEnum:
- """Map DB string to enum. Legacy UNLIMITED / unknown values become PREPAY_MANDATE."""
- if raw is None or (isinstance(raw, str) and raw.strip() == ""):
- return BillingModelEnum.PREPAY_MANDATE
- s = str(raw).strip().upper()
- if s == "UNLIMITED":
- return BillingModelEnum.PREPAY_MANDATE
- try:
- return BillingModelEnum(raw)
- except ValueError:
- return BillingModelEnum.PREPAY_MANDATE
diff --git a/modules/datamodels/datamodelSubscription.py b/modules/datamodels/datamodelSubscription.py
index fa9f2c87..3b0e46b9 100644
--- a/modules/datamodels/datamodelSubscription.py
+++ b/modules/datamodels/datamodelSubscription.py
@@ -72,6 +72,7 @@ class SubscriptionPlan(BaseModel):
maxFeatureInstances: Optional[int] = Field(None, description="Hard cap on active feature instances (None = unlimited)")
trialDays: Optional[int] = Field(None, description="Trial duration in days (only for trial plans)")
maxDataVolumeMB: Optional[int] = Field(None, description="Soft-limit for data volume in MB per mandate (None = unlimited)")
+ budgetAiCHF: float = Field(default=0.0, description="AI budget (CHF) included in subscription price per billing period")
successorPlanKey: Optional[str] = Field(None, description="Plan to transition to when trial ends")
@@ -87,6 +88,7 @@ registerModelLabels(
"maxUsers": {"en": "Max Users", "de": "Max. Benutzer", "fr": "Max. utilisateurs"},
"maxFeatureInstances": {"en": "Max Instances", "de": "Max. Instanzen", "fr": "Max. instances"},
"maxDataVolumeMB": {"en": "Data Volume (MB)", "de": "Datenvolumen (MB)"},
+ "budgetAiCHF": {"en": "AI Budget (CHF)", "de": "AI-Budget (CHF)"},
},
)
@@ -186,14 +188,15 @@ BUILTIN_PLANS: Dict[str, SubscriptionPlan] = {
maxUsers=None,
maxFeatureInstances=None,
maxDataVolumeMB=None,
+ budgetAiCHF=0.0,
),
"TRIAL_7D": SubscriptionPlan(
planKey="TRIAL_7D",
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.",
- "de": "Plattform 7 Tage testen — 1 User, bis zu 3 Feature-Instanzen.",
+ "en": "Try the platform for 7 days — 1 user, up to 3 feature instances, 5 CHF AI budget included.",
+ "de": "Plattform 7 Tage testen — 1 User, bis zu 3 Feature-Instanzen, 5 CHF AI-Budget inklusive.",
},
billingPeriod=BillingPeriodEnum.NONE,
autoRenew=False,
@@ -201,6 +204,7 @@ BUILTIN_PLANS: Dict[str, SubscriptionPlan] = {
maxFeatureInstances=3,
trialDays=7,
maxDataVolumeMB=500,
+ budgetAiCHF=5.0,
successorPlanKey="STANDARD_MONTHLY",
),
"STANDARD_MONTHLY": SubscriptionPlan(
@@ -208,26 +212,28 @@ BUILTIN_PLANS: Dict[str, SubscriptionPlan] = {
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.",
- "de": "Nutzungsbasierte Abrechnung pro aktivem User und Feature-Instanz, monatlich.",
+ "en": "Usage-based billing per active user and feature instance, billed monthly. Includes 10 CHF AI budget.",
+ "de": "Nutzungsbasierte Abrechnung pro aktivem User und Feature-Instanz, monatlich. Inkl. 10 CHF AI-Budget.",
},
billingPeriod=BillingPeriodEnum.MONTHLY,
pricePerUserCHF=90.0,
pricePerFeatureInstanceCHF=150.0,
maxDataVolumeMB=10240,
+ budgetAiCHF=10.0,
),
"STANDARD_YEARLY": SubscriptionPlan(
planKey="STANDARD_YEARLY",
selectableByUser=True,
title={"en": "Standard (Yearly)", "de": "Standard (Jährlich)", "fr": "Standard (Annuel)"},
description={
- "en": "Usage-based billing per active user and feature instance, billed yearly.",
- "de": "Nutzungsbasierte Abrechnung pro aktivem User und Feature-Instanz, jährlich.",
+ "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, jährlich. Inkl. 120 CHF AI-Budget.",
},
billingPeriod=BillingPeriodEnum.YEARLY,
pricePerUserCHF=1080.0,
pricePerFeatureInstanceCHF=1800.0,
maxDataVolumeMB=10240,
+ budgetAiCHF=120.0,
),
}
diff --git a/modules/datamodels/datamodelUam.py b/modules/datamodels/datamodelUam.py
index 5a057639..741ce3d5 100644
--- a/modules/datamodels/datamodelUam.py
+++ b/modules/datamodels/datamodelUam.py
@@ -60,12 +60,6 @@ class UserPermissions(BaseModel):
)
-class MandateType(str, Enum):
- SYSTEM = "system"
- PERSONAL = "personal"
- COMPANY = "company"
-
-
class Mandate(PowerOnModel):
"""
Mandate (Mandant/Tenant) model.
@@ -95,15 +89,6 @@ class Mandate(PowerOnModel):
description="Whether this is a system mandate (e.g. root mandate). Cannot be deleted.",
json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": True, "frontend_required": False}
)
- mandateType: MandateType = Field(
- default=MandateType.COMPANY,
- description="Fachlicher Mandantentyp: system (Root), personal (Solo), company (Team). Mutabel, rein informativ — keine Feature-Gates.",
- json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": False, "frontend_options": [
- {"value": "system", "label": {"en": "System", "de": "System"}},
- {"value": "personal", "label": {"en": "Personal", "de": "Persönlich"}},
- {"value": "company", "label": {"en": "Company", "de": "Unternehmen"}},
- ]}
- )
deletedAt: Optional[float] = Field(
default=None,
description="Timestamp when the mandate was soft-deleted. After 30 days, hard-delete is triggered.",
@@ -118,19 +103,6 @@ class Mandate(PowerOnModel):
return False
return v
- @field_validator('mandateType', mode='before')
- @classmethod
- def _coerceMandateType(cls, v):
- if v is None:
- return MandateType.COMPANY
- if isinstance(v, str):
- try:
- return MandateType(v)
- except ValueError:
- return MandateType.COMPANY
- return v
-
-
registerModelLabels(
"Mandate",
{"en": "Mandate", "de": "Mandant", "fr": "Mandat"},
@@ -140,7 +112,6 @@ registerModelLabels(
"label": {"en": "Label", "de": "Label", "fr": "Libellé"},
"enabled": {"en": "Enabled", "de": "Aktiviert", "fr": "Activé"},
"isSystem": {"en": "System Mandate", "de": "System-Mandant", "fr": "Mandat système"},
- "mandateType": {"en": "Mandate Type", "de": "Mandantentyp", "fr": "Type de mandat"},
"deletedAt": {"en": "Deleted at", "de": "Gelöscht am", "fr": "Supprimé le"},
},
)
diff --git a/modules/features/chatbot/service.py b/modules/features/chatbot/service.py
index 121ca29b..a98150b5 100644
--- a/modules/features/chatbot/service.py
+++ b/modules/features/chatbot/service.py
@@ -1222,23 +1222,21 @@ def _preflight_billing_check(services, mandateId: str, featureInstanceId: Option
balanceCheck = billingService.checkBalance(0.01)
if not balanceCheck.allowed:
mid = str(getattr(services, "mandateId", None) or mandateId or "")
- from modules.datamodels.datamodelBilling import BillingModelEnum
from modules.serviceCenter.services.serviceBilling.billingExhaustedNotify import (
maybeEmailMandatePoolExhausted,
)
- if balanceCheck.billingModel == BillingModelEnum.PREPAY_MANDATE:
- u = getattr(services, "user", None)
- ulabel = (
- (getattr(u, "email", None) or getattr(u, "username", None) or str(getattr(u, "id", "")))
- if u is not None else ""
- )
- maybeEmailMandatePoolExhausted(
- mid,
- str(getattr(u, "id", "") if u is not None else ""),
- ulabel,
- float(balanceCheck.currentBalance or 0.0),
- 0.01,
- )
+ u = getattr(services, "user", None)
+ ulabel = (
+ (getattr(u, "email", None) or getattr(u, "username", None) or str(getattr(u, "id", "")))
+ if u is not None else ""
+ )
+ maybeEmailMandatePoolExhausted(
+ mid,
+ str(getattr(u, "id", "") if u is not None else ""),
+ ulabel,
+ float(balanceCheck.currentBalance or 0.0),
+ 0.01,
+ )
raise BillingService.InsufficientBalanceException.fromBalanceCheck(
balanceCheck,
mid,
diff --git a/modules/features/workspace/routeFeatureWorkspace.py b/modules/features/workspace/routeFeatureWorkspace.py
index 7698181a..79295f35 100644
--- a/modules/features/workspace/routeFeatureWorkspace.py
+++ b/modules/features/workspace/routeFeatureWorkspace.py
@@ -87,6 +87,7 @@ class WorkspaceInputRequest(BaseModel):
workflowId: Optional[str] = Field(default=None, description="Continue existing workflow")
userLanguage: str = Field(default="en", description="User language code")
allowedProviders: List[str] = Field(default_factory=list, description="Restrict AI to these providers")
+ requireNeutralization: Optional[bool] = Field(default=None, description="Per-request neutralization override")
async def _getAiObjects() -> AiObjects:
@@ -588,6 +589,7 @@ async def streamWorkspaceStart(
userLanguage=userInput.userLanguage,
instanceConfig=instanceConfig,
allowedProviders=userInput.allowedProviders,
+ requireNeutralization=userInput.requireNeutralization,
)
)
eventManager.register_agent_task(queueId, agentTask)
@@ -643,6 +645,7 @@ async def _runWorkspaceAgent(
userLanguage: str = "en",
instanceConfig: Dict[str, Any] = None,
allowedProviders: List[str] = None,
+ requireNeutralization: Optional[bool] = None,
):
"""Run the serviceAgent loop and forward events to the SSE queue."""
try:
@@ -660,6 +663,8 @@ async def _runWorkspaceAgent(
if allowedProviders:
aiService.services.allowedProviders = allowedProviders
+ if requireNeutralization is not None:
+ ctx.requireNeutralization = requireNeutralization
wfRecord = chatInterface.getWorkflow(workflowId) if workflowId else None
wfName = ""
@@ -887,6 +892,7 @@ async def listWorkspaceWorkflows(
request: Request,
instanceId: str = Path(...),
includeArchived: bool = Query(default=False, description="Include archived workflows"),
+ search: str = Query(default="", description="Fulltext search in workflow titles and message content"),
context: RequestContext = Depends(getRequestContext),
):
"""List workspace workflows/conversations for this instance."""
@@ -930,10 +936,54 @@ async def listWorkspaceWorkflows(
item.setdefault("featureLabel", labels["featureLabel"])
item.setdefault("featureCode", labels["featureCode"])
item.setdefault("featureInstanceId", fiId)
+
+ lastMsg = chatInterface.getLastMessageTimestamp(item.get("id"))
+ if lastMsg:
+ item["lastMessageAt"] = lastMsg
+
items.append(item)
+
+ if search and search.strip():
+ searchLower = search.strip().lower()
+ matchedIds = set()
+ for item in items:
+ if searchLower in (item.get("name") or "").lower() or searchLower in (item.get("label") or "").lower():
+ matchedIds.add(item["id"])
+ contentHits = chatInterface.searchWorkflowsByContent(searchLower, limit=50)
+ matchedIds.update(contentHits)
+ items = [i for i in items if i["id"] in matchedIds]
+
return JSONResponse({"workflows": items})
+class ResolveRagRequest(BaseModel):
+ """Request body for resolving a chat via RAG."""
+ chatId: str = Field(..., description="Workflow/chat ID to resolve")
+
+
+@router.post("/{instanceId}/resolve-rag")
+@limiter.limit("60/minute")
+async def resolveRag(
+ request: Request,
+ instanceId: str = Path(...),
+ body: ResolveRagRequest = Body(...),
+ context: RequestContext = Depends(getRequestContext),
+):
+ """Build a RAG summary for a chat (workflow) to inject into the input area."""
+ _validateInstanceAccess(instanceId, context)
+ chatInterface = _getChatInterface(context, featureInstanceId=instanceId)
+ messages = chatInterface.getMessages(body.chatId) or []
+
+ texts = []
+ for msg in messages[:30]:
+ content = msg.get("message") if isinstance(msg, dict) else getattr(msg, "message", "")
+ if content:
+ texts.append(content[:500])
+
+ summary = "\n---\n".join(texts[:10]) if texts else ""
+ return JSONResponse({"summary": summary, "chatId": body.chatId, "messageCount": len(texts)})
+
+
class UpdateWorkflowRequest(BaseModel):
"""Request body for updating a workflow (PATCH)."""
name: Optional[str] = Field(default=None, description="New workflow name")
diff --git a/modules/interfaces/interfaceBootstrap.py b/modules/interfaces/interfaceBootstrap.py
index 98b70466..0c186475 100644
--- a/modules/interfaces/interfaceBootstrap.py
+++ b/modules/interfaces/interfaceBootstrap.py
@@ -418,8 +418,6 @@ def initRootMandate(db: DatabaseConnector) -> Optional[str]:
if existingMandates:
mandateId = existingMandates[0].get("id")
logger.info(f"Root mandate already exists with ID {mandateId}")
- # Ensure mandateType is set to system
- db.recordModify(Mandate, mandateId, {"mandateType": "system"})
return mandateId
# Check for legacy root mandates (name="Root" without isSystem flag) and migrate
@@ -435,8 +433,6 @@ def initRootMandate(db: DatabaseConnector) -> Optional[str]:
createdMandate = db.recordCreate(Mandate, rootMandate)
mandateId = createdMandate.get("id")
logger.info(f"Root mandate created with ID {mandateId}")
- # mandateType already set via Mandate constructor, but ensure:
- db.recordModify(Mandate, mandateId, {"mandateType": "system"})
return mandateId
@@ -2116,71 +2112,43 @@ def _createStoreResourceRules(db: DatabaseConnector) -> None:
def initRootMandateBilling(mandateId: str) -> None:
"""
- Initialize billing settings for root mandate.
- Root mandate uses PREPAY_USER model with default initial credit per user in settings (DEFAULT_USER_CREDIT_CHF at bootstrap only).
- Creates billing accounts for ALL users regardless of billing model (for audit trail).
-
- Args:
- mandateId: Root mandate ID
+ Initialize billing settings for root mandate (PREPAY_MANDATE).
+ Creates mandate pool account and user audit accounts.
"""
try:
from modules.interfaces.interfaceDbBilling import _getRootInterface
from modules.interfaces.interfaceDbApp import getRootInterface as getAppRootInterface
- from modules.datamodels.datamodelBilling import (
- BillingSettings,
- BillingModelEnum,
- DEFAULT_USER_CREDIT_CHF,
- parseBillingModelFromStoredValue,
- )
+ from modules.datamodels.datamodelBilling import BillingSettings
billingInterface = _getRootInterface()
appInterface = getAppRootInterface()
- # Check if settings already exist
existingSettings = billingInterface.getSettings(mandateId)
if existingSettings:
logger.info("Billing settings for root mandate already exist")
else:
settings = BillingSettings(
mandateId=mandateId,
- billingModel=BillingModelEnum.PREPAY_USER,
- defaultUserCredit=DEFAULT_USER_CREDIT_CHF,
warningThresholdPercent=10.0,
notifyOnWarning=True
)
-
billingInterface.createSettings(settings)
- logger.info(
- f"Created billing settings for root mandate: PREPAY_USER with {DEFAULT_USER_CREDIT_CHF} CHF default credit"
- )
+ logger.info("Created billing settings for root mandate: PREPAY_MANDATE")
existingSettings = billingInterface.getSettings(mandateId)
- # Always create user accounts for all users (audit trail)
if existingSettings:
- billingModel = parseBillingModelFromStoredValue(
- existingSettings.get("billingModel")
- ).value
-
- # Initial balance depends on billing model
- if billingModel == BillingModelEnum.PREPAY_USER.value:
- initialBalance = float(existingSettings.get("defaultUserCredit", 0.0))
- else:
- initialBalance = 0.0 # PREPAY_MANDATE: budget on pool account
-
+ billingInterface.getOrCreateMandateAccount(mandateId, initialBalance=0.0)
userMandates = appInterface.getUserMandatesByMandate(mandateId)
accountsCreated = 0
-
for um in userMandates:
userId = um.get("userId") if isinstance(um, dict) else getattr(um, "userId", None)
if userId:
existingAccount = billingInterface.getUserAccount(mandateId, userId)
if not existingAccount:
- billingInterface.getOrCreateUserAccount(mandateId, userId, initialBalance=initialBalance)
+ billingInterface.getOrCreateUserAccount(mandateId, userId, initialBalance=0.0)
accountsCreated += 1
- logger.debug(f"Created billing account for user {userId}")
-
if accountsCreated > 0:
- logger.info(f"Created {accountsCreated} billing accounts for root mandate users with {initialBalance} CHF each")
+ logger.info(f"Created {accountsCreated} billing audit accounts for root mandate users")
except Exception as e:
logger.warning(f"Failed to initialize root mandate billing (non-critical): {e}")
diff --git a/modules/interfaces/interfaceDbApp.py b/modules/interfaces/interfaceDbApp.py
index ee1dc379..13179634 100644
--- a/modules/interfaces/interfaceDbApp.py
+++ b/modules/interfaces/interfaceDbApp.py
@@ -1407,12 +1407,11 @@ class AppObjects:
return Mandate(**createdRecord)
- def _provisionMandateForUser(self, userId: str, mandateType: str, mandateName: str, planKey: str) -> Dict[str, Any]:
+ def _provisionMandateForUser(self, userId: str, mandateName: str, planKey: str) -> Dict[str, Any]:
"""
Atomic provisioning: create Mandate + UserMandate + Subscription + auto-create FeatureInstances.
Internal method — bypasses RBAC (used during registration when user has no permissions yet).
"""
- from modules.datamodels.datamodelUam import MandateType
from modules.datamodels.datamodelSubscription import MandateSubscription, SubscriptionStatusEnum, BUILTIN_PLANS
from modules.datamodels.datamodelFeatures import FeatureInstance
from modules.interfaces.interfaceBootstrap import copySystemRolesToMandate
@@ -1428,7 +1427,6 @@ class AppObjects:
label=mandateName,
enabled=True,
isSystem=False,
- mandateType=MandateType(mandateType),
)
createdMandate = self.db.recordCreate(Mandate, mandateData)
if not createdMandate or not createdMandate.get("id"):
@@ -1497,11 +1495,10 @@ class AppObjects:
except Exception as e:
logger.error(f"Error auto-creating instance for '{featureName}': {e}")
- logger.info(f"Provisioned mandate {mandateId} (type={mandateType}, plan={planKey}) for user {userId}, instances={createdInstances}")
+ logger.info(f"Provisioned mandate {mandateId} (plan={planKey}) for user {userId}, instances={createdInstances}")
return {
"mandateId": mandateId,
"planKey": planKey,
- "mandateType": mandateType,
"featureInstances": createdInstances,
}
except Exception as e:
@@ -1632,7 +1629,10 @@ class AppObjects:
from modules.datamodels.datamodelChat import ChatWorkflow, ChatMessage, ChatLog
from modules.datamodels.datamodelFiles import FileItem
from modules.datamodels.datamodelDataSource import DataSource
- from modules.datamodels.datamodelKnowledge import FileContentIndex
+ from modules.datamodels.datamodelKnowledge import FileContentIndex, ContentChunk
+ from modules.datamodels.datamodelFeatureDataSource import FeatureDataSource
+ from modules.datamodels.datamodelBilling import BillingSettings, BillingAccount, BillingTransaction
+ from modules.datamodels.datamodelRbac import FeatureAccessRole, UserMandateRole
from modules.features.neutralization.datamodelFeatureNeutralizer import DataNeutralizerAttributes
instances = self.db.getRecordset(FeatureInstance, recordFilter={"mandateId": mandateId})
@@ -1643,12 +1643,15 @@ class AppObjects:
if not instId:
continue
- # 0a. FileContentIndex (knowledge/RAG)
+ # 0a. ContentChunk (embeddings) + FileContentIndex (knowledge/RAG)
fciRecords = self.db.getRecordset(FileContentIndex, recordFilter={"featureInstanceId": instId})
for rec in fciRecords:
+ chunks = self.db.getRecordset(ContentChunk, recordFilter={"fileContentIndexId": rec.get("id")})
+ for chunk in chunks:
+ self.db.recordDelete(ContentChunk, chunk.get("id"))
self.db.recordDelete(FileContentIndex, rec.get("id"))
if fciRecords:
- logger.info(f"Cascade: deleted {len(fciRecords)} FileContentIndex records for instance {instId}")
+ logger.info(f"Cascade: deleted {len(fciRecords)} FileContentIndex records (with chunks) for instance {instId}")
# 0b. DataNeutralizerAttributes
dnaRecords = self.db.getRecordset(DataNeutralizerAttributes, recordFilter={"featureInstanceId": instId})
@@ -1664,6 +1667,13 @@ class AppObjects:
if dsRecords:
logger.info(f"Cascade: deleted {len(dsRecords)} DataSource records for instance {instId}")
+ # 0c2. FeatureDataSource
+ fdsRecords = self.db.getRecordset(FeatureDataSource, recordFilter={"featureInstanceId": instId})
+ for rec in fdsRecords:
+ self.db.recordDelete(FeatureDataSource, rec.get("id"))
+ if fdsRecords:
+ logger.info(f"Cascade: deleted {len(fdsRecords)} FeatureDataSource records for instance {instId}")
+
# 0d. FileItem
fileRecords = self.db.getRecordset(FileItem, recordFilter={"featureInstanceId": instId})
for rec in fileRecords:
@@ -1687,11 +1697,14 @@ class AppObjects:
if workflows:
logger.info(f"Cascade: deleted {len(workflows)} ChatWorkflows (with messages/logs) for instance {instId}")
- # 1. Delete FeatureAccess + FeatureAccessRole for all instances in this mandate
+ # 1. Delete FeatureAccess + FeatureAccessRole for all instances
for inst in instances:
instId = inst.get("id")
accesses = self.db.getRecordset(FeatureAccess, recordFilter={"featureInstanceId": instId})
for access in accesses:
+ roles = self.db.getRecordset(FeatureAccessRole, recordFilter={"featureAccessId": access.get("id")})
+ for role in roles:
+ self.db.recordDelete(FeatureAccessRole, role.get("id"))
self.db.recordDelete(FeatureAccess, access.get("id"))
self.db.recordDelete(FeatureInstance, instId)
logger.info(f"Cascade: deleted {len(instances)} FeatureInstances for mandate {mandateId}")
@@ -1699,6 +1712,9 @@ class AppObjects:
# 2. Delete UserMandate + UserMandateRole
memberships = self.db.getRecordset(UserMandate, recordFilter={"mandateId": mandateId})
for um in memberships:
+ umRoles = self.db.getRecordset(UserMandateRole, recordFilter={"userMandateId": um.get("id")})
+ for umr in umRoles:
+ self.db.recordDelete(UserMandateRole, umr.get("id"))
self.db.recordDelete(UserMandate, um.get("id"))
logger.info(f"Cascade: deleted {len(memberships)} UserMandates for mandate {mandateId}")
@@ -1718,6 +1734,20 @@ class AppObjects:
self.db.recordDelete(MandateSubscription, subId)
logger.info(f"Cascade: deleted {len(subs)} subscriptions for mandate {mandateId}")
+ # 3b. Delete Billing data
+ billingTxs = self.db.getRecordset(BillingTransaction, recordFilter={"mandateId": mandateId}) if hasattr(BillingTransaction, '__table_name__') else []
+ billingAccounts = self.db.getRecordset(BillingAccount, recordFilter={"mandateId": mandateId})
+ for acc in billingAccounts:
+ accTxs = self.db.getRecordset(BillingTransaction, recordFilter={"accountId": acc.get("id")})
+ for tx in accTxs:
+ self.db.recordDelete(BillingTransaction, tx.get("id"))
+ self.db.recordDelete(BillingAccount, acc.get("id"))
+ billingSettings = self.db.getRecordset(BillingSettings, recordFilter={"mandateId": mandateId})
+ for bs in billingSettings:
+ self.db.recordDelete(BillingSettings, bs.get("id"))
+ if billingAccounts or billingSettings:
+ logger.info(f"Cascade: deleted billing data for mandate {mandateId}")
+
# 4. Delete mandate-level Roles
from modules.datamodels.datamodelRbac import Role, AccessRule
roles = self.db.getRecordset(Role, recordFilter={"mandateId": mandateId})
@@ -1821,7 +1851,7 @@ class AppObjects:
def createUserMandate(self, userId: str, mandateId: str, roleIds: List[str] = None) -> UserMandate:
"""
Create a UserMandate record (add user to mandate).
- Also creates a billing account for the user if billing is configured for PREPAY_USER.
+ Also creates a billing audit account for the user if billing is configured.
INVARIANT: A UserMandate MUST have at least one UserMandateRole.
@@ -1871,43 +1901,20 @@ class AppObjects:
def _ensureUserBillingAccount(self, userId: str, mandateId: str) -> None:
"""
- Ensure a user has a billing account for the mandate if billing is configured.
- User accounts are always created for all billing models (for audit trail).
- Initial balance depends on billing model:
- - PREPAY_USER: defaultUserCredit from mandate BillingSettings when joining the root mandate (missing key => 0.0);
- other mandates get 0.0.
- - PREPAY_MANDATE: 0.0 on the user account (shared pool — no per-user start credit)
-
- Args:
- userId: User ID
- mandateId: Mandate ID
+ Ensure a user has a billing audit account for the mandate.
+ Balance is always on the mandate pool (PREPAY_MANDATE). User accounts are for audit trail only.
"""
try:
from modules.interfaces.interfaceDbBilling import _getRootInterface as getBillingRootInterface
- from modules.datamodels.datamodelBilling import BillingModelEnum, parseBillingModelFromStoredValue
billingInterface = getBillingRootInterface()
settings = billingInterface.getSettings(mandateId)
if not settings:
- return # No billing configured for this mandate
+ return
- billingModel = parseBillingModelFromStoredValue(settings.get("billingModel"))
-
- # Initial balance depends on billing model (start credit only on root mandate for PREPAY_USER)
- rootMandateId = self._getRootMandateId()
- isRootMandate = rootMandateId is not None and str(mandateId) == str(rootMandateId)
- if billingModel == BillingModelEnum.PREPAY_USER:
- initialBalance = (
- float(settings.get("defaultUserCredit", 0.0))
- if isRootMandate
- else 0.0
- )
- else:
- initialBalance = 0.0 # PREPAY_MANDATE: budget is on pool
-
- billingInterface.getOrCreateUserAccount(mandateId, userId, initialBalance=initialBalance)
- logger.info(f"Ensured billing account for user {userId} in mandate {mandateId} (model={billingModel.value}, initial={initialBalance} CHF)")
+ billingInterface.getOrCreateUserAccount(mandateId, userId, initialBalance=0.0)
+ logger.info(f"Ensured billing audit account for user {userId} in mandate {mandateId}")
except Exception as e:
logger.warning(f"Failed to create billing account for user {userId} (non-critical): {e}")
diff --git a/modules/interfaces/interfaceDbBilling.py b/modules/interfaces/interfaceDbBilling.py
index 343e2215..c8c13d13 100644
--- a/modules/interfaces/interfaceDbBilling.py
+++ b/modules/interfaces/interfaceDbBilling.py
@@ -24,14 +24,11 @@ from modules.datamodels.datamodelBilling import (
BillingSettings,
StripeWebhookEvent,
UsageStatistics,
- BillingModelEnum,
- AccountTypeEnum,
TransactionTypeEnum,
ReferenceTypeEnum,
PeriodTypeEnum,
BillingBalanceResponse,
BillingCheckResult,
- parseBillingModelFromStoredValue,
)
logger = logging.getLogger(__name__)
@@ -160,8 +157,6 @@ class BillingObjects:
"""
Get billing settings for a mandate.
- Normalizes billingModel for API (legacy UNLIMITED → PREPAY_MANDATE) and persists once.
-
Args:
mandateId: Mandate ID
@@ -175,27 +170,7 @@ class BillingObjects:
)
if not results:
return None
- row = dict(results[0])
- raw_bm = row.get("billingModel")
- parsed = parseBillingModelFromStoredValue(raw_bm)
- if str(raw_bm or "").strip().upper() == "UNLIMITED":
- try:
- self.updateSettings(
- row["id"],
- {"billingModel": BillingModelEnum.PREPAY_MANDATE.value},
- )
- logger.info(
- "Migrated billing settings for mandate %s: UNLIMITED → PREPAY_MANDATE",
- mandateId,
- )
- except Exception as mig_err:
- logger.warning(
- "Could not persist billing model migration for mandate %s: %s",
- mandateId,
- mig_err,
- )
- row["billingModel"] = parsed.value
- return row
+ return dict(results[0])
except Exception as e:
logger.error(f"Error getting billing settings: {e}")
return None
@@ -226,13 +201,12 @@ class BillingObjects:
"""
return self.db.recordModify(BillingSettings, settingsId, updates)
- def getOrCreateSettings(self, mandateId: str, defaultModel: BillingModelEnum = BillingModelEnum.PREPAY_MANDATE) -> Dict[str, Any]:
+ def getOrCreateSettings(self, mandateId: str) -> Dict[str, Any]:
"""
Get or create billing settings for a mandate.
Args:
mandateId: Mandate ID
- defaultModel: Default billing model if creating
Returns:
BillingSettings dict
@@ -243,8 +217,6 @@ class BillingObjects:
settings = BillingSettings(
mandateId=mandateId,
- billingModel=defaultModel,
- defaultUserCredit=0.0,
warningThresholdPercent=10.0,
notifyOnWarning=True,
)
@@ -281,7 +253,7 @@ class BillingObjects:
BillingAccount,
recordFilter={
"mandateId": mandateId,
- "accountType": AccountTypeEnum.MANDATE.value
+ "userId": None
}
)
return results[0] if results else None
@@ -305,8 +277,7 @@ class BillingObjects:
BillingAccount,
recordFilter={
"mandateId": mandateId,
- "userId": userId,
- "accountType": AccountTypeEnum.USER.value
+ "userId": userId
}
)
return results[0] if results else None
@@ -376,7 +347,6 @@ class BillingObjects:
account = BillingAccount(
mandateId=mandateId,
- accountType=AccountTypeEnum.MANDATE,
balance=initialBalance,
enabled=True
)
@@ -401,7 +371,6 @@ class BillingObjects:
account = BillingAccount(
mandateId=mandateId,
userId=userId,
- accountType=AccountTypeEnum.USER,
balance=initialBalance,
enabled=True
)
@@ -422,7 +391,7 @@ class BillingObjects:
def ensureAllMandateSettingsExist(self) -> int:
"""
Efficiently ensure all mandates have billing settings.
- Creates default settings (PREPAY_MANDATE, 0 CHF) for mandates without settings.
+ Creates default settings (0 CHF) for mandates without settings.
Uses bulk queries to minimize database connections.
Returns:
@@ -451,16 +420,13 @@ class BillingObjects:
if not mandateId or mandateId in existingMandateIds:
continue
- # Create default billing settings
settings = BillingSettings(
mandateId=mandateId,
- billingModel=BillingModelEnum.PREPAY_MANDATE,
- defaultUserCredit=0.0,
warningThresholdPercent=10.0,
notifyOnWarning=True,
)
self.createSettings(settings)
- existingMandateIds.add(mandateId) # Track newly created
+ existingMandateIds.add(mandateId)
settingsCreated += 1
if settingsCreated > 0:
@@ -475,11 +441,7 @@ class BillingObjects:
def ensureAllUserAccountsExist(self) -> int:
"""
Ensure all users across all mandates have billing accounts.
- User accounts are always created regardless of billing model (for audit trail).
- Initial balance depends on billing model:
- - PREPAY_USER: defaultUserCredit from settings only for the root mandate; other mandates get 0.0
- - PREPAY_MANDATE: 0.0 (budget is on pool)
-
+ User accounts are always created for audit trail with initial balance 0.0.
Uses bulk queries to minimize database connections.
Returns:
@@ -488,44 +450,29 @@ class BillingObjects:
try:
accountsCreated = 0
appDb = _getAppDatabaseConnector()
- rootMandateId = _getCachedRootMandateId()
- # Step 1: Get all billing settings (all mandates with settings get user accounts)
allSettings = self.db.getRecordset(BillingSettings)
- billingMandates = {} # mandateId -> (billingModel, defaultCredit)
- for s in allSettings:
- billingModel = parseBillingModelFromStoredValue(s.get("billingModel")).value
- mid = s.get("mandateId")
- isRoot = rootMandateId is not None and str(mid) == str(rootMandateId)
- if billingModel == BillingModelEnum.PREPAY_USER.value:
- defaultCredit = (
- float(s.get("defaultUserCredit", 0.0) or 0.0) if isRoot else 0.0
- )
- else:
- defaultCredit = 0.0
- billingMandates[mid] = (billingModel, defaultCredit)
+ billingMandateIds = set(
+ s.get("mandateId") for s in allSettings if s.get("mandateId")
+ )
- if not billingMandates:
+ if not billingMandateIds:
logger.debug("No billable mandates found, skipping account check")
return 0
- # Step 2: Get all existing USER accounts in one query
- allAccounts = self.db.getRecordset(
- BillingAccount,
- recordFilter={"accountType": AccountTypeEnum.USER.value}
- )
+ allAccounts = self.db.getRecordset(BillingAccount)
existingAccountKeys = set()
for acc in allAccounts:
+ if not acc.get("userId"):
+ continue
key = (acc.get("mandateId"), acc.get("userId"))
existingAccountKeys.add(key)
- # Step 3: Get all user-mandate combinations from APP database
allUserMandates = appDb.getRecordset(
UserMandate,
recordFilter={"enabled": True}
)
- # Step 4: Create missing accounts
for um in allUserMandates:
mandateId = um.get("mandateId")
userId = um.get("userId")
@@ -533,32 +480,20 @@ class BillingObjects:
if not mandateId or not userId:
continue
- if mandateId not in billingMandates:
+ if mandateId not in billingMandateIds:
continue
key = (mandateId, userId)
if key in existingAccountKeys:
continue
- billingModel, defaultCredit = billingMandates[mandateId]
-
account = BillingAccount(
mandateId=mandateId,
userId=userId,
- accountType=AccountTypeEnum.USER,
- balance=defaultCredit,
+ balance=0.0,
enabled=True
)
- created = self.createAccount(account)
-
- if defaultCredit > 0:
- self.createTransaction(BillingTransaction(
- accountId=created["id"],
- transactionType=TransactionTypeEnum.CREDIT,
- amount=defaultCredit,
- description="Initial credit for new user",
- referenceType=ReferenceTypeEnum.SYSTEM
- ))
+ self.createAccount(account)
existingAccountKeys.add(key)
accountsCreated += 1
@@ -810,35 +745,14 @@ class BillingObjects:
"""
Check if there's sufficient balance for an operation.
- - PREPAY_USER: user.balance >= estimatedCost
- - PREPAY_MANDATE: mandate pool balance >= estimatedCost
-
- User accounts are always ensured to exist (for audit trail).
- Root mandate + PREPAY_USER: initial credit from settings.defaultUserCredit on first create.
- Missing settings: treated as PREPAY_MANDATE with empty pool (strict).
+ Checks mandate pool balance against estimatedCost.
+ User accounts are ensured to exist for audit trail.
+ Missing settings: treated as PREPAY_MANDATE with empty pool.
"""
- settings = self.getSettings(mandateId)
- if not settings:
- billingModel = BillingModelEnum.PREPAY_MANDATE
- defaultCredit = 0.0
- else:
- billingModel = parseBillingModelFromStoredValue(settings.get("billingModel"))
- defaultCredit = float(settings.get("defaultUserCredit", 0.0) or 0.0)
+ self.getOrCreateUserAccount(mandateId, userId, initialBalance=0.0)
- rootMandateId = _getCachedRootMandateId()
- isRootMandate = rootMandateId is not None and str(mandateId) == str(rootMandateId)
- if billingModel == BillingModelEnum.PREPAY_USER:
- initialBalance = defaultCredit if isRootMandate else 0.0
- else:
- initialBalance = 0.0
- self.getOrCreateUserAccount(mandateId, userId, initialBalance=initialBalance)
-
- if billingModel == BillingModelEnum.PREPAY_USER:
- account = self.getUserAccount(mandateId, userId)
- currentBalance = account.get("balance", 0.0) if account else 0.0
- else:
- poolAccount = self.getOrCreateMandateAccount(mandateId)
- currentBalance = poolAccount.get("balance", 0.0)
+ poolAccount = self.getOrCreateMandateAccount(mandateId)
+ currentBalance = poolAccount.get("balance", 0.0)
if currentBalance < estimatedCost:
return BillingCheckResult(
@@ -846,10 +760,9 @@ class BillingObjects:
reason="INSUFFICIENT_BALANCE",
currentBalance=currentBalance,
requiredAmount=estimatedCost,
- billingModel=billingModel,
)
- return BillingCheckResult(allowed=True, currentBalance=currentBalance, billingModel=billingModel)
+ return BillingCheckResult(allowed=True, currentBalance=currentBalance)
def recordUsage(
self,
@@ -870,10 +783,8 @@ class BillingObjects:
"""
Record usage cost as a billing transaction.
- Transaction is ALWAYS recorded on the user's account (clean audit trail).
- Balance is deducted from the appropriate account based on billing model:
- - PREPAY_USER: deduct from user's own balance
- - PREPAY_MANDATE: deduct from mandate pool balance
+ Transaction is recorded on the user's account (audit trail).
+ Balance is always deducted from the mandate pool account (PREPAY_MANDATE).
"""
if priceCHF <= 0:
return None
@@ -883,9 +794,6 @@ class BillingObjects:
logger.debug(f"No billing settings for mandate {mandateId}, skipping usage recording")
return None
- billingModel = parseBillingModelFromStoredValue(settings.get("billingModel"))
-
- # Transaction is ALWAYS on the user's account (audit trail)
userAccount = self.getOrCreateUserAccount(mandateId, userId)
transaction = BillingTransaction(
@@ -906,13 +814,8 @@ class BillingObjects:
errorCount=errorCount
)
- # Determine where to deduct balance
- if billingModel == BillingModelEnum.PREPAY_USER:
- return self.createTransaction(transaction)
- if billingModel == BillingModelEnum.PREPAY_MANDATE:
- poolAccount = self.getOrCreateMandateAccount(mandateId)
- return self.createTransaction(transaction, balanceAccountId=poolAccount["id"])
- return None
+ poolAccount = self.getOrCreateMandateAccount(mandateId)
+ return self.createTransaction(transaction, balanceAccountId=poolAccount["id"])
# =========================================================================
# Workflow Cost Query
@@ -928,112 +831,6 @@ class BillingObjects:
)
return sum(t.get("amount", 0.0) for t in transactions)
- # =========================================================================
- # Billing Model Switch Operations
- # =========================================================================
-
- def switchBillingModel(self, mandateId: str, oldModel: BillingModelEnum, newModel: BillingModelEnum) -> Dict[str, Any]:
- """
- Switch billing model with budget migration logged as BillingTransactions.
-
- PREPAY_MANDATE -> PREPAY_USER: pool debited, equal shares credited to user accounts.
- PREPAY_USER -> PREPAY_MANDATE: user wallets debited, pool credited with sum.
- """
- result = {"oldModel": oldModel.value, "newModel": newModel.value, "migratedAmount": 0.0, "userCount": 0}
-
- if oldModel == newModel:
- return result
-
- if oldModel == BillingModelEnum.PREPAY_MANDATE and newModel == BillingModelEnum.PREPAY_USER:
- poolAccount = self.getMandateAccount(mandateId)
- userAccounts = self.db.getRecordset(
- BillingAccount,
- recordFilter={"mandateId": mandateId, "accountType": AccountTypeEnum.USER.value}
- )
- poolBalance = poolAccount.get("balance", 0.0) if poolAccount else 0.0
- n = len(userAccounts)
- if poolAccount and poolBalance > 0:
- self.createTransaction(
- BillingTransaction(
- accountId=poolAccount["id"],
- transactionType=TransactionTypeEnum.DEBIT,
- amount=poolBalance,
- description="Model switch: distributed from mandate pool to user wallets",
- referenceType=ReferenceTypeEnum.SYSTEM,
- )
- )
- result["migratedAmount"] = poolBalance
- if n > 0:
- remaining = poolBalance
- for i, acc in enumerate(userAccounts):
- if i == n - 1:
- share = round(remaining, 4)
- else:
- share = round(poolBalance / n, 4)
- remaining -= share
- if share > 0:
- self.createTransaction(
- BillingTransaction(
- accountId=acc["id"],
- transactionType=TransactionTypeEnum.CREDIT,
- amount=share,
- description="Model switch: share from mandate pool",
- referenceType=ReferenceTypeEnum.SYSTEM,
- )
- )
- result["userCount"] = n
- logger.info(
- "Switched %s MANDATE->USER: migrated %.4f CHF to %d user account(s) (transactions logged)",
- mandateId,
- result["migratedAmount"],
- result["userCount"],
- )
- return result
-
- if oldModel == BillingModelEnum.PREPAY_USER and newModel == BillingModelEnum.PREPAY_MANDATE:
- userAccounts = self.db.getRecordset(
- BillingAccount,
- recordFilter={"mandateId": mandateId, "accountType": AccountTypeEnum.USER.value}
- )
- totalUserBalance = sum(acc.get("balance", 0.0) for acc in userAccounts)
- for acc in userAccounts:
- b = acc.get("balance", 0.0)
- if b > 0:
- self.createTransaction(
- BillingTransaction(
- accountId=acc["id"],
- transactionType=TransactionTypeEnum.DEBIT,
- amount=b,
- description="Model switch: consolidated to mandate pool",
- referenceType=ReferenceTypeEnum.SYSTEM,
- )
- )
- poolAccount = self.getOrCreateMandateAccount(mandateId, initialBalance=0.0)
- if totalUserBalance > 0:
- self.createTransaction(
- BillingTransaction(
- accountId=poolAccount["id"],
- transactionType=TransactionTypeEnum.CREDIT,
- amount=totalUserBalance,
- description="Model switch: consolidated from user accounts",
- referenceType=ReferenceTypeEnum.SYSTEM,
- )
- )
- result["migratedAmount"] = totalUserBalance
- result["userCount"] = len(userAccounts)
- logger.info(
- "Switched %s USER->MANDATE: consolidated %.4f CHF from %d users into pool (transactions logged)",
- mandateId,
- totalUserBalance,
- len(userAccounts),
- )
- return result
-
- if newModel == BillingModelEnum.PREPAY_MANDATE:
- self.getOrCreateMandateAccount(mandateId, initialBalance=0.0)
-
- return result
-
# =========================================================================
# Statistics Operations
# =========================================================================
@@ -1128,10 +925,8 @@ class BillingObjects:
def getBalancesForUser(self, userId: str) -> List[BillingBalanceResponse]:
"""
Get all billing balances for a user across mandates.
+ Shows the mandate pool balance (shared budget visible to user).
- Shows the effective available budget:
- - PREPAY_USER: user's own account balance
- - PREPAY_MANDATE: mandate pool balance (shared budget visible to user)
Args:
userId: User ID
@@ -1163,27 +958,15 @@ class BillingObjects:
if not settings:
continue
- billingModel = parseBillingModelFromStoredValue(settings.get("billingModel"))
-
- if billingModel == BillingModelEnum.PREPAY_USER:
- account = self.getOrCreateUserAccount(mandateId, userId)
- if not account:
- continue
- balance = account.get("balance", 0.0)
- warningThreshold = account.get("warningThreshold", 0.0)
- elif billingModel == BillingModelEnum.PREPAY_MANDATE:
- poolAccount = self.getOrCreateMandateAccount(mandateId)
- if not poolAccount:
- continue
- balance = poolAccount.get("balance", 0.0)
- warningThreshold = poolAccount.get("warningThreshold", 0.0)
- else:
+ poolAccount = self.getOrCreateMandateAccount(mandateId)
+ if not poolAccount:
continue
+ balance = poolAccount.get("balance", 0.0)
+ warningThreshold = poolAccount.get("warningThreshold", 0.0)
balances.append(BillingBalanceResponse(
mandateId=mandateId,
mandateName=mandateName,
- billingModel=billingModel,
balance=balance,
warningThreshold=warningThreshold,
isWarning=balance <= warningThreshold,
@@ -1280,36 +1063,25 @@ class BillingObjects:
if not mandateId:
continue
- billingModel = parseBillingModelFromStoredValue(settings.get("billingModel"))
-
- # Get mandate info
mandate = appInterface.getMandate(mandateId)
mandateName = ""
if mandate:
mandateName = getattr(mandate, 'label', None) or getattr(mandate, 'name', None) or (mandate.get("label") or mandate.get("name", "") if isinstance(mandate, dict) else "")
- # Get user accounts count (always exist now for audit trail)
- userAccounts = self.db.getRecordset(
+ allMandateAccounts = self.db.getRecordset(
BillingAccount,
- recordFilter={"mandateId": mandateId, "accountType": AccountTypeEnum.USER.value}
+ recordFilter={"mandateId": mandateId}
)
- userCount = len(userAccounts)
+ userCount = sum(1 for acc in allMandateAccounts if acc.get("userId"))
- if billingModel == BillingModelEnum.PREPAY_USER:
- totalBalance = sum(acc.get("balance", 0.0) for acc in userAccounts)
- elif billingModel == BillingModelEnum.PREPAY_MANDATE:
- poolAccount = self.getMandateAccount(mandateId)
- totalBalance = poolAccount.get("balance", 0.0) if poolAccount else 0.0
- else:
- totalBalance = 0.0
+ poolAccount = self.getMandateAccount(mandateId)
+ totalBalance = poolAccount.get("balance", 0.0) if poolAccount else 0.0
balances.append({
"mandateId": mandateId,
"mandateName": mandateName,
- "billingModel": billingModel.value,
"totalBalance": totalBalance,
"userCount": userCount,
- "defaultUserCredit": float(settings.get("defaultUserCredit", 0.0) or 0.0),
"warningThresholdPercent": settings.get("warningThresholdPercent", 10.0),
})
@@ -1385,9 +1157,8 @@ class BillingObjects:
try:
appInterface = getAppInterface(self.currentUser)
- # Get all user accounts
- accountFilter = {"accountType": AccountTypeEnum.USER.value}
- allAccounts = self.db.getRecordset(BillingAccount, recordFilter=accountFilter)
+ allAccounts = self.db.getRecordset(BillingAccount)
+ allAccounts = [acc for acc in allAccounts if acc.get("userId")]
# Filter by mandate if specified
if mandateIds:
diff --git a/modules/interfaces/interfaceDbChat.py b/modules/interfaces/interfaceDbChat.py
index 192cbad4..60f4db44 100644
--- a/modules/interfaces/interfaceDbChat.py
+++ b/modules/interfaces/interfaceDbChat.py
@@ -651,6 +651,32 @@ class ChatObjects:
totalPages=totalPages
)
+ def getLastMessageTimestamp(self, workflowId: str) -> Optional[str]:
+ """Return the latest publishedAt/sysCreatedAt from ChatMessage for a workflow."""
+ messages = self._getRecordset(ChatMessage, recordFilter={"workflowId": workflowId})
+ if not messages:
+ return None
+ latest = None
+ for msg in messages:
+ ts = msg.get("publishedAt") or msg.get("sysCreatedAt")
+ if ts and (latest is None or str(ts) > str(latest)):
+ latest = ts
+ return str(latest) if latest else None
+
+ def searchWorkflowsByContent(self, query: str, limit: int = 50) -> List[str]:
+ """Return workflow IDs whose messages contain the query string (case-insensitive)."""
+ allMessages = self._getRecordset(ChatMessage)
+ matchedIds: set = set()
+ for msg in allMessages:
+ content = msg.get("message") or ""
+ if query in content.lower():
+ wfId = msg.get("workflowId")
+ if wfId:
+ matchedIds.add(wfId)
+ if len(matchedIds) >= limit:
+ break
+ return list(matchedIds)
+
def getWorkflow(self, workflowId: str) -> Optional[ChatWorkflow]:
"""Returns a workflow by ID if user has access."""
# Use RBAC filtering with featureInstanceId for instance-level isolation
diff --git a/modules/interfaces/interfaceDbSubscription.py b/modules/interfaces/interfaceDbSubscription.py
index f08025ea..2405ec73 100644
--- a/modules/interfaces/interfaceDbSubscription.py
+++ b/modules/interfaces/interfaceDbSubscription.py
@@ -293,9 +293,43 @@ class SubscriptionObjects:
if current + delta > cap:
from modules.serviceCenter.services.serviceSubscription.mainServiceSubscription import SubscriptionCapacityException
raise SubscriptionCapacityException(resourceType=resourceType, currentCount=current, maxAllowed=cap)
+ elif resourceType == "dataVolumeMB":
+ cap = plan.maxDataVolumeMB
+ if cap is None:
+ return True
+ currentMB = self._getMandateDataVolumeMB(mandateId)
+ if currentMB + delta > cap:
+ from modules.serviceCenter.services.serviceSubscription.mainServiceSubscription import SubscriptionCapacityException
+ raise SubscriptionCapacityException(resourceType=resourceType, currentCount=int(currentMB), maxAllowed=cap)
return True
+ def _getMandateDataVolumeMB(self, mandateId: str) -> float:
+ """Sum RAG index size (FileContentIndex.totalSize) across all feature instances of the mandate."""
+ try:
+ from modules.datamodels.datamodelKnowledge import FileContentIndex
+ knowledgeDb = _getAppDatabaseConnector()
+ indexes = knowledgeDb.getRecordset(FileContentIndex, recordFilter={"mandateId": mandateId})
+ totalBytes = sum(int(idx.get("totalSize") or 0) for idx in indexes)
+ return totalBytes / (1024 * 1024)
+ except Exception:
+ return 0.0
+
+ def getDataVolumeWarning(self, mandateId: str) -> Optional[Dict[str, Any]]:
+ """Return a warning dict if mandate uses >=80% of maxDataVolumeMB, else None."""
+ sub = self.getOperativeForMandate(mandateId)
+ if not sub:
+ return None
+ plan = self.getPlan(sub.get("planKey", ""))
+ if not plan or not plan.maxDataVolumeMB:
+ return None
+ usedMB = self._getMandateDataVolumeMB(mandateId)
+ limitMB = plan.maxDataVolumeMB
+ percent = (usedMB / limitMB * 100) if limitMB > 0 else 0
+ if percent >= 80:
+ return {"usedMB": round(usedMB, 2), "limitMB": limitMB, "percent": round(percent, 1), "warning": True}
+ return {"usedMB": round(usedMB, 2), "limitMB": limitMB, "percent": round(percent, 1), "warning": False}
+
# =========================================================================
# Counting (cross-DB queries against poweron_app)
# =========================================================================
diff --git a/modules/migration/migrateRootUsers.py b/modules/migration/migrateRootUsers.py
index 69d1b7af..11424987 100644
--- a/modules/migration/migrateRootUsers.py
+++ b/modules/migration/migrateRootUsers.py
@@ -241,8 +241,7 @@ def migrateRootUsers(db, dryRun: bool = False) -> dict:
try:
result = rootInterface._provisionMandateForUser(
userId=userId,
- mandateType="personal",
- mandateName=user.get("fullName") or username,
+ mandateName=f"Home {username}",
planKey="TRIAL_7D",
)
targetMandateId = result["mandateId"]
diff --git a/modules/routes/routeBilling.py b/modules/routes/routeBilling.py
index 88ec0cc6..4062163e 100644
--- a/modules/routes/routeBilling.py
+++ b/modules/routes/routeBilling.py
@@ -30,7 +30,6 @@ from modules.datamodels.datamodelBilling import (
BillingAccount,
BillingTransaction,
BillingSettings,
- BillingModelEnum,
TransactionTypeEnum,
ReferenceTypeEnum,
PeriodTypeEnum,
@@ -38,7 +37,6 @@ from modules.datamodels.datamodelBilling import (
BillingStatisticsResponse,
BillingStatisticsChartData,
BillingCheckResult,
- parseBillingModelFromStoredValue,
)
# Configure logger
@@ -229,14 +227,14 @@ def _filterTransactionsByScope(transactions: list, scope: BillingDataScope) -> l
class CreditAddRequest(BaseModel):
"""Request model for adding or deducting credit from an account."""
- userId: Optional[str] = Field(None, description="Target user ID (for PREPAY_USER model)")
+ userId: Optional[str] = Field(None, description="Target user ID for audit trail only (optional)")
amount: float = Field(..., description="Amount in CHF. Positive = credit, negative = deduction. Must not be zero.")
description: str = Field(default="Manual credit", description="Transaction description")
class CheckoutCreateRequest(BaseModel):
"""Request model for creating Stripe Checkout Session."""
- userId: Optional[str] = Field(None, description="Target user ID (for PREPAY_USER model)")
+ userId: Optional[str] = Field(None, description="Target user ID for audit trail only (optional)")
amount: float = Field(..., gt=0, description="Amount to pay in CHF (must be in allowed presets)")
returnUrl: str = Field(..., min_length=1, description="Absolute frontend URL used for Stripe success/cancel redirects")
@@ -262,8 +260,6 @@ class CheckoutConfirmResponse(BaseModel):
class BillingSettingsUpdate(BaseModel):
"""Request model for updating billing settings."""
- billingModel: Optional[BillingModelEnum] = None
- defaultUserCredit: Optional[float] = Field(None, ge=0)
warningThresholdPercent: Optional[float] = Field(None, ge=0, le=100)
notifyOnWarning: Optional[bool] = None
notifyEmails: Optional[List[str]] = None
@@ -293,7 +289,6 @@ class AccountSummary(BaseModel):
id: str
mandateId: str
userId: Optional[str]
- accountType: str
balance: float
warningThreshold: float
enabled: bool
@@ -317,10 +312,8 @@ class MandateBalanceResponse(BaseModel):
"""Mandate-level balance summary."""
mandateId: str
mandateName: str
- billingModel: str
totalBalance: float
userCount: int
- defaultUserCredit: float
warningThresholdPercent: float
@@ -414,15 +407,7 @@ def _creditStripeSessionIfNeeded(
if not settings:
raise HTTPException(status_code=404, detail="Billing settings not found")
- billing_model = parseBillingModelFromStoredValue(settings.get("billingModel"))
- if billing_model == BillingModelEnum.PREPAY_USER:
- if not user_id:
- raise HTTPException(status_code=400, detail="userId required for PREPAY_USER")
- account = billingInterface.getOrCreateUserAccount(mandate_id, user_id, initialBalance=0.0)
- elif billing_model == BillingModelEnum.PREPAY_MANDATE:
- account = billingInterface.getOrCreateMandateAccount(mandate_id, initialBalance=0.0)
- else:
- raise HTTPException(status_code=400, detail=f"Cannot add credit to {billing_model.value}")
+ account = billingInterface.getOrCreateMandateAccount(mandate_id, initialBalance=0.0)
transaction = BillingTransaction(
accountId=account["id"],
@@ -516,7 +501,6 @@ def getBalanceForMandate(
return BillingBalanceResponse(
mandateId=targetMandateId,
mandateName=mandateName,
- billingModel=checkResult.billingModel or BillingModelEnum.PREPAY_MANDATE,
balance=checkResult.currentBalance or 0.0,
warningThreshold=0.0, # TODO: Get from account
isWarning=False,
@@ -608,8 +592,6 @@ def getStatistics(
costByFeature={}
)
- billingModel = parseBillingModelFromStoredValue(settings.get("billingModel"))
-
# Transactions are always on user accounts (audit trail)
account = billingInterface.getUserAccount(ctx.mandateId, ctx.user.id)
@@ -734,18 +716,6 @@ def createOrUpdateSettings(
if existingSettings:
updates = settingsUpdate.model_dump(exclude_none=True)
if updates:
- # Check if billing model is changing - trigger budget migration
- if "billingModel" in updates:
- oldModel = parseBillingModelFromStoredValue(existingSettings.get("billingModel"))
- newModel = (
- BillingModelEnum(updates["billingModel"])
- if isinstance(updates["billingModel"], str)
- else updates["billingModel"]
- )
- if oldModel != newModel:
- migrationResult = billingInterface.switchBillingModel(targetMandateId, oldModel, newModel)
- logger.info(f"Billing model migration for {targetMandateId}: {migrationResult}")
-
result = billingInterface.updateSettings(existingSettings["id"], updates)
return result or existingSettings
return existingSettings
@@ -754,16 +724,6 @@ def createOrUpdateSettings(
newSettings = BillingSettings(
mandateId=targetMandateId,
- billingModel=(
- settingsUpdate.billingModel
- if settingsUpdate.billingModel is not None
- else BillingModelEnum.PREPAY_MANDATE
- ),
- defaultUserCredit=(
- settingsUpdate.defaultUserCredit
- if settingsUpdate.defaultUserCredit is not None
- else 0.0
- ),
warningThresholdPercent=(
settingsUpdate.warningThresholdPercent
if settingsUpdate.warningThresholdPercent is not None
@@ -797,34 +757,15 @@ def addCredit(
):
"""
Add credit to a billing account (SysAdmin only).
- For PREPAY_USER model, specify userId. For PREPAY_MANDATE, leave userId empty.
"""
try:
- # Get settings to determine billing model
billingInterface = getBillingInterface(ctx.user, targetMandateId)
settings = billingInterface.getSettings(targetMandateId)
if not settings:
raise HTTPException(status_code=404, detail="Billing settings not found for this mandate")
- billingModel = parseBillingModelFromStoredValue(settings.get("billingModel"))
-
- # Validate request based on billing model
- if billingModel == BillingModelEnum.PREPAY_USER:
- if not creditRequest.userId:
- raise HTTPException(status_code=400, detail="userId is required for PREPAY_USER model")
-
- # Create user-level account if needed and add credit
- account = billingInterface.getOrCreateUserAccount(
- targetMandateId,
- creditRequest.userId,
- initialBalance=0.0
- )
- elif billingModel == BillingModelEnum.PREPAY_MANDATE:
- # Create mandate-level account if needed and add credit
- account = billingInterface.getOrCreateMandateAccount(targetMandateId, initialBalance=0.0)
- else:
- raise HTTPException(status_code=400, detail=f"Cannot add credit to {billingModel.value} billing model")
+ account = billingInterface.getOrCreateMandateAccount(targetMandateId, initialBalance=0.0)
if creditRequest.amount == 0:
raise HTTPException(status_code=400, detail="Amount must not be zero")
@@ -867,8 +808,7 @@ def createCheckoutSession(
):
"""
Create Stripe Checkout Session for credit top-up. Returns redirect URL.
- RBAC: PREPAY_USER requires mandate membership (user loads own account),
- PREPAY_MANDATE requires mandate admin role.
+ Requires mandate admin role.
"""
try:
billingInterface = getBillingInterface(ctx.user, targetMandateId)
@@ -877,20 +817,8 @@ def createCheckoutSession(
if not settings:
raise HTTPException(status_code=404, detail="Billing settings not found for this mandate")
- billingModel = parseBillingModelFromStoredValue(settings.get("billingModel"))
-
- if billingModel == BillingModelEnum.PREPAY_USER:
- if not checkoutRequest.userId:
- raise HTTPException(status_code=400, detail="userId is required for PREPAY_USER model")
- if str(checkoutRequest.userId) != str(ctx.user.id):
- raise HTTPException(status_code=403, detail="Users can only load credit to their own account")
- if not _isMemberOfMandate(ctx, targetMandateId):
- raise HTTPException(status_code=403, detail="User is not a member of this mandate")
- elif billingModel == BillingModelEnum.PREPAY_MANDATE:
- if not _isAdminOfMandate(ctx, targetMandateId):
- raise HTTPException(status_code=403, detail="Mandate admin role required to load mandate credit")
- else:
- raise HTTPException(status_code=400, detail=f"Cannot add credit to {billingModel.value} billing model")
+ if not _isAdminOfMandate(ctx, targetMandateId):
+ raise HTTPException(status_code=403, detail="Mandate admin role required to load mandate credit")
from modules.serviceCenter.services.serviceBilling.stripeCheckout import create_checkout_session
redirect_url = create_checkout_session(
@@ -944,19 +872,8 @@ def confirmCheckoutSession(
if not settings:
raise HTTPException(status_code=404, detail="Billing settings not found")
- billing_model = parseBillingModelFromStoredValue(settings.get("billingModel"))
- if billing_model == BillingModelEnum.PREPAY_USER:
- if not user_id:
- raise HTTPException(status_code=400, detail="userId required for PREPAY_USER")
- if str(user_id) != str(ctx.user.id):
- raise HTTPException(status_code=403, detail="Users can only confirm their own payment sessions")
- if not _isMemberOfMandate(ctx, mandate_id):
- raise HTTPException(status_code=403, detail="User is not a member of this mandate")
- elif billing_model == BillingModelEnum.PREPAY_MANDATE:
- if not _isAdminOfMandate(ctx, mandate_id):
- raise HTTPException(status_code=403, detail="Mandate admin role required")
- else:
- raise HTTPException(status_code=400, detail=f"Cannot add credit to {billing_model.value}")
+ if not _isAdminOfMandate(ctx, mandate_id):
+ raise HTTPException(status_code=403, detail="Mandate admin role required")
root_billing_interface = _getRootInterface()
return _creditStripeSessionIfNeeded(root_billing_interface, session_dict, eventId=None)
@@ -1321,7 +1238,6 @@ def getAccounts(
id=acc.get("id"),
mandateId=acc.get("mandateId"),
userId=acc.get("userId"),
- accountType=acc.get("accountType"),
balance=acc.get("balance", 0.0),
warningThreshold=acc.get("warningThreshold", 0.0),
enabled=acc.get("enabled", True)
diff --git a/modules/routes/routeSecurityLocal.py b/modules/routes/routeSecurityLocal.py
index f066fda2..fb71444b 100644
--- a/modules/routes/routeSecurityLocal.py
+++ b/modules/routes/routeSecurityLocal.py
@@ -17,7 +17,7 @@ from jose import jwt
from modules.auth import getCurrentUser, limiter, SECRET_KEY, ALGORITHM, getRequestContext, RequestContext
from modules.auth import createAccessToken, createRefreshToken, setAccessTokenCookie, setRefreshTokenCookie, clearAccessTokenCookie, clearRefreshTokenCookie
from modules.interfaces.interfaceDbApp import getInterface, getRootInterface
-from modules.datamodels.datamodelUam import User, UserInDB, AuthAuthority, Mandate, MandateType
+from modules.datamodels.datamodelUam import User, UserInDB, AuthAuthority, Mandate
from modules.datamodels.datamodelSecurity import Token, TokenPurpose
from modules.shared.configuration import APP_CONFIG
from modules.shared.timeUtils import getUtcTimestamp
@@ -87,6 +87,22 @@ router = APIRouter(
}
)
+def _ensureHomeMandate(rootInterface, user) -> None:
+ """Ensure user has a Home mandate. Creates 'Home {username}' if none exists."""
+ userMandates = rootInterface.getUserMandates(str(user.id))
+ homeMandateName = f"Home {user.username}"
+ for um in userMandates:
+ mandate = rootInterface.getMandate(um.mandateId)
+ if mandate and (mandate.name or "").startswith("Home ") and not mandate.isSystem:
+ return
+ rootInterface._provisionMandateForUser(
+ userId=str(user.id),
+ mandateName=homeMandateName,
+ planKey="TRIAL_7D",
+ )
+ logger.info(f"Created Home mandate '{homeMandateName}' for user {user.username}")
+
+
@router.post("/login")
@limiter.limit("30/minute")
def login(
@@ -183,6 +199,12 @@ def login(
except Exception as subErr:
logger.error(f"Error activating subscriptions on login: {subErr}")
+ # Ensure user has a Home mandate (created on first login if missing)
+ try:
+ _ensureHomeMandate(rootInterface, user)
+ except Exception as homeErr:
+ logger.error(f"Error ensuring Home mandate for user {user.username}: {homeErr}")
+
# Log successful login (app log file + audit DB for traceability)
logger.info("Login successful for username=%s (userId=%s)", formData.username, str(user.id))
try:
@@ -298,32 +320,35 @@ def register_user(
detail="Failed to register user"
)
- # Provision mandate for new user
+ # Provision Home mandate for every new user ("Home {username}")
provisionResult = None
try:
- if registrationType == "company":
- if not companyName:
- raise HTTPException(
- status_code=status.HTTP_400_BAD_REQUEST,
- detail="companyName is required for company registration"
- )
- provisionResult = appInterface._provisionMandateForUser(
+ homeMandateName = f"Home {user.username}"
+ provisionResult = appInterface._provisionMandateForUser(
+ userId=str(user.id),
+ mandateName=homeMandateName,
+ planKey="TRIAL_7D",
+ )
+ logger.info(f"Provisioned Home mandate for user {user.id}: {provisionResult}")
+ except Exception as provErr:
+ logger.error(f"Error provisioning Home mandate for user {user.id}: {provErr}")
+
+ # If company registration, also create a company mandate with the paid plan
+ if registrationType == "company":
+ if not companyName:
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail="companyName is required for company registration"
+ )
+ try:
+ companyResult = appInterface._provisionMandateForUser(
userId=str(user.id),
- mandateType="company",
mandateName=companyName,
planKey="STANDARD_MONTHLY",
)
- else:
- provisionResult = appInterface._provisionMandateForUser(
- userId=str(user.id),
- mandateType="personal",
- mandateName=user.fullName or user.username,
- planKey="TRIAL_7D",
- )
- logger.info(f"Provisioned mandate for user {user.id}: {provisionResult}")
- except Exception as provErr:
- logger.error(f"Error provisioning mandate for user {user.id}: {provErr}")
- # Don't fail registration if provisioning fails — user can still use store
+ logger.info(f"Provisioned company mandate for user {user.id}: {companyResult}")
+ except Exception as compErr:
+ logger.error(f"Error provisioning company mandate for user {user.id}: {compErr}")
# Generate reset token for password setup
token, expires = appInterface.generateResetTokenAndExpiry()
@@ -406,7 +431,6 @@ Falls Sie sich nicht registriert haben, können Sie diese E-Mail ignorieren."""
}
if provisionResult:
responseData["mandateId"] = provisionResult.get("mandateId")
- responseData["mandateType"] = provisionResult.get("mandateType")
return responseData
except ValueError as e:
@@ -698,37 +722,24 @@ Falls Sie diese Anforderung nicht gestellt haben, können Sie diese E-Mail ignor
def onboarding_provision(
request: Request,
currentUser: User = Depends(getCurrentUser),
- mandateType: str = Body("personal", embed=True),
companyName: str = Body(None, embed=True),
+ planKey: str = Body("TRIAL_7D", embed=True),
) -> Dict[str, Any]:
- """Post-login onboarding: provision mandate for OAuth users who registered without one."""
+ """Post-login onboarding: ensure Home mandate exists and optionally create a company mandate."""
try:
appInterface = getRootInterface()
- userMandates = appInterface.getUserMandates(str(currentUser.id))
- hasOwnMandate = False
- for um in userMandates:
- mandate = appInterface.getMandate(um.mandateId)
- if mandate and not mandate.isSystem:
- hasOwnMandate = True
- break
+ _ensureHomeMandate(appInterface, currentUser)
- if hasOwnMandate:
- return {"message": "User already has a mandate", "alreadyProvisioned": True}
-
- if mandateType == "company":
- mandateName = companyName or currentUser.fullName or currentUser.username
- planKey = "STANDARD_MONTHLY"
- else:
- mandateName = currentUser.fullName or currentUser.username
- planKey = "TRIAL_7D"
-
- result = appInterface._provisionMandateForUser(
- userId=str(currentUser.id),
- mandateType=mandateType,
- mandateName=mandateName,
- planKey=planKey,
- )
+ result = None
+ if companyName and companyName.strip():
+ if planKey not in ("STANDARD_MONTHLY", "STANDARD_YEARLY"):
+ planKey = "STANDARD_MONTHLY"
+ result = appInterface._provisionMandateForUser(
+ userId=str(currentUser.id),
+ mandateName=companyName.strip(),
+ planKey=planKey,
+ )
try:
activatedCount = appInterface._activatePendingSubscriptions(str(currentUser.id))
@@ -740,8 +751,7 @@ def onboarding_provision(
logger.info(f"Onboarding provision for {currentUser.username}: {result}")
return {
"message": "Mandate provisioned successfully",
- "mandateId": result.get("mandateId"),
- "mandateType": result.get("mandateType"),
+ "mandateId": result.get("mandateId") if result else None,
"alreadyProvisioned": False,
}
diff --git a/modules/routes/routeStore.py b/modules/routes/routeStore.py
index cbd4ef6e..19b81ca7 100644
--- a/modules/routes/routeStore.py
+++ b/modules/routes/routeStore.py
@@ -146,10 +146,10 @@ def listUserMandates(
adminMandateIds = _getUserAdminMandateIds(db, userId)
if not adminMandateIds:
+ homeMandateName = f"Home {context.user.username}"
provisionResult = rootInterface._provisionMandateForUser(
userId=userId,
- mandateType="personal",
- mandateName=context.user.fullName or context.user.username,
+ mandateName=homeMandateName,
planKey="TRIAL_7D",
)
adminMandateIds = [provisionResult["mandateId"]]
@@ -164,7 +164,6 @@ def listUserMandates(
"id": mid,
"name": m.get("name", ""),
"label": m.get("label") or m.get("name", ""),
- "mandateType": m.get("mandateType", "company"),
})
return result
except Exception as e:
diff --git a/modules/routes/routeSubscription.py b/modules/routes/routeSubscription.py
index 0c5eed4e..7aad386f 100644
--- a/modules/routes/routeSubscription.py
+++ b/modules/routes/routeSubscription.py
@@ -468,7 +468,12 @@ def _getDataVolumeUsage(
size = f.get("fileSize") if isinstance(f, dict) else getattr(f, "fileSize", 0)
totalBytes += (size or 0)
- usedMB = round(totalBytes / (1024 * 1024), 2)
+ filesMB = round(totalBytes / (1024 * 1024), 2)
+
+ from modules.datamodels.datamodelKnowledge import FileContentIndex
+ ragIndexes = rootIf.db.getRecordset(FileContentIndex, recordFilter={"mandateId": mandateId})
+ ragBytes = sum(int(idx.get("totalSize") or 0) if isinstance(idx, dict) else int(getattr(idx, "totalSize", 0) or 0) for idx in ragIndexes)
+ ragMB = round(ragBytes / (1024 * 1024), 2)
maxMB = None
subs = rootIf.db.getRecordset(MandateSubscription, recordFilter={"mandateId": mandateId})
@@ -484,10 +489,14 @@ def _getDataVolumeUsage(
if maxMB:
break
+ usedMB = ragMB
+ percentUsed = round((usedMB / maxMB) * 100, 1) if maxMB else None
return {
"mandateId": mandateId,
"usedMB": usedMB,
+ "filesMB": filesMB,
+ "ragIndexMB": ragMB,
"maxDataVolumeMB": maxMB,
- "percentUsed": round((usedMB / maxMB) * 100, 1) if maxMB else None,
- "warning": usedMB >= (maxMB * 0.8) if maxMB else False,
+ "percentUsed": percentUsed,
+ "warning": (percentUsed or 0) >= 80,
}
diff --git a/modules/serviceCenter/context.py b/modules/serviceCenter/context.py
index f9ab0a44..acad6d61 100644
--- a/modules/serviceCenter/context.py
+++ b/modules/serviceCenter/context.py
@@ -20,6 +20,7 @@ class ServiceCenterContext:
feature_instance_id: Optional[str] = None
workflow_id: Optional[str] = None
workflow: Any = None
+ requireNeutralization: Optional[bool] = None
@property
def mandateId(self) -> Optional[str]:
diff --git a/modules/serviceCenter/services/serviceAgent/mainServiceAgent.py b/modules/serviceCenter/services/serviceAgent/mainServiceAgent.py
index 539d3672..c4e1f877 100644
--- a/modules/serviceCenter/services/serviceAgent/mainServiceAgent.py
+++ b/modules/serviceCenter/services/serviceAgent/mainServiceAgent.py
@@ -322,14 +322,20 @@ class AgentService:
def _createAiCallFn(self) -> Callable[[AiCallRequest], AiCallResponse]:
"""Create the AI call function that wraps serviceAi with billing."""
+ ctxNeutralization = getattr(self.ctx, 'requireNeutralization', None)
async def _aiCallFn(request: AiCallRequest) -> AiCallResponse:
+ if ctxNeutralization is not None and request.requireNeutralization is None:
+ request.requireNeutralization = ctxNeutralization
aiService = self.services.ai
return await aiService.callAi(request)
return _aiCallFn
def _createAiCallStreamFn(self):
"""Create the streaming AI call function. Yields str deltas, then AiCallResponse."""
+ ctxNeutralization = getattr(self.ctx, 'requireNeutralization', None)
async def _aiCallStreamFn(request: AiCallRequest):
+ if ctxNeutralization is not None and request.requireNeutralization is None:
+ request.requireNeutralization = ctxNeutralization
aiService = self.services.ai
async for chunk in aiService.callAiStream(request):
yield chunk
diff --git a/modules/serviceCenter/services/serviceAi/mainServiceAi.py b/modules/serviceCenter/services/serviceAi/mainServiceAi.py
index 541835a3..b25374d5 100644
--- a/modules/serviceCenter/services/serviceAi/mainServiceAi.py
+++ b/modules/serviceCenter/services/serviceAi/mainServiceAi.py
@@ -17,7 +17,6 @@ from modules.shared.jsonUtils import (
)
from .subJsonResponseHandling import JsonResponseHandler
from modules.datamodels.datamodelAi import JsonAccumulationState
-from modules.datamodels.datamodelBilling import BillingModelEnum
from modules.serviceCenter.services.serviceBilling.billingExhaustedNotify import (
maybeEmailMandatePoolExhausted,
)
@@ -747,15 +746,14 @@ detectedIntent-Werte:
f"Balance {balance_str} CHF, "
f"Reason: {reason}"
)
- if balanceCheck.billingModel == BillingModelEnum.PREPAY_MANDATE:
- ulabel = (getattr(user, "email", None) or getattr(user, "username", None) or str(user.id))
- maybeEmailMandatePoolExhausted(
- str(mandateId),
- str(user.id),
- str(ulabel),
- float(balanceCheck.currentBalance or 0.0),
- float(estimatedCost),
- )
+ ulabel = (getattr(user, "email", None) or getattr(user, "username", None) or str(user.id))
+ maybeEmailMandatePoolExhausted(
+ str(mandateId),
+ str(user.id),
+ str(ulabel),
+ float(balanceCheck.currentBalance or 0.0),
+ float(estimatedCost),
+ )
raise InsufficientBalanceException.fromBalanceCheck(
balanceCheck,
str(mandateId),
diff --git a/modules/serviceCenter/services/serviceBilling/mainServiceBilling.py b/modules/serviceCenter/services/serviceBilling/mainServiceBilling.py
index 790612ed..3a33f1f6 100644
--- a/modules/serviceCenter/services/serviceBilling/mainServiceBilling.py
+++ b/modules/serviceCenter/services/serviceBilling/mainServiceBilling.py
@@ -16,13 +16,11 @@ from datetime import datetime
from modules.datamodels.datamodelUam import User
from modules.datamodels.datamodelBilling import (
- BillingModelEnum,
BillingCheckResult,
TransactionTypeEnum,
ReferenceTypeEnum,
BillingTransaction,
BillingBalanceResponse,
- parseBillingModelFromStoredValue,
)
from modules.interfaces.interfaceDbBilling import getInterface as getBillingInterface
@@ -369,20 +367,10 @@ class BillingService:
logger.warning(f"No billing settings for mandate {self.mandateId}")
return None
- billingModel = parseBillingModelFromStoredValue(settings.get("billingModel"))
-
- # Get or create account
- if billingModel == BillingModelEnum.PREPAY_USER:
- account = self._billingInterface.getOrCreateUserAccount(
- self.mandateId,
- self.currentUser.id,
- initialBalance=0.0
- )
- else:
- account = self._billingInterface.getOrCreateMandateAccount(
- self.mandateId,
- initialBalance=0.0
- )
+ account = self._billingInterface.getOrCreateMandateAccount(
+ self.mandateId,
+ initialBalance=0.0
+ )
# Create credit transaction
transaction = BillingTransaction(
@@ -429,45 +417,32 @@ BILLING_USER_ACTION_TOP_UP_SELF = "TOP_UP_SELF"
BILLING_USER_ACTION_CONTACT_MANDATE_ADMIN = "CONTACT_MANDATE_ADMIN"
-def _userActionForBillingModel(bm: BillingModelEnum) -> str:
- if bm == BillingModelEnum.PREPAY_USER:
- return BILLING_USER_ACTION_TOP_UP_SELF
+def _defaultInsufficientBalanceUserAction() -> str:
return BILLING_USER_ACTION_CONTACT_MANDATE_ADMIN
def _buildInsufficientBalanceMessages(
- bm: BillingModelEnum,
currentBalance: float,
requiredAmount: float,
) -> tuple:
bal_s = f"{currentBalance:.2f}"
req_s = f"{requiredAmount:.2f}"
- if bm == BillingModelEnum.PREPAY_USER:
- msg_de = (
- f"Ihr persönliches Guthaben ist aufgebraucht (aktuell CHF {bal_s}, benötigt mindestens CHF {req_s}). "
- "Bitte laden Sie unter „Billing“ Guthaben nach."
- )
- msg_en = (
- f"Your personal balance is exhausted (current CHF {bal_s}, at least CHF {req_s} required). "
- "Please top up under Billing."
- )
- else:
- msg_de = (
- f"Das Mandanten-Budget ist aufgebraucht (aktuell CHF {bal_s}, benötigt mindestens CHF {req_s}). "
- "Bitte informieren Sie die Administratorin bzw. den Administrator Ihres Mandanten. "
- "Die in den Billing-Einstellungen hinterlegten Kontakte wurden per E-Mail informiert (falls konfiguriert)."
- )
- msg_en = (
- f"The organization budget is exhausted (current CHF {bal_s}, at least CHF {req_s} required). "
- "Please contact your mandate administrator. Billing notification contacts were emailed if configured."
- )
+ msg_de = (
+ f"Das Mandanten-Budget ist aufgebraucht (aktuell CHF {bal_s}, benötigt mindestens CHF {req_s}). "
+ "Bitte informieren Sie die Administratorin bzw. den Administrator Ihres Mandanten. "
+ "Die in den Billing-Einstellungen hinterlegten Kontakte wurden per E-Mail informiert (falls konfiguriert)."
+ )
+ msg_en = (
+ f"The organization budget is exhausted (current CHF {bal_s}, at least CHF {req_s} required). "
+ "Please contact your mandate administrator. Billing notification contacts were emailed if configured."
+ )
return msg_de, msg_en
class InsufficientBalanceException(Exception):
"""Raised when there's insufficient balance for an operation.
- Carries structured fields for API/SSE clients (userAction, billingModel, localized hints).
+ Carries structured fields for API/SSE clients (userAction, localized hints).
"""
def __init__(
@@ -476,7 +451,6 @@ class InsufficientBalanceException(Exception):
requiredAmount: float,
message: Optional[str] = None,
*,
- billing_model: Optional[BillingModelEnum] = None,
mandate_id: str = "",
user_action: Optional[str] = None,
message_de: Optional[str] = None,
@@ -484,12 +458,8 @@ class InsufficientBalanceException(Exception):
):
self.currentBalance = float(currentBalance)
self.requiredAmount = float(requiredAmount)
- self.billing_model = billing_model
self.mandate_id = mandate_id or ""
- if billing_model is not None:
- self.user_action = user_action or _userActionForBillingModel(billing_model)
- else:
- self.user_action = user_action or BILLING_USER_ACTION_TOP_UP_SELF
+ self.user_action = user_action or _defaultInsufficientBalanceUserAction()
if message_de is not None and message_en is not None:
self.message_de = message_de
@@ -500,8 +470,7 @@ class InsufficientBalanceException(Exception):
self.message_de = message
self.message_en = message
else:
- bm = billing_model or BillingModelEnum.PREPAY_USER
- md, me = _buildInsufficientBalanceMessages(bm, self.currentBalance, self.requiredAmount)
+ md, me = _buildInsufficientBalanceMessages(self.currentBalance, self.requiredAmount)
self.message_de = md
self.message_en = me
self.message = md
@@ -514,14 +483,12 @@ class InsufficientBalanceException(Exception):
mandate_id: str,
required_amount: float,
) -> "InsufficientBalanceException":
- bm = check.billingModel or BillingModelEnum.PREPAY_MANDATE
bal = float(check.currentBalance or 0.0)
- msg_de, msg_en = _buildInsufficientBalanceMessages(bm, bal, required_amount)
+ msg_de, msg_en = _buildInsufficientBalanceMessages(bal, required_amount)
return cls(
bal,
required_amount,
message=msg_de,
- billing_model=bm,
mandate_id=mandate_id or "",
message_de=msg_de,
message_en=msg_en,
@@ -538,8 +505,6 @@ class InsufficientBalanceException(Exception):
"messageEn": self.message_en,
"userAction": self.user_action,
}
- if self.billing_model is not None:
- out["billingModel"] = self.billing_model.value
if self.mandate_id:
out["mandateId"] = self.mandate_id
if self.user_action == BILLING_USER_ACTION_TOP_UP_SELF:
diff --git a/modules/serviceCenter/services/serviceBilling/stripeCheckout.py b/modules/serviceCenter/services/serviceBilling/stripeCheckout.py
index 8d6b4a57..bc98cc65 100644
--- a/modules/serviceCenter/services/serviceBilling/stripeCheckout.py
+++ b/modules/serviceCenter/services/serviceBilling/stripeCheckout.py
@@ -65,7 +65,7 @@ def create_checkout_session(
Args:
mandate_id: Target mandate ID
- user_id: Target user ID (for PREPAY_USER) or None (for mandate pool)
+ user_id: Target user ID for audit trail (optional)
amount_chf: Amount in CHF (must be in ALLOWED_AMOUNTS_CHF)
Returns:
diff --git a/tests/test_phase123_basic.py b/tests/test_phase123_basic.py
index 18c4188f..222c6043 100644
--- a/tests/test_phase123_basic.py
+++ b/tests/test_phase123_basic.py
@@ -26,12 +26,11 @@ def _check(label, condition, detail=""):
print("\n--- Phase 1: Data Models ---")
try:
- from modules.datamodels.datamodelUam import Mandate, MandateType
- _check("MandateType Enum exists", hasattr(MandateType, "SYSTEM"))
- _check("MandateType values", set(MandateType) == {MandateType.SYSTEM, MandateType.PERSONAL, MandateType.COMPANY})
- m = Mandate(name="test", label="test", mandateType="personal")
- _check("Mandate has mandateType field", hasattr(m, "mandateType"))
- _check("Mandate mandateType coercion", m.mandateType == MandateType.PERSONAL)
+ from modules.datamodels.datamodelUam import Mandate
+ m = Mandate(name="test", label="test")
+ _check("Mandate has isSystem field", hasattr(m, "isSystem"))
+ _check("Mandate isSystem default False", m.isSystem is False)
+ _check("Mandate no mandateType field", not hasattr(m, "mandateType"))
except Exception as e:
errors.append(f"Phase 1 DataModel: {e}")
print(f" [FAIL] Phase 1 DataModel import: {e}")