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