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:
logger.info(f"Billing startup: Created {settingsCreated} missing mandate billing settings")
# Step 2: Ensure all users have billing accounts (for PREPAY_USER mandates)
# Step 2: Ensure all users have billing audit accounts
accountsCreated = billingInterface.ensureAllUserAccountsExist()
if accountsCreated > 0:
logger.info(f"Billing startup: Created {accountsCreated} missing user accounts")
@ -500,7 +500,7 @@ app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
async def _insufficientBalanceHandler(request: Request, exc: Exception):
"""HTTP 402 with structured billing hint (PREPAY_USER vs PREPAY_MANDATE)."""
"""HTTP 402 with structured billing hint."""
payload = exc.toClientDict() if hasattr(exc, "toClientDict") else {"error": "INSUFFICIENT_BALANCE", "message": str(exc)}
return JSONResponse(status_code=402, content={"detail": payload})

View file

@ -11,22 +11,6 @@ from modules.shared.attributeUtils import registerModelLabels
import uuid
class BillingModelEnum(str, Enum):
"""Billing model types (prepaid only; legacy UNLIMITED in DB maps to PREPAY_MANDATE)."""
PREPAY_MANDATE = "PREPAY_MANDATE" # Prepaid budget shared by all users in mandate
PREPAY_USER = "PREPAY_USER" # Prepaid budget per user within mandate
# Nur fuer initRootMandateBilling (Root-Mandant PREPAY_USER + Startguthaben in Settings).
DEFAULT_USER_CREDIT_CHF = 5.0
class AccountTypeEnum(str, Enum):
"""Account type for billing accounts."""
MANDATE = "MANDATE" # Account for entire mandate
USER = "USER" # Account for specific user within mandate
class TransactionTypeEnum(str, Enum):
"""Transaction types for billing."""
CREDIT = "CREDIT" # Credit/top-up (positive)
@ -55,8 +39,7 @@ class BillingAccount(PowerOnModel):
default_factory=lambda: str(uuid.uuid4()), description="Primary key"
)
mandateId: str = Field(..., description="Foreign key to Mandate")
userId: Optional[str] = Field(None, description="Foreign key to User (only for PREPAY_USER)")
accountType: AccountTypeEnum = Field(..., description="Account type: MANDATE or USER")
userId: Optional[str] = Field(None, description="Foreign key to User (None = mandate pool account, set = user audit account)")
balance: float = Field(default=0.0, description="Current balance in CHF")
warningThreshold: float = Field(default=0.0, description="Warning threshold in CHF")
lastWarningAt: Optional[datetime] = Field(None, description="Last warning sent timestamp")
@ -70,7 +53,6 @@ registerModelLabels(
"id": {"en": "ID", "de": "ID"},
"mandateId": {"en": "Mandate ID", "de": "Mandanten-ID"},
"userId": {"en": "User ID", "de": "Benutzer-ID"},
"accountType": {"en": "Account Type", "de": "Kontotyp"},
"balance": {"en": "Balance (CHF)", "de": "Guthaben (CHF)"},
"warningThreshold": {"en": "Warning Threshold (CHF)", "de": "Warnschwelle (CHF)"},
"lastWarningAt": {"en": "Last Warning", "de": "Letzte Warnung"},
@ -130,27 +112,28 @@ registerModelLabels(
class BillingSettings(BaseModel):
"""Billing settings per mandate."""
"""Billing settings per mandate. Only PREPAY_MANDATE model."""
id: str = Field(
default_factory=lambda: str(uuid.uuid4()), description="Primary key"
)
mandateId: str = Field(..., description="Foreign key to Mandate (UNIQUE)")
billingModel: BillingModelEnum = Field(..., description="Billing model")
# Configuration
defaultUserCredit: float = Field(
default=0.0,
description="Automatic initial credit (CHF) for PREPAY_USER only when a user is newly added to the root mandate; other mandates use 0 on join.",
)
warningThresholdPercent: float = Field(default=10.0, description="Warning threshold as percentage")
# Stripe
stripeCustomerId: Optional[str] = Field(None, description="Stripe Customer ID (cus_xxx) — one per mandate")
# Notifications (e.g. mandate owner / finance — also used when PREPAY_MANDATE pool is exhausted)
# Auto-Recharge for AI budget
autoRechargeEnabled: bool = Field(default=False, description="Auto-buy AI budget when low")
rechargeAmountCHF: float = Field(default=10.0, description="Amount per auto-recharge (CHF, prepaid via Stripe)")
rechargeMaxPerMonth: int = Field(default=3, description="Max auto-recharges per month")
rechargesThisMonth: int = Field(default=0, description="Counter: auto-recharges used this month")
monthResetAt: Optional[datetime] = Field(None, description="When rechargesThisMonth was last reset")
# Notifications
notifyEmails: List[str] = Field(
default_factory=list,
description="Email addresses for billing alerts (mandate pool exhausted, warnings, etc.)",
description="Email addresses for billing alerts (pool exhausted, warnings, etc.)",
)
notifyOnWarning: bool = Field(default=True, description="Send email when warning threshold is reached")
@ -161,16 +144,14 @@ registerModelLabels(
{
"id": {"en": "ID", "de": "ID"},
"mandateId": {"en": "Mandate ID", "de": "Mandanten-ID"},
"billingModel": {"en": "Billing Model", "de": "Abrechnungsmodell"},
"defaultUserCredit": {
"en": "Root start credit (CHF)",
"de": "Startguthaben nur Root-Mandant (CHF)",
},
"warningThresholdPercent": {"en": "Warning Threshold (%)", "de": "Warnschwelle (%)"},
"stripeCustomerId": {"en": "Stripe Customer ID", "de": "Stripe-Kunden-ID"},
"autoRechargeEnabled": {"en": "Auto-Recharge", "de": "Auto-Nachladung"},
"rechargeAmountCHF": {"en": "Recharge Amount (CHF)", "de": "Nachladebetrag (CHF)"},
"rechargeMaxPerMonth": {"en": "Max Recharges/Month", "de": "Max. Nachladungen/Monat"},
"notifyEmails": {
"en": "Billing notification emails (owner / admin)",
"de": "E-Mails für Billing-Alerts (Inhaber/Admin)",
"de": "E-Mails fuer Billing-Alerts (Inhaber/Admin)",
},
"notifyOnWarning": {"en": "Notify on Warning", "de": "Bei Warnung benachrichtigen"},
},
@ -239,7 +220,6 @@ class BillingBalanceResponse(BaseModel):
"""Response model for balance endpoint."""
mandateId: str
mandateName: str
billingModel: BillingModelEnum
balance: float
currency: str = "CHF"
warningThreshold: float
@ -270,20 +250,8 @@ class BillingCheckResult(BaseModel):
reason: Optional[str] = None
currentBalance: Optional[float] = None
requiredAmount: Optional[float] = None
billingModel: Optional[BillingModelEnum] = None
upgradeRequired: Optional[bool] = None
subscriptionUiPath: Optional[str] = None
userAction: Optional[str] = None
def parseBillingModelFromStoredValue(raw: Optional[str]) -> BillingModelEnum:
"""Map DB string to enum. Legacy UNLIMITED / unknown values become PREPAY_MANDATE."""
if raw is None or (isinstance(raw, str) and raw.strip() == ""):
return BillingModelEnum.PREPAY_MANDATE
s = str(raw).strip().upper()
if s == "UNLIMITED":
return BillingModelEnum.PREPAY_MANDATE
try:
return BillingModelEnum(raw)
except ValueError:
return BillingModelEnum.PREPAY_MANDATE

View file

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

View file

@ -60,12 +60,6 @@ class UserPermissions(BaseModel):
)
class MandateType(str, Enum):
SYSTEM = "system"
PERSONAL = "personal"
COMPANY = "company"
class Mandate(PowerOnModel):
"""
Mandate (Mandant/Tenant) model.
@ -95,15 +89,6 @@ class Mandate(PowerOnModel):
description="Whether this is a system mandate (e.g. root mandate). Cannot be deleted.",
json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": True, "frontend_required": False}
)
mandateType: MandateType = Field(
default=MandateType.COMPANY,
description="Fachlicher Mandantentyp: system (Root), personal (Solo), company (Team). Mutabel, rein informativ — keine Feature-Gates.",
json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": False, "frontend_options": [
{"value": "system", "label": {"en": "System", "de": "System"}},
{"value": "personal", "label": {"en": "Personal", "de": "Persönlich"}},
{"value": "company", "label": {"en": "Company", "de": "Unternehmen"}},
]}
)
deletedAt: Optional[float] = Field(
default=None,
description="Timestamp when the mandate was soft-deleted. After 30 days, hard-delete is triggered.",
@ -118,19 +103,6 @@ class Mandate(PowerOnModel):
return False
return v
@field_validator('mandateType', mode='before')
@classmethod
def _coerceMandateType(cls, v):
if v is None:
return MandateType.COMPANY
if isinstance(v, str):
try:
return MandateType(v)
except ValueError:
return MandateType.COMPANY
return v
registerModelLabels(
"Mandate",
{"en": "Mandate", "de": "Mandant", "fr": "Mandat"},
@ -140,7 +112,6 @@ registerModelLabels(
"label": {"en": "Label", "de": "Label", "fr": "Libellé"},
"enabled": {"en": "Enabled", "de": "Aktiviert", "fr": "Activé"},
"isSystem": {"en": "System Mandate", "de": "System-Mandant", "fr": "Mandat système"},
"mandateType": {"en": "Mandate Type", "de": "Mandantentyp", "fr": "Type de mandat"},
"deletedAt": {"en": "Deleted at", "de": "Gelöscht am", "fr": "Supprimé le"},
},
)

View file

@ -1222,23 +1222,21 @@ def _preflight_billing_check(services, mandateId: str, featureInstanceId: Option
balanceCheck = billingService.checkBalance(0.01)
if not balanceCheck.allowed:
mid = str(getattr(services, "mandateId", None) or mandateId or "")
from modules.datamodels.datamodelBilling import BillingModelEnum
from modules.serviceCenter.services.serviceBilling.billingExhaustedNotify import (
maybeEmailMandatePoolExhausted,
)
if balanceCheck.billingModel == BillingModelEnum.PREPAY_MANDATE:
u = getattr(services, "user", None)
ulabel = (
(getattr(u, "email", None) or getattr(u, "username", None) or str(getattr(u, "id", "")))
if u is not None else ""
)
maybeEmailMandatePoolExhausted(
mid,
str(getattr(u, "id", "") if u is not None else ""),
ulabel,
float(balanceCheck.currentBalance or 0.0),
0.01,
)
u = getattr(services, "user", None)
ulabel = (
(getattr(u, "email", None) or getattr(u, "username", None) or str(getattr(u, "id", "")))
if u is not None else ""
)
maybeEmailMandatePoolExhausted(
mid,
str(getattr(u, "id", "") if u is not None else ""),
ulabel,
float(balanceCheck.currentBalance or 0.0),
0.01,
)
raise BillingService.InsufficientBalanceException.fromBalanceCheck(
balanceCheck,
mid,

View file

@ -87,6 +87,7 @@ class WorkspaceInputRequest(BaseModel):
workflowId: Optional[str] = Field(default=None, description="Continue existing workflow")
userLanguage: str = Field(default="en", description="User language code")
allowedProviders: List[str] = Field(default_factory=list, description="Restrict AI to these providers")
requireNeutralization: Optional[bool] = Field(default=None, description="Per-request neutralization override")
async def _getAiObjects() -> AiObjects:
@ -588,6 +589,7 @@ async def streamWorkspaceStart(
userLanguage=userInput.userLanguage,
instanceConfig=instanceConfig,
allowedProviders=userInput.allowedProviders,
requireNeutralization=userInput.requireNeutralization,
)
)
eventManager.register_agent_task(queueId, agentTask)
@ -643,6 +645,7 @@ async def _runWorkspaceAgent(
userLanguage: str = "en",
instanceConfig: Dict[str, Any] = None,
allowedProviders: List[str] = None,
requireNeutralization: Optional[bool] = None,
):
"""Run the serviceAgent loop and forward events to the SSE queue."""
try:
@ -660,6 +663,8 @@ async def _runWorkspaceAgent(
if allowedProviders:
aiService.services.allowedProviders = allowedProviders
if requireNeutralization is not None:
ctx.requireNeutralization = requireNeutralization
wfRecord = chatInterface.getWorkflow(workflowId) if workflowId else None
wfName = ""
@ -887,6 +892,7 @@ async def listWorkspaceWorkflows(
request: Request,
instanceId: str = Path(...),
includeArchived: bool = Query(default=False, description="Include archived workflows"),
search: str = Query(default="", description="Fulltext search in workflow titles and message content"),
context: RequestContext = Depends(getRequestContext),
):
"""List workspace workflows/conversations for this instance."""
@ -930,10 +936,54 @@ async def listWorkspaceWorkflows(
item.setdefault("featureLabel", labels["featureLabel"])
item.setdefault("featureCode", labels["featureCode"])
item.setdefault("featureInstanceId", fiId)
lastMsg = chatInterface.getLastMessageTimestamp(item.get("id"))
if lastMsg:
item["lastMessageAt"] = lastMsg
items.append(item)
if search and search.strip():
searchLower = search.strip().lower()
matchedIds = set()
for item in items:
if searchLower in (item.get("name") or "").lower() or searchLower in (item.get("label") or "").lower():
matchedIds.add(item["id"])
contentHits = chatInterface.searchWorkflowsByContent(searchLower, limit=50)
matchedIds.update(contentHits)
items = [i for i in items if i["id"] in matchedIds]
return JSONResponse({"workflows": items})
class ResolveRagRequest(BaseModel):
"""Request body for resolving a chat via RAG."""
chatId: str = Field(..., description="Workflow/chat ID to resolve")
@router.post("/{instanceId}/resolve-rag")
@limiter.limit("60/minute")
async def resolveRag(
request: Request,
instanceId: str = Path(...),
body: ResolveRagRequest = Body(...),
context: RequestContext = Depends(getRequestContext),
):
"""Build a RAG summary for a chat (workflow) to inject into the input area."""
_validateInstanceAccess(instanceId, context)
chatInterface = _getChatInterface(context, featureInstanceId=instanceId)
messages = chatInterface.getMessages(body.chatId) or []
texts = []
for msg in messages[:30]:
content = msg.get("message") if isinstance(msg, dict) else getattr(msg, "message", "")
if content:
texts.append(content[:500])
summary = "\n---\n".join(texts[:10]) if texts else ""
return JSONResponse({"summary": summary, "chatId": body.chatId, "messageCount": len(texts)})
class UpdateWorkflowRequest(BaseModel):
"""Request body for updating a workflow (PATCH)."""
name: Optional[str] = Field(default=None, description="New workflow name")

View file

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

View file

@ -1407,12 +1407,11 @@ class AppObjects:
return Mandate(**createdRecord)
def _provisionMandateForUser(self, userId: str, mandateType: str, mandateName: str, planKey: str) -> Dict[str, Any]:
def _provisionMandateForUser(self, userId: str, mandateName: str, planKey: str) -> Dict[str, Any]:
"""
Atomic provisioning: create Mandate + UserMandate + Subscription + auto-create FeatureInstances.
Internal method bypasses RBAC (used during registration when user has no permissions yet).
"""
from modules.datamodels.datamodelUam import MandateType
from modules.datamodels.datamodelSubscription import MandateSubscription, SubscriptionStatusEnum, BUILTIN_PLANS
from modules.datamodels.datamodelFeatures import FeatureInstance
from modules.interfaces.interfaceBootstrap import copySystemRolesToMandate
@ -1428,7 +1427,6 @@ class AppObjects:
label=mandateName,
enabled=True,
isSystem=False,
mandateType=MandateType(mandateType),
)
createdMandate = self.db.recordCreate(Mandate, mandateData)
if not createdMandate or not createdMandate.get("id"):
@ -1497,11 +1495,10 @@ class AppObjects:
except Exception as e:
logger.error(f"Error auto-creating instance for '{featureName}': {e}")
logger.info(f"Provisioned mandate {mandateId} (type={mandateType}, plan={planKey}) for user {userId}, instances={createdInstances}")
logger.info(f"Provisioned mandate {mandateId} (plan={planKey}) for user {userId}, instances={createdInstances}")
return {
"mandateId": mandateId,
"planKey": planKey,
"mandateType": mandateType,
"featureInstances": createdInstances,
}
except Exception as e:
@ -1632,7 +1629,10 @@ class AppObjects:
from modules.datamodels.datamodelChat import ChatWorkflow, ChatMessage, ChatLog
from modules.datamodels.datamodelFiles import FileItem
from modules.datamodels.datamodelDataSource import DataSource
from modules.datamodels.datamodelKnowledge import FileContentIndex
from modules.datamodels.datamodelKnowledge import FileContentIndex, ContentChunk
from modules.datamodels.datamodelFeatureDataSource import FeatureDataSource
from modules.datamodels.datamodelBilling import BillingSettings, BillingAccount, BillingTransaction
from modules.datamodels.datamodelRbac import FeatureAccessRole, UserMandateRole
from modules.features.neutralization.datamodelFeatureNeutralizer import DataNeutralizerAttributes
instances = self.db.getRecordset(FeatureInstance, recordFilter={"mandateId": mandateId})
@ -1643,12 +1643,15 @@ class AppObjects:
if not instId:
continue
# 0a. FileContentIndex (knowledge/RAG)
# 0a. ContentChunk (embeddings) + FileContentIndex (knowledge/RAG)
fciRecords = self.db.getRecordset(FileContentIndex, recordFilter={"featureInstanceId": instId})
for rec in fciRecords:
chunks = self.db.getRecordset(ContentChunk, recordFilter={"fileContentIndexId": rec.get("id")})
for chunk in chunks:
self.db.recordDelete(ContentChunk, chunk.get("id"))
self.db.recordDelete(FileContentIndex, rec.get("id"))
if fciRecords:
logger.info(f"Cascade: deleted {len(fciRecords)} FileContentIndex records for instance {instId}")
logger.info(f"Cascade: deleted {len(fciRecords)} FileContentIndex records (with chunks) for instance {instId}")
# 0b. DataNeutralizerAttributes
dnaRecords = self.db.getRecordset(DataNeutralizerAttributes, recordFilter={"featureInstanceId": instId})
@ -1664,6 +1667,13 @@ class AppObjects:
if dsRecords:
logger.info(f"Cascade: deleted {len(dsRecords)} DataSource records for instance {instId}")
# 0c2. FeatureDataSource
fdsRecords = self.db.getRecordset(FeatureDataSource, recordFilter={"featureInstanceId": instId})
for rec in fdsRecords:
self.db.recordDelete(FeatureDataSource, rec.get("id"))
if fdsRecords:
logger.info(f"Cascade: deleted {len(fdsRecords)} FeatureDataSource records for instance {instId}")
# 0d. FileItem
fileRecords = self.db.getRecordset(FileItem, recordFilter={"featureInstanceId": instId})
for rec in fileRecords:
@ -1687,11 +1697,14 @@ class AppObjects:
if workflows:
logger.info(f"Cascade: deleted {len(workflows)} ChatWorkflows (with messages/logs) for instance {instId}")
# 1. Delete FeatureAccess + FeatureAccessRole for all instances in this mandate
# 1. Delete FeatureAccess + FeatureAccessRole for all instances
for inst in instances:
instId = inst.get("id")
accesses = self.db.getRecordset(FeatureAccess, recordFilter={"featureInstanceId": instId})
for access in accesses:
roles = self.db.getRecordset(FeatureAccessRole, recordFilter={"featureAccessId": access.get("id")})
for role in roles:
self.db.recordDelete(FeatureAccessRole, role.get("id"))
self.db.recordDelete(FeatureAccess, access.get("id"))
self.db.recordDelete(FeatureInstance, instId)
logger.info(f"Cascade: deleted {len(instances)} FeatureInstances for mandate {mandateId}")
@ -1699,6 +1712,9 @@ class AppObjects:
# 2. Delete UserMandate + UserMandateRole
memberships = self.db.getRecordset(UserMandate, recordFilter={"mandateId": mandateId})
for um in memberships:
umRoles = self.db.getRecordset(UserMandateRole, recordFilter={"userMandateId": um.get("id")})
for umr in umRoles:
self.db.recordDelete(UserMandateRole, umr.get("id"))
self.db.recordDelete(UserMandate, um.get("id"))
logger.info(f"Cascade: deleted {len(memberships)} UserMandates for mandate {mandateId}")
@ -1718,6 +1734,20 @@ class AppObjects:
self.db.recordDelete(MandateSubscription, subId)
logger.info(f"Cascade: deleted {len(subs)} subscriptions for mandate {mandateId}")
# 3b. Delete Billing data
billingTxs = self.db.getRecordset(BillingTransaction, recordFilter={"mandateId": mandateId}) if hasattr(BillingTransaction, '__table_name__') else []
billingAccounts = self.db.getRecordset(BillingAccount, recordFilter={"mandateId": mandateId})
for acc in billingAccounts:
accTxs = self.db.getRecordset(BillingTransaction, recordFilter={"accountId": acc.get("id")})
for tx in accTxs:
self.db.recordDelete(BillingTransaction, tx.get("id"))
self.db.recordDelete(BillingAccount, acc.get("id"))
billingSettings = self.db.getRecordset(BillingSettings, recordFilter={"mandateId": mandateId})
for bs in billingSettings:
self.db.recordDelete(BillingSettings, bs.get("id"))
if billingAccounts or billingSettings:
logger.info(f"Cascade: deleted billing data for mandate {mandateId}")
# 4. Delete mandate-level Roles
from modules.datamodels.datamodelRbac import Role, AccessRule
roles = self.db.getRecordset(Role, recordFilter={"mandateId": mandateId})
@ -1821,7 +1851,7 @@ class AppObjects:
def createUserMandate(self, userId: str, mandateId: str, roleIds: List[str] = None) -> UserMandate:
"""
Create a UserMandate record (add user to mandate).
Also creates a billing account for the user if billing is configured for PREPAY_USER.
Also creates a billing audit account for the user if billing is configured.
INVARIANT: A UserMandate MUST have at least one UserMandateRole.
@ -1871,43 +1901,20 @@ class AppObjects:
def _ensureUserBillingAccount(self, userId: str, mandateId: str) -> None:
"""
Ensure a user has a billing account for the mandate if billing is configured.
User accounts are always created for all billing models (for audit trail).
Initial balance depends on billing model:
- PREPAY_USER: defaultUserCredit from mandate BillingSettings when joining the root mandate (missing key => 0.0);
other mandates get 0.0.
- PREPAY_MANDATE: 0.0 on the user account (shared pool no per-user start credit)
Args:
userId: User ID
mandateId: Mandate ID
Ensure a user has a billing audit account for the mandate.
Balance is always on the mandate pool (PREPAY_MANDATE). User accounts are for audit trail only.
"""
try:
from modules.interfaces.interfaceDbBilling import _getRootInterface as getBillingRootInterface
from modules.datamodels.datamodelBilling import BillingModelEnum, parseBillingModelFromStoredValue
billingInterface = getBillingRootInterface()
settings = billingInterface.getSettings(mandateId)
if not settings:
return # No billing configured for this mandate
return
billingModel = parseBillingModelFromStoredValue(settings.get("billingModel"))
# Initial balance depends on billing model (start credit only on root mandate for PREPAY_USER)
rootMandateId = self._getRootMandateId()
isRootMandate = rootMandateId is not None and str(mandateId) == str(rootMandateId)
if billingModel == BillingModelEnum.PREPAY_USER:
initialBalance = (
float(settings.get("defaultUserCredit", 0.0))
if isRootMandate
else 0.0
)
else:
initialBalance = 0.0 # PREPAY_MANDATE: budget is on pool
billingInterface.getOrCreateUserAccount(mandateId, userId, initialBalance=initialBalance)
logger.info(f"Ensured billing account for user {userId} in mandate {mandateId} (model={billingModel.value}, initial={initialBalance} CHF)")
billingInterface.getOrCreateUserAccount(mandateId, userId, initialBalance=0.0)
logger.info(f"Ensured billing audit account for user {userId} in mandate {mandateId}")
except Exception as e:
logger.warning(f"Failed to create billing account for user {userId} (non-critical): {e}")

View file

@ -24,14 +24,11 @@ from modules.datamodels.datamodelBilling import (
BillingSettings,
StripeWebhookEvent,
UsageStatistics,
BillingModelEnum,
AccountTypeEnum,
TransactionTypeEnum,
ReferenceTypeEnum,
PeriodTypeEnum,
BillingBalanceResponse,
BillingCheckResult,
parseBillingModelFromStoredValue,
)
logger = logging.getLogger(__name__)
@ -160,8 +157,6 @@ class BillingObjects:
"""
Get billing settings for a mandate.
Normalizes billingModel for API (legacy UNLIMITED PREPAY_MANDATE) and persists once.
Args:
mandateId: Mandate ID
@ -175,27 +170,7 @@ class BillingObjects:
)
if not results:
return None
row = dict(results[0])
raw_bm = row.get("billingModel")
parsed = parseBillingModelFromStoredValue(raw_bm)
if str(raw_bm or "").strip().upper() == "UNLIMITED":
try:
self.updateSettings(
row["id"],
{"billingModel": BillingModelEnum.PREPAY_MANDATE.value},
)
logger.info(
"Migrated billing settings for mandate %s: UNLIMITED → PREPAY_MANDATE",
mandateId,
)
except Exception as mig_err:
logger.warning(
"Could not persist billing model migration for mandate %s: %s",
mandateId,
mig_err,
)
row["billingModel"] = parsed.value
return row
return dict(results[0])
except Exception as e:
logger.error(f"Error getting billing settings: {e}")
return None
@ -226,13 +201,12 @@ class BillingObjects:
"""
return self.db.recordModify(BillingSettings, settingsId, updates)
def getOrCreateSettings(self, mandateId: str, defaultModel: BillingModelEnum = BillingModelEnum.PREPAY_MANDATE) -> Dict[str, Any]:
def getOrCreateSettings(self, mandateId: str) -> Dict[str, Any]:
"""
Get or create billing settings for a mandate.
Args:
mandateId: Mandate ID
defaultModel: Default billing model if creating
Returns:
BillingSettings dict
@ -243,8 +217,6 @@ class BillingObjects:
settings = BillingSettings(
mandateId=mandateId,
billingModel=defaultModel,
defaultUserCredit=0.0,
warningThresholdPercent=10.0,
notifyOnWarning=True,
)
@ -281,7 +253,7 @@ class BillingObjects:
BillingAccount,
recordFilter={
"mandateId": mandateId,
"accountType": AccountTypeEnum.MANDATE.value
"userId": None
}
)
return results[0] if results else None
@ -305,8 +277,7 @@ class BillingObjects:
BillingAccount,
recordFilter={
"mandateId": mandateId,
"userId": userId,
"accountType": AccountTypeEnum.USER.value
"userId": userId
}
)
return results[0] if results else None
@ -376,7 +347,6 @@ class BillingObjects:
account = BillingAccount(
mandateId=mandateId,
accountType=AccountTypeEnum.MANDATE,
balance=initialBalance,
enabled=True
)
@ -401,7 +371,6 @@ class BillingObjects:
account = BillingAccount(
mandateId=mandateId,
userId=userId,
accountType=AccountTypeEnum.USER,
balance=initialBalance,
enabled=True
)
@ -422,7 +391,7 @@ class BillingObjects:
def ensureAllMandateSettingsExist(self) -> int:
"""
Efficiently ensure all mandates have billing settings.
Creates default settings (PREPAY_MANDATE, 0 CHF) for mandates without settings.
Creates default settings (0 CHF) for mandates without settings.
Uses bulk queries to minimize database connections.
Returns:
@ -451,16 +420,13 @@ class BillingObjects:
if not mandateId or mandateId in existingMandateIds:
continue
# Create default billing settings
settings = BillingSettings(
mandateId=mandateId,
billingModel=BillingModelEnum.PREPAY_MANDATE,
defaultUserCredit=0.0,
warningThresholdPercent=10.0,
notifyOnWarning=True,
)
self.createSettings(settings)
existingMandateIds.add(mandateId) # Track newly created
existingMandateIds.add(mandateId)
settingsCreated += 1
if settingsCreated > 0:
@ -475,11 +441,7 @@ class BillingObjects:
def ensureAllUserAccountsExist(self) -> int:
"""
Ensure all users across all mandates have billing accounts.
User accounts are always created regardless of billing model (for audit trail).
Initial balance depends on billing model:
- PREPAY_USER: defaultUserCredit from settings only for the root mandate; other mandates get 0.0
- PREPAY_MANDATE: 0.0 (budget is on pool)
User accounts are always created for audit trail with initial balance 0.0.
Uses bulk queries to minimize database connections.
Returns:
@ -488,44 +450,29 @@ class BillingObjects:
try:
accountsCreated = 0
appDb = _getAppDatabaseConnector()
rootMandateId = _getCachedRootMandateId()
# Step 1: Get all billing settings (all mandates with settings get user accounts)
allSettings = self.db.getRecordset(BillingSettings)
billingMandates = {} # mandateId -> (billingModel, defaultCredit)
for s in allSettings:
billingModel = parseBillingModelFromStoredValue(s.get("billingModel")).value
mid = s.get("mandateId")
isRoot = rootMandateId is not None and str(mid) == str(rootMandateId)
if billingModel == BillingModelEnum.PREPAY_USER.value:
defaultCredit = (
float(s.get("defaultUserCredit", 0.0) or 0.0) if isRoot else 0.0
)
else:
defaultCredit = 0.0
billingMandates[mid] = (billingModel, defaultCredit)
billingMandateIds = set(
s.get("mandateId") for s in allSettings if s.get("mandateId")
)
if not billingMandates:
if not billingMandateIds:
logger.debug("No billable mandates found, skipping account check")
return 0
# Step 2: Get all existing USER accounts in one query
allAccounts = self.db.getRecordset(
BillingAccount,
recordFilter={"accountType": AccountTypeEnum.USER.value}
)
allAccounts = self.db.getRecordset(BillingAccount)
existingAccountKeys = set()
for acc in allAccounts:
if not acc.get("userId"):
continue
key = (acc.get("mandateId"), acc.get("userId"))
existingAccountKeys.add(key)
# Step 3: Get all user-mandate combinations from APP database
allUserMandates = appDb.getRecordset(
UserMandate,
recordFilter={"enabled": True}
)
# Step 4: Create missing accounts
for um in allUserMandates:
mandateId = um.get("mandateId")
userId = um.get("userId")
@ -533,32 +480,20 @@ class BillingObjects:
if not mandateId or not userId:
continue
if mandateId not in billingMandates:
if mandateId not in billingMandateIds:
continue
key = (mandateId, userId)
if key in existingAccountKeys:
continue
billingModel, defaultCredit = billingMandates[mandateId]
account = BillingAccount(
mandateId=mandateId,
userId=userId,
accountType=AccountTypeEnum.USER,
balance=defaultCredit,
balance=0.0,
enabled=True
)
created = self.createAccount(account)
if defaultCredit > 0:
self.createTransaction(BillingTransaction(
accountId=created["id"],
transactionType=TransactionTypeEnum.CREDIT,
amount=defaultCredit,
description="Initial credit for new user",
referenceType=ReferenceTypeEnum.SYSTEM
))
self.createAccount(account)
existingAccountKeys.add(key)
accountsCreated += 1
@ -810,35 +745,14 @@ class BillingObjects:
"""
Check if there's sufficient balance for an operation.
- PREPAY_USER: user.balance >= estimatedCost
- PREPAY_MANDATE: mandate pool balance >= estimatedCost
User accounts are always ensured to exist (for audit trail).
Root mandate + PREPAY_USER: initial credit from settings.defaultUserCredit on first create.
Missing settings: treated as PREPAY_MANDATE with empty pool (strict).
Checks mandate pool balance against estimatedCost.
User accounts are ensured to exist for audit trail.
Missing settings: treated as PREPAY_MANDATE with empty pool.
"""
settings = self.getSettings(mandateId)
if not settings:
billingModel = BillingModelEnum.PREPAY_MANDATE
defaultCredit = 0.0
else:
billingModel = parseBillingModelFromStoredValue(settings.get("billingModel"))
defaultCredit = float(settings.get("defaultUserCredit", 0.0) or 0.0)
self.getOrCreateUserAccount(mandateId, userId, initialBalance=0.0)
rootMandateId = _getCachedRootMandateId()
isRootMandate = rootMandateId is not None and str(mandateId) == str(rootMandateId)
if billingModel == BillingModelEnum.PREPAY_USER:
initialBalance = defaultCredit if isRootMandate else 0.0
else:
initialBalance = 0.0
self.getOrCreateUserAccount(mandateId, userId, initialBalance=initialBalance)
if billingModel == BillingModelEnum.PREPAY_USER:
account = self.getUserAccount(mandateId, userId)
currentBalance = account.get("balance", 0.0) if account else 0.0
else:
poolAccount = self.getOrCreateMandateAccount(mandateId)
currentBalance = poolAccount.get("balance", 0.0)
poolAccount = self.getOrCreateMandateAccount(mandateId)
currentBalance = poolAccount.get("balance", 0.0)
if currentBalance < estimatedCost:
return BillingCheckResult(
@ -846,10 +760,9 @@ class BillingObjects:
reason="INSUFFICIENT_BALANCE",
currentBalance=currentBalance,
requiredAmount=estimatedCost,
billingModel=billingModel,
)
return BillingCheckResult(allowed=True, currentBalance=currentBalance, billingModel=billingModel)
return BillingCheckResult(allowed=True, currentBalance=currentBalance)
def recordUsage(
self,
@ -870,10 +783,8 @@ class BillingObjects:
"""
Record usage cost as a billing transaction.
Transaction is ALWAYS recorded on the user's account (clean audit trail).
Balance is deducted from the appropriate account based on billing model:
- PREPAY_USER: deduct from user's own balance
- PREPAY_MANDATE: deduct from mandate pool balance
Transaction is recorded on the user's account (audit trail).
Balance is always deducted from the mandate pool account (PREPAY_MANDATE).
"""
if priceCHF <= 0:
return None
@ -883,9 +794,6 @@ class BillingObjects:
logger.debug(f"No billing settings for mandate {mandateId}, skipping usage recording")
return None
billingModel = parseBillingModelFromStoredValue(settings.get("billingModel"))
# Transaction is ALWAYS on the user's account (audit trail)
userAccount = self.getOrCreateUserAccount(mandateId, userId)
transaction = BillingTransaction(
@ -906,13 +814,8 @@ class BillingObjects:
errorCount=errorCount
)
# Determine where to deduct balance
if billingModel == BillingModelEnum.PREPAY_USER:
return self.createTransaction(transaction)
if billingModel == BillingModelEnum.PREPAY_MANDATE:
poolAccount = self.getOrCreateMandateAccount(mandateId)
return self.createTransaction(transaction, balanceAccountId=poolAccount["id"])
return None
poolAccount = self.getOrCreateMandateAccount(mandateId)
return self.createTransaction(transaction, balanceAccountId=poolAccount["id"])
# =========================================================================
# Workflow Cost Query
@ -928,112 +831,6 @@ class BillingObjects:
)
return sum(t.get("amount", 0.0) for t in transactions)
# =========================================================================
# Billing Model Switch Operations
# =========================================================================
def switchBillingModel(self, mandateId: str, oldModel: BillingModelEnum, newModel: BillingModelEnum) -> Dict[str, Any]:
"""
Switch billing model with budget migration logged as BillingTransactions.
PREPAY_MANDATE -> PREPAY_USER: pool debited, equal shares credited to user accounts.
PREPAY_USER -> PREPAY_MANDATE: user wallets debited, pool credited with sum.
"""
result = {"oldModel": oldModel.value, "newModel": newModel.value, "migratedAmount": 0.0, "userCount": 0}
if oldModel == newModel:
return result
if oldModel == BillingModelEnum.PREPAY_MANDATE and newModel == BillingModelEnum.PREPAY_USER:
poolAccount = self.getMandateAccount(mandateId)
userAccounts = self.db.getRecordset(
BillingAccount,
recordFilter={"mandateId": mandateId, "accountType": AccountTypeEnum.USER.value}
)
poolBalance = poolAccount.get("balance", 0.0) if poolAccount else 0.0
n = len(userAccounts)
if poolAccount and poolBalance > 0:
self.createTransaction(
BillingTransaction(
accountId=poolAccount["id"],
transactionType=TransactionTypeEnum.DEBIT,
amount=poolBalance,
description="Model switch: distributed from mandate pool to user wallets",
referenceType=ReferenceTypeEnum.SYSTEM,
)
)
result["migratedAmount"] = poolBalance
if n > 0:
remaining = poolBalance
for i, acc in enumerate(userAccounts):
if i == n - 1:
share = round(remaining, 4)
else:
share = round(poolBalance / n, 4)
remaining -= share
if share > 0:
self.createTransaction(
BillingTransaction(
accountId=acc["id"],
transactionType=TransactionTypeEnum.CREDIT,
amount=share,
description="Model switch: share from mandate pool",
referenceType=ReferenceTypeEnum.SYSTEM,
)
)
result["userCount"] = n
logger.info(
"Switched %s MANDATE->USER: migrated %.4f CHF to %d user account(s) (transactions logged)",
mandateId,
result["migratedAmount"],
result["userCount"],
)
return result
if oldModel == BillingModelEnum.PREPAY_USER and newModel == BillingModelEnum.PREPAY_MANDATE:
userAccounts = self.db.getRecordset(
BillingAccount,
recordFilter={"mandateId": mandateId, "accountType": AccountTypeEnum.USER.value}
)
totalUserBalance = sum(acc.get("balance", 0.0) for acc in userAccounts)
for acc in userAccounts:
b = acc.get("balance", 0.0)
if b > 0:
self.createTransaction(
BillingTransaction(
accountId=acc["id"],
transactionType=TransactionTypeEnum.DEBIT,
amount=b,
description="Model switch: consolidated to mandate pool",
referenceType=ReferenceTypeEnum.SYSTEM,
)
)
poolAccount = self.getOrCreateMandateAccount(mandateId, initialBalance=0.0)
if totalUserBalance > 0:
self.createTransaction(
BillingTransaction(
accountId=poolAccount["id"],
transactionType=TransactionTypeEnum.CREDIT,
amount=totalUserBalance,
description="Model switch: consolidated from user accounts",
referenceType=ReferenceTypeEnum.SYSTEM,
)
)
result["migratedAmount"] = totalUserBalance
result["userCount"] = len(userAccounts)
logger.info(
"Switched %s USER->MANDATE: consolidated %.4f CHF from %d users into pool (transactions logged)",
mandateId,
totalUserBalance,
len(userAccounts),
)
return result
if newModel == BillingModelEnum.PREPAY_MANDATE:
self.getOrCreateMandateAccount(mandateId, initialBalance=0.0)
return result
# =========================================================================
# Statistics Operations
# =========================================================================
@ -1128,10 +925,8 @@ class BillingObjects:
def getBalancesForUser(self, userId: str) -> List[BillingBalanceResponse]:
"""
Get all billing balances for a user across mandates.
Shows the mandate pool balance (shared budget visible to user).
Shows the effective available budget:
- PREPAY_USER: user's own account balance
- PREPAY_MANDATE: mandate pool balance (shared budget visible to user)
Args:
userId: User ID
@ -1163,27 +958,15 @@ class BillingObjects:
if not settings:
continue
billingModel = parseBillingModelFromStoredValue(settings.get("billingModel"))
if billingModel == BillingModelEnum.PREPAY_USER:
account = self.getOrCreateUserAccount(mandateId, userId)
if not account:
continue
balance = account.get("balance", 0.0)
warningThreshold = account.get("warningThreshold", 0.0)
elif billingModel == BillingModelEnum.PREPAY_MANDATE:
poolAccount = self.getOrCreateMandateAccount(mandateId)
if not poolAccount:
continue
balance = poolAccount.get("balance", 0.0)
warningThreshold = poolAccount.get("warningThreshold", 0.0)
else:
poolAccount = self.getOrCreateMandateAccount(mandateId)
if not poolAccount:
continue
balance = poolAccount.get("balance", 0.0)
warningThreshold = poolAccount.get("warningThreshold", 0.0)
balances.append(BillingBalanceResponse(
mandateId=mandateId,
mandateName=mandateName,
billingModel=billingModel,
balance=balance,
warningThreshold=warningThreshold,
isWarning=balance <= warningThreshold,
@ -1280,36 +1063,25 @@ class BillingObjects:
if not mandateId:
continue
billingModel = parseBillingModelFromStoredValue(settings.get("billingModel"))
# Get mandate info
mandate = appInterface.getMandate(mandateId)
mandateName = ""
if mandate:
mandateName = getattr(mandate, 'label', None) or getattr(mandate, 'name', None) or (mandate.get("label") or mandate.get("name", "") if isinstance(mandate, dict) else "")
# Get user accounts count (always exist now for audit trail)
userAccounts = self.db.getRecordset(
allMandateAccounts = self.db.getRecordset(
BillingAccount,
recordFilter={"mandateId": mandateId, "accountType": AccountTypeEnum.USER.value}
recordFilter={"mandateId": mandateId}
)
userCount = len(userAccounts)
userCount = sum(1 for acc in allMandateAccounts if acc.get("userId"))
if billingModel == BillingModelEnum.PREPAY_USER:
totalBalance = sum(acc.get("balance", 0.0) for acc in userAccounts)
elif billingModel == BillingModelEnum.PREPAY_MANDATE:
poolAccount = self.getMandateAccount(mandateId)
totalBalance = poolAccount.get("balance", 0.0) if poolAccount else 0.0
else:
totalBalance = 0.0
poolAccount = self.getMandateAccount(mandateId)
totalBalance = poolAccount.get("balance", 0.0) if poolAccount else 0.0
balances.append({
"mandateId": mandateId,
"mandateName": mandateName,
"billingModel": billingModel.value,
"totalBalance": totalBalance,
"userCount": userCount,
"defaultUserCredit": float(settings.get("defaultUserCredit", 0.0) or 0.0),
"warningThresholdPercent": settings.get("warningThresholdPercent", 10.0),
})
@ -1385,9 +1157,8 @@ class BillingObjects:
try:
appInterface = getAppInterface(self.currentUser)
# Get all user accounts
accountFilter = {"accountType": AccountTypeEnum.USER.value}
allAccounts = self.db.getRecordset(BillingAccount, recordFilter=accountFilter)
allAccounts = self.db.getRecordset(BillingAccount)
allAccounts = [acc for acc in allAccounts if acc.get("userId")]
# Filter by mandate if specified
if mandateIds:

View file

@ -651,6 +651,32 @@ class ChatObjects:
totalPages=totalPages
)
def getLastMessageTimestamp(self, workflowId: str) -> Optional[str]:
"""Return the latest publishedAt/sysCreatedAt from ChatMessage for a workflow."""
messages = self._getRecordset(ChatMessage, recordFilter={"workflowId": workflowId})
if not messages:
return None
latest = None
for msg in messages:
ts = msg.get("publishedAt") or msg.get("sysCreatedAt")
if ts and (latest is None or str(ts) > str(latest)):
latest = ts
return str(latest) if latest else None
def searchWorkflowsByContent(self, query: str, limit: int = 50) -> List[str]:
"""Return workflow IDs whose messages contain the query string (case-insensitive)."""
allMessages = self._getRecordset(ChatMessage)
matchedIds: set = set()
for msg in allMessages:
content = msg.get("message") or ""
if query in content.lower():
wfId = msg.get("workflowId")
if wfId:
matchedIds.add(wfId)
if len(matchedIds) >= limit:
break
return list(matchedIds)
def getWorkflow(self, workflowId: str) -> Optional[ChatWorkflow]:
"""Returns a workflow by ID if user has access."""
# Use RBAC filtering with featureInstanceId for instance-level isolation

View file

@ -293,9 +293,43 @@ class SubscriptionObjects:
if current + delta > cap:
from modules.serviceCenter.services.serviceSubscription.mainServiceSubscription import SubscriptionCapacityException
raise SubscriptionCapacityException(resourceType=resourceType, currentCount=current, maxAllowed=cap)
elif resourceType == "dataVolumeMB":
cap = plan.maxDataVolumeMB
if cap is None:
return True
currentMB = self._getMandateDataVolumeMB(mandateId)
if currentMB + delta > cap:
from modules.serviceCenter.services.serviceSubscription.mainServiceSubscription import SubscriptionCapacityException
raise SubscriptionCapacityException(resourceType=resourceType, currentCount=int(currentMB), maxAllowed=cap)
return True
def _getMandateDataVolumeMB(self, mandateId: str) -> float:
"""Sum RAG index size (FileContentIndex.totalSize) across all feature instances of the mandate."""
try:
from modules.datamodels.datamodelKnowledge import FileContentIndex
knowledgeDb = _getAppDatabaseConnector()
indexes = knowledgeDb.getRecordset(FileContentIndex, recordFilter={"mandateId": mandateId})
totalBytes = sum(int(idx.get("totalSize") or 0) for idx in indexes)
return totalBytes / (1024 * 1024)
except Exception:
return 0.0
def getDataVolumeWarning(self, mandateId: str) -> Optional[Dict[str, Any]]:
"""Return a warning dict if mandate uses >=80% of maxDataVolumeMB, else None."""
sub = self.getOperativeForMandate(mandateId)
if not sub:
return None
plan = self.getPlan(sub.get("planKey", ""))
if not plan or not plan.maxDataVolumeMB:
return None
usedMB = self._getMandateDataVolumeMB(mandateId)
limitMB = plan.maxDataVolumeMB
percent = (usedMB / limitMB * 100) if limitMB > 0 else 0
if percent >= 80:
return {"usedMB": round(usedMB, 2), "limitMB": limitMB, "percent": round(percent, 1), "warning": True}
return {"usedMB": round(usedMB, 2), "limitMB": limitMB, "percent": round(percent, 1), "warning": False}
# =========================================================================
# Counting (cross-DB queries against poweron_app)
# =========================================================================

View file

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

View file

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

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 createAccessToken, createRefreshToken, setAccessTokenCookie, setRefreshTokenCookie, clearAccessTokenCookie, clearRefreshTokenCookie
from modules.interfaces.interfaceDbApp import getInterface, getRootInterface
from modules.datamodels.datamodelUam import User, UserInDB, AuthAuthority, Mandate, MandateType
from modules.datamodels.datamodelUam import User, UserInDB, AuthAuthority, Mandate
from modules.datamodels.datamodelSecurity import Token, TokenPurpose
from modules.shared.configuration import APP_CONFIG
from modules.shared.timeUtils import getUtcTimestamp
@ -87,6 +87,22 @@ router = APIRouter(
}
)
def _ensureHomeMandate(rootInterface, user) -> None:
"""Ensure user has a Home mandate. Creates 'Home {username}' if none exists."""
userMandates = rootInterface.getUserMandates(str(user.id))
homeMandateName = f"Home {user.username}"
for um in userMandates:
mandate = rootInterface.getMandate(um.mandateId)
if mandate and (mandate.name or "").startswith("Home ") and not mandate.isSystem:
return
rootInterface._provisionMandateForUser(
userId=str(user.id),
mandateName=homeMandateName,
planKey="TRIAL_7D",
)
logger.info(f"Created Home mandate '{homeMandateName}' for user {user.username}")
@router.post("/login")
@limiter.limit("30/minute")
def login(
@ -183,6 +199,12 @@ def login(
except Exception as subErr:
logger.error(f"Error activating subscriptions on login: {subErr}")
# Ensure user has a Home mandate (created on first login if missing)
try:
_ensureHomeMandate(rootInterface, user)
except Exception as homeErr:
logger.error(f"Error ensuring Home mandate for user {user.username}: {homeErr}")
# Log successful login (app log file + audit DB for traceability)
logger.info("Login successful for username=%s (userId=%s)", formData.username, str(user.id))
try:
@ -298,32 +320,35 @@ def register_user(
detail="Failed to register user"
)
# Provision mandate for new user
# Provision Home mandate for every new user ("Home {username}")
provisionResult = None
try:
if registrationType == "company":
if not companyName:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="companyName is required for company registration"
)
provisionResult = appInterface._provisionMandateForUser(
homeMandateName = f"Home {user.username}"
provisionResult = appInterface._provisionMandateForUser(
userId=str(user.id),
mandateName=homeMandateName,
planKey="TRIAL_7D",
)
logger.info(f"Provisioned Home mandate for user {user.id}: {provisionResult}")
except Exception as provErr:
logger.error(f"Error provisioning Home mandate for user {user.id}: {provErr}")
# If company registration, also create a company mandate with the paid plan
if registrationType == "company":
if not companyName:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="companyName is required for company registration"
)
try:
companyResult = appInterface._provisionMandateForUser(
userId=str(user.id),
mandateType="company",
mandateName=companyName,
planKey="STANDARD_MONTHLY",
)
else:
provisionResult = appInterface._provisionMandateForUser(
userId=str(user.id),
mandateType="personal",
mandateName=user.fullName or user.username,
planKey="TRIAL_7D",
)
logger.info(f"Provisioned mandate for user {user.id}: {provisionResult}")
except Exception as provErr:
logger.error(f"Error provisioning mandate for user {user.id}: {provErr}")
# Don't fail registration if provisioning fails — user can still use store
logger.info(f"Provisioned company mandate for user {user.id}: {companyResult}")
except Exception as compErr:
logger.error(f"Error provisioning company mandate for user {user.id}: {compErr}")
# Generate reset token for password setup
token, expires = appInterface.generateResetTokenAndExpiry()
@ -406,7 +431,6 @@ Falls Sie sich nicht registriert haben, können Sie diese E-Mail ignorieren."""
}
if provisionResult:
responseData["mandateId"] = provisionResult.get("mandateId")
responseData["mandateType"] = provisionResult.get("mandateType")
return responseData
except ValueError as e:
@ -698,37 +722,24 @@ Falls Sie diese Anforderung nicht gestellt haben, können Sie diese E-Mail ignor
def onboarding_provision(
request: Request,
currentUser: User = Depends(getCurrentUser),
mandateType: str = Body("personal", embed=True),
companyName: str = Body(None, embed=True),
planKey: str = Body("TRIAL_7D", embed=True),
) -> Dict[str, Any]:
"""Post-login onboarding: provision mandate for OAuth users who registered without one."""
"""Post-login onboarding: ensure Home mandate exists and optionally create a company mandate."""
try:
appInterface = getRootInterface()
userMandates = appInterface.getUserMandates(str(currentUser.id))
hasOwnMandate = False
for um in userMandates:
mandate = appInterface.getMandate(um.mandateId)
if mandate and not mandate.isSystem:
hasOwnMandate = True
break
_ensureHomeMandate(appInterface, currentUser)
if hasOwnMandate:
return {"message": "User already has a mandate", "alreadyProvisioned": True}
if mandateType == "company":
mandateName = companyName or currentUser.fullName or currentUser.username
planKey = "STANDARD_MONTHLY"
else:
mandateName = currentUser.fullName or currentUser.username
planKey = "TRIAL_7D"
result = appInterface._provisionMandateForUser(
userId=str(currentUser.id),
mandateType=mandateType,
mandateName=mandateName,
planKey=planKey,
)
result = None
if companyName and companyName.strip():
if planKey not in ("STANDARD_MONTHLY", "STANDARD_YEARLY"):
planKey = "STANDARD_MONTHLY"
result = appInterface._provisionMandateForUser(
userId=str(currentUser.id),
mandateName=companyName.strip(),
planKey=planKey,
)
try:
activatedCount = appInterface._activatePendingSubscriptions(str(currentUser.id))
@ -740,8 +751,7 @@ def onboarding_provision(
logger.info(f"Onboarding provision for {currentUser.username}: {result}")
return {
"message": "Mandate provisioned successfully",
"mandateId": result.get("mandateId"),
"mandateType": result.get("mandateType"),
"mandateId": result.get("mandateId") if result else None,
"alreadyProvisioned": False,
}

View file

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

View file

@ -468,7 +468,12 @@ def _getDataVolumeUsage(
size = f.get("fileSize") if isinstance(f, dict) else getattr(f, "fileSize", 0)
totalBytes += (size or 0)
usedMB = round(totalBytes / (1024 * 1024), 2)
filesMB = round(totalBytes / (1024 * 1024), 2)
from modules.datamodels.datamodelKnowledge import FileContentIndex
ragIndexes = rootIf.db.getRecordset(FileContentIndex, recordFilter={"mandateId": mandateId})
ragBytes = sum(int(idx.get("totalSize") or 0) if isinstance(idx, dict) else int(getattr(idx, "totalSize", 0) or 0) for idx in ragIndexes)
ragMB = round(ragBytes / (1024 * 1024), 2)
maxMB = None
subs = rootIf.db.getRecordset(MandateSubscription, recordFilter={"mandateId": mandateId})
@ -484,10 +489,14 @@ def _getDataVolumeUsage(
if maxMB:
break
usedMB = ragMB
percentUsed = round((usedMB / maxMB) * 100, 1) if maxMB else None
return {
"mandateId": mandateId,
"usedMB": usedMB,
"filesMB": filesMB,
"ragIndexMB": ragMB,
"maxDataVolumeMB": maxMB,
"percentUsed": round((usedMB / maxMB) * 100, 1) if maxMB else None,
"warning": usedMB >= (maxMB * 0.8) if maxMB else False,
"percentUsed": percentUsed,
"warning": (percentUsed or 0) >= 80,
}

View file

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

View file

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

View file

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

View file

@ -16,13 +16,11 @@ from datetime import datetime
from modules.datamodels.datamodelUam import User
from modules.datamodels.datamodelBilling import (
BillingModelEnum,
BillingCheckResult,
TransactionTypeEnum,
ReferenceTypeEnum,
BillingTransaction,
BillingBalanceResponse,
parseBillingModelFromStoredValue,
)
from modules.interfaces.interfaceDbBilling import getInterface as getBillingInterface
@ -369,20 +367,10 @@ class BillingService:
logger.warning(f"No billing settings for mandate {self.mandateId}")
return None
billingModel = parseBillingModelFromStoredValue(settings.get("billingModel"))
# Get or create account
if billingModel == BillingModelEnum.PREPAY_USER:
account = self._billingInterface.getOrCreateUserAccount(
self.mandateId,
self.currentUser.id,
initialBalance=0.0
)
else:
account = self._billingInterface.getOrCreateMandateAccount(
self.mandateId,
initialBalance=0.0
)
account = self._billingInterface.getOrCreateMandateAccount(
self.mandateId,
initialBalance=0.0
)
# Create credit transaction
transaction = BillingTransaction(
@ -429,45 +417,32 @@ BILLING_USER_ACTION_TOP_UP_SELF = "TOP_UP_SELF"
BILLING_USER_ACTION_CONTACT_MANDATE_ADMIN = "CONTACT_MANDATE_ADMIN"
def _userActionForBillingModel(bm: BillingModelEnum) -> str:
if bm == BillingModelEnum.PREPAY_USER:
return BILLING_USER_ACTION_TOP_UP_SELF
def _defaultInsufficientBalanceUserAction() -> str:
return BILLING_USER_ACTION_CONTACT_MANDATE_ADMIN
def _buildInsufficientBalanceMessages(
bm: BillingModelEnum,
currentBalance: float,
requiredAmount: float,
) -> tuple:
bal_s = f"{currentBalance:.2f}"
req_s = f"{requiredAmount:.2f}"
if bm == BillingModelEnum.PREPAY_USER:
msg_de = (
f"Ihr persönliches Guthaben ist aufgebraucht (aktuell CHF {bal_s}, benötigt mindestens CHF {req_s}). "
"Bitte laden Sie unter „Billing“ Guthaben nach."
)
msg_en = (
f"Your personal balance is exhausted (current CHF {bal_s}, at least CHF {req_s} required). "
"Please top up under Billing."
)
else:
msg_de = (
f"Das Mandanten-Budget ist aufgebraucht (aktuell CHF {bal_s}, benötigt mindestens CHF {req_s}). "
"Bitte informieren Sie die Administratorin bzw. den Administrator Ihres Mandanten. "
"Die in den Billing-Einstellungen hinterlegten Kontakte wurden per E-Mail informiert (falls konfiguriert)."
)
msg_en = (
f"The organization budget is exhausted (current CHF {bal_s}, at least CHF {req_s} required). "
"Please contact your mandate administrator. Billing notification contacts were emailed if configured."
)
msg_de = (
f"Das Mandanten-Budget ist aufgebraucht (aktuell CHF {bal_s}, benötigt mindestens CHF {req_s}). "
"Bitte informieren Sie die Administratorin bzw. den Administrator Ihres Mandanten. "
"Die in den Billing-Einstellungen hinterlegten Kontakte wurden per E-Mail informiert (falls konfiguriert)."
)
msg_en = (
f"The organization budget is exhausted (current CHF {bal_s}, at least CHF {req_s} required). "
"Please contact your mandate administrator. Billing notification contacts were emailed if configured."
)
return msg_de, msg_en
class InsufficientBalanceException(Exception):
"""Raised when there's insufficient balance for an operation.
Carries structured fields for API/SSE clients (userAction, billingModel, localized hints).
Carries structured fields for API/SSE clients (userAction, localized hints).
"""
def __init__(
@ -476,7 +451,6 @@ class InsufficientBalanceException(Exception):
requiredAmount: float,
message: Optional[str] = None,
*,
billing_model: Optional[BillingModelEnum] = None,
mandate_id: str = "",
user_action: Optional[str] = None,
message_de: Optional[str] = None,
@ -484,12 +458,8 @@ class InsufficientBalanceException(Exception):
):
self.currentBalance = float(currentBalance)
self.requiredAmount = float(requiredAmount)
self.billing_model = billing_model
self.mandate_id = mandate_id or ""
if billing_model is not None:
self.user_action = user_action or _userActionForBillingModel(billing_model)
else:
self.user_action = user_action or BILLING_USER_ACTION_TOP_UP_SELF
self.user_action = user_action or _defaultInsufficientBalanceUserAction()
if message_de is not None and message_en is not None:
self.message_de = message_de
@ -500,8 +470,7 @@ class InsufficientBalanceException(Exception):
self.message_de = message
self.message_en = message
else:
bm = billing_model or BillingModelEnum.PREPAY_USER
md, me = _buildInsufficientBalanceMessages(bm, self.currentBalance, self.requiredAmount)
md, me = _buildInsufficientBalanceMessages(self.currentBalance, self.requiredAmount)
self.message_de = md
self.message_en = me
self.message = md
@ -514,14 +483,12 @@ class InsufficientBalanceException(Exception):
mandate_id: str,
required_amount: float,
) -> "InsufficientBalanceException":
bm = check.billingModel or BillingModelEnum.PREPAY_MANDATE
bal = float(check.currentBalance or 0.0)
msg_de, msg_en = _buildInsufficientBalanceMessages(bm, bal, required_amount)
msg_de, msg_en = _buildInsufficientBalanceMessages(bal, required_amount)
return cls(
bal,
required_amount,
message=msg_de,
billing_model=bm,
mandate_id=mandate_id or "",
message_de=msg_de,
message_en=msg_en,
@ -538,8 +505,6 @@ class InsufficientBalanceException(Exception):
"messageEn": self.message_en,
"userAction": self.user_action,
}
if self.billing_model is not None:
out["billingModel"] = self.billing_model.value
if self.mandate_id:
out["mandateId"] = self.mandate_id
if self.user_action == BILLING_USER_ACTION_TOP_UP_SELF:

View file

@ -65,7 +65,7 @@ def create_checkout_session(
Args:
mandate_id: Target mandate ID
user_id: Target user ID (for PREPAY_USER) or None (for mandate pool)
user_id: Target user ID for audit trail (optional)
amount_chf: Amount in CHF (must be in ALLOWED_AMOUNTS_CHF)
Returns:

View file

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