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
@@ -17,6 +17,7 @@ from app.application.ports import PaymentProviderPort
from app.application.ports.realtime import emit_wallet_changed
from app.core.errors import NotFoundError, PermissionDeniedError, ValidationError
from app.domain.billing.models import (
CreditKind,
CreditWallet,
Invoice,
Payment,
@@ -31,6 +32,7 @@ from app.infrastructure.repositories.billing import (
PlanRepository,
SubscriptionRepository,
)
from app.infrastructure.repositories.credit import CreditLedgerRepository
#: A tenant may not buy an arbitrary amount of credit in one action.
MIN_TOPUP = 1.0
@@ -56,6 +58,8 @@ class BillingService:
self.subscriptions = subscriptions
self.ledger = ledger
self.payment_provider = payment_provider
# Settled payments are credited through the credit ledger, in the wallets' session.
self.credit_ledger = CreditLedgerRepository(wallets.session)
# -- reads ---------------------------------------------------------------
@@ -107,6 +111,8 @@ class BillingService:
) -> CreditWallet:
"""Grant (or, for a platform owner, claw back) credit.
Superseded by the owner-only ``CreditService``, which writes the credit ledger.
``allow_negative`` is reserved for the platform owner console; an org
admin may only ever add credit.
"""
@@ -171,7 +177,9 @@ class BillingService:
This is the only path that turns money into credit, so it is idempotent
with respect to status: a payment that is already final is returned
untouched rather than credited twice.
untouched rather than credited twice. The credit is one ``adjustment`` row in
the credit ledger, written in this session, so it shows in the owner's credit
history and updates ``last_credit_at``.
"""
payment = next(
(p for p in await self.payments.list_for_org(organization_id) if p.id == payment_id),
@@ -183,12 +191,15 @@ class BillingService:
return payment
if succeeded:
wallet = await self.wallets.adjust(
organization_id=organization_id, delta=payment.amount
credit = await self.credit_ledger.apply(
organization_id=organization_id,
amount=payment.amount,
kind=CreditKind.ADJUSTMENT,
reason="billing/settle-payment",
)
final = await self.payments.set_status(payment_id, PaymentStatus.SUCCEEDED)
await emit_wallet_changed(
organization_id=organization_id, balance=wallet.balance
organization_id=organization_id, balance=credit.balance_after
)
else:
final = await self.payments.set_status(payment_id, PaymentStatus.FAILED)
@@ -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="اعتبار آغازین",
)
@@ -45,8 +45,9 @@ TIER_LABELS: dict[str, str] = {
#: Policy defaults for an organization that has never been configured. Kept in
#: code (not only in the row default) so ``get_policy`` can answer for any org
#: without writing a row first.
DEFAULT_MAX_EMPLOYEES = 5
#: without writing a row first. D-A: the default cap is 7 employees per
#: organization; ``settings.engine_max_employees`` stays the upper bound.
DEFAULT_MAX_EMPLOYEES = 7
@dataclass(slots=True)
@@ -221,6 +221,11 @@ class OrgSetupService:
)
)
self.session.add(CreditWalletRow(organization_id=org.id, balance=0.0, currency="USD"))
# Prepaid: the optional starting credit (0 by default) goes through the ledger.
await self.session.flush()
from app.application.services.credit_service import CreditService
await CreditService(self.session).grant_starting_credit(org.id)
# -- create the single app -------------------------------------------
project_row = await self._create_single_app(org.id, user.id, payload, catalog_defaults)
@@ -32,6 +32,7 @@ from app.application.ports.realtime import (
emit_task_created,
emit_task_status,
emit_usage_recorded,
emit_wallet_changed,
)
from app.core.errors import (
ConflictError,
@@ -61,6 +62,13 @@ from app.domain.workforce.models import (
ToolCallStatus,
can_transition_run,
)
from app.domain.workforce.pipeline import (
Risk,
Stage,
TaskKind,
TaskPipelineState,
resolve_pipeline,
)
from app.domain.workforce.tools import (
TOOL_CATALOG,
ToolSpec,
@@ -74,6 +82,7 @@ from app.infrastructure.repositories.ai_control import (
RoutingPolicyRepository,
)
from app.infrastructure.repositories.audit import UsageLedgerRepository
from app.infrastructure.repositories.engine import PipelineStateRepository
from app.infrastructure.repositories.projects import ProjectRepository
from app.infrastructure.repositories.workforce import (
AgentTemplateRepository,
@@ -94,6 +103,12 @@ from app.infrastructure.repositories.workforce import (
#: non-zero placeholder and mark it in the usage metadata.
_UNPRICED = 0.0000001
#: Said when the legacy "run" button is pressed on a task the engine owns.
_ENGINE_OWNS_TASK = (
"این کار را موتور خودکار اجرا می‌کند؛ "
"برای توقف، ادامه یا لغو از دکمه‌های همین کار استفاده کنید."
)
_SYSTEM_PROMPT = (
"تو دستیار هوش مصنوعی سازمان هستی. پاسخ‌ها را کوتاه، دقیق و عملی بده. "
"هیچ‌گاه نام مدل، ارائه‌دهنده یا جزئیات زیرساخت را فاش نکن."
@@ -126,7 +141,7 @@ async def record_model_usage(
card = await rate_cards.get_by_unit(unit)
unit_price = card.customer_price if card else _UNPRICED
internal_cost = card.internal_cost_estimate if card else 0.0
await ledger.record(
balance = await ledger.record(
usage=UsageRecord(
organization_id=organization_id,
project_id=project_id,
@@ -141,6 +156,9 @@ async def record_model_usage(
metadata_json={"priced": card is not None},
)
)
if balance is not None:
# The debit moved the wallet: the sidebar badge follows it live.
await emit_wallet_changed(organization_id=organization_id, balance=balance)
# Live push: the billing page and the sidebar credit badge react to
# consumption as it happens instead of at the next page load.
await emit_usage_recorded(
@@ -406,6 +424,7 @@ class WorkforceService:
"""
from app.application.container_org import build_memory, build_model_grants
from app.application.engine.manager import ManagerTurns
from app.infrastructure.repositories.billing import CreditWalletRepository
from app.infrastructure.repositories.engine import (
EmployeeProfileRepository,
PipelineStateRepository,
@@ -426,6 +445,7 @@ class WorkforceService:
memory=build_memory(session),
profiles=EmployeeProfileRepository(session),
states=PipelineStateRepository(session),
wallets=CreditWalletRepository(session),
)
# ------------------------------------------------------------------ #
@@ -443,6 +463,13 @@ class WorkforceService:
thread_id: str | None = None,
assigned_employee_id: str | None = None,
) -> Task:
"""A user order to the manager: the task enters the engine at manager review.
The task gets a pipeline state at ``pending_manager_review``, so the scheduler's
triage turn takes it from there and picks the real pipeline and assignee (D-D).
Without a state the legacy one-shot drain would run it outside the engine. MCP
``tasks_create`` calls this method, so its tasks enter the engine too.
"""
if not title.strip():
raise ValidationError("عنوان کار نمی‌تواند خالی باشد.")
await self._own_project_or_404(organization_id=organization_id, project_id=project_id)
@@ -451,6 +478,7 @@ class WorkforceService:
await self.get_employee_or_404(
organization_id=organization_id, employee_id=assigned_employee_id
)
project_id = project_id or await self._project_id_for(organization_id)
task = await self.tasks.create(
Task(
organization_id=organization_id,
@@ -462,6 +490,21 @@ class WorkforceService:
created_by_user_id=created_by_user_id,
)
)
await PipelineStateRepository(self.tasks.session).create(
TaskPipelineState(
task_id=task.id,
organization_id=organization_id,
project_id=project_id,
pipeline=resolve_pipeline(
manager_choice=None,
task_kind=TaskKind.FEATURE,
has_active_reviewer=False,
),
kind=TaskKind.FEATURE,
risk=Risk.MEDIUM,
stage=Stage.PENDING_MANAGER_REVIEW,
)
)
# Live push: the tasks list and the manager-chat sidebar update the
# moment the task exists, whoever created it and from where.
await emit_task_created(
@@ -472,6 +515,16 @@ class WorkforceService:
)
return task
async def _project_id_for(self, organization_id: str) -> str | None:
"""The project a project-less order lands in: the one its employees work in.
Mirrors ``ManagerTurns._project_id_for``, so a task made here and a task the
manager makes from a chat get the same project.
"""
for employee in await self.employees.list_for_org(organization_id, limit=1):
return employee.project_id
return None
async def list_tasks(
self,
organization_id: str,
@@ -506,6 +559,9 @@ class WorkforceService:
employee_id: str | None = None,
) -> Run:
task = await self.get_task_or_404(organization_id=organization_id, task_id=task_id)
if await PipelineStateRepository(self.tasks.session).get(task.id) is not None:
# The engine owns this task; its own buttons (pause, resume, cancel, answer) drive it.
raise ConflictError(_ENGINE_OWNS_TASK)
if task.status in (TaskStatus.DONE, TaskStatus.CANCELLED):
raise ConflictError("این کار قبلاً بسته شده است.")