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