cleaned mandate and unified mandate to be standard type

This commit is contained in:
ValueOn AG 2026-03-28 23:54:11 +01:00
parent 1f42c015d6
commit 1fdf238aaf
22 changed files with 366 additions and 665 deletions

4
app.py
View file

@ -374,7 +374,7 @@ async def lifespan(app: FastAPI):
if settingsCreated > 0: if settingsCreated > 0:
logger.info(f"Billing startup: Created {settingsCreated} missing mandate billing settings") 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() accountsCreated = billingInterface.ensureAllUserAccountsExist()
if accountsCreated > 0: if accountsCreated > 0:
logger.info(f"Billing startup: Created {accountsCreated} missing user accounts") 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): 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)} payload = exc.toClientDict() if hasattr(exc, "toClientDict") else {"error": "INSUFFICIENT_BALANCE", "message": str(exc)}
return JSONResponse(status_code=402, content={"detail": payload}) return JSONResponse(status_code=402, content={"detail": payload})

View file

@ -11,22 +11,6 @@ from modules.shared.attributeUtils import registerModelLabels
import uuid 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): class TransactionTypeEnum(str, Enum):
"""Transaction types for billing.""" """Transaction types for billing."""
CREDIT = "CREDIT" # Credit/top-up (positive) CREDIT = "CREDIT" # Credit/top-up (positive)
@ -55,8 +39,7 @@ class BillingAccount(PowerOnModel):
default_factory=lambda: str(uuid.uuid4()), description="Primary key" default_factory=lambda: str(uuid.uuid4()), description="Primary key"
) )
mandateId: str = Field(..., description="Foreign key to Mandate") mandateId: str = Field(..., description="Foreign key to Mandate")
userId: Optional[str] = Field(None, description="Foreign key to User (only for PREPAY_USER)") userId: Optional[str] = Field(None, description="Foreign key to User (None = mandate pool account, set = user audit account)")
accountType: AccountTypeEnum = Field(..., description="Account type: MANDATE or USER")
balance: float = Field(default=0.0, description="Current balance in CHF") balance: float = Field(default=0.0, description="Current balance in CHF")
warningThreshold: float = Field(default=0.0, description="Warning threshold in CHF") warningThreshold: float = Field(default=0.0, description="Warning threshold in CHF")
lastWarningAt: Optional[datetime] = Field(None, description="Last warning sent timestamp") lastWarningAt: Optional[datetime] = Field(None, description="Last warning sent timestamp")
@ -70,7 +53,6 @@ registerModelLabels(
"id": {"en": "ID", "de": "ID"}, "id": {"en": "ID", "de": "ID"},
"mandateId": {"en": "Mandate ID", "de": "Mandanten-ID"}, "mandateId": {"en": "Mandate ID", "de": "Mandanten-ID"},
"userId": {"en": "User ID", "de": "Benutzer-ID"}, "userId": {"en": "User ID", "de": "Benutzer-ID"},
"accountType": {"en": "Account Type", "de": "Kontotyp"},
"balance": {"en": "Balance (CHF)", "de": "Guthaben (CHF)"}, "balance": {"en": "Balance (CHF)", "de": "Guthaben (CHF)"},
"warningThreshold": {"en": "Warning Threshold (CHF)", "de": "Warnschwelle (CHF)"}, "warningThreshold": {"en": "Warning Threshold (CHF)", "de": "Warnschwelle (CHF)"},
"lastWarningAt": {"en": "Last Warning", "de": "Letzte Warnung"}, "lastWarningAt": {"en": "Last Warning", "de": "Letzte Warnung"},
@ -130,27 +112,28 @@ registerModelLabels(
class BillingSettings(BaseModel): class BillingSettings(BaseModel):
"""Billing settings per mandate.""" """Billing settings per mandate. Only PREPAY_MANDATE model."""
id: str = Field( id: str = Field(
default_factory=lambda: str(uuid.uuid4()), description="Primary key" default_factory=lambda: str(uuid.uuid4()), description="Primary key"
) )
mandateId: str = Field(..., description="Foreign key to Mandate (UNIQUE)") 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") warningThresholdPercent: float = Field(default=10.0, description="Warning threshold as percentage")
# Stripe # Stripe
stripeCustomerId: Optional[str] = Field(None, description="Stripe Customer ID (cus_xxx) — one per mandate") 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( notifyEmails: List[str] = Field(
default_factory=list, 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") notifyOnWarning: bool = Field(default=True, description="Send email when warning threshold is reached")
@ -161,16 +144,14 @@ registerModelLabels(
{ {
"id": {"en": "ID", "de": "ID"}, "id": {"en": "ID", "de": "ID"},
"mandateId": {"en": "Mandate ID", "de": "Mandanten-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 (%)"}, "warningThresholdPercent": {"en": "Warning Threshold (%)", "de": "Warnschwelle (%)"},
"stripeCustomerId": {"en": "Stripe Customer ID", "de": "Stripe-Kunden-ID"}, "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": { "notifyEmails": {
"en": "Billing notification emails (owner / admin)", "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"}, "notifyOnWarning": {"en": "Notify on Warning", "de": "Bei Warnung benachrichtigen"},
}, },
@ -239,7 +220,6 @@ class BillingBalanceResponse(BaseModel):
"""Response model for balance endpoint.""" """Response model for balance endpoint."""
mandateId: str mandateId: str
mandateName: str mandateName: str
billingModel: BillingModelEnum
balance: float balance: float
currency: str = "CHF" currency: str = "CHF"
warningThreshold: float warningThreshold: float
@ -270,20 +250,8 @@ class BillingCheckResult(BaseModel):
reason: Optional[str] = None reason: Optional[str] = None
currentBalance: Optional[float] = None currentBalance: Optional[float] = None
requiredAmount: Optional[float] = None requiredAmount: Optional[float] = None
billingModel: Optional[BillingModelEnum] = None
upgradeRequired: Optional[bool] = None upgradeRequired: Optional[bool] = None
subscriptionUiPath: Optional[str] = None subscriptionUiPath: Optional[str] = None
userAction: 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

View file

@ -72,6 +72,7 @@ class SubscriptionPlan(BaseModel):
maxFeatureInstances: Optional[int] = Field(None, description="Hard cap on active feature instances (None = unlimited)") 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)") 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)") 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") 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"}, "maxUsers": {"en": "Max Users", "de": "Max. Benutzer", "fr": "Max. utilisateurs"},
"maxFeatureInstances": {"en": "Max Instances", "de": "Max. Instanzen", "fr": "Max. instances"}, "maxFeatureInstances": {"en": "Max Instances", "de": "Max. Instanzen", "fr": "Max. instances"},
"maxDataVolumeMB": {"en": "Data Volume (MB)", "de": "Datenvolumen (MB)"}, "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, maxUsers=None,
maxFeatureInstances=None, maxFeatureInstances=None,
maxDataVolumeMB=None, maxDataVolumeMB=None,
budgetAiCHF=0.0,
), ),
"TRIAL_7D": SubscriptionPlan( "TRIAL_7D": SubscriptionPlan(
planKey="TRIAL_7D", planKey="TRIAL_7D",
selectableByUser=False, selectableByUser=False,
title={"en": "Free Trial (7 days)", "de": "Gratis-Testphase (7 Tage)", "fr": "Essai gratuit (7 jours)"}, title={"en": "Free Trial (7 days)", "de": "Gratis-Testphase (7 Tage)", "fr": "Essai gratuit (7 jours)"},
description={ description={
"en": "Try the platform for 7 days — 1 user, up to 3 feature instances.", "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.", "de": "Plattform 7 Tage testen — 1 User, bis zu 3 Feature-Instanzen, 5 CHF AI-Budget inklusive.",
}, },
billingPeriod=BillingPeriodEnum.NONE, billingPeriod=BillingPeriodEnum.NONE,
autoRenew=False, autoRenew=False,
@ -201,6 +204,7 @@ BUILTIN_PLANS: Dict[str, SubscriptionPlan] = {
maxFeatureInstances=3, maxFeatureInstances=3,
trialDays=7, trialDays=7,
maxDataVolumeMB=500, maxDataVolumeMB=500,
budgetAiCHF=5.0,
successorPlanKey="STANDARD_MONTHLY", successorPlanKey="STANDARD_MONTHLY",
), ),
"STANDARD_MONTHLY": SubscriptionPlan( "STANDARD_MONTHLY": SubscriptionPlan(
@ -208,26 +212,28 @@ BUILTIN_PLANS: Dict[str, SubscriptionPlan] = {
selectableByUser=True, selectableByUser=True,
title={"en": "Standard (Monthly)", "de": "Standard (Monatlich)", "fr": "Standard (Mensuel)"}, title={"en": "Standard (Monthly)", "de": "Standard (Monatlich)", "fr": "Standard (Mensuel)"},
description={ description={
"en": "Usage-based billing per active user and feature instance, billed monthly.", "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.", "de": "Nutzungsbasierte Abrechnung pro aktivem User und Feature-Instanz, monatlich. Inkl. 10 CHF AI-Budget.",
}, },
billingPeriod=BillingPeriodEnum.MONTHLY, billingPeriod=BillingPeriodEnum.MONTHLY,
pricePerUserCHF=90.0, pricePerUserCHF=90.0,
pricePerFeatureInstanceCHF=150.0, pricePerFeatureInstanceCHF=150.0,
maxDataVolumeMB=10240, maxDataVolumeMB=10240,
budgetAiCHF=10.0,
), ),
"STANDARD_YEARLY": SubscriptionPlan( "STANDARD_YEARLY": SubscriptionPlan(
planKey="STANDARD_YEARLY", planKey="STANDARD_YEARLY",
selectableByUser=True, selectableByUser=True,
title={"en": "Standard (Yearly)", "de": "Standard (Jährlich)", "fr": "Standard (Annuel)"}, title={"en": "Standard (Yearly)", "de": "Standard (Jährlich)", "fr": "Standard (Annuel)"},
description={ description={
"en": "Usage-based billing per active user and feature instance, billed yearly.", "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.", "de": "Nutzungsbasierte Abrechnung pro aktivem User und Feature-Instanz, jährlich. Inkl. 120 CHF AI-Budget.",
}, },
billingPeriod=BillingPeriodEnum.YEARLY, billingPeriod=BillingPeriodEnum.YEARLY,
pricePerUserCHF=1080.0, pricePerUserCHF=1080.0,
pricePerFeatureInstanceCHF=1800.0, pricePerFeatureInstanceCHF=1800.0,
maxDataVolumeMB=10240, maxDataVolumeMB=10240,
budgetAiCHF=120.0,
), ),
} }

View file

@ -60,12 +60,6 @@ class UserPermissions(BaseModel):
) )
class MandateType(str, Enum):
SYSTEM = "system"
PERSONAL = "personal"
COMPANY = "company"
class Mandate(PowerOnModel): class Mandate(PowerOnModel):
""" """
Mandate (Mandant/Tenant) model. 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.", 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} 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( deletedAt: Optional[float] = Field(
default=None, default=None,
description="Timestamp when the mandate was soft-deleted. After 30 days, hard-delete is triggered.", 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 False
return v 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( registerModelLabels(
"Mandate", "Mandate",
{"en": "Mandate", "de": "Mandant", "fr": "Mandat"}, {"en": "Mandate", "de": "Mandant", "fr": "Mandat"},
@ -140,7 +112,6 @@ registerModelLabels(
"label": {"en": "Label", "de": "Label", "fr": "Libellé"}, "label": {"en": "Label", "de": "Label", "fr": "Libellé"},
"enabled": {"en": "Enabled", "de": "Aktiviert", "fr": "Activé"}, "enabled": {"en": "Enabled", "de": "Aktiviert", "fr": "Activé"},
"isSystem": {"en": "System Mandate", "de": "System-Mandant", "fr": "Mandat système"}, "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"}, "deletedAt": {"en": "Deleted at", "de": "Gelöscht am", "fr": "Supprimé le"},
}, },
) )

