- Prepaid credit system: org wallet, debit on usage, chat gate, admin credit console - Engine UI: /manager, /tasks, /tasks/[id], /workforce, /dashboard, /traces pages - Direct task creation (HTTP POST /tasks, MCP tasks_create) at pending_manager_review - Pause/resume/cancel/answer actions in engine API and UI - Employee cap (7 per org default), review pipeline guard (active reviewer required) - Scheduler: wide window prevents org starvation; archived employees excluded from cap - Tenant isolation: project-id cross-org 404, engine never uses another tenant's sandbox - Order route fix: manager built with pipeline states + employee profiles - Contract extension: ModelGrantPort.get_org_policy with DefaultOrgPolicy - Engine event types in @agy/contracts (task.step, task.gate, run.log, run.tool, team.changed) - api-engine.ts typed client; api-credit.ts client - Alembic migration c4d5e6f7a8b9: prepaid credit + engine UI tables - 859 tests passed, 1 skipped; ruff/mypy/tsc/eslint/vitest all green Refs: PLAN-v2.md §9 (T1–T43), todo-v2-final.md
320 lines
11 KiB
Python
320 lines
11 KiB
Python
"""Billing endpoints: summary, usage ledger, invoices, payments and checkout.
|
|
|
|
Billing is prepaid (D-B): an organization spends the credit in its wallet, and only the
|
|
platform owner adds credit, with ``add-credit`` or by settling a payment. Reading billing
|
|
requires ``billing.view``; ``checkout``, which only creates a pending payment, requires
|
|
``billing.manage``. A tenant can therefore never see or move another organization's money,
|
|
and an organization admin cannot give its own wallet credit.
|
|
"""
|
|
|
|
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.ports.realtime import emit_wallet_changed
|
|
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.core.errors import PermissionDeniedError, ValidationError
|
|
from app.domain.audit.models import AuditAction
|
|
from app.domain.billing.models import CreditKind
|
|
from app.domain.identity.models import CAN_MANAGE_BILLING, CAN_VIEW_BILLING
|
|
from app.infrastructure.repositories.billing import CreditWalletRepository
|
|
from app.infrastructure.repositories.credit import CreditLedgerRepository
|
|
from app.interfaces.http.deps import (
|
|
OrgContext,
|
|
client_meta,
|
|
get_billing_service,
|
|
get_org_context,
|
|
)
|
|
|
|
router = APIRouter(prefix="/organizations/{org_id}/billing", tags=["billing"])
|
|
|
|
#: Said to anyone who is not the platform owner and tries to move an organization's credit.
|
|
OWNER_ONLY_CREDIT = "اعتبار سازمان را فقط مالک سامانه افزایش میدهد."
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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="Platform owner only. Positive tops up; negative claws back."
|
|
)
|
|
|
|
|
|
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),
|
|
session: AsyncSession = Depends(get_session),
|
|
) -> WalletOut:
|
|
"""Add (or claw back) credit on the platform owner's word. Nobody else may (D-B).
|
|
|
|
The change is one history row of kind ``adjustment``, so it shows in the owner's
|
|
credit history for this organization.
|
|
"""
|
|
if not context.user.is_platform_owner:
|
|
raise PermissionDeniedError(OWNER_ONLY_CREDIT)
|
|
if payload.amount == 0:
|
|
raise ValidationError("مقدار اعتبار نمیتواند صفر باشد.")
|
|
|
|
transaction = await CreditLedgerRepository(session).apply(
|
|
organization_id=context.org_id,
|
|
amount=payload.amount,
|
|
kind=CreditKind.ADJUSTMENT,
|
|
reason="billing/add-credit",
|
|
actor_user_id=context.user.id,
|
|
)
|
|
if payload.amount < 0 and transaction.balance_after < 0:
|
|
# Refuse to leave the wallet in debt. The request's session rolls back on this
|
|
# error, so the balance change and its history row are both undone.
|
|
raise ValidationError("اعتبار کافی برای این کسر وجود ندارد.")
|
|
|
|
wallet = await CreditWalletRepository(session).ensure(context.org_id)
|
|
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": transaction.balance_after},
|
|
ip=ip, user_agent=ua,
|
|
)
|
|
# Live push: the sidebar credit badge follows the wallet, whoever moved it.
|
|
await emit_wallet_changed(
|
|
organization_id=context.org_id, balance=transaction.balance_after
|
|
)
|
|
return WalletOut(
|
|
balance=transaction.balance_after,
|
|
currency=wallet.currency,
|
|
low_balance=transaction.balance_after <= wallet.low_balance_threshold,
|
|
)
|
|
|
|
|
|
@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. Only the platform owner may: a succeeded payment credits the wallet.
|
|
|
|
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.
|
|
"""
|
|
if not context.user.is_platform_owner:
|
|
raise PermissionDeniedError(OWNER_ONLY_CREDIT)
|
|
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,
|
|
)
|