# 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.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(BaseModel): """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(BaseModel): """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") # 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 (%)"}, "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.""" allowed: bool reason: Optional[str] = None currentBalance: Optional[float] = None requiredAmount: Optional[float] = None billingModel: Optional[BillingModelEnum] = 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