"""Billing endpoints: summary, usage ledger, invoices, credit and checkout. Reading billing requires ``billing.view``; changing it requires ``billing.manage``. A tenant can therefore never see — or move — another organization's money. """ from __future__ import annotations from datetime import datetime from fastapi import APIRouter, Depends, Query, Request, status from pydantic import BaseModel, ConfigDict, Field from sqlalchemy.ext.asyncio import AsyncSession from app.application.services.audit_service import record_audit from app.application.services.billing_service import BillingService from app.core.database import get_session from app.domain.audit.models import AuditAction from app.domain.identity.models import CAN_MANAGE_BILLING, CAN_VIEW_BILLING from app.interfaces.http.deps import ( OrgContext, client_meta, get_billing_service, get_org_context, ) router = APIRouter(prefix="/organizations/{org_id}/billing", tags=["billing"]) # --------------------------------------------------------------------------- # Schemas # --------------------------------------------------------------------------- class UnitAggregateOut(BaseModel): unit: str quantity: float total_price: float class BillingSummaryOut(BaseModel): balance: float currency: str low_balance: bool low_balance_threshold: float total_spend: float by_unit: list[UnitAggregateOut] by_employee: dict[str, float] by_project: dict[str, float] class UsageEventOut(BaseModel): model_config = ConfigDict(from_attributes=True) id: str unit: str quantity: float unit_price: float total_price: float project_id: str | None employee_id: str | None run_id: str | None created_at: datetime class InvoiceOut(BaseModel): model_config = ConfigDict(from_attributes=True) id: str amount: float currency: str status: str period_start: datetime | None period_end: datetime | None created_at: datetime class PaymentOut(BaseModel): model_config = ConfigDict(from_attributes=True) id: str amount: float currency: str status: str provider: str created_at: datetime class AddCreditRequest(BaseModel): amount: float = Field(description="Positive to top up; negative is platform-owner only.") class WalletOut(BaseModel): balance: float currency: str low_balance: bool class CheckoutRequest(BaseModel): amount: float = Field(gt=0) success_url: str = Field(default="", max_length=512) cancel_url: str = Field(default="", max_length=512) class CheckoutResponse(BaseModel): payment_id: str checkout_url: str amount: float class SettlePaymentRequest(BaseModel): succeeded: bool = True # --------------------------------------------------------------------------- # Reads # --------------------------------------------------------------------------- @router.get("/summary", response_model=BillingSummaryOut) async def billing_summary( since: datetime | None = Query(default=None), until: datetime | None = Query(default=None), context: OrgContext = Depends(get_org_context), service: BillingService = Depends(get_billing_service), ) -> BillingSummaryOut: context.require(CAN_VIEW_BILLING) summary = await service.summary( organization_id=context.org_id, since=since, until=until ) return BillingSummaryOut( balance=summary["balance"], currency=summary["currency"], low_balance=summary["low_balance"], low_balance_threshold=summary["low_balance_threshold"], total_spend=summary["total_spend"], by_unit=[ UnitAggregateOut( unit=str(a.unit), quantity=a.quantity, total_price=a.total_price ) for a in summary["by_unit"] ], by_employee=summary["by_employee"], by_project=summary["by_project"], ) @router.get("/usage-events", response_model=list[UsageEventOut]) async def list_usage_events( limit: int = Query(default=100, ge=1, le=500), offset: int = Query(default=0, ge=0), context: OrgContext = Depends(get_org_context), service: BillingService = Depends(get_billing_service), ) -> list[UsageEventOut]: context.require(CAN_VIEW_BILLING) rows = await service.list_usage_events( organization_id=context.org_id, limit=limit, offset=offset ) return [ UsageEventOut( id=r.id, unit=r.unit, quantity=r.quantity, unit_price=r.unit_price, total_price=r.total_price, project_id=r.project_id, employee_id=r.employee_id, run_id=r.run_id, created_at=r.created_at, ) for r in rows ] @router.get("/invoices", response_model=list[InvoiceOut]) async def list_invoices( context: OrgContext = Depends(get_org_context), service: BillingService = Depends(get_billing_service), ) -> list[InvoiceOut]: context.require(CAN_VIEW_BILLING) invoices = await service.list_invoices(context.org_id) return [ InvoiceOut( id=i.id, amount=i.amount, currency=i.currency, status=str(i.status), period_start=i.period_start, period_end=i.period_end, created_at=i.created_at, ) for i in invoices ] @router.get("/payments", response_model=list[PaymentOut]) async def list_payments( context: OrgContext = Depends(get_org_context), service: BillingService = Depends(get_billing_service), ) -> list[PaymentOut]: context.require(CAN_VIEW_BILLING) wallet = await service.wallet_for(context.org_id) payments = await service.list_payments(context.org_id) return [ PaymentOut( id=p.id, amount=p.amount, currency=wallet.currency, status=str(p.status), provider=p.provider, created_at=p.created_at, ) for p in payments ] # --------------------------------------------------------------------------- # Writes # --------------------------------------------------------------------------- @router.post("/add-credit", response_model=WalletOut) async def add_credit( payload: AddCreditRequest, request: Request, context: OrgContext = Depends(get_org_context), service: BillingService = Depends(get_billing_service), session: AsyncSession = Depends(get_session), ) -> WalletOut: # A negative delta is a platform-owner-only clawback. if payload.amount < 0: if not context.user.is_platform_owner: context.require(CAN_MANAGE_BILLING) # will raise PermissionDeniedError from app.core.errors import PermissionDeniedError raise PermissionDeniedError("کسر اعتبار فقط توسط مالک سامانه انجام می‌شود.") else: context.require(CAN_MANAGE_BILLING) wallet = await service.add_credit( organization_id=context.org_id, amount=payload.amount, allow_negative=context.user.is_platform_owner, ) ip, ua = client_meta(request) await record_audit( session, action=AuditAction.BILLING_CREDIT, organization_id=context.org_id, actor_user_id=context.user.id, target_type="credit_wallet", target_id=wallet.id, metadata={"amount": payload.amount, "balance": wallet.balance}, ip=ip, user_agent=ua, ) return WalletOut( balance=wallet.balance, currency=wallet.currency, low_balance=wallet.is_low ) @router.post("/checkout", response_model=CheckoutResponse, status_code=status.HTTP_201_CREATED) async def start_checkout( payload: CheckoutRequest, context: OrgContext = Depends(get_org_context), service: BillingService = Depends(get_billing_service), ) -> CheckoutResponse: context.require(CAN_MANAGE_BILLING) payment, url = await service.start_checkout( organization_id=context.org_id, amount=payload.amount, success_url=payload.success_url, cancel_url=payload.cancel_url, ) return CheckoutResponse( payment_id=payment.id, checkout_url=url, amount=payment.amount ) @router.post("/payments/{payment_id}/settle", response_model=PaymentOut) async def settle_payment( payment_id: str, payload: SettlePaymentRequest, request: Request, context: OrgContext = Depends(get_org_context), service: BillingService = Depends(get_billing_service), session: AsyncSession = Depends(get_session), ) -> PaymentOut: """Finalise a payment. In production the payment provider calls a webhook; this endpoint is the manual/dev equivalent. It is the only path that converts money into credit. """ context.require(CAN_MANAGE_BILLING) payment = await service.settle_payment( organization_id=context.org_id, payment_id=payment_id, succeeded=payload.succeeded ) wallet = await service.wallet_for(context.org_id) ip, ua = client_meta(request) await record_audit( session, action=AuditAction.BILLING_PAYMENT, organization_id=context.org_id, actor_user_id=context.user.id, target_type="payment", target_id=payment.id, metadata={"status": str(payment.status), "amount": payment.amount}, ip=ip, user_agent=ua, ) return PaymentOut( id=payment.id, amount=payment.amount, currency=wallet.currency, status=str(payment.status), provider=payment.provider, created_at=payment.created_at, )