View file

@ -1222,11 +1222,9 @@ def _preflight_billing_check(services, mandateId: str, featureInstanceId: Option
balanceCheck = billingService.checkBalance(0.01) balanceCheck = billingService.checkBalance(0.01)
if not balanceCheck.allowed: if not balanceCheck.allowed:
mid = str(getattr(services, "mandateId", None) or mandateId or "") mid = str(getattr(services, "mandateId", None) or mandateId or "")
from modules.datamodels.datamodelBilling import BillingModelEnum
from modules.serviceCenter.services.serviceBilling.billingExhaustedNotify import ( from modules.serviceCenter.services.serviceBilling.billingExhaustedNotify import (
maybeEmailMandatePoolExhausted, maybeEmailMandatePoolExhausted,
) )
if balanceCheck.billingModel == BillingModelEnum.PREPAY_MANDATE:
u = getattr(services, "user", None) u = getattr(services, "user", None)
ulabel = ( ulabel = (
(getattr(u, "email", None) or getattr(u, "username", None) or str(getattr(u, "id", ""))) (getattr(u, "email", None) or getattr(u, "username", None) or str(getattr(u, "id", "")))

View file

@ -87,6 +87,7 @@ class WorkspaceInputRequest(BaseModel):
workflowId: Optional[str] = Field(default=None, description="Continue existing workflow") workflowId: Optional[str] = Field(default=None, description="Continue existing workflow")
userLanguage: str = Field(default="en", description="User language code") userLanguage: str = Field(default="en", description="User language code")
allowedProviders: List[str] = Field(default_factory=list, description="Restrict AI to these providers") 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: async def _getAiObjects() -> AiObjects:
@ -588,6 +589,7 @@ async def streamWorkspaceStart(
userLanguage=userInput.userLanguage, userLanguage=userInput.userLanguage,
instanceConfig=instanceConfig, instanceConfig=instanceConfig,
allowedProviders=userInput.allowedProviders, allowedProviders=userInput.allowedProviders,
requireNeutralization=userInput.requireNeutralization,
) )
) )
eventManager.register_agent_task(queueId, agentTask) eventManager.register_agent_task(queueId, agentTask)
@ -643,6 +645,7 @@ async def _runWorkspaceAgent(
userLanguage: str = "en", userLanguage: str = "en",
instanceConfig: Dict[str, Any] = None, instanceConfig: Dict[str, Any] = None,
allowedProviders: List[str] = None, allowedProviders: List[str] = None,
requireNeutralization: Optional[bool] = None,
): ):
"""Run the serviceAgent loop and forward events to the SSE queue.""" """Run the serviceAgent loop and forward events to the SSE queue."""
try: try:
@ -660,6 +663,8 @@ async def _runWorkspaceAgent(
if allowedProviders: if allowedProviders:
aiService.services.allowedProviders = allowedProviders aiService.services.allowedProviders = allowedProviders
if requireNeutralization is not None:
ctx.requireNeutralization = requireNeutralization
wfRecord = chatInterface.getWorkflow(workflowId) if workflowId else None wfRecord = chatInterface.getWorkflow(workflowId) if workflowId else None
wfName = "" wfName = ""
@ -887,6 +892,7 @@ async def listWorkspaceWorkflows(
request: Request, request: Request,
instanceId: str = Path(...), instanceId: str = Path(...),
includeArchived: bool = Query(default=False, description="Include archived workflows"), 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), context: RequestContext = Depends(getRequestContext),
): ):
"""List workspace workflows/conversations for this instance.""" """List workspace workflows/conversations for this instance."""
@ -930,10 +936,54 @@ async def listWorkspaceWorkflows(
item.setdefault("featureLabel", labels["featureLabel"]) item.setdefault("featureLabel", labels["featureLabel"])
item.setdefault("featureCode", labels["featureCode"]) item.setdefault("featureCode", labels["featureCode"])
item.setdefault("featureInstanceId", fiId) item.setdefault("featureInstanceId", fiId)
lastMsg = chatInterface.getLastMessageTimestamp(item.get("id"))
if lastMsg:
item["lastMessageAt"] = lastMsg
items.append(item) 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}) 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): class UpdateWorkflowRequest(BaseModel):
"""Request body for updating a workflow (PATCH).""" """Request body for updating a workflow (PATCH)."""
name: Optional[str] = Field(default=None, description="New workflow name") name: Optional[str] = Field(default=None, description="New workflow name")

View file

