Files
smart-org/apps/api/app/interfaces/http/routers/billing.py
T
mjalmoudy94 717e53a7ce نسخهٔ وب مستقل — پلتفرم نیروی کار هوش مصنوعی
مخزن اختصاصی برنامهٔ وب؛ محتوای پوشهٔ web در ریشهٔ مخزن قرار گرفت.
تاریخچهٔ پروژهٔ ربات (agy-bot) در این مخزن وجود ندارد و هیچ وابستگی runtime به آن نیست.
2026-10-13 00:39:28 +03:30

295 lines
9.2 KiB
Python

"""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,
)