feat(billing): Nutzerhinweise bei leerem Budget + Mandats-Mail (402/SSE) Gateway - InsufficientBalanceException: billingModel, userAction (TOP_UP_SELF / CONTACT_MANDATE_ADMIN), DE/EN-Texte, toClientDict(), fromBalanceCheck() - HTTP 402 + JSON detail für globale API-Fehlerbehandlung - AI/Chatbot: vor Raise ggf. E-Mail an BillingSettings.notifyEmails (PREPAY_MANDATE, Throttle 1h/Mandat) via billingExhaustedNotify - Agent-Loop & Workspace-Route: SSE-ERROR mit strukturiertem Billing-Payload - datamodelBilling: notifyEmails-Doku für Pool-Alerts frontend_nyla - useWorkspace: SSE onError für INSUFFICIENT_BALANCE mit messageDe/En und Hinweis auf Billing-Pfad bei TOP_UP_SELF
1627 lines
62 KiB
Python
1627 lines
62 KiB
Python
# Copyright (c) 2025 Patrick Motsch
|
|
# All rights reserved.
|
|
"""
|
|
Billing routes for the backend API.
|
|
Implements the endpoints for billing management and usage tracking.
|
|
|
|
Features:
|
|
- User endpoints: View balance, transactions, statistics
|
|
- Admin endpoints: Manage settings, add credits, view all accounts
|
|
"""
|
|
|
|
from fastapi import APIRouter, HTTPException, Depends, Body, Path, Request, Response, Query, Header
|
|
from typing import List, Dict, Any, Optional
|
|
from fastapi import status
|
|
import logging
|
|
from datetime import date, datetime
|
|
from pydantic import BaseModel, Field
|
|
|
|
# Import auth module
|
|
from modules.auth import limiter, requireSysAdminRole, getRequestContext, RequestContext
|
|
|
|
# Import billing components
|
|
from modules.interfaces.interfaceDbBilling import getInterface as getBillingInterface, _getRootInterface
|
|
from modules.serviceCenter.services.serviceBilling.mainServiceBilling import getService as getBillingService
|
|
from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResponse, PaginationMetadata, normalize_pagination_dict
|
|
from modules.routes.routeDataUsers import _applyFiltersAndSort
|
|
from modules.datamodels.datamodelBilling import (
|
|
BillingAccount,
|
|
BillingTransaction,
|
|
BillingSettings,
|
|
BillingModelEnum,
|
|
TransactionTypeEnum,
|
|
ReferenceTypeEnum,
|
|
PeriodTypeEnum,
|
|
BillingBalanceResponse,
|
|
BillingStatisticsResponse,
|
|
BillingStatisticsChartData,
|
|
BillingCheckResult,
|
|
parseBillingModelFromStoredValue,
|
|
)
|
|
|
|
# Configure logger
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
# =============================================================================
|
|
# Billing RBAC Data Scope
|
|
# =============================================================================
|
|
#
|
|
# RBAC rules for billing data visibility:
|
|
#
|
|
# SysAdmin → ALL transactions and statistics across all mandates
|
|
# Mandate-Admin → ALL user data within their administrated mandates
|
|
# Feature-Instance-Admin→ Data for their administrated feature instances
|
|
# Regular User → ONLY their own data within their mandates
|
|
#
|
|
|
|
class BillingDataScope:
|
|
"""
|
|
Determines what billing data a user can see based on RBAC roles.
|
|
|
|
Evaluated once per request and used to filter transactions/statistics.
|
|
"""
|
|
__slots__ = ('isGlobalAdmin', 'adminMandateIds', 'adminFeatureInstanceIds',
|
|
'memberMandateIds', 'userId')
|
|
|
|
def __init__(self, userId: str):
|
|
self.isGlobalAdmin: bool = False
|
|
self.adminMandateIds: list = []
|
|
self.adminFeatureInstanceIds: list = []
|
|
self.memberMandateIds: list = []
|
|
self.userId: str = userId
|
|
|
|
|
|
def _getBillingDataScope(user) -> BillingDataScope:
|
|
"""
|
|
Determine what billing data a user can see based on RBAC.
|
|
|
|
Uses rootInterface (privileged) to check roles across all mandates
|
|
and feature instances without RBAC restrictions on the lookup itself.
|
|
|
|
Returns:
|
|
BillingDataScope with the user's visibility boundaries.
|
|
"""
|
|
scope = BillingDataScope(userId=user.id)
|
|
|
|
from modules.auth.authentication import _hasSysAdminRole
|
|
if _hasSysAdminRole(str(user.id)):
|
|
scope.isGlobalAdmin = True
|
|
return scope
|
|
|
|
from modules.interfaces.interfaceDbApp import getRootInterface
|
|
rootInterface = getRootInterface()
|
|
|
|
# --- Mandate roles ---
|
|
userMandates = rootInterface.getUserMandates(user.id)
|
|
for um in userMandates:
|
|
mandateId = getattr(um, 'mandateId', None)
|
|
umId = getattr(um, 'id', None)
|
|
if not mandateId or not umId:
|
|
continue
|
|
|
|
roleIds = rootInterface.getRoleIdsForUserMandate(umId)
|
|
isAdmin = False
|
|
for roleId in roleIds:
|
|
role = rootInterface.getRole(roleId)
|
|
if role and role.roleLabel == "admin" and not role.featureInstanceId:
|
|
isAdmin = True
|
|
break
|
|
|
|
if isAdmin:
|
|
scope.adminMandateIds.append(mandateId)
|
|
else:
|
|
scope.memberMandateIds.append(mandateId)
|
|
|
|
# --- Feature instance roles ---
|
|
featureAccesses = rootInterface.getFeatureAccessesForUser(user.id)
|
|
for fa in featureAccesses:
|
|
fiId = getattr(fa, 'featureInstanceId', None)
|
|
faId = getattr(fa, 'id', None)
|
|
if not fiId or not faId:
|
|
continue
|
|
|
|
roleIds = rootInterface.getRoleIdsForFeatureAccess(faId)
|
|
for roleId in roleIds:
|
|
role = rootInterface.getRole(roleId)
|
|
if role and role.roleLabel == "admin":
|
|
scope.adminFeatureInstanceIds.append(fiId)
|
|
break
|
|
|
|
logger.debug(
|
|
f"BillingDataScope for user {user.id}: "
|
|
f"globalAdmin={scope.isGlobalAdmin}, "
|
|
f"adminMandates={scope.adminMandateIds}, "
|
|
f"adminInstances={scope.adminFeatureInstanceIds}, "
|
|
f"memberMandates={scope.memberMandateIds}"
|
|
)
|
|
return scope
|
|
|
|
|
|
def _isAdminOfMandate(ctx: RequestContext, targetMandateId: str) -> bool:
|
|
"""Check if user is SysAdmin or admin of the specified mandate."""
|
|
if ctx.hasSysAdminRole:
|
|
return True
|
|
try:
|
|
from modules.interfaces.interfaceDbApp import getRootInterface
|
|
rootInterface = getRootInterface()
|
|
userMandates = rootInterface.getUserMandates(str(ctx.user.id))
|
|
for um in userMandates:
|
|
if str(getattr(um, 'mandateId', None)) != str(targetMandateId):
|
|
continue
|
|
if not getattr(um, 'enabled', True):
|
|
continue
|
|
umId = str(getattr(um, 'id', ''))
|
|
roleIds = rootInterface.getRoleIdsForUserMandate(umId)
|
|
for roleId in roleIds:
|
|
role = rootInterface.getRole(roleId)
|
|
if role and role.roleLabel == "admin" and not role.featureInstanceId:
|
|
return True
|
|
return False
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
def _isMemberOfMandate(ctx: RequestContext, targetMandateId: str) -> bool:
|
|
"""Check if user has any enabled membership in the specified mandate."""
|
|
try:
|
|
from modules.interfaces.interfaceDbApp import getRootInterface
|
|
rootInterface = getRootInterface()
|
|
userMandates = rootInterface.getUserMandates(str(ctx.user.id))
|
|
for um in userMandates:
|
|
if str(getattr(um, 'mandateId', None)) != str(targetMandateId):
|
|
continue
|
|
if not getattr(um, 'enabled', True):
|
|
continue
|
|
return True
|
|
return False
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
def _filterTransactionsByScope(transactions: list, scope: BillingDataScope) -> list:
|
|
"""
|
|
Filter a list of transaction dicts based on the user's BillingDataScope.
|
|
|
|
Rules:
|
|
- SysAdmin: no filter
|
|
- Mandate-Admin: all transactions in their admin mandates
|
|
- Feature-Instance-Admin: transactions for their admin feature instances
|
|
- Regular user: only transactions where createdByUserId/userId matches
|
|
"""
|
|
if scope.isGlobalAdmin:
|
|
return transactions
|
|
|
|
adminMandateSet = set(scope.adminMandateIds)
|
|
adminFiSet = set(scope.adminFeatureInstanceIds)
|
|
memberMandateSet = set(scope.memberMandateIds)
|
|
|
|
result = []
|
|
for t in transactions:
|
|
mandateId = t.get("mandateId")
|
|
fiId = t.get("featureInstanceId")
|
|
txUserId = t.get("createdByUserId") or t.get("userId")
|
|
|
|
# Mandate admin → sees all transactions in their mandate
|
|
if mandateId and mandateId in adminMandateSet:
|
|
result.append(t)
|
|
continue
|
|
|
|
# Feature instance admin → sees all transactions for their instances
|
|
if fiId and fiId in adminFiSet:
|
|
result.append(t)
|
|
continue
|
|
|
|
# Regular member → only own transactions
|
|
if mandateId and mandateId in memberMandateSet:
|
|
if txUserId and txUserId == scope.userId:
|
|
result.append(t)
|
|
continue
|
|
|
|
return result
|
|
|
|
|
|
# =============================================================================
|
|
# Request/Response Models
|
|
# =============================================================================
|
|
|
|
class CreditAddRequest(BaseModel):
|
|
"""Request model for adding credit to an account."""
|
|
userId: Optional[str] = Field(None, description="Target user ID (for PREPAY_USER model)")
|
|
amount: float = Field(..., gt=0, description="Amount to credit in CHF")
|
|
description: str = Field(default="Manual credit", description="Transaction description")
|
|
|
|
|
|
class CheckoutCreateRequest(BaseModel):
|
|
"""Request model for creating Stripe Checkout Session."""
|
|
userId: Optional[str] = Field(None, description="Target user ID (for PREPAY_USER model)")
|
|
amount: float = Field(..., gt=0, description="Amount to pay in CHF (must be in allowed presets)")
|
|
returnUrl: str = Field(..., min_length=1, description="Absolute frontend URL used for Stripe success/cancel redirects")
|
|
|
|
|
|
class CheckoutCreateResponse(BaseModel):
|
|
"""Response model for Checkout Session creation."""
|
|
redirectUrl: str = Field(..., description="Stripe Checkout URL for redirect")
|
|
|
|
|
|
class CheckoutConfirmRequest(BaseModel):
|
|
"""Request model for confirming Stripe Checkout after redirect."""
|
|
sessionId: str = Field(..., min_length=1, description="Stripe Checkout Session ID (cs_xxx)")
|
|
|
|
|
|
class CheckoutConfirmResponse(BaseModel):
|
|
"""Response model for Stripe Checkout confirmation."""
|
|
credited: bool = Field(..., description="True if a new billing credit was created")
|
|
alreadyCredited: bool = Field(..., description="True if session was already credited before")
|
|
sessionId: str = Field(..., description="Stripe Checkout Session ID")
|
|
mandateId: str = Field(..., description="Mandate ID from Stripe metadata")
|
|
amountChf: float = Field(..., description="Credited amount in CHF")
|
|
|
|
|
|
class BillingSettingsUpdate(BaseModel):
|
|
"""Request model for updating billing settings."""
|
|
billingModel: Optional[BillingModelEnum] = None
|
|
defaultUserCredit: Optional[float] = Field(None, ge=0)
|
|
warningThresholdPercent: Optional[float] = Field(None, ge=0, le=100)
|
|
notifyOnWarning: Optional[bool] = None
|
|
notifyEmails: Optional[List[str]] = None
|
|
|
|
|
|
class TransactionResponse(BaseModel):
|
|
"""Response model for a billing transaction."""
|
|
id: str
|
|
accountId: str
|
|
transactionType: TransactionTypeEnum
|
|
amount: float
|
|
description: str
|
|
referenceType: Optional[ReferenceTypeEnum]
|
|
workflowId: Optional[str]
|
|
featureCode: Optional[str]
|
|
featureInstanceId: Optional[str] = None
|
|
aicoreProvider: Optional[str]
|
|
aicoreModel: Optional[str] = None
|
|
createdByUserId: Optional[str] = None
|
|
createdAt: Optional[datetime]
|
|
mandateId: Optional[str] = None
|
|
mandateName: Optional[str] = None
|
|
|
|
|
|
class AccountSummary(BaseModel):
|
|
"""Summary of a billing account."""
|
|
id: str
|
|
mandateId: str
|
|
userId: Optional[str]
|
|
accountType: str
|
|
balance: float
|
|
warningThreshold: float
|
|
enabled: bool
|
|
|
|
|
|
class UsageReportResponse(BaseModel):
|
|
"""Usage report for a period."""
|
|
period: str
|
|
totalCost: float
|
|
transactionCount: int
|
|
costByProvider: Dict[str, float]
|
|
costByModel: Dict[str, float] = {}
|
|
costByFeature: Dict[str, float]
|
|
|
|
|
|
# =============================================================================
|
|
# Response Models for Mandate/User Views
|
|
# =============================================================================
|
|
|
|
class MandateBalanceResponse(BaseModel):
|
|
"""Mandate-level balance summary."""
|
|
mandateId: str
|
|
mandateName: str
|
|
billingModel: str
|
|
totalBalance: float
|
|
userCount: int
|
|
defaultUserCredit: float
|
|
warningThresholdPercent: float
|
|
|
|
|
|
class UserBalanceResponse(BaseModel):
|
|
"""User-level balance summary."""
|
|
accountId: str
|
|
mandateId: str
|
|
mandateName: str
|
|
userId: str
|
|
userName: str
|
|
balance: float
|
|
warningThreshold: float
|
|
isWarning: bool
|
|
enabled: bool
|
|
|
|
|
|
class UserTransactionResponse(BaseModel):
|
|
"""User-level transaction with user context."""
|
|
id: str
|
|
accountId: str
|
|
transactionType: TransactionTypeEnum
|
|
amount: float
|
|
description: str
|
|
referenceType: Optional[ReferenceTypeEnum]
|
|
workflowId: Optional[str]
|
|
featureCode: Optional[str]
|
|
featureInstanceId: Optional[str] = None
|
|
aicoreProvider: Optional[str]
|
|
aicoreModel: Optional[str] = None
|
|
createdByUserId: Optional[str] = None
|
|
createdAt: Optional[datetime]
|
|
mandateId: Optional[str] = None
|
|
mandateName: Optional[str] = None
|
|
userId: Optional[str] = None
|
|
userName: Optional[str] = None
|
|
|
|
|
|
def _getStripeClient():
|
|
"""Initialize and return configured Stripe SDK module."""
|
|
import stripe
|
|
from modules.shared.configuration import APP_CONFIG
|
|
|
|
api_version = APP_CONFIG.get("STRIPE_API_VERSION")
|
|
if api_version:
|
|
stripe.api_version = api_version
|
|
|
|
secret_key = APP_CONFIG.get("STRIPE_SECRET_KEY_SECRET") or APP_CONFIG.get("STRIPE_SECRET_KEY")
|
|
if not secret_key:
|
|
raise ValueError("STRIPE_SECRET_KEY_SECRET not configured")
|
|
|
|
stripe.api_key = secret_key
|
|
return stripe
|
|
|
|
|
|
def _creditStripeSessionIfNeeded(
|
|
billingInterface,
|
|
session: Dict[str, Any],
|
|
eventId: Optional[str] = None,
|
|
) -> CheckoutConfirmResponse:
|
|
"""
|
|
Credit balance from Stripe Checkout session if not already credited.
|
|
Uses Checkout session ID for idempotency across webhook + manual confirmation flows.
|
|
"""
|
|
from modules.serviceCenter.services.serviceBilling.stripeCheckout import ALLOWED_AMOUNTS_CHF
|
|
|
|
session_id = session.get("id")
|
|
metadata = session.get("metadata") or {}
|
|
mandate_id = metadata.get("mandateId")
|
|
user_id = metadata.get("userId") or None
|
|
amount_chf_str = metadata.get("amountChf", "0")
|
|
|
|
if not session_id:
|
|
raise HTTPException(status_code=400, detail="Stripe session id missing")
|
|
if not mandate_id:
|
|
raise HTTPException(status_code=400, detail="Invalid session metadata: mandateId missing")
|
|
|
|
existing_payment_tx = billingInterface.getPaymentTransactionByReferenceId(session_id)
|
|
if existing_payment_tx:
|
|
if eventId and not billingInterface.getStripeWebhookEventByEventId(eventId):
|
|
billingInterface.createStripeWebhookEvent(eventId)
|
|
return CheckoutConfirmResponse(
|
|
credited=False,
|
|
alreadyCredited=True,
|
|
sessionId=session_id,
|
|
mandateId=mandate_id,
|
|
amountChf=float(existing_payment_tx.get("amount", 0.0)),
|
|
)
|
|
|
|
try:
|
|
amount_chf = float(amount_chf_str)
|
|
except (TypeError, ValueError):
|
|
amount_chf = None
|
|
|
|
if amount_chf is None or amount_chf not in ALLOWED_AMOUNTS_CHF:
|
|
amount_total = session.get("amount_total")
|
|
if amount_total is not None:
|
|
amount_chf = amount_total / 100.0
|
|
else:
|
|
raise HTTPException(status_code=400, detail="Invalid amount in Stripe session")
|
|
|
|
settings = billingInterface.getSettings(mandate_id)
|
|
if not settings:
|
|
raise HTTPException(status_code=404, detail="Billing settings not found")
|
|
|
|
billing_model = parseBillingModelFromStoredValue(settings.get("billingModel"))
|
|
if billing_model == BillingModelEnum.PREPAY_USER:
|
|
if not user_id:
|
|
raise HTTPException(status_code=400, detail="userId required for PREPAY_USER")
|
|
account = billingInterface.getOrCreateUserAccount(mandate_id, user_id, initialBalance=0.0)
|
|
elif billing_model == BillingModelEnum.PREPAY_MANDATE:
|
|
account = billingInterface.getOrCreateMandateAccount(mandate_id, initialBalance=0.0)
|
|
else:
|
|
raise HTTPException(status_code=400, detail=f"Cannot add credit to {billing_model.value}")
|
|
|
|
transaction = BillingTransaction(
|
|
accountId=account["id"],
|
|
transactionType=TransactionTypeEnum.CREDIT,
|
|
amount=amount_chf,
|
|
description="Stripe-Zahlung",
|
|
referenceType=ReferenceTypeEnum.PAYMENT,
|
|
referenceId=session_id,
|
|
createdByUserId=user_id,
|
|
)
|
|
billingInterface.createTransaction(transaction)
|
|
|
|
if eventId and not billingInterface.getStripeWebhookEventByEventId(eventId):
|
|
billingInterface.createStripeWebhookEvent(eventId)
|
|
|
|
logger.info(f"Stripe credit applied: {amount_chf} CHF for session {session_id} on mandate {mandate_id}")
|
|
return CheckoutConfirmResponse(
|
|
credited=True,
|
|
alreadyCredited=False,
|
|
sessionId=session_id,
|
|
mandateId=mandate_id,
|
|
amountChf=amount_chf,
|
|
)
|
|
|
|
|
|
# =============================================================================
|
|
# Router Setup
|
|
# =============================================================================
|
|
|
|
router = APIRouter(
|
|
prefix="/api/billing",
|
|
tags=["Billing"],
|
|
responses={404: {"description": "Not found"}}
|
|
)
|
|
|
|
# =============================================================================
|
|
# User Endpoints
|
|
# =============================================================================
|
|
|
|
@router.get("/balance", response_model=List[BillingBalanceResponse])
|
|
@limiter.limit("60/minute")
|
|
def getBalance(
|
|
request: Request,
|
|
ctx: RequestContext = Depends(getRequestContext)
|
|
):
|
|
"""
|
|
Get billing balances for all mandates the current user belongs to.
|
|
Returns balance information for each mandate.
|
|
"""
|
|
try:
|
|
billingService = getBillingService(
|
|
ctx.user,
|
|
ctx.mandateId,
|
|
featureCode="billing"
|
|
)
|
|
|
|
balances = billingService.getBalancesForUser()
|
|
return balances
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error getting billing balance: {e}")
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
@router.get("/balance/{targetMandateId}", response_model=BillingBalanceResponse)
|
|
@limiter.limit("60/minute")
|
|
def getBalanceForMandate(
|
|
request: Request,
|
|
targetMandateId: str = Path(..., description="Mandate ID"),
|
|
ctx: RequestContext = Depends(getRequestContext)
|
|
):
|
|
"""
|
|
Get billing balance for a specific mandate.
|
|
"""
|
|
try:
|
|
billingService = getBillingService(
|
|
ctx.user,
|
|
targetMandateId,
|
|
featureCode="billing"
|
|
)
|
|
|
|
# Check balance
|
|
checkResult = billingService.checkBalance(0.0)
|
|
|
|
# Get mandate name from app interface
|
|
from modules.interfaces.interfaceDbApp import getInterface as getAppInterface
|
|
appInterface = getAppInterface(ctx.user, mandateId=targetMandateId)
|
|
mandate = appInterface.getMandate(targetMandateId)
|
|
mandateName = (mandate.get("label") or mandate.get("name", "")) if mandate else ""
|
|
|
|
return BillingBalanceResponse(
|
|
mandateId=targetMandateId,
|
|
mandateName=mandateName,
|
|
billingModel=checkResult.billingModel or BillingModelEnum.PREPAY_MANDATE,
|
|
balance=checkResult.currentBalance or 0.0,
|
|
warningThreshold=0.0, # TODO: Get from account
|
|
isWarning=False,
|
|
)
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error getting billing balance for mandate {targetMandateId}: {e}")
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
@router.get("/transactions", response_model=List[TransactionResponse])
|
|
@limiter.limit("30/minute")
|
|
def getTransactions(
|
|
request: Request,
|
|
limit: int = Query(default=50, ge=1, le=500),
|
|
offset: int = Query(default=0, ge=0),
|
|
ctx: RequestContext = Depends(getRequestContext)
|
|
):
|
|
"""
|
|
Get transaction history across all mandates the user belongs to.
|
|
"""
|
|
try:
|
|
billingService = getBillingService(
|
|
ctx.user,
|
|
ctx.mandateId,
|
|
featureCode="billing"
|
|
)
|
|
|
|
# Fetch enough transactions for pagination
|
|
transactions = billingService.getTransactionHistory(limit=offset + limit)
|
|
|
|
# Convert to response model
|
|
result = []
|
|
for t in transactions[offset:offset + limit]:
|
|
result.append(TransactionResponse(
|
|
id=t.get("id"),
|
|
accountId=t.get("accountId"),
|
|
transactionType=TransactionTypeEnum(t.get("transactionType", "DEBIT")),
|
|
amount=t.get("amount", 0.0),
|
|
description=t.get("description", ""),
|
|
referenceType=ReferenceTypeEnum(t["referenceType"]) if t.get("referenceType") else None,
|
|
workflowId=t.get("workflowId"),
|
|
featureCode=t.get("featureCode"),
|
|
featureInstanceId=t.get("featureInstanceId"),
|
|
aicoreProvider=t.get("aicoreProvider"),
|
|
aicoreModel=t.get("aicoreModel"),
|
|
createdByUserId=t.get("createdByUserId"),
|
|
createdAt=t.get("_createdAt"),
|
|
mandateId=t.get("mandateId"),
|
|
mandateName=t.get("mandateName")
|
|
))
|
|
|
|
return result
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error getting billing transactions: {e}")
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
@router.get("/statistics/{period}", response_model=UsageReportResponse)
|
|
@limiter.limit("30/minute")
|
|
def getStatistics(
|
|
request: Request,
|
|
period: str = Path(..., description="Period: 'day', 'month', or 'year'"),
|
|
year: int = Query(..., description="Year"),
|
|
month: Optional[int] = Query(None, description="Month (1-12, required for 'day' period)"),
|
|
ctx: RequestContext = Depends(getRequestContext)
|
|
):
|
|
"""
|
|
Get usage statistics for a period.
|
|
"""
|
|
try:
|
|
# Validate period
|
|
if period not in ["day", "month", "year"]:
|
|
raise HTTPException(status_code=400, detail="Invalid period. Use 'day', 'month', or 'year'")
|
|
|
|
if period == "day" and not month:
|
|
raise HTTPException(status_code=400, detail="Month is required for 'day' period")
|
|
|
|
billingInterface = getBillingInterface(ctx.user, ctx.mandateId)
|
|
settings = billingInterface.getSettings(ctx.mandateId)
|
|
|
|
if not settings:
|
|
return UsageReportResponse(
|
|
period=period,
|
|
totalCost=0.0,
|
|
transactionCount=0,
|
|
costByProvider={},
|
|
costByFeature={}
|
|
)
|
|
|
|
billingModel = parseBillingModelFromStoredValue(settings.get("billingModel"))
|
|
|
|
# Transactions are always on user accounts (audit trail)
|
|
account = billingInterface.getUserAccount(ctx.mandateId, ctx.user.id)
|
|
|
|
if not account:
|
|
return UsageReportResponse(
|
|
period=period,
|
|
totalCost=0.0,
|
|
transactionCount=0,
|
|
costByProvider={},
|
|
costByFeature={}
|
|
)
|
|
|
|
# Calculate date range
|
|
if period == "day":
|
|
startDate = date(year, month, 1)
|
|
if month == 12:
|
|
endDate = date(year + 1, 1, 1)
|
|
else:
|
|
endDate = date(year, month + 1, 1)
|
|
elif period == "month":
|
|
startDate = date(year, 1, 1)
|
|
endDate = date(year + 1, 1, 1)
|
|
else: # year
|
|
startDate = date(year, 1, 1)
|
|
endDate = date(year + 1, 1, 1)
|
|
|
|
# Get statistics from transactions
|
|
stats = billingInterface.calculateStatisticsFromTransactions(
|
|
account["id"],
|
|
startDate,
|
|
endDate
|
|
)
|
|
|
|
return UsageReportResponse(
|
|
period=period,
|
|
totalCost=stats.get("totalCostCHF", 0.0),
|
|
transactionCount=stats.get("transactionCount", 0),
|
|
costByProvider=stats.get("costByProvider", {}),
|
|
costByModel=stats.get("costByModel", {}),
|
|
costByFeature=stats.get("costByFeature", {})
|
|
)
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Error getting billing statistics: {e}")
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
@router.get("/providers", response_model=List[str])
|
|
@limiter.limit("60/minute")
|
|
def getAllowedProviders(
|
|
request: Request,
|
|
ctx: RequestContext = Depends(getRequestContext)
|
|
):
|
|
"""
|
|
Get list of AICore providers the current user is allowed to use.
|
|
"""
|
|
try:
|
|
billingService = getBillingService(
|
|
ctx.user,
|
|
ctx.mandateId,
|
|
featureCode="billing"
|
|
)
|
|
|
|
return billingService.getallowedProviders()
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error getting allowed providers: {e}")
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
# =============================================================================
|
|
# Admin Endpoints
|
|
# =============================================================================
|
|
|
|
@router.get("/admin/settings/{targetMandateId}", response_model=Dict[str, Any])
|
|
@limiter.limit("30/minute")
|
|
def getSettingsAdmin(
|
|
request: Request,
|
|
targetMandateId: str = Path(..., description="Mandate ID"),
|
|
ctx: RequestContext = Depends(getRequestContext),
|
|
):
|
|
"""
|
|
Get billing settings for a mandate.
|
|
Access: SysAdmin (any mandate) or MandateAdmin (own mandate).
|
|
"""
|
|
if not _isAdminOfMandate(ctx, targetMandateId):
|
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin role required for this mandate")
|
|
try:
|
|
billingInterface = getBillingInterface(ctx.user, targetMandateId)
|
|
settings = billingInterface.getSettings(targetMandateId)
|
|
|
|
if not settings:
|
|
raise HTTPException(status_code=404, detail="Billing settings not found")
|
|
|
|
return settings
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Error getting billing settings: {e}")
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
@router.post("/admin/settings/{targetMandateId}", response_model=Dict[str, Any])
|
|
@limiter.limit("10/minute")
|
|
def createOrUpdateSettings(
|
|
request: Request,
|
|
targetMandateId: str = Path(..., description="Mandate ID"),
|
|
settingsUpdate: BillingSettingsUpdate = Body(...),
|
|
ctx: RequestContext = Depends(getRequestContext),
|
|
_admin = Depends(requireSysAdminRole)
|
|
):
|
|
"""
|
|
Create or update billing settings for a mandate (SysAdmin only).
|
|
"""
|
|
try:
|
|
billingInterface = getBillingInterface(ctx.user, targetMandateId)
|
|
existingSettings = billingInterface.getSettings(targetMandateId)
|
|
|
|
if existingSettings:
|
|
updates = settingsUpdate.model_dump(exclude_none=True)
|
|
if updates:
|
|
# Check if billing model is changing - trigger budget migration
|
|
if "billingModel" in updates:
|
|
oldModel = parseBillingModelFromStoredValue(existingSettings.get("billingModel"))
|
|
newModel = (
|
|
BillingModelEnum(updates["billingModel"])
|
|
if isinstance(updates["billingModel"], str)
|
|
else updates["billingModel"]
|
|
)
|
|
if oldModel != newModel:
|
|
migrationResult = billingInterface.switchBillingModel(targetMandateId, oldModel, newModel)
|
|
logger.info(f"Billing model migration for {targetMandateId}: {migrationResult}")
|
|
|
|
result = billingInterface.updateSettings(existingSettings["id"], updates)
|
|
return result or existingSettings
|
|
return existingSettings
|
|
else:
|
|
from modules.datamodels.datamodelBilling import BillingSettings
|
|
|
|
newSettings = BillingSettings(
|
|
mandateId=targetMandateId,
|
|
billingModel=(
|
|
settingsUpdate.billingModel
|
|
if settingsUpdate.billingModel is not None
|
|
else BillingModelEnum.PREPAY_MANDATE
|
|
),
|
|
defaultUserCredit=(
|
|
settingsUpdate.defaultUserCredit
|
|
if settingsUpdate.defaultUserCredit is not None
|
|
else 0.0
|
|
),
|
|
warningThresholdPercent=(
|
|
settingsUpdate.warningThresholdPercent
|
|
if settingsUpdate.warningThresholdPercent is not None
|
|
else 10.0
|
|
),
|
|
notifyOnWarning=(
|
|
settingsUpdate.notifyOnWarning
|
|
if settingsUpdate.notifyOnWarning is not None
|
|
else True
|
|
),
|
|
notifyEmails=settingsUpdate.notifyEmails or [],
|
|
)
|
|
|
|
return billingInterface.createSettings(newSettings)
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Error updating billing settings: {e}")
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
@router.post("/admin/credit/{targetMandateId}", response_model=Dict[str, Any])
|
|
@limiter.limit("10/minute")
|
|
def addCredit(
|
|
request: Request,
|
|
targetMandateId: str = Path(..., description="Mandate ID"),
|
|
creditRequest: CreditAddRequest = Body(...),
|
|
ctx: RequestContext = Depends(getRequestContext),
|
|
_admin = Depends(requireSysAdminRole)
|
|
):
|
|
"""
|
|
Add credit to a billing account (SysAdmin only).
|
|
For PREPAY_USER model, specify userId. For PREPAY_MANDATE, leave userId empty.
|
|
"""
|
|
try:
|
|
# Get settings to determine billing model
|
|
billingInterface = getBillingInterface(ctx.user, targetMandateId)
|
|
settings = billingInterface.getSettings(targetMandateId)
|
|
|
|
if not settings:
|
|
raise HTTPException(status_code=404, detail="Billing settings not found for this mandate")
|
|
|
|
billingModel = parseBillingModelFromStoredValue(settings.get("billingModel"))
|
|
|
|
# Validate request based on billing model
|
|
if billingModel == BillingModelEnum.PREPAY_USER:
|
|
if not creditRequest.userId:
|
|
raise HTTPException(status_code=400, detail="userId is required for PREPAY_USER model")
|
|
|
|
# Create user-level account if needed and add credit
|
|
account = billingInterface.getOrCreateUserAccount(
|
|
targetMandateId,
|
|
creditRequest.userId,
|
|
initialBalance=0.0
|
|
)
|
|
elif billingModel == BillingModelEnum.PREPAY_MANDATE:
|
|
# Create mandate-level account if needed and add credit
|
|
account = billingInterface.getOrCreateMandateAccount(targetMandateId, initialBalance=0.0)
|
|
else:
|
|
raise HTTPException(status_code=400, detail=f"Cannot add credit to {billingModel.value} billing model")
|
|
|
|
# Create credit transaction
|
|
from modules.datamodels.datamodelBilling import BillingTransaction
|
|
|
|
transaction = BillingTransaction(
|
|
accountId=account["id"],
|
|
transactionType=TransactionTypeEnum.CREDIT,
|
|
amount=creditRequest.amount,
|
|
description=creditRequest.description,
|
|
referenceType=ReferenceTypeEnum.ADMIN
|
|
)
|
|
|
|
result = billingInterface.createTransaction(transaction)
|
|
|
|
logger.info(f"Added {creditRequest.amount} CHF credit to account {account['id']} in mandate {targetMandateId}")
|
|
|
|
return result
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Error adding credit: {e}")
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
@router.post("/checkout/create/{targetMandateId}", response_model=CheckoutCreateResponse)
|
|
@limiter.limit("10/minute")
|
|
def createCheckoutSession(
|
|
request: Request,
|
|
targetMandateId: str = Path(..., description="Mandate ID"),
|
|
checkoutRequest: CheckoutCreateRequest = Body(...),
|
|
ctx: RequestContext = Depends(getRequestContext),
|
|
):
|
|
"""
|
|
Create Stripe Checkout Session for credit top-up. Returns redirect URL.
|
|
RBAC: PREPAY_USER requires mandate membership (user loads own account),
|
|
PREPAY_MANDATE requires mandate admin role.
|
|
"""
|
|
try:
|
|
billingInterface = getBillingInterface(ctx.user, targetMandateId)
|
|
settings = billingInterface.getSettings(targetMandateId)
|
|
|
|
if not settings:
|
|
raise HTTPException(status_code=404, detail="Billing settings not found for this mandate")
|
|
|
|
billingModel = parseBillingModelFromStoredValue(settings.get("billingModel"))
|
|
|
|
if billingModel == BillingModelEnum.PREPAY_USER:
|
|
if not checkoutRequest.userId:
|
|
raise HTTPException(status_code=400, detail="userId is required for PREPAY_USER model")
|
|
if str(checkoutRequest.userId) != str(ctx.user.id):
|
|
raise HTTPException(status_code=403, detail="Users can only load credit to their own account")
|
|
if not _isMemberOfMandate(ctx, targetMandateId):
|
|
raise HTTPException(status_code=403, detail="User is not a member of this mandate")
|
|
elif billingModel == BillingModelEnum.PREPAY_MANDATE:
|
|
if not _isAdminOfMandate(ctx, targetMandateId):
|
|
raise HTTPException(status_code=403, detail="Mandate admin role required to load mandate credit")
|
|
else:
|
|
raise HTTPException(status_code=400, detail=f"Cannot add credit to {billingModel.value} billing model")
|
|
|
|
from modules.serviceCenter.services.serviceBilling.stripeCheckout import create_checkout_session
|
|
redirect_url = create_checkout_session(
|
|
mandate_id=targetMandateId,
|
|
user_id=checkoutRequest.userId,
|
|
amount_chf=checkoutRequest.amount,
|
|
return_url=checkoutRequest.returnUrl
|
|
)
|
|
return CheckoutCreateResponse(redirectUrl=redirect_url)
|
|
|
|
except ValueError as e:
|
|
raise HTTPException(status_code=400, detail=str(e))
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Error creating checkout session: {e}")
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
@router.post("/checkout/confirm", response_model=CheckoutConfirmResponse)
|
|
@limiter.limit("20/minute")
|
|
def confirmCheckoutSession(
|
|
request: Request,
|
|
confirmRequest: CheckoutConfirmRequest = Body(...),
|
|
ctx: RequestContext = Depends(getRequestContext),
|
|
):
|
|
"""
|
|
Confirm Stripe Checkout success by session ID and apply credit idempotently.
|
|
This is a fallback/reconciliation path in addition to webhook processing.
|
|
"""
|
|
try:
|
|
stripe = _getStripeClient()
|
|
session = stripe.checkout.Session.retrieve(confirmRequest.sessionId)
|
|
if not session:
|
|
raise HTTPException(status_code=404, detail="Stripe Checkout Session not found")
|
|
|
|
session_dict = session.to_dict_recursive() if hasattr(session, "to_dict_recursive") else dict(session)
|
|
metadata = session_dict.get("metadata") or {}
|
|
mandate_id = metadata.get("mandateId")
|
|
user_id = metadata.get("userId") or None
|
|
|
|
if not mandate_id:
|
|
raise HTTPException(status_code=400, detail="Invalid session metadata: mandateId missing")
|
|
|
|
payment_status = session_dict.get("payment_status")
|
|
if payment_status != "paid":
|
|
raise HTTPException(status_code=409, detail=f"Payment not completed yet (payment_status={payment_status})")
|
|
|
|
billingInterface = getBillingInterface(ctx.user, mandate_id)
|
|
settings = billingInterface.getSettings(mandate_id)
|
|
if not settings:
|
|
raise HTTPException(status_code=404, detail="Billing settings not found")
|
|
|
|
billing_model = parseBillingModelFromStoredValue(settings.get("billingModel"))
|
|
if billing_model == BillingModelEnum.PREPAY_USER:
|
|
if not user_id:
|
|
raise HTTPException(status_code=400, detail="userId required for PREPAY_USER")
|
|
if str(user_id) != str(ctx.user.id):
|
|
raise HTTPException(status_code=403, detail="Users can only confirm their own payment sessions")
|
|
if not _isMemberOfMandate(ctx, mandate_id):
|
|
raise HTTPException(status_code=403, detail="User is not a member of this mandate")
|
|
elif billing_model == BillingModelEnum.PREPAY_MANDATE:
|
|
if not _isAdminOfMandate(ctx, mandate_id):
|
|
raise HTTPException(status_code=403, detail="Mandate admin role required")
|
|
else:
|
|
raise HTTPException(status_code=400, detail=f"Cannot add credit to {billing_model.value}")
|
|
|
|
root_billing_interface = _getRootInterface()
|
|
return _creditStripeSessionIfNeeded(root_billing_interface, session_dict, eventId=None)
|
|
except HTTPException:
|
|
raise
|
|
except ValueError as e:
|
|
raise HTTPException(status_code=400, detail=str(e))
|
|
except Exception as e:
|
|
logger.error(f"Error confirming checkout session {confirmRequest.sessionId}: {e}")
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
@router.post("/webhook/stripe")
|
|
async def stripeWebhook(
|
|
request: Request,
|
|
stripe_signature: Optional[str] = Header(None, alias="Stripe-Signature")
|
|
):
|
|
"""
|
|
Stripe webhook endpoint. Verifies signature and processes checkout.session.completed.
|
|
No JWT auth - Stripe authenticates via Stripe-Signature header.
|
|
"""
|
|
from modules.shared.configuration import APP_CONFIG
|
|
|
|
webhook_secret = APP_CONFIG.get("STRIPE_WEBHOOK_SECRET")
|
|
if not webhook_secret:
|
|
logger.error("STRIPE_WEBHOOK_SECRET not configured")
|
|
raise HTTPException(status_code=500, detail="Webhook not configured")
|
|
|
|
if not stripe_signature:
|
|
raise HTTPException(status_code=400, detail="Missing Stripe-Signature header")
|
|
|
|
payload = await request.body()
|
|
|
|
try:
|
|
import stripe
|
|
event = stripe.Webhook.construct_event(
|
|
payload, stripe_signature, webhook_secret
|
|
)
|
|
except ValueError as e:
|
|
logger.warning(f"Stripe webhook invalid payload: {e}")
|
|
raise HTTPException(status_code=400, detail="Invalid payload")
|
|
except Exception as e:
|
|
logger.warning(f"Stripe webhook signature verification failed: {e}")
|
|
raise HTTPException(status_code=400, detail="Invalid signature")
|
|
|
|
logger.info(f"Stripe webhook received: event={event.id}, type={event.type}")
|
|
|
|
accepted_event_types = {"checkout.session.completed", "checkout.session.async_payment_succeeded"}
|
|
if event.type not in accepted_event_types:
|
|
return {"received": True}
|
|
|
|
session = event.data.object
|
|
event_id = event.id
|
|
|
|
billingInterface = _getRootInterface()
|
|
if billingInterface.getStripeWebhookEventByEventId(event_id):
|
|
logger.info(f"Stripe event {event_id} already processed, skipping")
|
|
return {"received": True}
|
|
|
|
session_dict = session.to_dict_recursive() if hasattr(session, "to_dict_recursive") else dict(session)
|
|
result = _creditStripeSessionIfNeeded(billingInterface, session_dict, eventId=event_id)
|
|
logger.info(
|
|
f"Stripe webhook processed session {result.sessionId}: "
|
|
f"credited={result.credited}, alreadyCredited={result.alreadyCredited}"
|
|
)
|
|
return {"received": True}
|
|
|
|
|
|
@router.get("/admin/accounts/{targetMandateId}", response_model=List[AccountSummary])
|
|
@limiter.limit("30/minute")
|
|
def getAccounts(
|
|
request: Request,
|
|
targetMandateId: str = Path(..., description="Mandate ID"),
|
|
ctx: RequestContext = Depends(getRequestContext),
|
|
):
|
|
"""
|
|
Get all billing accounts for a mandate.
|
|
Access: SysAdmin (any mandate) or MandateAdmin (own mandate).
|
|
"""
|
|
if not _isAdminOfMandate(ctx, targetMandateId):
|
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin role required for this mandate")
|
|
try:
|
|
billingInterface = getBillingInterface(ctx.user, targetMandateId)
|
|
|
|
# Get all accounts for this mandate via interface
|
|
accounts = billingInterface.getAccountsByMandate(targetMandateId)
|
|
|
|
result = []
|
|
for acc in accounts:
|
|
result.append(AccountSummary(
|
|
id=acc.get("id"),
|
|
mandateId=acc.get("mandateId"),
|
|
userId=acc.get("userId"),
|
|
accountType=acc.get("accountType"),
|
|
balance=acc.get("balance", 0.0),
|
|
warningThreshold=acc.get("warningThreshold", 0.0),
|
|
enabled=acc.get("enabled", True)
|
|
))
|
|
|
|
return result
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error getting billing accounts: {e}")
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
class MandateUserSummary(BaseModel):
|
|
"""Summary of a user for billing admin purposes."""
|
|
id: str
|
|
username: Optional[str] = None
|
|
email: Optional[str] = None
|
|
firstName: Optional[str] = None
|
|
lastName: Optional[str] = None
|
|
displayName: Optional[str] = None
|
|
|
|
|
|
@router.get("/admin/users/{targetMandateId}", response_model=List[MandateUserSummary])
|
|
@limiter.limit("30/minute")
|
|
def getUsersForMandate(
|
|
request: Request,
|
|
targetMandateId: str = Path(..., description="Mandate ID"),
|
|
ctx: RequestContext = Depends(getRequestContext),
|
|
):
|
|
"""
|
|
Get all users belonging to a mandate.
|
|
Access: SysAdmin (any mandate) or MandateAdmin (own mandate).
|
|
Used by billing admin to select users for credit assignment.
|
|
"""
|
|
if not _isAdminOfMandate(ctx, targetMandateId):
|
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin role required for this mandate")
|
|
try:
|
|
from modules.interfaces.interfaceDbApp import getInterface as getAppInterface
|
|
|
|
appInterface = getAppInterface(ctx.user, mandateId=targetMandateId)
|
|
userMandates = appInterface.getUserMandatesByMandate(targetMandateId)
|
|
|
|
result = []
|
|
for um in userMandates:
|
|
userId = um.get("userId") if isinstance(um, dict) else getattr(um, "userId", None)
|
|
if not userId:
|
|
continue
|
|
|
|
user = appInterface.getUser(userId)
|
|
if not user:
|
|
continue
|
|
|
|
# Handle both Pydantic models and dicts
|
|
if isinstance(user, dict):
|
|
username = user.get("username", "")
|
|
firstName = user.get("firstName", "")
|
|
lastName = user.get("lastName", "")
|
|
email = user.get("email", "")
|
|
else:
|
|
username = getattr(user, "username", "") or ""
|
|
firstName = getattr(user, "firstName", "") or ""
|
|
lastName = getattr(user, "lastName", "") or ""
|
|
email = getattr(user, "email", "") or ""
|
|
|
|
displayName = f"{firstName} {lastName}".strip() or username or userId
|
|
|
|
result.append(MandateUserSummary(
|
|
id=userId,
|
|
username=username,
|
|
email=email,
|
|
firstName=firstName,
|
|
lastName=lastName,
|
|
displayName=displayName
|
|
))
|
|
|
|
return result
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error getting users for mandate {targetMandateId}: {e}")
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
@router.get("/admin/transactions/{targetMandateId}", response_model=List[TransactionResponse])
|
|
@limiter.limit("30/minute")
|
|
def getTransactionsAdmin(
|
|
request: Request,
|
|
targetMandateId: str = Path(..., description="Mandate ID"),
|
|
limit: int = Query(default=100, ge=1, le=1000),
|
|
ctx: RequestContext = Depends(getRequestContext),
|
|
):
|
|
"""
|
|
Get all transactions for a mandate.
|
|
Access: SysAdmin (any mandate) or MandateAdmin (own mandate).
|
|
"""
|
|
if not _isAdminOfMandate(ctx, targetMandateId):
|
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin role required for this mandate")
|
|
try:
|
|
billingInterface = getBillingInterface(ctx.user, targetMandateId)
|
|
transactions = billingInterface.getTransactionsByMandate(targetMandateId, limit=limit)
|
|
|
|
result = []
|
|
for t in transactions:
|
|
result.append(TransactionResponse(
|
|
id=t.get("id"),
|
|
accountId=t.get("accountId"),
|
|
transactionType=TransactionTypeEnum(t.get("transactionType", "DEBIT")),
|
|
amount=t.get("amount", 0.0),
|
|
description=t.get("description", ""),
|
|
referenceType=ReferenceTypeEnum(t["referenceType"]) if t.get("referenceType") else None,
|
|
workflowId=t.get("workflowId"),
|
|
featureCode=t.get("featureCode"),
|
|
featureInstanceId=t.get("featureInstanceId"),
|
|
aicoreProvider=t.get("aicoreProvider"),
|
|
aicoreModel=t.get("aicoreModel"),
|
|
createdByUserId=t.get("createdByUserId"),
|
|
createdAt=t.get("_createdAt")
|
|
))
|
|
|
|
return result
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error getting billing transactions for mandate {targetMandateId}: {e}")
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
# =============================================================================
|
|
# Mandate View Endpoints (for Admins)
|
|
# =============================================================================
|
|
|
|
@router.get("/view/mandates/balances", response_model=List[MandateBalanceResponse])
|
|
@limiter.limit("30/minute")
|
|
def getMandateViewBalances(
|
|
request: Request,
|
|
ctx: RequestContext = Depends(getRequestContext),
|
|
_admin = Depends(requireSysAdminRole)
|
|
):
|
|
"""
|
|
Get mandate-level balances (SysAdmin only).
|
|
Shows aggregated balances per mandate.
|
|
"""
|
|
try:
|
|
billingInterface = getBillingInterface(ctx.user, ctx.mandateId)
|
|
balances = billingInterface.getMandateBalances()
|
|
|
|
return [MandateBalanceResponse(**b) for b in balances]
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error getting mandate view balances: {e}")
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
@router.get("/view/mandates/transactions", response_model=List[TransactionResponse])
|
|
@limiter.limit("30/minute")
|
|
def getMandateViewTransactions(
|
|
request: Request,
|
|
limit: int = Query(default=100, ge=1, le=1000),
|
|
ctx: RequestContext = Depends(getRequestContext),
|
|
_admin = Depends(requireSysAdminRole)
|
|
):
|
|
"""
|
|
Get all transactions across mandates (SysAdmin only).
|
|
"""
|
|
try:
|
|
billingInterface = getBillingInterface(ctx.user, ctx.mandateId)
|
|
transactions = billingInterface.getMandateTransactions(limit=limit)
|
|
|
|
result = []
|
|
for t in transactions:
|
|
result.append(TransactionResponse(
|
|
id=t.get("id"),
|
|
accountId=t.get("accountId"),
|
|
transactionType=TransactionTypeEnum(t.get("transactionType", "DEBIT")),
|
|
amount=t.get("amount", 0.0),
|
|
description=t.get("description", ""),
|
|
referenceType=ReferenceTypeEnum(t["referenceType"]) if t.get("referenceType") else None,
|
|
workflowId=t.get("workflowId"),
|
|
featureCode=t.get("featureCode"),
|
|
featureInstanceId=t.get("featureInstanceId"),
|
|
aicoreProvider=t.get("aicoreProvider"),
|
|
aicoreModel=t.get("aicoreModel"),
|
|
createdByUserId=t.get("createdByUserId"),
|
|
createdAt=t.get("_createdAt"),
|
|
mandateId=t.get("mandateId"),
|
|
mandateName=t.get("mandateName")
|
|
))
|
|
|
|
return result
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error getting mandate view transactions: {e}")
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
# =============================================================================
|
|
# User View Endpoints (RBAC-based)
|
|
# =============================================================================
|
|
|
|
@router.get("/view/users/balances", response_model=List[UserBalanceResponse])
|
|
@limiter.limit("30/minute")
|
|
def getUserViewBalances(
|
|
request: Request,
|
|
ctx: RequestContext = Depends(getRequestContext)
|
|
):
|
|
"""
|
|
Get user-level balances.
|
|
|
|
RBAC filtering:
|
|
- SysAdmin: sees all user balances across all mandates
|
|
- Mandate-Admin: sees user balances for mandates they administrate
|
|
- Regular user: sees only their own balances
|
|
"""
|
|
try:
|
|
billingInterface = getBillingInterface(ctx.user, ctx.mandateId)
|
|
|
|
# Evaluate RBAC scope
|
|
scope = _getBillingDataScope(ctx.user)
|
|
|
|
# Determine mandate IDs for data loading
|
|
if scope.isGlobalAdmin:
|
|
mandateIds = None
|
|
else:
|
|
mandateIds = scope.adminMandateIds + scope.memberMandateIds
|
|
if not mandateIds:
|
|
return []
|
|
|
|
allBalances = billingInterface.getUserBalancesForMandates(mandateIds)
|
|
|
|
# RBAC filter: mandate admins see all in their mandates, regular users only own
|
|
if not scope.isGlobalAdmin:
|
|
adminMandateSet = set(scope.adminMandateIds)
|
|
allBalances = [
|
|
b for b in allBalances
|
|
if b.get("mandateId") in adminMandateSet or b.get("userId") == scope.userId
|
|
]
|
|
|
|
return [UserBalanceResponse(**b) for b in allBalances]
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error getting user view balances: {e}")
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
class ViewStatisticsResponse(BaseModel):
|
|
"""Aggregated statistics across all user's mandates."""
|
|
totalCost: float = 0.0
|
|
transactionCount: int = 0
|
|
costByProvider: Dict[str, float] = {}
|
|
costByModel: Dict[str, float] = {}
|
|
costByFeature: Dict[str, float] = {}
|
|
costByMandate: Dict[str, float] = {}
|
|
timeSeries: List[Dict[str, Any]] = []
|
|
|
|
|
|
@router.get("/view/statistics")
|
|
@limiter.limit("30/minute")
|
|
def getUserViewStatistics(
|
|
request: Request,
|
|
period: str = Query(default="month", description="Period: 'day' or 'month'"),
|
|
year: int = Query(default=None, description="Year"),
|
|
month: Optional[int] = Query(None, description="Month (1-12, required for period='day')"),
|
|
scope: str = Query(default="all", description="Scope: 'personal' (own costs only), 'mandate' (filter by mandateId), 'all' (RBAC-filtered)"),
|
|
mandateId: Optional[str] = Query(None, description="Mandate ID filter (used with scope='mandate')"),
|
|
ctx: RequestContext = Depends(getRequestContext)
|
|
) -> ViewStatisticsResponse:
|
|
"""
|
|
Get aggregated usage statistics across all user's mandates.
|
|
|
|
Scope:
|
|
- personal: only the current user's own transactions (ignores admin role)
|
|
- mandate: transactions for a specific mandate (requires mandateId parameter)
|
|
- all: RBAC-filtered (SysAdmin sees everything, admin sees mandate, user sees own)
|
|
|
|
- period='month': returns monthly time series for the given year
|
|
- period='day': returns daily time series for the given month/year
|
|
"""
|
|
try:
|
|
from datetime import timedelta
|
|
|
|
if year is None:
|
|
year = datetime.now().year
|
|
|
|
if period == "day" and not month:
|
|
month = datetime.now().month
|
|
|
|
billingInterface = getBillingInterface(ctx.user, ctx.mandateId)
|
|
|
|
# Evaluate RBAC scope
|
|
rbacScope = _getBillingDataScope(ctx.user)
|
|
|
|
# Determine mandate IDs for data loading
|
|
if rbacScope.isGlobalAdmin:
|
|
loadMandateIds = None
|
|
else:
|
|
loadMandateIds = rbacScope.adminMandateIds + rbacScope.memberMandateIds
|
|
if not loadMandateIds:
|
|
logger.warning("No mandate IDs found for user")
|
|
return ViewStatisticsResponse()
|
|
|
|
# Scope=mandate: restrict to specific mandate
|
|
if scope == "mandate" and mandateId:
|
|
loadMandateIds = [mandateId]
|
|
|
|
# Get all transactions
|
|
allTransactions = billingInterface.getUserTransactionsForMandates(loadMandateIds, limit=10000)
|
|
|
|
# Apply RBAC filter (respects admin/user roles)
|
|
allTransactions = _filterTransactionsByScope(allTransactions, rbacScope)
|
|
|
|
# Scope=personal: further filter to only own transactions
|
|
if scope == "personal":
|
|
userId = str(ctx.user.id)
|
|
allTransactions = [
|
|
t for t in allTransactions
|
|
if (t.get("createdByUserId") or t.get("userId")) == userId
|
|
]
|
|
|
|
logger.info(f"View statistics: {len(allTransactions)} RBAC-filtered transactions for period={period}, year={year}, month={month}")
|
|
|
|
# Calculate date range
|
|
if period == "day":
|
|
startDate = date(year, month, 1)
|
|
if month == 12:
|
|
endDate = date(year + 1, 1, 1)
|
|
else:
|
|
endDate = date(year, month + 1, 1)
|
|
else:
|
|
startDate = date(year, 1, 1)
|
|
endDate = date(year + 1, 1, 1)
|
|
|
|
# Filter by date range and only DEBIT transactions
|
|
debits = []
|
|
skippedNoDate = 0
|
|
skippedDateRange = 0
|
|
skippedNotDebit = 0
|
|
|
|
for t in allTransactions:
|
|
createdAt = t.get("_createdAt")
|
|
if not createdAt:
|
|
skippedNoDate += 1
|
|
continue
|
|
|
|
# Parse date from various formats (DB stores as DOUBLE PRECISION / Unix timestamp)
|
|
txDate = None
|
|
if isinstance(createdAt, (int, float)):
|
|
txDate = datetime.fromtimestamp(createdAt).date()
|
|
elif isinstance(createdAt, datetime):
|
|
txDate = createdAt.date()
|
|
elif isinstance(createdAt, date) and not isinstance(createdAt, datetime):
|
|
txDate = createdAt
|
|
elif isinstance(createdAt, str):
|
|
try:
|
|
# Try as float string first (Unix timestamp)
|
|
txDate = datetime.fromtimestamp(float(createdAt)).date()
|
|
except (ValueError, TypeError):
|
|
try:
|
|
txDate = datetime.fromisoformat(createdAt.replace("Z", "+00:00")).date()
|
|
except (ValueError, TypeError):
|
|
skippedNoDate += 1
|
|
continue
|
|
else:
|
|
skippedNoDate += 1
|
|
continue
|
|
|
|
if txDate < startDate or txDate >= endDate:
|
|
skippedDateRange += 1
|
|
continue
|
|
|
|
# Compare transactionType - handle both string and enum
|
|
txType = t.get("transactionType")
|
|
txTypeStr = str(txType) if txType is not None else ""
|
|
if txTypeStr != "DEBIT" and txTypeStr != "TransactionTypeEnum.DEBIT":
|
|
# Also check .value for enum objects
|
|
txTypeValue = getattr(txType, 'value', txTypeStr)
|
|
if txTypeValue != "DEBIT":
|
|
skippedNotDebit += 1
|
|
continue
|
|
|
|
t["_txDate"] = txDate
|
|
debits.append(t)
|
|
|
|
logger.info(f"View statistics: {len(debits)} DEBIT transactions after filter. "
|
|
f"Skipped: noDate={skippedNoDate}, dateRange={skippedDateRange}, notDebit={skippedNotDebit}")
|
|
|
|
# Aggregate totals
|
|
totalCost = sum(t.get("amount", 0) for t in debits)
|
|
|
|
costByProvider: Dict[str, float] = {}
|
|
costByModel: Dict[str, float] = {}
|
|
costByFeature: Dict[str, float] = {}
|
|
costByMandate: Dict[str, float] = {}
|
|
|
|
for t in debits:
|
|
provider = t.get("aicoreProvider") or "unknown"
|
|
costByProvider[provider] = costByProvider.get(provider, 0) + t.get("amount", 0)
|
|
|
|
model = t.get("aicoreModel") or "unknown"
|
|
costByModel[model] = costByModel.get(model, 0) + t.get("amount", 0)
|
|
|
|
mandate = t.get("mandateName") or t.get("mandateId") or "unknown"
|
|
featureCode = t.get("featureCode") or "unknown"
|
|
featureKey = f"{mandate} / {featureCode}"
|
|
costByFeature[featureKey] = costByFeature.get(featureKey, 0) + t.get("amount", 0)
|
|
|
|
mandate = t.get("mandateName") or t.get("mandateId") or "unknown"
|
|
costByMandate[mandate] = costByMandate.get(mandate, 0) + t.get("amount", 0)
|
|
|
|
# Build time series (raw data only, no display logic)
|
|
timeSeries = []
|
|
if period == "day":
|
|
numDays = (endDate - startDate).days
|
|
for day in range(numDays):
|
|
d = startDate + timedelta(days=day)
|
|
dayCost = sum(t.get("amount", 0) for t in debits if t["_txDate"] == d)
|
|
dayCount = sum(1 for t in debits if t["_txDate"] == d)
|
|
if dayCost > 0 or dayCount > 0:
|
|
timeSeries.append({
|
|
"date": d.isoformat(),
|
|
"cost": round(dayCost, 4),
|
|
"count": dayCount
|
|
})
|
|
else:
|
|
for m in range(1, 13):
|
|
mStart = date(year, m, 1)
|
|
mEnd = date(year, m + 1, 1) if m < 12 else date(year + 1, 1, 1)
|
|
monthCost = sum(t.get("amount", 0) for t in debits if mStart <= t["_txDate"] < mEnd)
|
|
monthCount = sum(1 for t in debits if mStart <= t["_txDate"] < mEnd)
|
|
timeSeries.append({
|
|
"date": f"{year}-{m:02d}",
|
|
"cost": round(monthCost, 4),
|
|
"count": monthCount
|
|
})
|
|
|
|
return ViewStatisticsResponse(
|
|
totalCost=round(totalCost, 4),
|
|
transactionCount=len(debits),
|
|
costByProvider=costByProvider,
|
|
costByModel=costByModel,
|
|
costByFeature=costByFeature,
|
|
costByMandate=costByMandate,
|
|
timeSeries=timeSeries
|
|
)
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error getting view statistics: {e}", exc_info=True)
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
@router.get("/view/users/transactions", response_model=PaginatedResponse[UserTransactionResponse])
|
|
@limiter.limit("30/minute")
|
|
def getUserViewTransactions(
|
|
request: Request,
|
|
pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams object"),
|
|
ctx: RequestContext = Depends(getRequestContext)
|
|
) -> PaginatedResponse[UserTransactionResponse]:
|
|
"""
|
|
Get user-level transactions with pagination support.
|
|
|
|
RBAC filtering:
|
|
- SysAdmin: sees all user transactions across all mandates
|
|
- Mandate-Admin: sees all user transactions for mandates they administrate
|
|
- Feature-Instance-Admin: sees transactions for their feature instances
|
|
- Regular user: sees only their own transactions
|
|
|
|
Query Parameters:
|
|
- pagination: JSON-encoded PaginationParams object, or None for no pagination
|
|
"""
|
|
try:
|
|
billingInterface = getBillingInterface(ctx.user, ctx.mandateId)
|
|
|
|
# Parse pagination params
|
|
paginationParams = None
|
|
if pagination:
|
|
import json
|
|
paginationDict = json.loads(pagination)
|
|
paginationDict = normalize_pagination_dict(paginationDict)
|
|
paginationParams = PaginationParams(**paginationDict)
|
|
|
|
# Evaluate RBAC scope
|
|
scope = _getBillingDataScope(ctx.user)
|
|
|
|
# Determine mandate IDs for data loading
|
|
if scope.isGlobalAdmin:
|
|
mandateIds = None # Load all
|
|
else:
|
|
# Load data for all mandates the user belongs to (admin + member)
|
|
mandateIds = scope.adminMandateIds + scope.memberMandateIds
|
|
if not mandateIds:
|
|
return PaginatedResponse(items=[], pagination=None)
|
|
|
|
allTransactions = billingInterface.getUserTransactionsForMandates(mandateIds, limit=10000)
|
|
|
|
# Apply RBAC filter
|
|
allTransactions = _filterTransactionsByScope(allTransactions, scope)
|
|
|
|
logger.debug(f"RBAC-filtered {len(allTransactions)} transactions for user {ctx.user.id}")
|
|
|
|
# Convert to response objects as dicts for filtering/sorting
|
|
transactionDicts = []
|
|
for t in allTransactions:
|
|
transactionDicts.append({
|
|
"id": t.get("id"),
|
|
"accountId": t.get("accountId"),
|
|
"transactionType": t.get("transactionType", "DEBIT"),
|
|
"amount": t.get("amount", 0.0),
|
|
"description": t.get("description", ""),
|
|
"referenceType": t.get("referenceType"),
|
|
"workflowId": t.get("workflowId"),
|
|
"featureCode": t.get("featureCode"),
|
|
"featureInstanceId": t.get("featureInstanceId"),
|
|
"aicoreProvider": t.get("aicoreProvider"),
|
|
"aicoreModel": t.get("aicoreModel"),
|
|
"createdByUserId": t.get("createdByUserId"),
|
|
"createdAt": t.get("_createdAt"),
|
|
"mandateId": t.get("mandateId"),
|
|
"mandateName": t.get("mandateName"),
|
|
"userId": t.get("userId"),
|
|
"userName": t.get("userName"),
|
|
})
|
|
|
|
# Apply filters and sorting
|
|
filteredDicts = _applyFiltersAndSort(transactionDicts, paginationParams)
|
|
|
|
# Convert to response models
|
|
def _toResponse(d):
|
|
return UserTransactionResponse(
|
|
id=d.get("id"),
|
|
accountId=d.get("accountId"),
|
|
transactionType=TransactionTypeEnum(d.get("transactionType", "DEBIT")),
|
|
amount=d.get("amount", 0.0),
|
|
description=d.get("description", ""),
|
|
referenceType=ReferenceTypeEnum(d["referenceType"]) if d.get("referenceType") else None,
|
|
workflowId=d.get("workflowId"),
|
|
featureCode=d.get("featureCode"),
|
|
featureInstanceId=d.get("featureInstanceId"),
|
|
aicoreProvider=d.get("aicoreProvider"),
|
|
aicoreModel=d.get("aicoreModel"),
|
|
createdByUserId=d.get("createdByUserId"),
|
|
createdAt=d.get("createdAt"),
|
|
mandateId=d.get("mandateId"),
|
|
mandateName=d.get("mandateName"),
|
|
userId=d.get("userId"),
|
|
userName=d.get("userName")
|
|
)
|
|
|
|
if paginationParams:
|
|
import math
|
|
totalItems = len(filteredDicts)
|
|
totalPages = math.ceil(totalItems / paginationParams.pageSize) if totalItems > 0 else 0
|
|
startIdx = (paginationParams.page - 1) * paginationParams.pageSize
|
|
endIdx = startIdx + paginationParams.pageSize
|
|
paginatedDicts = filteredDicts[startIdx:endIdx]
|
|
|
|
return PaginatedResponse(
|
|
items=[_toResponse(d) for d in paginatedDicts],
|
|
pagination=PaginationMetadata(
|
|
currentPage=paginationParams.page,
|
|
pageSize=paginationParams.pageSize,
|
|
totalItems=totalItems,
|
|
totalPages=totalPages,
|
|
sort=paginationParams.sort,
|
|
filters=paginationParams.filters
|
|
)
|
|
)
|
|
else:
|
|
return PaginatedResponse(
|
|
items=[_toResponse(d) for d in filteredDicts],
|
|
pagination=None
|
|
)
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error getting user view transactions: {e}")
|
|
raise HTTPException(status_code=500, detail=str(e))
|