280 lines
12 KiB
Python
280 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
|
|
|
|
# End-customer price for storage above plan-included volume (CHF per GB per month).
|
|
STORAGE_PRICE_PER_GB_CHF = 0.50
|
|
|
|
|
|
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)
|
|
STORAGE = "STORAGE" # Metered storage overage (prepay pool)
|
|
SUBSCRIPTION = "SUBSCRIPTION" # AI budget credit from subscription plan
|
|
|
|
|
|
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 (None = mandate pool account, set = user audit account)")
|
|
balance: float = Field(default=0.0, description="Current balance in CHF")
|
|
warningThreshold: float = Field(default=0.0, description="Warning threshold in CHF")
|
|
lastWarningAt: Optional[datetime] = Field(None, description="Last warning sent timestamp")
|
|
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"},
|
|
"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. Only PREPAY_MANDATE model."""
|
|
id: str = Field(
|
|
default_factory=lambda: str(uuid.uuid4()), description="Primary key"
|
|
)
|
|
mandateId: str = Field(..., description="Foreign key to Mandate (UNIQUE)")
|
|
|
|
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")
|
|
|
|
# Auto-Recharge for AI budget
|
|
autoRechargeEnabled: bool = Field(default=False, description="Auto-buy AI budget when low")
|
|
rechargeAmountCHF: float = Field(default=10.0, description="Amount per auto-recharge (CHF, prepaid via Stripe)")
|
|
rechargeMaxPerMonth: int = Field(default=3, description="Max auto-recharges per month")
|
|
rechargesThisMonth: int = Field(default=0, description="Counter: auto-recharges used this month")
|
|
monthResetAt: Optional[datetime] = Field(None, description="When rechargesThisMonth was last reset")
|
|
|
|
# Notifications
|
|
notifyEmails: List[str] = Field(
|
|
default_factory=list,
|
|
description="Email addresses for billing alerts (pool exhausted, warnings, etc.)",
|
|
)
|
|
notifyOnWarning: bool = Field(default=True, description="Send email when warning threshold is reached")
|
|
|
|
# Storage overage (high-watermark within subscription period; resets on new period)
|
|
storageHighWatermarkMB: float = Field(
|
|
default=0.0, description="Peak indexed data volume MB this billing period"
|
|
)
|
|
storagePeriodStartAt: Optional[datetime] = Field(
|
|
None, description="Subscription billing period start used for storage reset"
|
|
)
|
|
storageBilledUpToMB: float = Field(
|
|
default=0.0,
|
|
description="Overage MB already debited this period (above plan-included volume)",
|
|
)
|
|
|
|
|
|
registerModelLabels(
|
|
"BillingSettings",
|
|
{"en": "Billing Settings", "de": "Abrechnungseinstellungen"},
|
|
{
|
|
"id": {"en": "ID", "de": "ID"},
|
|
"mandateId": {"en": "Mandate ID", "de": "Mandanten-ID"},
|
|
"warningThresholdPercent": {"en": "Warning Threshold (%)", "de": "Warnschwelle (%)"},
|
|
"stripeCustomerId": {"en": "Stripe Customer ID", "de": "Stripe-Kunden-ID"},
|
|
"autoRechargeEnabled": {"en": "Auto-Recharge", "de": "Auto-Nachladung"},
|
|
"rechargeAmountCHF": {"en": "Recharge Amount (CHF)", "de": "Nachladebetrag (CHF)"},
|
|
"rechargeMaxPerMonth": {"en": "Max Recharges/Month", "de": "Max. Nachladungen/Monat"},
|
|
"notifyEmails": {
|
|
"en": "Billing notification emails (owner / admin)",
|
|
"de": "E-Mails fuer Billing-Alerts (Inhaber/Admin)",
|
|
},
|
|
"notifyOnWarning": {"en": "Notify on Warning", "de": "Bei Warnung benachrichtigen"},
|
|
"storageHighWatermarkMB": {"en": "Storage peak (MB)", "de": "Speicher-Peak (MB)"},
|
|
"storagePeriodStartAt": {"en": "Storage period start", "de": "Speicher-Periodenbeginn"},
|
|
"storageBilledUpToMB": {
|
|
"en": "Storage billed overage (MB)",
|
|
"de": "Speicher abgerechneter Überhang (MB)",
|
|
},
|
|
},
|
|
)
|
|
|
|
|
|
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
|
|
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
|
|
upgradeRequired: Optional[bool] = None
|
|
subscriptionUiPath: Optional[str] = None
|
|
userAction: Optional[str] = None
|
|
|
|
|