@ -418,8 +418,6 @@ def initRootMandate(db: DatabaseConnector) -> Optional[str]:
if existingMandates: if existingMandates:
mandateId = existingMandates[0].get("id") mandateId = existingMandates[0].get("id")
logger.info(f"Root mandate already exists with ID {mandateId}") logger.info(f"Root mandate already exists with ID {mandateId}")
# Ensure mandateType is set to system
db.recordModify(Mandate, mandateId, {"mandateType": "system"})
return mandateId return mandateId
# Check for legacy root mandates (name="Root" without isSystem flag) and migrate # 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) createdMandate = db.recordCreate(Mandate, rootMandate)
mandateId = createdMandate.get("id") mandateId = createdMandate.get("id")
logger.info(f"Root mandate created with ID {mandateId}") 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 return mandateId
@ -2116,71 +2112,43 @@ def _createStoreResourceRules(db: DatabaseConnector) -> None:
def initRootMandateBilling(mandateId: str) -> None: def initRootMandateBilling(mandateId: str) -> None:
""" """
Initialize billing settings for root mandate. Initialize billing settings for root mandate (PREPAY_MANDATE).
Root mandate uses PREPAY_USER model with default initial credit per user in settings (DEFAULT_USER_CREDIT_CHF at bootstrap only). Creates mandate pool account and user audit accounts.
Creates billing accounts for ALL users regardless of billing model (for audit trail).
Args:
mandateId: Root mandate ID
""" """
try: try:
from modules.interfaces.interfaceDbBilling import _getRootInterface from modules.interfaces.interfaceDbBilling import _getRootInterface
from modules.interfaces.interfaceDbApp import getRootInterface as getAppRootInterface from modules.interfaces.interfaceDbApp import getRootInterface as getAppRootInterface
from modules.datamodels.datamodelBilling import ( from modules.datamodels.datamodelBilling import BillingSettings
BillingSettings,
BillingModelEnum,
DEFAULT_USER_CREDIT_CHF,
parseBillingModelFromStoredValue,
)
billingInterface = _getRootInterface() billingInterface = _getRootInterface()
appInterface = getAppRootInterface() appInterface = getAppRootInterface()
# Check if settings already exist
existingSettings = billingInterface.getSettings(mandateId) existingSettings = billingInterface.getSettings(mandateId)
if existingSettings: if existingSettings:
logger.info("Billing settings for root mandate already exist") logger.info("Billing settings for root mandate already exist")
else: else:
settings = BillingSettings( settings = BillingSettings(
mandateId=mandateId, mandateId=mandateId,
billingModel=BillingModelEnum.PREPAY_USER,
defaultUserCredit=DEFAULT_USER_CREDIT_CHF,
warningThresholdPercent=10.0, warningThresholdPercent=10.0,
notifyOnWarning=True notifyOnWarning=True
) )
billingInterface.createSettings(settings) billingInterface.createSettings(settings)
logger.info( logger.info("Created billing settings for root mandate: PREPAY_MANDATE")
f"Created billing settings for root mandate: PREPAY_USER with {DEFAULT_USER_CREDIT_CHF} CHF default credit"
)
existingSettings = billingInterface.getSettings(mandateId) existingSettings = billingInterface.getSettings(mandateId)
# Always create user accounts for all users (audit trail)
if existingSettings: if existingSettings:
billingModel = parseBillingModelFromStoredValue( billingInterface.getOrCreateMandateAccount(mandateId, initialBalance=0.0)
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
userMandates = appInterface.getUserMandatesByMandate(mandateId) userMandates = appInterface.getUserMandatesByMandate(mandateId)
accountsCreated = 0 accountsCreated = 0
for um in userMandates: for um in userMandates:
userId = um.get("userId") if isinstance(um, dict) else getattr(um, "userId", None) userId = um.get("userId") if isinstance(um, dict) else getattr(um, "userId", None)
if userId: if userId:
existingAccount = billingInterface.getUserAccount(mandateId, userId) existingAccount = billingInterface.getUserAccount(mandateId, userId)
if not existingAccount: if not existingAccount:
billingInterface.getOrCreateUserAccount(mandateId, userId, initialBalance=initialBalance) billingInterface.getOrCreateUserAccount(mandateId, userId, initialBalance=0.0)
accountsCreated += 1 accountsCreated += 1
logger.debug(f"Created billing account for user {userId}")
if accountsCreated > 0: 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: except Exception as e:
logger.warning(f"Failed to initialize root mandate billing (non-critical): {e}") logger.warning(f"Failed to initialize root mandate billing (non-critical): {e}")

View file

@ -1407,12 +1407,11 @@ class AppObjects:
return Mandate(**createdRecord) 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. Atomic provisioning: create Mandate + UserMandate + Subscription + auto-create FeatureInstances.
Internal method bypasses RBAC (used during registration when user has no permissions yet). 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.datamodelSubscription import MandateSubscription, SubscriptionStatusEnum, BUILTIN_PLANS
from modules.datamodels.datamodelFeatures import FeatureInstance from modules.datamodels.datamodelFeatures import FeatureInstance
from modules.interfaces.interfaceBootstrap import copySystemRolesToMandate from modules.interfaces.interfaceBootstrap import copySystemRolesToMandate
@ -1428,7 +1427,6 @@ class AppObjects:
label=mandateName, label=mandateName,
enabled=True, enabled=True,
isSystem=False, isSystem=False,
mandateType=MandateType(mandateType),
) )
createdMandate = self.db.recordCreate(Mandate, mandateData) createdMandate = self.db.recordCreate(Mandate, mandateData)
if not createdMandate or not createdMandate.get("id"): if not createdMandate or not createdMandate.get("id"):
@ -1497,11 +1495,10 @@ class AppObjects:
except Exception as e: except Exception as e:
logger.error(f"Error auto-creating instance for '{featureName}': {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 { return {
"mandateId": mandateId, "mandateId": mandateId,
"planKey": planKey, "planKey": planKey,
"mandateType": mandateType,
"featureInstances": createdInstances, "featureInstances": createdInstances,
} }
except Exception as e: except Exception as e:
@ -1632,7 +1629,10 @@ class AppObjects:
from modules.datamodels.datamodelChat import ChatWorkflow, ChatMessage, ChatLog from modules.datamodels.datamodelChat import ChatWorkflow, ChatMessage, ChatLog
from modules.datamodels.datamodelFiles import FileItem from modules.datamodels.datamodelFiles import FileItem
from modules.datamodels.datamodelDataSource import DataSource 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 from modules.features.neutralization.datamodelFeatureNeutralizer import DataNeutralizerAttributes
instances = self.db.getRecordset(FeatureInstance, recordFilter={"mandateId": mandateId}) instances = self.db.getRecordset(FeatureInstance, recordFilter={"mandateId": mandateId})
@ -1643,12 +1643,15 @@ class AppObjects:
if not instId: if not instId:
continue continue
# 0a. FileContentIndex (knowledge/RAG) # 0a. ContentChunk (embeddings) + FileContentIndex (knowledge/RAG)
fciRecords = self.db.getRecordset(FileContentIndex, recordFilter={"featureInstanceId": instId}) fciRecords = self.db.getRecordset(FileContentIndex, recordFilter={"featureInstanceId": instId})
for rec in fciRecords: 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")) self.db.recordDelete(FileContentIndex, rec.get("id"))
if fciRecords: 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 # 0b. DataNeutralizerAttributes
dnaRecords = self.db.getRecordset(DataNeutralizerAttributes, recordFilter={"featureInstanceId": instId}) dnaRecords = self.db.getRecordset(DataNeutralizerAttributes, recordFilter={"featureInstanceId": instId})
@ -1664,6 +1667,13 @@ class AppObjects:
if dsRecords: if dsRecords:
logger.info(f"Cascade: deleted {len(dsRecords)} DataSource records for instance {instId}") 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 # 0d. FileItem
fileRecords = self.db.getRecordset(FileItem, recordFilter={"featureInstanceId": instId}) fileRecords = self.db.getRecordset(FileItem, recordFilter={"featureInstanceId": instId})
for rec in fileRecords: for rec in fileRecords:
@ -1687,11 +1697,14 @@ class AppObjects:
if workflows: if workflows:
logger.info(f"Cascade: deleted {len(workflows)} ChatWorkflows (with messages/logs) for instance {instId}") 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: for inst in instances:
instId = inst.get("id") instId = inst.get("id")
accesses = self.db.getRecordset(FeatureAccess, recordFilter={"featureInstanceId": instId}) accesses = self.db.getRecordset(FeatureAccess, recordFilter={"featureInstanceId": instId})
for access in accesses: 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(FeatureAccess, access.get("id"))
self.db.recordDelete(FeatureInstance, instId) self.db.recordDelete(FeatureInstance, instId)
logger.info(f"Cascade: deleted {len(instances)} FeatureInstances for mandate {mandateId}") logger.info(f"Cascade: deleted {len(instances)} FeatureInstances for mandate {mandateId}")
@ -1699,6 +1712,9 @@ class AppObjects:
# 2. Delete UserMandate + UserMandateRole # 2. Delete UserMandate + UserMandateRole
memberships = self.db.getRecordset(UserMandate, recordFilter={"mandateId": mandateId}) memberships = self.db.getRecordset(UserMandate, recordFilter={"mandateId": mandateId})
for um in memberships: 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")) self.db.recordDelete(UserMandate, um.get("id"))
logger.info(f"Cascade: deleted {len(memberships)} UserMandates for mandate {mandateId}") logger.info(f"Cascade: deleted {len(memberships)} UserMandates for mandate {mandateId}")
@ -1718,6 +1734,20 @@ class AppObjects:
self.db.recordDelete(MandateSubscription, subId) self.db.recordDelete(MandateSubscription, subId)
logger.info(f"Cascade: deleted {len(subs)} subscriptions for mandate {mandateId}") 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 # 4. Delete mandate-level Roles
from modules.datamodels.datamodelRbac import Role, AccessRule from modules.datamodels.datamodelRbac import Role, AccessRule
roles = self.db.getRecordset(Role, recordFilter={"mandateId": mandateId}) 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: def createUserMandate(self, userId: str, mandateId: str, roleIds: List[str] = None) -> UserMandate:
""" """
Create a UserMandate record (add user to mandate). 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. INVARIANT: A UserMandate MUST have at least one UserMandateRole.
@ -1871,43 +1901,20 @@ class AppObjects:
def _ensureUserBillingAccount(self, userId: str, mandateId: str) -> None: def _ensureUserBillingAccount(self, userId: str, mandateId: str) -> None:
""" """
Ensure a user has a billing account for the mandate if billing is configured. Ensure a user has a billing audit account for the mandate.
User accounts are always created for all billing models (for audit trail). Balance is always on the mandate pool (PREPAY_MANDATE). User accounts are for audit trail only.
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
""" """
try: try:
from modules.interfaces.interfaceDbBilling import _getRootInterface as getBillingRootInterface from modules.interfaces.interfaceDbBilling import _getRootInterface as getBillingRootInterface
from modules.datamodels.datamodelBilling import BillingModelEnum, parseBillingModelFromStoredValue
billingInterface = getBillingRootInterface() billingInterface = getBillingRootInterface()
settings = billingInterface.getSettings(mandateId) settings = billingInterface.getSettings(mandateId)
if not settings: if not settings:
return # No billing configured for this mandate return
billingModel = parseBillingModelFromStoredValue(settings.get("billingModel")) billingInterface.getOrCreateUserAccount(mandateId, userId, initialBalance=0.0)
logger.info(f"Ensured billing audit account for user {userId} in mandate {mandateId}")
# 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)")
except Exception as e: except Exception as e:
logger.warning(f"Failed to create billing account for user {userId} (non-critical): {e}") logger.warning(f"Failed to create billing account for user {userId} (non-critical): {e}")

