cleaned mandate and unified mandate to be standard type
This commit is contained in:
parent
1f42c015d6
commit
1fdf238aaf
22 changed files with 366 additions and 665 deletions
4
app.py
4
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})
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"},
|
||||
},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
# =========================================================================
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
|
|
|||
Loading…
Reference in a new issue