281 lines
12 KiB
Python
281 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.shared.attributeUtils import registerModelLabels
|
|
import uuid
|
|
|
|
|
|
class BillingModelEnum(str, Enum):
|
|
"""Billing model types."""
|
|
PREPAY_MANDATE = "PREPAY_MANDATE" # Prepaid budget shared by all users in mandate
|
|
PREPAY_USER = "PREPAY_USER" # Prepaid budget per user within mandate
|
|
CREDIT_POSTPAY = "CREDIT_POSTPAY" # Credit with monthly invoice (requires billing address)
|
|
UNLIMITED = "UNLIMITED" # No cost limitation (internal mandates only)
|
|
|
|
|
|
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 BillingAddress(BaseModel):
|
|
"""Billing address for CREDIT_POSTPAY mandates."""
|
|
company: str = Field(..., description="Company name")
|
|
street: str = Field(..., description="Street and number")
|
|
zip: str = Field(..., description="Postal code")
|
|
city: str = Field(..., description="City")
|
|
country: str = Field(default="CH", description="Country code")
|
|
vatNumber: Optional[str] = Field(None, description="VAT number (optional)")
|
|
|
|
|
|
registerModelLabels(
|
|
"BillingAddress",
|
|
{"en": "Billing Address", "de": "Rechnungsadresse"},
|
|
{
|
|
"company": {"en": "Company", "de": "Firma"},
|
|
"street": {"en": "Street", "de": "Strasse"},
|
|
"zip": {"en": "ZIP", "de": "PLZ"},
|
|
"city": {"en": "City", "de": "Ort"},
|
|
"country": {"en": "Country", "de": "Land"},
|
|
"vatNumber": {"en": "VAT Number", "de": "MwSt-Nummer"},
|
|
},
|
|
)
|
|
|
|
|
|
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")
|
|
creditLimit: Optional[float] = Field(None, description="Credit limit in CHF (only for CREDIT_POSTPAY)")
|
|
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)"},
|
|
"creditLimit": {"en": "Credit Limit (CHF)", "de": "Kreditlimit (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., chatplayground, 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")
|
|
|
|
|
|
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=10.0, description="Initial credit in CHF for new users (PREPAY_USER)")
|
|
warningThresholdPercent: float = Field(default=10.0, description="Warning threshold as percentage")
|
|
blockOnZeroBalance: bool = Field(default=True, description="Block AI features when balance is zero")
|
|
|
|
# Billing address (required for CREDIT_POSTPAY)
|
|
billingAddress: Optional[BillingAddress] = Field(None, description="Billing address")
|
|
|
|
# Notifications
|
|
notifyEmails: List[str] = Field(default_factory=list, description="Email addresses for billing notifications")
|
|
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": "Default User Credit (CHF)", "de": "Standard-Startguthaben (CHF)"},
|
|
"warningThresholdPercent": {"en": "Warning Threshold (%)", "de": "Warnschwelle (%)"},
|
|
"blockOnZeroBalance": {"en": "Block on Zero Balance", "de": "Bei 0 blockieren"},
|
|
"billingAddress": {"en": "Billing Address", "de": "Rechnungsadresse"},
|
|
"notifyEmails": {"en": "Notification Emails", "de": "Benachrichtigungs-Emails"},
|
|
"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., {'chatplayground': 15.00, 'automation': 5.80})"
|
|
)
|
|
|
|
|
|
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
|
|
creditLimit: Optional[float] = None
|
|
|
|
|
|
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
|