View file

@ -24,14 +24,11 @@ from modules.datamodels.datamodelBilling import (
BillingSettings, BillingSettings,
StripeWebhookEvent, StripeWebhookEvent,
UsageStatistics, UsageStatistics,
BillingModelEnum,
AccountTypeEnum,
TransactionTypeEnum, TransactionTypeEnum,
ReferenceTypeEnum, ReferenceTypeEnum,
PeriodTypeEnum, PeriodTypeEnum,
BillingBalanceResponse, BillingBalanceResponse,
BillingCheckResult, BillingCheckResult,
parseBillingModelFromStoredValue,
) )
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -160,8 +157,6 @@ class BillingObjects:
""" """
Get billing settings for a mandate. Get billing settings for a mandate.
Normalizes billingModel for API (legacy UNLIMITED PREPAY_MANDATE) and persists once.
Args: Args:
mandateId: Mandate ID mandateId: Mandate ID
@ -175,27 +170,7 @@ class BillingObjects:
) )
if not results: if not results:
return None return None
row = dict(results[0]) return 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
except Exception as e: except Exception as e:
logger.error(f"Error getting billing settings: {e}") logger.error(f"Error getting billing settings: {e}")
return None return None
@ -226,13 +201,12 @@ class BillingObjects:
""" """
return self.db.recordModify(BillingSettings, settingsId, updates) 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. Get or create billing settings for a mandate.
Args: Args:
mandateId: Mandate ID mandateId: Mandate ID
defaultModel: Default billing model if creating
Returns: Returns:
BillingSettings dict BillingSettings dict
@ -243,8 +217,6 @@ class BillingObjects:
settings = BillingSettings( settings = BillingSettings(
mandateId=mandateId, mandateId=mandateId,
billingModel=defaultModel,
defaultUserCredit=0.0,
warningThresholdPercent=10.0, warningThresholdPercent=10.0,
notifyOnWarning=True, notifyOnWarning=True,
) )
@ -281,7 +253,7 @@ class BillingObjects:
BillingAccount, BillingAccount,
recordFilter={ recordFilter={
"mandateId": mandateId, "mandateId": mandateId,
"accountType": AccountTypeEnum.MANDATE.value "userId": None
} }
) )
return results[0] if results else None return results[0] if results else None
@ -305,8 +277,7 @@ class BillingObjects:
BillingAccount, BillingAccount,
recordFilter={ recordFilter={
"mandateId": mandateId, "mandateId": mandateId,
"userId": userId, "userId": userId
"accountType": AccountTypeEnum.USER.value
} }
) )
return results[0] if results else None return results[0] if results else None
@ -376,7 +347,6 @@ class BillingObjects:
account = BillingAccount( account = BillingAccount(
mandateId=mandateId, mandateId=mandateId,
accountType=AccountTypeEnum.MANDATE,
balance=initialBalance, balance=initialBalance,
enabled=True enabled=True
) )
@ -401,7 +371,6 @@ class BillingObjects:
account = BillingAccount( account = BillingAccount(
mandateId=mandateId, mandateId=mandateId,
userId=userId, userId=userId,
accountType=AccountTypeEnum.USER,
balance=initialBalance, balance=initialBalance,
enabled=True enabled=True
) )
@ -422,7 +391,7 @@ class BillingObjects:
def ensureAllMandateSettingsExist(self) -> int: def ensureAllMandateSettingsExist(self) -> int:
""" """
Efficiently ensure all mandates have billing settings. 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. Uses bulk queries to minimize database connections.
Returns: Returns:
@ -451,16 +420,13 @@ class BillingObjects:
if not mandateId or mandateId in existingMandateIds: if not mandateId or mandateId in existingMandateIds:
continue continue
# Create default billing settings
settings = BillingSettings( settings = BillingSettings(
mandateId=mandateId, mandateId=mandateId,
billingModel=BillingModelEnum.PREPAY_MANDATE,
defaultUserCredit=0.0,
warningThresholdPercent=10.0, warningThresholdPercent=10.0,
notifyOnWarning=True, notifyOnWarning=True,
) )
self.createSettings(settings) self.createSettings(settings)
existingMandateIds.add(mandateId) # Track newly created existingMandateIds.add(mandateId)
settingsCreated += 1 settingsCreated += 1
if settingsCreated > 0: if settingsCreated > 0:
@ -475,11 +441,7 @@ class BillingObjects:
def ensureAllUserAccountsExist(self) -> int: def ensureAllUserAccountsExist(self) -> int:
""" """
Ensure all users across all mandates have billing accounts. Ensure all users across all mandates have billing accounts.
User accounts are always created regardless of billing model (for audit trail). User accounts are always created for audit trail with initial balance 0.0.
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)
Uses bulk queries to minimize database connections. Uses bulk queries to minimize database connections.
Returns: Returns:
@ -488,44 +450,29 @@ class BillingObjects:
try: try:
accountsCreated = 0 accountsCreated = 0
appDb = _getAppDatabaseConnector() appDb = _getAppDatabaseConnector()
rootMandateId = _getCachedRootMandateId()
# Step 1: Get all billing settings (all mandates with settings get user accounts)
allSettings = self.db.getRecordset(BillingSettings) allSettings = self.db.getRecordset(BillingSettings)
billingMandates = {} # mandateId -> (billingModel, defaultCredit) billingMandateIds = set(
for s in allSettings: s.get("mandateId") for s in allSettings if s.get("mandateId")
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)
if not billingMandates: if not billingMandateIds:
logger.debug("No billable mandates found, skipping account check") logger.debug("No billable mandates found, skipping account check")
return 0 return 0
# Step 2: Get all existing USER accounts in one query allAccounts = self.db.getRecordset(BillingAccount)
allAccounts = self.db.getRecordset(
BillingAccount,
recordFilter={"accountType": AccountTypeEnum.USER.value}
)
existingAccountKeys = set() existingAccountKeys = set()
for acc in allAccounts: for acc in allAccounts:
if not acc.get("userId"):
continue
key = (acc.get("mandateId"), acc.get("userId")) key = (acc.get("mandateId"), acc.get("userId"))
existingAccountKeys.add(key) existingAccountKeys.add(key)
# Step 3: Get all user-mandate combinations from APP database
allUserMandates = appDb.getRecordset( allUserMandates = appDb.getRecordset(
UserMandate, UserMandate,
recordFilter={"enabled": True} recordFilter={"enabled": True}
) )
# Step 4: Create missing accounts
for um in allUserMandates: for um in allUserMandates:
mandateId = um.get("mandateId") mandateId = um.get("mandateId")
userId = um.get("userId") userId = um.get("userId")
@ -533,32 +480,20 @@ class BillingObjects:
if not mandateId or not userId: if not mandateId or not userId:
continue continue
if mandateId not in billingMandates: if mandateId not in billingMandateIds:
continue continue
key = (mandateId, userId) key = (mandateId, userId)
if key in existingAccountKeys: if key in existingAccountKeys:
continue continue
billingModel, defaultCredit = billingMandates[mandateId]
account = BillingAccount( account = BillingAccount(
mandateId=mandateId, mandateId=mandateId,
userId=userId, userId=userId,
accountType=AccountTypeEnum.USER, balance=0.0,
balance=defaultCredit,
enabled=True enabled=True
) )
created = self.createAccount(account) 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
))
existingAccountKeys.add(key) existingAccountKeys.add(key)
accountsCreated += 1 accountsCreated += 1
@ -810,33 +745,12 @@ class BillingObjects:
""" """
Check if there's sufficient balance for an operation. Check if there's sufficient balance for an operation.
- PREPAY_USER: user.balance >= estimatedCost Checks mandate pool balance against estimatedCost.
- PREPAY_MANDATE: mandate pool balance >= estimatedCost User accounts are ensured to exist for audit trail.
Missing settings: treated as PREPAY_MANDATE with empty pool.
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).
""" """
settings = self.getSettings(mandateId) self.getOrCreateUserAccount(mandateId, userId, initialBalance=0.0)
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)
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) poolAccount = self.getOrCreateMandateAccount(mandateId)
currentBalance = poolAccount.get("balance", 0.0) currentBalance = poolAccount.get("balance", 0.0)
@ -846,10 +760,9 @@ class BillingObjects:
reason="INSUFFICIENT_BALANCE", reason="INSUFFICIENT_BALANCE",
currentBalance=currentBalance, currentBalance=currentBalance,
requiredAmount=estimatedCost, requiredAmount=estimatedCost,
billingModel=billingModel,
) )
return BillingCheckResult(allowed=True, currentBalance=currentBalance, billingModel=billingModel) return BillingCheckResult(allowed=True, currentBalance=currentBalance)
def recordUsage( def recordUsage(
self, self,
@ -870,10 +783,8 @@ class BillingObjects:
""" """
Record usage cost as a billing transaction. Record usage cost as a billing transaction.
Transaction is ALWAYS recorded on the user's account (clean audit trail). Transaction is recorded on the user's account (audit trail).
Balance is deducted from the appropriate account based on billing model: Balance is always deducted from the mandate pool account (PREPAY_MANDATE).
- PREPAY_USER: deduct from user's own balance
- PREPAY_MANDATE: deduct from mandate pool balance
""" """
if priceCHF <= 0: if priceCHF <= 0:
return None return None
@ -883,9 +794,6 @@ class BillingObjects:
logger.debug(f"No billing settings for mandate {mandateId}, skipping usage recording") logger.debug(f"No billing settings for mandate {mandateId}, skipping usage recording")
return None return None
billingModel = parseBillingModelFromStoredValue(settings.get("billingModel"))
# Transaction is ALWAYS on the user's account (audit trail)
userAccount = self.getOrCreateUserAccount(mandateId, userId) userAccount = self.getOrCreateUserAccount(mandateId, userId)
transaction = BillingTransaction( transaction = BillingTransaction(
@ -906,13 +814,8 @@ class BillingObjects:
errorCount=errorCount 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) poolAccount = self.getOrCreateMandateAccount(mandateId)
return self.createTransaction(transaction, balanceAccountId=poolAccount["id"]) return self.createTransaction(transaction, balanceAccountId=poolAccount["id"])
return None
# ========================================================================= # =========================================================================
# Workflow Cost Query # Workflow Cost Query
@ -928,112 +831,6 @@ class BillingObjects:
) )
return sum(t.get("amount", 0.0) for t in transactions) 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 # Statistics Operations
# ========================================================================= # =========================================================================
@ -1128,10 +925,8 @@ class BillingObjects:
def getBalancesForUser(self, userId: str) -> List[BillingBalanceResponse]: def getBalancesForUser(self, userId: str) -> List[BillingBalanceResponse]:
""" """
Get all billing balances for a user across mandates. 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: Args:
userId: User ID userId: User ID
@ -1163,27 +958,15 @@ class BillingObjects:
if not settings: if not settings:
continue 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) poolAccount = self.getOrCreateMandateAccount(mandateId)
if not poolAccount: if not poolAccount:
continue continue
balance = poolAccount.get("balance", 0.0) balance = poolAccount.get("balance", 0.0)
warningThreshold = poolAccount.get("warningThreshold", 0.0) warningThreshold = poolAccount.get("warningThreshold", 0.0)
else:
continue
balances.append(BillingBalanceResponse( balances.append(BillingBalanceResponse(
mandateId=mandateId, mandateId=mandateId,
mandateName=mandateName, mandateName=mandateName,
billingModel=billingModel,
balance=balance, balance=balance,
warningThreshold=warningThreshold, warningThreshold=warningThreshold,
isWarning=balance <= warningThreshold, isWarning=balance <= warningThreshold,
@ -1280,36 +1063,25 @@ class BillingObjects:
if not mandateId: if not mandateId:
continue continue
billingModel = parseBillingModelFromStoredValue(settings.get("billingModel"))
# Get mandate info
mandate = appInterface.getMandate(mandateId) mandate = appInterface.getMandate(mandateId)
mandateName = "" mandateName = ""
if mandate: 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 "") 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) allMandateAccounts = self.db.getRecordset(
userAccounts = self.db.getRecordset(
BillingAccount, 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) poolAccount = self.getMandateAccount(mandateId)
totalBalance = poolAccount.get("balance", 0.0) if poolAccount else 0.0 totalBalance = poolAccount.get("balance", 0.0) if poolAccount else 0.0
else:
totalBalance = 0.0
balances.append({ balances.append({
"mandateId": mandateId, "mandateId": mandateId,
"mandateName": mandateName, "mandateName": mandateName,
"billingModel": billingModel.value,
"totalBalance": totalBalance, "totalBalance": totalBalance,
"userCount": userCount, "userCount": userCount,
"defaultUserCredit": float(settings.get("defaultUserCredit", 0.0) or 0.0),
"warningThresholdPercent": settings.get("warningThresholdPercent", 10.0), "warningThresholdPercent": settings.get("warningThresholdPercent", 10.0),
}) })
@ -1385,9 +1157,8 @@ class BillingObjects:
try: try:
appInterface = getAppInterface(self.currentUser) appInterface = getAppInterface(self.currentUser)
# Get all user accounts allAccounts = self.db.getRecordset(BillingAccount)
accountFilter = {"accountType": AccountTypeEnum.USER.value} allAccounts = [acc for acc in allAccounts if acc.get("userId")]
allAccounts = self.db.getRecordset(BillingAccount, recordFilter=accountFilter)
# Filter by mandate if specified # Filter by mandate if specified
if mandateIds: if mandateIds:

View file

@ -651,6 +651,32 @@ class ChatObjects:
totalPages=totalPages 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]: def getWorkflow(self, workflowId: str) -> Optional[ChatWorkflow]:
"""Returns a workflow by ID if user has access.""" """Returns a workflow by ID if user has access."""
# Use RBAC filtering with featureInstanceId for instance-level isolation # Use RBAC filtering with featureInstanceId for instance-level isolation

View file

@ -293,9 +293,43 @@ class SubscriptionObjects:
if current + delta > cap: if current + delta > cap:
from modules.serviceCenter.services.serviceSubscription.mainServiceSubscription import SubscriptionCapacityException from modules.serviceCenter.services.serviceSubscription.mainServiceSubscription import SubscriptionCapacityException
raise SubscriptionCapacityException(resourceType=resourceType, currentCount=current, maxAllowed=cap) 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 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) # Counting (cross-DB queries against poweron_app)
# ========================================================================= # =========================================================================

View file

@ -241,8 +241,7 @@ def migrateRootUsers(db, dryRun: bool = False) -> dict:
try: try:
result = rootInterface._provisionMandateForUser( result = rootInterface._provisionMandateForUser(
userId=userId, userId=userId,
mandateType="personal", mandateName=f"Home {username}",
mandateName=user.get("fullName") or username,
planKey="TRIAL_7D", planKey="TRIAL_7D",
) )
targetMandateId = result["mandateId"] targetMandateId = result["mandateId"]

View file

@ -30,7 +30,6 @@ from modules.datamodels.datamodelBilling import (
BillingAccount, BillingAccount,
BillingTransaction, BillingTransaction,
BillingSettings, BillingSettings,
BillingModelEnum,
TransactionTypeEnum, TransactionTypeEnum,
ReferenceTypeEnum, ReferenceTypeEnum,
PeriodTypeEnum, PeriodTypeEnum,
@ -38,7 +37,6 @@ from modules.datamodels.datamodelBilling import (
BillingStatisticsResponse, BillingStatisticsResponse,
BillingStatisticsChartData, BillingStatisticsChartData,
BillingCheckResult, BillingCheckResult,
parseBillingModelFromStoredValue,
) )
# Configure logger # Configure logger
@ -229,14 +227,14 @@ def _filterTransactionsByScope(transactions: list, scope: BillingDataScope) -> l
class CreditAddRequest(BaseModel): class CreditAddRequest(BaseModel):
"""Request model for adding or deducting credit from an account.""" """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.") amount: float = Field(..., description="Amount in CHF. Positive = credit, negative = deduction. Must not be zero.")
description: str = Field(default="Manual credit", description="Transaction description") description: str = Field(default="Manual credit", description="Transaction description")
class CheckoutCreateRequest(BaseModel): class CheckoutCreateRequest(BaseModel):
"""Request model for creating Stripe Checkout Session.""" """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)") 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") 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): class BillingSettingsUpdate(BaseModel):
"""Request model for updating billing settings.""" """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) warningThresholdPercent: Optional[float] = Field(None, ge=0, le=100)
notifyOnWarning: Optional[bool] = None notifyOnWarning: Optional[bool] = None
notifyEmails: Optional[List[str]] = None notifyEmails: Optional[List[str]] = None
@ -293,7 +289,6 @@ class AccountSummary(BaseModel):
id: str id: str
mandateId: str mandateId: str
userId: Optional[str] userId: Optional[str]
accountType: str
balance: float balance: float
warningThreshold: float warningThreshold: float
enabled: bool enabled: bool
@ -317,10 +312,8 @@ class MandateBalanceResponse(BaseModel):
"""Mandate-level balance summary.""" """Mandate-level balance summary."""
mandateId: str mandateId: str
mandateName: str mandateName: str
billingModel: str
totalBalance: float totalBalance: float
userCount: int userCount: int
defaultUserCredit: float
warningThresholdPercent: float warningThresholdPercent: float
@ -414,15 +407,7 @@ def _creditStripeSessionIfNeeded(
if not settings: if not settings:
raise HTTPException(status_code=404, detail="Billing settings not found") 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) account = billingInterface.getOrCreateMandateAccount(mandate_id, initialBalance=0.0)
else:
raise HTTPException(status_code=400, detail=f"Cannot add credit to {billing_model.value}")
transaction = BillingTransaction( transaction = BillingTransaction(
accountId=account["id"], accountId=account["id"],
@ -516,7 +501,6 @@ def getBalanceForMandate(
return BillingBalanceResponse( return BillingBalanceResponse(
mandateId=targetMandateId, mandateId=targetMandateId,
mandateName=mandateName, mandateName=mandateName,
billingModel=checkResult.billingModel or BillingModelEnum.PREPAY_MANDATE,
balance=checkResult.currentBalance or 0.0, balance=checkResult.currentBalance or 0.0,
warningThreshold=0.0, # TODO: Get from account warningThreshold=0.0, # TODO: Get from account
isWarning=False, isWarning=False,
@ -608,8 +592,6 @@ def getStatistics(
costByFeature={} costByFeature={}
) )
billingModel = parseBillingModelFromStoredValue(settings.get("billingModel"))
# Transactions are always on user accounts (audit trail) # Transactions are always on user accounts (audit trail)
account = billingInterface.getUserAccount(ctx.mandateId, ctx.user.id) account = billingInterface.getUserAccount(ctx.mandateId, ctx.user.id)
@ -734,18 +716,6 @@ def createOrUpdateSettings(
if existingSettings: if existingSettings:
updates = settingsUpdate.model_dump(exclude_none=True) updates = settingsUpdate.model_dump(exclude_none=True)
if updates: 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) result = billingInterface.updateSettings(existingSettings["id"], updates)
return result or existingSettings return result or existingSettings
return existingSettings return existingSettings
@ -754,16 +724,6 @@ def createOrUpdateSettings(
newSettings = BillingSettings( newSettings = BillingSettings(
mandateId=targetMandateId, 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=( warningThresholdPercent=(
settingsUpdate.warningThresholdPercent settingsUpdate.warningThresholdPercent
if settingsUpdate.warningThresholdPercent is not None if settingsUpdate.warningThresholdPercent is not None
@ -797,34 +757,15 @@ def addCredit(
): ):
""" """
Add credit to a billing account (SysAdmin only). Add credit to a billing account (SysAdmin only).
For PREPAY_USER model, specify userId. For PREPAY_MANDATE, leave userId empty.
""" """
try: try:
# Get settings to determine billing model
billingInterface = getBillingInterface(ctx.user, targetMandateId) billingInterface = getBillingInterface(ctx.user, targetMandateId)
settings = billingInterface.getSettings(targetMandateId) settings = billingInterface.getSettings(targetMandateId)
if not settings: if not settings:
raise HTTPException(status_code=404, detail="Billing settings not found for this mandate") 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) account = billingInterface.getOrCreateMandateAccount(targetMandateId, initialBalance=0.0)
else:
raise HTTPException(status_code=400, detail=f"Cannot add credit to {billingModel.value} billing model")
if creditRequest.amount == 0: if creditRequest.amount == 0:
raise HTTPException(status_code=400, detail="Amount must not be zero") 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. Create Stripe Checkout Session for credit top-up. Returns redirect URL.
RBAC: PREPAY_USER requires mandate membership (user loads own account), Requires mandate admin role.
PREPAY_MANDATE requires mandate admin role.
""" """
try: try:
billingInterface = getBillingInterface(ctx.user, targetMandateId) billingInterface = getBillingInterface(ctx.user, targetMandateId)
@ -877,20 +817,8 @@ def createCheckoutSession(
if not settings: if not settings:
raise HTTPException(status_code=404, detail="Billing settings not found for this mandate") 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): if not _isAdminOfMandate(ctx, targetMandateId):
raise HTTPException(status_code=403, detail="Mandate admin role required to load mandate credit") 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")
from modules.serviceCenter.services.serviceBilling.stripeCheckout import create_checkout_session from modules.serviceCenter.services.serviceBilling.stripeCheckout import create_checkout_session
redirect_url = create_checkout_session( redirect_url = create_checkout_session(
@ -944,19 +872,8 @@ def confirmCheckoutSession(
if not settings: if not settings:
raise HTTPException(status_code=404, detail="Billing settings not found") 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): if not _isAdminOfMandate(ctx, mandate_id):
raise HTTPException(status_code=403, detail="Mandate admin role required") 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}")
root_billing_interface = _getRootInterface() root_billing_interface = _getRootInterface()
return _creditStripeSessionIfNeeded(root_billing_interface, session_dict, eventId=None) return _creditStripeSessionIfNeeded(root_billing_interface, session_dict, eventId=None)
@ -1321,7 +1238,6 @@ def getAccounts(
id=acc.get("id"), id=acc.get("id"),
mandateId=acc.get("mandateId"), mandateId=acc.get("mandateId"),
userId=acc.get("userId"), userId=acc.get("userId"),
accountType=acc.get("accountType"),
balance=acc.get("balance", 0.0), balance=acc.get("balance", 0.0),
warningThreshold=acc.get("warningThreshold", 0.0), warningThreshold=acc.get("warningThreshold", 0.0),
enabled=acc.get("enabled", True) enabled=acc.get("enabled", True)

View file

@ -17,7 +17,7 @@ from jose import jwt
from modules.auth import getCurrentUser, limiter, SECRET_KEY, ALGORITHM, getRequestContext, RequestContext from modules.auth import getCurrentUser, limiter, SECRET_KEY, ALGORITHM, getRequestContext, RequestContext
from modules.auth import createAccessToken, createRefreshToken, setAccessTokenCookie, setRefreshTokenCookie, clearAccessTokenCookie, clearRefreshTokenCookie from modules.auth import createAccessToken, createRefreshToken, setAccessTokenCookie, setRefreshTokenCookie, clearAccessTokenCookie, clearRefreshTokenCookie
from modules.interfaces.interfaceDbApp import getInterface, getRootInterface 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.datamodels.datamodelSecurity import Token, TokenPurpose
from modules.shared.configuration import APP_CONFIG from modules.shared.configuration import APP_CONFIG
from modules.shared.timeUtils import getUtcTimestamp 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") @router.post("/login")
@limiter.limit("30/minute") @limiter.limit("30/minute")
def login( def login(
@ -183,6 +199,12 @@ def login(
except Exception as subErr: except Exception as subErr:
logger.error(f"Error activating subscriptions on login: {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) # Log successful login (app log file + audit DB for traceability)
logger.info("Login successful for username=%s (userId=%s)", formData.username, str(user.id)) logger.info("Login successful for username=%s (userId=%s)", formData.username, str(user.id))
try: try:
@ -298,32 +320,35 @@ def register_user(
detail="Failed to register user" detail="Failed to register user"
) )
# Provision mandate for new user # Provision Home mandate for every new user ("Home {username}")
provisionResult = None provisionResult = None
try: try:
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 registrationType == "company":
if not companyName: if not companyName:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail="companyName is required for company registration" detail="companyName is required for company registration"
) )
provisionResult = appInterface._provisionMandateForUser( try:
companyResult = appInterface._provisionMandateForUser(
userId=str(user.id), userId=str(user.id),
mandateType="company",
mandateName=companyName, mandateName=companyName,
planKey="STANDARD_MONTHLY", planKey="STANDARD_MONTHLY",
) )
else: logger.info(f"Provisioned company mandate for user {user.id}: {companyResult}")
provisionResult = appInterface._provisionMandateForUser( except Exception as compErr:
userId=str(user.id), logger.error(f"Error provisioning company mandate for user {user.id}: {compErr}")
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
# Generate reset token for password setup # Generate reset token for password setup
token, expires = appInterface.generateResetTokenAndExpiry() token, expires = appInterface.generateResetTokenAndExpiry()
@ -406,7 +431,6 @@ Falls Sie sich nicht registriert haben, können Sie diese E-Mail ignorieren."""
} }
if provisionResult: if provisionResult:
responseData["mandateId"] = provisionResult.get("mandateId") responseData["mandateId"] = provisionResult.get("mandateId")
responseData["mandateType"] = provisionResult.get("mandateType")
return responseData return responseData
except ValueError as e: except ValueError as e:
@ -698,35 +722,22 @@ Falls Sie diese Anforderung nicht gestellt haben, können Sie diese E-Mail ignor
def onboarding_provision( def onboarding_provision(
request: Request, request: Request,
currentUser: User = Depends(getCurrentUser), currentUser: User = Depends(getCurrentUser),
mandateType: str = Body("personal", embed=True),
companyName: str = Body(None, embed=True), companyName: str = Body(None, embed=True),
planKey: str = Body("TRIAL_7D", embed=True),
) -> Dict[str, Any]: ) -> 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: try:
appInterface = getRootInterface() appInterface = getRootInterface()
userMandates = appInterface.getUserMandates(str(currentUser.id)) _ensureHomeMandate(appInterface, currentUser)
hasOwnMandate = False
for um in userMandates:
mandate = appInterface.getMandate(um.mandateId)
if mandate and not mandate.isSystem:
hasOwnMandate = True
break
if hasOwnMandate: result = None
return {"message": "User already has a mandate", "alreadyProvisioned": True} if companyName and companyName.strip():
if planKey not in ("STANDARD_MONTHLY", "STANDARD_YEARLY"):
if mandateType == "company":
mandateName = companyName or currentUser.fullName or currentUser.username
planKey = "STANDARD_MONTHLY" planKey = "STANDARD_MONTHLY"
else:
mandateName = currentUser.fullName or currentUser.username
planKey = "TRIAL_7D"
result = appInterface._provisionMandateForUser( result = appInterface._provisionMandateForUser(
userId=str(currentUser.id), userId=str(currentUser.id),
mandateType=mandateType, mandateName=companyName.strip(),
mandateName=mandateName,
planKey=planKey, planKey=planKey,
) )
@ -740,8 +751,7 @@ def onboarding_provision(
logger.info(f"Onboarding provision for {currentUser.username}: {result}") logger.info(f"Onboarding provision for {currentUser.username}: {result}")
return { return {
"message": "Mandate provisioned successfully", "message": "Mandate provisioned successfully",
"mandateId": result.get("mandateId"), "mandateId": result.get("mandateId") if result else None,
"mandateType": result.get("mandateType"),
"alreadyProvisioned": False, "alreadyProvisioned": False,
} }

View file

@ -146,10 +146,10 @@ def listUserMandates(
adminMandateIds = _getUserAdminMandateIds(db, userId) adminMandateIds = _getUserAdminMandateIds(db, userId)
if not adminMandateIds: if not adminMandateIds:
homeMandateName = f"Home {context.user.username}"
provisionResult = rootInterface._provisionMandateForUser( provisionResult = rootInterface._provisionMandateForUser(
userId=userId, userId=userId,
mandateType="personal", mandateName=homeMandateName,
mandateName=context.user.fullName or context.user.username,
planKey="TRIAL_7D", planKey="TRIAL_7D",
) )
adminMandateIds = [provisionResult["mandateId"]] adminMandateIds = [provisionResult["mandateId"]]
@ -164,7 +164,6 @@ def listUserMandates(
"id": mid, "id": mid,
"name": m.get("name", ""), "name": m.get("name", ""),
"label": m.get("label") or m.get("name", ""), "label": m.get("label") or m.get("name", ""),
"mandateType": m.get("mandateType", "company"),
}) })
return result return result
except Exception as e: except Exception as e:

