278 lines
12 KiB
Python
278 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.i18nRegistry import i18nModel
|
|
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"
|
|
|
|
|
|
@i18nModel("Abrechnungskonto")
|
|
class BillingAccount(PowerOnModel):
|
|
"""Billing account for mandate or user-mandate combination."""
|
|
id: str = Field(
|
|
default_factory=lambda: str(uuid.uuid4()),
|
|
description="Primary key",
|
|
json_schema_extra={"label": "ID"},
|
|
)
|
|
mandateId: str = Field(
|
|
...,
|
|
description="Foreign key to Mandate",
|
|
json_schema_extra={"label": "Mandanten-ID", "fk_target": {"db": "poweron_app", "table": "Mandate"}},
|
|
)
|
|
userId: Optional[str] = Field(
|
|
None,
|
|
description="Foreign key to User (None = mandate pool account, set = user audit account)",
|
|
json_schema_extra={"label": "Benutzer-ID", "fk_target": {"db": "poweron_app", "table": "User"}},
|
|
)
|
|
balance: float = Field(default=0.0, description="Current balance in CHF", json_schema_extra={"label": "Guthaben (CHF)"})
|
|
warningThreshold: float = Field(
|
|
default=0.0,
|
|
description="Warning threshold in CHF",
|
|
json_schema_extra={"label": "Warnschwelle (CHF)"},
|
|
)
|
|
lastWarningAt: Optional[datetime] = Field(
|
|
None,
|
|
description="Last warning sent timestamp",
|
|
json_schema_extra={"label": "Letzte Warnung"},
|
|
)
|
|
enabled: bool = Field(default=True, description="Account is active", json_schema_extra={"label": "Aktiv"})
|
|
|
|
|
|
@i18nModel("Transaktion")
|
|
class BillingTransaction(PowerOnModel):
|
|
"""Single billing transaction (credit, debit, adjustment)."""
|
|
id: str = Field(
|
|
default_factory=lambda: str(uuid.uuid4()),
|
|
description="Primary key",
|
|
json_schema_extra={"label": "ID"},
|
|
)
|
|
accountId: str = Field(
|
|
...,
|
|
description="Foreign key to BillingAccount",
|
|
json_schema_extra={"label": "Konto-ID", "fk_target": {"db": "poweron_billing", "table": "BillingAccount"}},
|
|
)
|
|
transactionType: TransactionTypeEnum = Field(..., description="Transaction type", json_schema_extra={"label": "Typ"})
|
|
amount: float = Field(..., description="Amount in CHF (always positive)", json_schema_extra={"label": "Betrag (CHF)"})
|
|
description: str = Field(..., description="Transaction description", json_schema_extra={"label": "Beschreibung"})
|
|
|
|
# Reference to source
|
|
referenceType: Optional[ReferenceTypeEnum] = Field(None, description="Reference type", json_schema_extra={"label": "Referenztyp"})
|
|
referenceId: Optional[str] = Field(None, description="Reference ID", json_schema_extra={"label": "Referenz-ID"})
|
|
|
|
# Context for workflow transactions
|
|
workflowId: Optional[str] = Field(
|
|
None,
|
|
description="Workflow ID (for WORKFLOW transactions; may be Chat or Graphical Editor)",
|
|
json_schema_extra={"label": "Workflow-ID"},
|
|
)
|
|
featureInstanceId: Optional[str] = Field(
|
|
None,
|
|
description="Feature instance ID",
|
|
json_schema_extra={"label": "Feature-Instanz-ID", "fk_target": {"db": "poweron_app", "table": "FeatureInstance"}},
|
|
)
|
|
featureCode: Optional[str] = Field(
|
|
None,
|
|
description="Feature code (e.g., automation)",
|
|
json_schema_extra={"label": "Feature-Code", "fk_target": {"db": "poweron_app", "table": "Feature", "column": "code"}},
|
|
)
|
|
aicoreProvider: Optional[str] = Field(None, description="AICore provider (anthropic, openai, etc.)", json_schema_extra={"label": "AI-Anbieter"})
|
|
aicoreModel: Optional[str] = Field(None, description="AICore model name (e.g., claude-4-sonnet, gpt-4o)", json_schema_extra={"label": "AI-Modell"})
|
|
createdByUserId: Optional[str] = Field(
|
|
None,
|
|
description="User who created/caused this transaction",
|
|
json_schema_extra={"label": "Erstellt von Benutzer", "fk_target": {"db": "poweron_app", "table": "User"}},
|
|
)
|
|
|
|
# AI call metadata (for per-call analytics)
|
|
processingTime: Optional[float] = Field(None, description="Processing time in seconds", json_schema_extra={"label": "Verarbeitungszeit (s)"})
|
|
bytesSent: Optional[int] = Field(None, description="Bytes sent to AI model", json_schema_extra={"label": "Gesendete Bytes"})
|
|
bytesReceived: Optional[int] = Field(None, description="Bytes received from AI model", json_schema_extra={"label": "Empfangene Bytes"})
|
|
errorCount: Optional[int] = Field(None, description="Number of errors in this call", json_schema_extra={"label": "Fehleranzahl"})
|
|
|
|
|
|
@i18nModel("Abrechnungseinstellungen")
|
|
class BillingSettings(BaseModel):
|
|
"""Billing settings per mandate. Only PREPAY_MANDATE model."""
|
|
id: str = Field(
|
|
default_factory=lambda: str(uuid.uuid4()),
|
|
description="Primary key",
|
|
json_schema_extra={"label": "ID"},
|
|
)
|
|
mandateId: str = Field(
|
|
...,
|
|
description="Foreign key to Mandate (UNIQUE)",
|
|
json_schema_extra={"label": "Mandanten-ID", "fk_target": {"db": "poweron_app", "table": "Mandate"}},
|
|
)
|
|
|
|
warningThresholdPercent: float = Field(
|
|
default=10.0,
|
|
description="Benachrichtigung wenn das AI-Guthaben unter diesen Prozentsatz des Gesamtbudgets fällt",
|
|
json_schema_extra={"label": "Warnschwelle (%)"},
|
|
)
|
|
|
|
# Stripe
|
|
stripeCustomerId: Optional[str] = Field(
|
|
None,
|
|
description="Stripe Customer ID (cus_xxx) — one per mandate",
|
|
json_schema_extra={"label": "Stripe-Kunden-ID"},
|
|
)
|
|
|
|
# Auto-Recharge for AI budget
|
|
autoRechargeEnabled: bool = Field(default=False, description="Auto-buy AI budget when low", json_schema_extra={"label": "Auto-Nachladung"})
|
|
rechargeAmountCHF: float = Field(
|
|
default=10.0,
|
|
description="Amount per auto-recharge (CHF, prepaid via Stripe)",
|
|
json_schema_extra={"label": "Nachladebetrag (CHF)"},
|
|
)
|
|
rechargeMaxPerMonth: int = Field(default=3, description="Max auto-recharges per month", json_schema_extra={"label": "Max. Nachladungen/Monat"})
|
|
rechargesThisMonth: int = Field(default=0, description="Counter: auto-recharges used this month", json_schema_extra={"label": "Nachladungen diesen Monat"})
|
|
monthResetAt: Optional[datetime] = Field(None, description="When rechargesThisMonth was last reset", json_schema_extra={"label": "Monats-Reset"})
|
|
|
|
# Notifications
|
|
notifyEmails: List[str] = Field(
|
|
default_factory=list,
|
|
description="Email addresses for billing alerts (pool exhausted, warnings, etc.)",
|
|
json_schema_extra={"label": "E-Mails fuer Billing-Alerts (Inhaber/Admin)"},
|
|
)
|
|
notifyOnWarning: bool = Field(default=True, description="Send email when warning threshold is reached", json_schema_extra={"label": "Bei Warnung benachrichtigen"})
|
|
|
|
# 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",
|
|
json_schema_extra={"label": "Speicher-Peak (MB)"},
|
|
)
|
|
storagePeriodStartAt: Optional[datetime] = Field(
|
|
None,
|
|
description="Subscription billing period start used for storage reset",
|
|
json_schema_extra={"label": "Speicher-Periodenbeginn"},
|
|
)
|
|
storageBilledUpToMB: float = Field(
|
|
default=0.0,
|
|
description="Overage MB already debited this period (above plan-included volume)",
|
|
json_schema_extra={"label": "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",
|
|
)
|
|
|
|
|
|
@i18nModel("Nutzungsstatistik")
|
|
class UsageStatistics(BaseModel):
|
|
"""Aggregated usage statistics for quick retrieval."""
|
|
id: str = Field(
|
|
default_factory=lambda: str(uuid.uuid4()),
|
|
description="Primary key",
|
|
json_schema_extra={"label": "ID"},
|
|
)
|
|
accountId: str = Field(
|
|
...,
|
|
description="Foreign key to BillingAccount",
|
|
json_schema_extra={"label": "Konto-ID", "fk_target": {"db": "poweron_billing", "table": "BillingAccount"}},
|
|
)
|
|
periodType: PeriodTypeEnum = Field(..., description="Period type", json_schema_extra={"label": "Periodentyp"})
|
|
periodStart: date = Field(..., description="Period start date", json_schema_extra={"label": "Periodenbeginn"})
|
|
|
|
# Aggregated values
|
|
totalCostCHF: float = Field(default=0.0, description="Total cost in CHF", json_schema_extra={"label": "Gesamtkosten (CHF)"})
|
|
transactionCount: int = Field(default=0, description="Number of transactions", json_schema_extra={"label": "Anzahl Transaktionen"})
|
|
|
|
# Breakdown by provider
|
|
costByProvider: Dict[str, float] = Field(
|
|
default_factory=dict,
|
|
description="Cost breakdown by provider (e.g., {'anthropic': 12.50, 'openai': 8.30})",
|
|
json_schema_extra={"label": "Kosten nach Anbieter"},
|
|
)
|
|
|
|
# Breakdown by feature
|
|
costByFeature: Dict[str, float] = Field(
|
|
default_factory=dict,
|
|
description="Cost breakdown by feature (e.g., {'automation': 5.80, 'workspace': 3.20})",
|
|
json_schema_extra={"label": "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
|
|
|