API and persisted records use PowerOnModel system fields: - sysCreatedAt, sysCreatedBy, sysModifiedAt, sysModifiedBy Removed legacy JSON/DB field names: - _createdAt, _createdBy, _modifiedAt, _modifiedBy Frontend (frontend_nyla) and gateway call sites were updated accordingly. Database: - Bootstrap runs idempotent backfill (_migrateSystemFieldColumns) from old underscore columns and selected business duplicates into sys* where sys* IS NULL. - Re-run app bootstrap against each PostgreSQL database after deploy. - Optional: DROP INDEX IF EXISTS "idx_invitation_createdby" if an old index remains; new index: idx_invitation_syscreatedby on Invitation(sysCreatedBy). Tests: - RBAC integration tests aligned with current GROUP mandate filter and UserMandate-based UserConnection GROUP clause; buildRbacWhereClause(..., mandateId=...) must be passed explicitly (same as production request context).
289 lines
12 KiB
Python
289 lines
12 KiB
Python
# Copyright (c) 2025 Patrick Motsch
|
|
# All rights reserved.
|
|
"""Billing models: BillingAccount, BillingTransaction, BillingSettings, UsageStatistics."""
|
|
|
|
from typing import List, Dict, Any, Optional
|
|
from enum import Enum
|
|
from datetime import date, datetime, timezone
|
|
from pydantic import BaseModel, Field
|
|
from modules.datamodels.datamodelBase import PowerOnModel
|
|
from modules.shared.attributeUtils import registerModelLabels
|
|
import uuid
|
|
|
|
|
|
class BillingModelEnum(str, Enum):
|
|
"""Billing model types (prepaid only; legacy UNLIMITED in DB maps to PREPAY_MANDATE)."""
|
|
PREPAY_MANDATE = "PREPAY_MANDATE" # Prepaid budget shared by all users in mandate
|
|
PREPAY_USER = "PREPAY_USER" # Prepaid budget per user within mandate
|
|
|
|
|
|
# Nur fuer initRootMandateBilling (Root-Mandant PREPAY_USER + Startguthaben in Settings).
|
|
DEFAULT_USER_CREDIT_CHF = 5.0
|
|
|
|
|
|
class AccountTypeEnum(str, Enum):
|
|
"""Account type for billing accounts."""
|
|
MANDATE = "MANDATE" # Account for entire mandate
|
|
USER = "USER" # Account for specific user within mandate
|
|
|
|
|
|
class TransactionTypeEnum(str, Enum):
|
|
"""Transaction types for billing."""
|
|
CREDIT = "CREDIT" # Credit/top-up (positive)
|
|
DEBIT = "DEBIT" # Debit/usage (positive amount, reduces balance)
|
|
ADJUSTMENT = "ADJUSTMENT" # Manual adjustment by admin
|
|
|
|
|
|
class ReferenceTypeEnum(str, Enum):
|
|
"""Reference types for transactions."""
|
|
WORKFLOW = "WORKFLOW" # AI workflow usage
|
|
PAYMENT = "PAYMENT" # Payment/top-up
|
|
ADMIN = "ADMIN" # Admin adjustment
|
|
SYSTEM = "SYSTEM" # System credit (e.g., initial credit)
|
|
|
|
|
|
class PeriodTypeEnum(str, Enum):
|
|
"""Period types for usage statistics."""
|
|
DAY = "DAY"
|
|
MONTH = "MONTH"
|
|
YEAR = "YEAR"
|
|
|
|
|
|
class BillingAccount(PowerOnModel):
|
|
"""Billing account for mandate or user-mandate combination."""
|
|
id: str = Field(
|
|
default_factory=lambda: str(uuid.uuid4()), description="Primary key"
|
|
)
|
|
mandateId: str = Field(..., description="Foreign key to Mandate")
|
|
userId: Optional[str] = Field(None, description="Foreign key to User (only for PREPAY_USER)")
|
|
accountType: AccountTypeEnum = Field(..., description="Account type: MANDATE or USER")
|
|
balance: float = Field(default=0.0, description="Current balance in CHF")
|
|
warningThreshold: float = Field(default=0.0, description="Warning threshold in CHF")
|
|
lastWarningAt: Optional[datetime] = Field(None, description="Last warning sent timestamp")
|
|
enabled: bool = Field(default=True, description="Account is active")
|
|
|
|
|
|
registerModelLabels(
|
|
"BillingAccount",
|
|
{"en": "Billing Account", "de": "Abrechnungskonto"},
|
|
{
|
|
"id": {"en": "ID", "de": "ID"},
|
|
"mandateId": {"en": "Mandate ID", "de": "Mandanten-ID"},
|
|
"userId": {"en": "User ID", "de": "Benutzer-ID"},
|
|
"accountType": {"en": "Account Type", "de": "Kontotyp"},
|
|
"balance": {"en": "Balance (CHF)", "de": "Guthaben (CHF)"},
|
|
"warningThreshold": {"en": "Warning Threshold (CHF)", "de": "Warnschwelle (CHF)"},
|
|
"lastWarningAt": {"en": "Last Warning", "de": "Letzte Warnung"},
|
|
"enabled": {"en": "Enabled", "de": "Aktiv"},
|
|
},
|
|
)
|
|
|
|
|
|
class BillingTransaction(PowerOnModel):
|
|
"""Single billing transaction (credit, debit, adjustment)."""
|
|
id: str = Field(
|
|
default_factory=lambda: str(uuid.uuid4()), description="Primary key"
|
|
)
|
|
accountId: str = Field(..., description="Foreign key to BillingAccount")
|
|
transactionType: TransactionTypeEnum = Field(..., description="Transaction type")
|
|
amount: float = Field(..., description="Amount in CHF (always positive)")
|
|
description: str = Field(..., description="Transaction description")
|
|
|
|
# Reference to source
|
|
referenceType: Optional[ReferenceTypeEnum] = Field(None, description="Reference type")
|
|
referenceId: Optional[str] = Field(None, description="Reference ID")
|
|
|
|
# Context for workflow transactions
|
|
workflowId: Optional[str] = Field(None, description="Workflow ID (for WORKFLOW transactions)")
|
|
featureInstanceId: Optional[str] = Field(None, description="Feature instance ID")
|
|
featureCode: Optional[str] = Field(None, description="Feature code (e.g., automation)")
|
|
aicoreProvider: Optional[str] = Field(None, description="AICore provider (anthropic, openai, etc.)")
|
|
aicoreModel: Optional[str] = Field(None, description="AICore model name (e.g., claude-4-sonnet, gpt-4o)")
|
|
createdByUserId: Optional[str] = Field(None, description="User who created/caused this transaction")
|
|
|
|
# AI call metadata (for per-call analytics)
|
|
processingTime: Optional[float] = Field(None, description="Processing time in seconds")
|
|
bytesSent: Optional[int] = Field(None, description="Bytes sent to AI model")
|
|
bytesReceived: Optional[int] = Field(None, description="Bytes received from AI model")
|
|
errorCount: Optional[int] = Field(None, description="Number of errors in this call")
|
|
|
|
|
|
registerModelLabels(
|
|
"BillingTransaction",
|
|
{"en": "Billing Transaction", "de": "Transaktion"},
|
|
{
|
|
"id": {"en": "ID", "de": "ID"},
|
|
"accountId": {"en": "Account ID", "de": "Konto-ID"},
|
|
"transactionType": {"en": "Type", "de": "Typ"},
|
|
"amount": {"en": "Amount (CHF)", "de": "Betrag (CHF)"},
|
|
"description": {"en": "Description", "de": "Beschreibung"},
|
|
"referenceType": {"en": "Reference Type", "de": "Referenztyp"},
|
|
"referenceId": {"en": "Reference ID", "de": "Referenz-ID"},
|
|
"workflowId": {"en": "Workflow ID", "de": "Workflow-ID"},
|
|
"featureInstanceId": {"en": "Feature Instance ID", "de": "Feature-Instanz-ID"},
|
|
"featureCode": {"en": "Feature Code", "de": "Feature-Code"},
|
|
"aicoreProvider": {"en": "AI Provider", "de": "AI-Anbieter"},
|
|
"aicoreModel": {"en": "AI Model", "de": "AI-Modell"},
|
|
"createdByUserId": {"en": "Created By User", "de": "Erstellt von Benutzer"},
|
|
},
|
|
)
|
|
|
|
|
|
class BillingSettings(BaseModel):
|
|
"""Billing settings per mandate."""
|
|
id: str = Field(
|
|
default_factory=lambda: str(uuid.uuid4()), description="Primary key"
|
|
)
|
|
mandateId: str = Field(..., description="Foreign key to Mandate (UNIQUE)")
|
|
billingModel: BillingModelEnum = Field(..., description="Billing model")
|
|
|
|
# Configuration
|
|
defaultUserCredit: float = Field(
|
|
default=0.0,
|
|
description="Automatic initial credit (CHF) for PREPAY_USER only when a user is newly added to the root mandate; other mandates use 0 on join.",
|
|
)
|
|
warningThresholdPercent: float = Field(default=10.0, description="Warning threshold as percentage")
|
|
|
|
# Stripe
|
|
stripeCustomerId: Optional[str] = Field(None, description="Stripe Customer ID (cus_xxx) — one per mandate")
|
|
|
|
# Notifications (e.g. mandate owner / finance — also used when PREPAY_MANDATE pool is exhausted)
|
|
notifyEmails: List[str] = Field(
|
|
default_factory=list,
|
|
description="Email addresses for billing alerts (mandate pool exhausted, warnings, etc.)",
|
|
)
|
|
notifyOnWarning: bool = Field(default=True, description="Send email when warning threshold is reached")
|
|
|
|
|
|
registerModelLabels(
|
|
"BillingSettings",
|
|
{"en": "Billing Settings", "de": "Abrechnungseinstellungen"},
|
|
{
|
|
"id": {"en": "ID", "de": "ID"},
|
|
"mandateId": {"en": "Mandate ID", "de": "Mandanten-ID"},
|
|
"billingModel": {"en": "Billing Model", "de": "Abrechnungsmodell"},
|
|
"defaultUserCredit": {
|
|
"en": "Root start credit (CHF)",
|
|
"de": "Startguthaben nur Root-Mandant (CHF)",
|
|
},
|
|
"warningThresholdPercent": {"en": "Warning Threshold (%)", "de": "Warnschwelle (%)"},
|
|
"stripeCustomerId": {"en": "Stripe Customer ID", "de": "Stripe-Kunden-ID"},
|
|
"notifyEmails": {
|
|
"en": "Billing notification emails (owner / admin)",
|
|
"de": "E-Mails für Billing-Alerts (Inhaber/Admin)",
|
|
},
|
|
"notifyOnWarning": {"en": "Notify on Warning", "de": "Bei Warnung benachrichtigen"},
|
|
},
|
|
)
|
|
|
|
|
|
class StripeWebhookEvent(BaseModel):
|
|
"""Stores processed Stripe webhook event IDs for idempotency."""
|
|
id: str = Field(
|
|
default_factory=lambda: str(uuid.uuid4()), description="Primary key"
|
|
)
|
|
event_id: str = Field(..., description="Stripe event ID (evt_xxx)")
|
|
processed_at: datetime = Field(
|
|
default_factory=lambda: datetime.now(timezone.utc),
|
|
description="When the event was processed"
|
|
)
|
|
|
|
|
|
class UsageStatistics(BaseModel):
|
|
"""Aggregated usage statistics for quick retrieval."""
|
|
id: str = Field(
|
|
default_factory=lambda: str(uuid.uuid4()), description="Primary key"
|
|
)
|
|
accountId: str = Field(..., description="Foreign key to BillingAccount")
|
|
periodType: PeriodTypeEnum = Field(..., description="Period type")
|
|
periodStart: date = Field(..., description="Period start date")
|
|
|
|
# Aggregated values
|
|
totalCostCHF: float = Field(default=0.0, description="Total cost in CHF")
|
|
transactionCount: int = Field(default=0, description="Number of transactions")
|
|
|
|
# Breakdown by provider
|
|
costByProvider: Dict[str, float] = Field(
|
|
default_factory=dict,
|
|
description="Cost breakdown by provider (e.g., {'anthropic': 12.50, 'openai': 8.30})"
|
|
)
|
|
|
|
# Breakdown by feature
|
|
costByFeature: Dict[str, float] = Field(
|
|
default_factory=dict,
|
|
description="Cost breakdown by feature (e.g., {'automation': 5.80, 'workspace': 3.20})"
|
|
)
|
|
|
|
|
|
registerModelLabels(
|
|
"UsageStatistics",
|
|
{"en": "Usage Statistics", "de": "Nutzungsstatistik"},
|
|
{
|
|
"id": {"en": "ID", "de": "ID"},
|
|
"accountId": {"en": "Account ID", "de": "Konto-ID"},
|
|
"periodType": {"en": "Period Type", "de": "Periodentyp"},
|
|
"periodStart": {"en": "Period Start", "de": "Periodenbeginn"},
|
|
"totalCostCHF": {"en": "Total Cost (CHF)", "de": "Gesamtkosten (CHF)"},
|
|
"transactionCount": {"en": "Transaction Count", "de": "Anzahl Transaktionen"},
|
|
"costByProvider": {"en": "Cost by Provider", "de": "Kosten nach Anbieter"},
|
|
"costByFeature": {"en": "Cost by Feature", "de": "Kosten nach Feature"},
|
|
},
|
|
)
|
|
|
|
|
|
# ============================================================================
|
|
# Response Models for API
|
|
# ============================================================================
|
|
|
|
class BillingBalanceResponse(BaseModel):
|
|
"""Response model for balance endpoint."""
|
|
mandateId: str
|
|
mandateName: str
|
|
billingModel: BillingModelEnum
|
|
balance: float
|
|
currency: str = "CHF"
|
|
warningThreshold: float
|
|
isWarning: bool
|
|
|
|
|
|
class BillingStatisticsChartData(BaseModel):
|
|
"""Chart data point for statistics."""
|
|
label: str
|
|
totalCost: float
|
|
byProvider: Dict[str, float]
|
|
|
|
|
|
class BillingStatisticsResponse(BaseModel):
|
|
"""Response model for statistics endpoint."""
|
|
mandateId: str
|
|
period: PeriodTypeEnum
|
|
year: int
|
|
month: Optional[int] = None
|
|
currency: str = "CHF"
|
|
data: List[BillingStatisticsChartData]
|
|
totals: Dict[str, Any]
|
|
|
|
|
|
class BillingCheckResult(BaseModel):
|
|
"""Result of a billing balance check (budget + subscription gate)."""
|
|
allowed: bool
|
|
reason: Optional[str] = None
|
|
currentBalance: Optional[float] = None
|
|
requiredAmount: Optional[float] = None
|
|
billingModel: Optional[BillingModelEnum] = None
|
|
upgradeRequired: Optional[bool] = None
|
|
subscriptionUiPath: Optional[str] = None
|
|
userAction: Optional[str] = None
|
|
|
|
|
|
def parseBillingModelFromStoredValue(raw: Optional[str]) -> BillingModelEnum:
|
|
"""Map DB string to enum. Legacy UNLIMITED / unknown values become PREPAY_MANDATE."""
|
|
if raw is None or (isinstance(raw, str) and raw.strip() == ""):
|
|
return BillingModelEnum.PREPAY_MANDATE
|
|
s = str(raw).strip().upper()
|
|
if s == "UNLIMITED":
|
|
return BillingModelEnum.PREPAY_MANDATE
|
|
try:
|
|
return BillingModelEnum(raw)
|
|
except ValueError:
|
|
return BillingModelEnum.PREPAY_MANDATE
|