View file

@ -468,7 +468,12 @@ def _getDataVolumeUsage(
size = f.get("fileSize") if isinstance(f, dict) else getattr(f, "fileSize", 0) size = f.get("fileSize") if isinstance(f, dict) else getattr(f, "fileSize", 0)
totalBytes += (size or 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 maxMB = None
subs = rootIf.db.getRecordset(MandateSubscription, recordFilter={"mandateId": mandateId}) subs = rootIf.db.getRecordset(MandateSubscription, recordFilter={"mandateId": mandateId})
@ -484,10 +489,14 @@ def _getDataVolumeUsage(
if maxMB: if maxMB:
break break
usedMB = ragMB
percentUsed = round((usedMB / maxMB) * 100, 1) if maxMB else None
return { return {
"mandateId": mandateId, "mandateId": mandateId,
"usedMB": usedMB, "usedMB": usedMB,
"filesMB": filesMB,
"ragIndexMB": ragMB,
"maxDataVolumeMB": maxMB, "maxDataVolumeMB": maxMB,
"percentUsed": round((usedMB / maxMB) * 100, 1) if maxMB else None, "percentUsed": percentUsed,
"warning": usedMB >= (maxMB * 0.8) if maxMB else False, "warning": (percentUsed or 0) >= 80,
} }

View file

@ -20,6 +20,7 @@ class ServiceCenterContext:
feature_instance_id: Optional[str] = None feature_instance_id: Optional[str] = None
workflow_id: Optional[str] = None workflow_id: Optional[str] = None
workflow: Any = None workflow: Any = None
requireNeutralization: Optional[bool] = None
@property @property
def mandateId(self) -> Optional[str]: def mandateId(self) -> Optional[str]:

View file

@ -322,14 +322,20 @@ class AgentService:
def _createAiCallFn(self) -> Callable[[AiCallRequest], AiCallResponse]: def _createAiCallFn(self) -> Callable[[AiCallRequest], AiCallResponse]:
"""Create the AI call function that wraps serviceAi with billing.""" """Create the AI call function that wraps serviceAi with billing."""
ctxNeutralization = getattr(self.ctx, 'requireNeutralization', None)
async def _aiCallFn(request: AiCallRequest) -> AiCallResponse: async def _aiCallFn(request: AiCallRequest) -> AiCallResponse:
if ctxNeutralization is not None and request.requireNeutralization is None:
request.requireNeutralization = ctxNeutralization
aiService = self.services.ai aiService = self.services.ai
return await aiService.callAi(request) return await aiService.callAi(request)
return _aiCallFn return _aiCallFn
def _createAiCallStreamFn(self): def _createAiCallStreamFn(self):
"""Create the streaming AI call function. Yields str deltas, then AiCallResponse.""" """Create the streaming AI call function. Yields str deltas, then AiCallResponse."""
ctxNeutralization = getattr(self.ctx, 'requireNeutralization', None)
async def _aiCallStreamFn(request: AiCallRequest): async def _aiCallStreamFn(request: AiCallRequest):
if ctxNeutralization is not None and request.requireNeutralization is None:
request.requireNeutralization = ctxNeutralization
aiService = self.services.ai aiService = self.services.ai
async for chunk in aiService.callAiStream(request): async for chunk in aiService.callAiStream(request):
yield chunk yield chunk

View file

@ -17,7 +17,6 @@ from modules.shared.jsonUtils import (
) )
from .subJsonResponseHandling import JsonResponseHandler from .subJsonResponseHandling import JsonResponseHandler
from modules.datamodels.datamodelAi import JsonAccumulationState from modules.datamodels.datamodelAi import JsonAccumulationState
from modules.datamodels.datamodelBilling import BillingModelEnum
from modules.serviceCenter.services.serviceBilling.billingExhaustedNotify import ( from modules.serviceCenter.services.serviceBilling.billingExhaustedNotify import (
maybeEmailMandatePoolExhausted, maybeEmailMandatePoolExhausted,
) )
@ -747,7 +746,6 @@ detectedIntent-Werte:
f"Balance {balance_str} CHF, " f"Balance {balance_str} CHF, "
f"Reason: {reason}" f"Reason: {reason}"
) )
if balanceCheck.billingModel == BillingModelEnum.PREPAY_MANDATE:
ulabel = (getattr(user, "email", None) or getattr(user, "username", None) or str(user.id)) ulabel = (getattr(user, "email", None) or getattr(user, "username", None) or str(user.id))
maybeEmailMandatePoolExhausted( maybeEmailMandatePoolExhausted(
str(mandateId), str(mandateId),

View file

@ -16,13 +16,11 @@ from datetime import datetime
from modules.datamodels.datamodelUam import User from modules.datamodels.datamodelUam import User
from modules.datamodels.datamodelBilling import ( from modules.datamodels.datamodelBilling import (
BillingModelEnum,
BillingCheckResult, BillingCheckResult,
TransactionTypeEnum, TransactionTypeEnum,
ReferenceTypeEnum, ReferenceTypeEnum,
BillingTransaction, BillingTransaction,
BillingBalanceResponse, BillingBalanceResponse,
parseBillingModelFromStoredValue,
) )
from modules.interfaces.interfaceDbBilling import getInterface as getBillingInterface from modules.interfaces.interfaceDbBilling import getInterface as getBillingInterface
@ -369,16 +367,6 @@ class BillingService:
logger.warning(f"No billing settings for mandate {self.mandateId}") logger.warning(f"No billing settings for mandate {self.mandateId}")
return None 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( account = self._billingInterface.getOrCreateMandateAccount(
self.mandateId, self.mandateId,
initialBalance=0.0 initialBalance=0.0
@ -429,29 +417,16 @@ BILLING_USER_ACTION_TOP_UP_SELF = "TOP_UP_SELF"
BILLING_USER_ACTION_CONTACT_MANDATE_ADMIN = "CONTACT_MANDATE_ADMIN" BILLING_USER_ACTION_CONTACT_MANDATE_ADMIN = "CONTACT_MANDATE_ADMIN"
def _userActionForBillingModel(bm: BillingModelEnum) -> str: def _defaultInsufficientBalanceUserAction() -> str:
if bm == BillingModelEnum.PREPAY_USER:
return BILLING_USER_ACTION_TOP_UP_SELF
return BILLING_USER_ACTION_CONTACT_MANDATE_ADMIN return BILLING_USER_ACTION_CONTACT_MANDATE_ADMIN
def _buildInsufficientBalanceMessages( def _buildInsufficientBalanceMessages(
bm: BillingModelEnum,
currentBalance: float, currentBalance: float,
requiredAmount: float, requiredAmount: float,
) -> tuple: ) -> tuple:
bal_s = f"{currentBalance:.2f}" bal_s = f"{currentBalance:.2f}"
req_s = f"{requiredAmount:.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 = ( msg_de = (
f"Das Mandanten-Budget ist aufgebraucht (aktuell CHF {bal_s}, benötigt mindestens CHF {req_s}). " 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. " "Bitte informieren Sie die Administratorin bzw. den Administrator Ihres Mandanten. "
@ -467,7 +442,7 @@ def _buildInsufficientBalanceMessages(
class InsufficientBalanceException(Exception): class InsufficientBalanceException(Exception):
"""Raised when there's insufficient balance for an operation. """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__( def __init__(
@ -476,7 +451,6 @@ class InsufficientBalanceException(Exception):
requiredAmount: float, requiredAmount: float,
message: Optional[str] = None, message: Optional[str] = None,
*, *,
billing_model: Optional[BillingModelEnum] = None,
mandate_id: str = "", mandate_id: str = "",
user_action: Optional[str] = None, user_action: Optional[str] = None,
message_de: Optional[str] = None, message_de: Optional[str] = None,
@ -484,12 +458,8 @@ class InsufficientBalanceException(Exception):
): ):
self.currentBalance = float(currentBalance) self.currentBalance = float(currentBalance)
self.requiredAmount = float(requiredAmount) self.requiredAmount = float(requiredAmount)
self.billing_model = billing_model
self.mandate_id = mandate_id or "" self.mandate_id = mandate_id or ""
if billing_model is not None: self.user_action = user_action or _defaultInsufficientBalanceUserAction()
self.user_action = user_action or _userActionForBillingModel(billing_model)
else:
self.user_action = user_action or BILLING_USER_ACTION_TOP_UP_SELF
if message_de is not None and message_en is not None: if message_de is not None and message_en is not None:
self.message_de = message_de self.message_de = message_de
@ -500,8 +470,7 @@ class InsufficientBalanceException(Exception):
self.message_de = message self.message_de = message
self.message_en = message self.message_en = message
else: else:
bm = billing_model or BillingModelEnum.PREPAY_USER md, me = _buildInsufficientBalanceMessages(self.currentBalance, self.requiredAmount)
md, me = _buildInsufficientBalanceMessages(bm, self.currentBalance, self.requiredAmount)
self.message_de = md self.message_de = md
self.message_en = me self.message_en = me
self.message = md self.message = md
@ -514,14 +483,12 @@ class InsufficientBalanceException(Exception):
mandate_id: str, mandate_id: str,
required_amount: float, required_amount: float,
) -> "InsufficientBalanceException": ) -> "InsufficientBalanceException":
bm = check.billingModel or BillingModelEnum.PREPAY_MANDATE
bal = float(check.currentBalance or 0.0) 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( return cls(
bal, bal,
required_amount, required_amount,
message=msg_de, message=msg_de,
billing_model=bm,
mandate_id=mandate_id or "", mandate_id=mandate_id or "",
message_de=msg_de, message_de=msg_de,
message_en=msg_en, message_en=msg_en,
@ -538,8 +505,6 @@ class InsufficientBalanceException(Exception):
"messageEn": self.message_en, "messageEn": self.message_en,
"userAction": self.user_action, "userAction": self.user_action,
} }
if self.billing_model is not None:
out["billingModel"] = self.billing_model.value
if self.mandate_id: if self.mandate_id:
out["mandateId"] = self.mandate_id out["mandateId"] = self.mandate_id
if self.user_action == BILLING_USER_ACTION_TOP_UP_SELF: if self.user_action == BILLING_USER_ACTION_TOP_UP_SELF:

View file

@ -65,7 +65,7 @@ def create_checkout_session(
Args: Args:
mandate_id: Target mandate ID 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) amount_chf: Amount in CHF (must be in ALLOWED_AMOUNTS_CHF)
Returns: Returns:

View file

@ -26,12 +26,11 @@ def _check(label, condition, detail=""):
print("\n--- Phase 1: Data Models ---") print("\n--- Phase 1: Data Models ---")
try: try:
from modules.datamodels.datamodelUam import Mandate, MandateType from modules.datamodels.datamodelUam import Mandate
_check("MandateType Enum exists", hasattr(MandateType, "SYSTEM")) m = Mandate(name="test", label="test")
_check("MandateType values", set(MandateType) == {MandateType.SYSTEM, MandateType.PERSONAL, MandateType.COMPANY}) _check("Mandate has isSystem field", hasattr(m, "isSystem"))
m = Mandate(name="test", label="test", mandateType="personal") _check("Mandate isSystem default False", m.isSystem is False)
_check("Mandate has mandateType field", hasattr(m, "mandateType")) _check("Mandate no mandateType field", not hasattr(m, "mandateType"))
_check("Mandate mandateType coercion", m.mandateType == MandateType.PERSONAL)
except Exception as e: except Exception as e:
errors.append(f"Phase 1 DataModel: {e}") errors.append(f"Phase 1 DataModel: {e}")
print(f" [FAIL] Phase 1 DataModel import: {e}") print(f" [FAIL] Phase 1 DataModel import: {e}")