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:
mjalmoudy94
2026-10-14 23:19:05 +03:30
parent fef5a9deab
commit d532929189
117 changed files with 13119 additions and 640 deletions
@@ -0,0 +1,136 @@
"""Prepaid credit: the platform owner's top-ups, the history and the balances.
An organization's wallet is only ever moved through ``CreditLedgerRepository``, so every
change has a history row. This service is the owner's side of that ledger: it credits one
organization by hand, reads its manual history, lists every organization's balance for the
owner's credit page, and grants the optional starting credit to a new organization. Usage
debits are not written here; ``UsageLedgerRepository.record`` writes them.
Only the platform owner may call the write methods and the cross-organization ``balances``.
The routers enforce that; this service does not look at who is calling.
"""
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
from sqlalchemy.ext.asyncio import AsyncSession
from app.application.ports.realtime import emit_wallet_changed
from app.core.config import settings
from app.core.errors import NotFoundError, ValidationError
from app.domain.billing.models import CreditKind, CreditTransaction
from app.domain.workforce.pipeline import Stage
from app.infrastructure.repositories.billing import CreditWalletRepository
from app.infrastructure.repositories.credit import CreditLedgerRepository
from app.infrastructure.repositories.engine import PipelineStateRepository
from app.infrastructure.repositories.identity import OrganizationRepository
#: The most one owner action may credit at once. A bigger top-up is made in steps.
MAX_OWNER_CREDIT = 100_000.0
#: The reason is required and stored with the history row (at most 500 characters).
_REASON_MIN = 3
_REASON_MAX = 500
#: The entries a history page shows: what someone did to the wallet, not usage debits.
_MANUAL_KINDS = (CreditKind.OWNER_CREDIT, CreditKind.STARTING_CREDIT, CreditKind.ADJUSTMENT)
@dataclass(slots=True, kw_only=True)
class OrgBalance:
"""One organization's row on the platform owner's credit page."""
organization_id: str
name: str
slug: str
balance: float
currency: str
paused_tasks: int
last_credit_at: datetime | None
class CreditService:
def __init__(self, session: AsyncSession) -> None:
self.session = session
self.ledger = CreditLedgerRepository(session)
self.wallets = CreditWalletRepository(session)
self.orgs = OrganizationRepository(session)
self.states = PipelineStateRepository(session)
async def owner_credit(
self,
*,
organization_id: str,
amount: float,
reason: str,
actor_user_id: str,
) -> CreditTransaction:
"""Credit one organization's wallet on the platform owner's word."""
if await self.orgs.get(organization_id) is None:
raise NotFoundError("سازمان یافت نشد.")
if not 0 < amount <= MAX_OWNER_CREDIT:
raise ValidationError(
f"مقدار اعتبار باید بیشتر از صفر و حداکثر {MAX_OWNER_CREDIT:g} باشد."
)
clean_reason = reason.strip()
if not _REASON_MIN <= len(clean_reason) <= _REASON_MAX:
raise ValidationError("دلیل افزودن اعتبار لازم است.")
transaction = await self.ledger.apply(
organization_id=organization_id,
amount=amount,
kind=CreditKind.OWNER_CREDIT,
reason=clean_reason,
actor_user_id=actor_user_id,
)
# Live push: the sidebar credit badge follows the wallet as it moves.
await emit_wallet_changed(
organization_id=organization_id, balance=transaction.balance_after
)
return transaction
async def history(self, organization_id: str, *, limit: int = 50) -> list[CreditTransaction]:
"""One organization's manual credit history, newest first. Usage debits are left out."""
return await self.ledger.history(organization_id, kinds=_MANUAL_KINDS, limit=limit)
async def balances(self) -> list[OrgBalance]:
"""Every organization's balance, paused task count and last credit date.
Cross-organization on purpose: only the platform owner's credit page may call it.
A missing wallet is shown as zero.
"""
paused = await self.states.count_at_by_org(Stage.PAUSED)
last_credit = await self.ledger.last_credit_at_by_org()
rows: list[OrgBalance] = []
for org in await self.orgs.list_all():
wallet = await self.wallets.get_for_org(org.id)
rows.append(
OrgBalance(
organization_id=org.id,
name=org.name,
slug=org.slug,
balance=wallet.balance if wallet else 0.0,
currency=wallet.currency if wallet else "USD",
paused_tasks=paused.get(org.id, 0),
last_credit_at=last_credit.get(org.id),
)
)
return rows
async def grant_starting_credit(self, organization_id: str) -> CreditTransaction | None:
"""Give a new organization the configured starting credit, if one is set.
``BILLING_STARTING_CREDIT`` defaults to 0, so by default nothing is granted and the
owner credits new organizations by hand.
"""
amount = float(getattr(settings, "billing_starting_credit", 0.0))
if amount <= 0:
return None
return await self.ledger.apply(
organization_id=organization_id,
amount=amount,
kind=CreditKind.STARTING_CREDIT,
reason="اعتبار آغازین",
)