feat(integration): prepaid billing, engine UI, credits console, direct tasks, v2 final
- 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
This commit is contained in:
@@ -1,8 +1,10 @@
|
||||
"""Billing endpoints: summary, usage ledger, invoices, credit and checkout.
|
||||
"""Billing endpoints: summary, usage ledger, invoices, payments and checkout.
|
||||
|
||||
Reading billing requires ``billing.view``; changing it requires
|
||||
``billing.manage``. A tenant can therefore never see — or move — another
|
||||
organization's money.
|
||||
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
|
||||
@@ -13,11 +15,16 @@ 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,
|
||||
@@ -27,6 +34,9 @@ from app.interfaces.http.deps import (
|
||||
|
||||
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
|
||||
@@ -88,7 +98,9 @@ class PaymentOut(BaseModel):
|
||||
|
||||
|
||||
class AddCreditRequest(BaseModel):
|
||||
amount: float = Field(description="Positive to top up; negative is platform-owner only.")
|
||||
amount: float = Field(
|
||||
description="Platform owner only. Positive tops up; negative claws back."
|
||||
)
|
||||
|
||||
|
||||
class WalletOut(BaseModel):
|
||||
@@ -210,35 +222,47 @@ 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
|
||||
"""Add (or claw back) credit on the platform owner's word. Nobody else may (D-B).
|
||||
|
||||
raise PermissionDeniedError("کسر اعتبار فقط توسط مالک سامانه انجام میشود.")
|
||||
else:
|
||||
context.require(CAN_MANAGE_BILLING)
|
||||
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("مقدار اعتبار نمیتواند صفر باشد.")
|
||||
|
||||
wallet = await service.add_credit(
|
||||
transaction = await CreditLedgerRepository(session).apply(
|
||||
organization_id=context.org_id,
|
||||
amount=payload.amount,
|
||||
allow_negative=context.user.is_platform_owner,
|
||||
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": wallet.balance},
|
||||
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=wallet.balance, currency=wallet.currency, low_balance=wallet.is_low
|
||||
balance=transaction.balance_after,
|
||||
currency=wallet.currency,
|
||||
low_balance=transaction.balance_after <= wallet.low_balance_threshold,
|
||||
)
|
||||
|
||||
|
||||
@@ -269,12 +293,13 @@ async def settle_payment(
|
||||
service: BillingService = Depends(get_billing_service),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> PaymentOut:
|
||||
"""Finalise a payment.
|
||||
"""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.
|
||||
"""
|
||||
context.require(CAN_MANAGE_BILLING)
|
||||
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
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user