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
+218
View File
@@ -0,0 +1,218 @@
"""Every usage row debits its organization's prepaid wallet once, in the same transaction.
What these tests defend:
* a model call debits its price: the balance falls by that price and exactly one
``usage`` history row names the usage row it paid for
* a zero-priced usage row is still recorded, but debits nothing
* a sandbox usage debits the wallet too (every unit goes through the one ledger)
* another organization's wallet is never touched
* a rolled-back session leaves neither the usage row nor the debit
* the live wallet event carries the new balance, so the sidebar badge follows it
"""
from __future__ import annotations
import pytest_asyncio
from sqlalchemy import select
from app.application.ports.audit import UsageRecord
from app.application.ports.realtime import RealtimeEvent, get_publisher, set_publisher
from app.application.services.workforce_service import record_model_usage
from app.domain.ai_control.models import RateCard, RateUnit
from app.domain.base import new_id
from app.domain.billing.models import CreditKind
from app.infrastructure.db.models import OrganizationRow, UsageEventRow, UserRow
from app.infrastructure.repositories.ai_control import RateCardRepository
from app.infrastructure.repositories.audit import UsageLedgerRepository
from app.infrastructure.repositories.billing import CreditWalletRepository
from app.infrastructure.repositories.credit import CreditLedgerRepository
from tests.billing_helpers import fund_org
class _Recorder:
"""Stands in for the realtime bus and keeps every event it is given."""
def __init__(self) -> None:
self.events: list[RealtimeEvent] = []
async def publish(self, event: RealtimeEvent) -> None:
self.events.append(event)
@pytest_asyncio.fixture
async def recorder():
"""Install a recording publisher for one test and put the previous one back."""
previous = get_publisher()
bus = _Recorder()
set_publisher(bus)
try:
yield bus
finally:
set_publisher(previous)
@pytest_asyncio.fixture
async def two_orgs(session_factory):
"""Two organizations created directly, so neither starts with a wallet."""
org_ids: list[str] = []
async with session_factory() as session:
for label in ("الف", "ب"):
user = UserRow(email=f"debit-{new_id()[:8]}@example.com", password_hash="x")
session.add(user)
await session.flush()
org = OrganizationRow(
name=f"سازمان {label}", slug=f"debit-{new_id()[:8]}", owner_user_id=user.id
)
session.add(org)
await session.flush()
org_ids.append(org.id)
await session.commit()
return session_factory, org_ids
async def _balance(session_factory, organization_id: str) -> float:
async with session_factory() as session:
wallet = await CreditWalletRepository(session).get_for_org(organization_id)
assert wallet is not None
return wallet.balance
async def _usage_ids(session_factory, organization_id: str) -> list[str]:
async with session_factory() as session:
result = await session.execute(
select(UsageEventRow.id).where(UsageEventRow.organization_id == organization_id)
)
return list(result.scalars())
async def test_a_model_call_debits_its_price_and_names_its_usage_row(two_orgs, recorder):
session_factory, (org_a, _org_b) = two_orgs
await fund_org(session_factory, org_a, 100.0)
async with session_factory() as session:
await RateCardRepository(session).upsert(
RateCard(
name="توکن ورودی",
unit=RateUnit.MODEL_INPUT_TOKENS,
customer_price=2.5,
internal_cost_estimate=1.0,
)
)
await session.commit()
async with session_factory() as session:
await record_model_usage(
ledger=UsageLedgerRepository(session),
rate_cards=RateCardRepository(session),
organization_id=org_a,
unit=RateUnit.MODEL_INPUT_TOKENS,
quantity=4.0,
)
await session.commit()
# Four units at 2.5 each: the wallet falls by 10.
assert await _balance(session_factory, org_a) == 90.0
usage_ids = await _usage_ids(session_factory, org_a)
assert len(usage_ids) == 1
async with session_factory() as session:
debits = await CreditLedgerRepository(session).history(
org_a, kinds=(CreditKind.USAGE,)
)
assert len(debits) == 1
assert debits[0].amount == -10.0
assert debits[0].balance_after == 90.0
assert debits[0].usage_event_id == usage_ids[0]
wallet_events = [e for e in recorder.events if e.type == "billing.wallet"]
assert len(wallet_events) == 1
assert wallet_events[0].data["balance"] == 90.0
async def test_a_zero_priced_usage_row_is_recorded_but_debits_nothing(two_orgs):
session_factory, (org_a, _org_b) = two_orgs
await fund_org(session_factory, org_a, 100.0)
async with session_factory() as session:
balance_after = await UsageLedgerRepository(session).record(
usage=UsageRecord(
organization_id=org_a,
unit=RateUnit.TOOL_CALL,
quantity=1.0,
total_price=0.0,
)
)
await session.commit()
assert balance_after is None
assert len(await _usage_ids(session_factory, org_a)) == 1
assert await _balance(session_factory, org_a) == 100.0
async with session_factory() as session:
debits = await CreditLedgerRepository(session).history(
org_a, kinds=(CreditKind.USAGE,)
)
assert debits == []
async def test_a_sandbox_usage_debits_the_wallet_too(two_orgs):
session_factory, (org_a, _org_b) = two_orgs
await fund_org(session_factory, org_a, 20.0)
async with session_factory() as session:
balance_after = await UsageLedgerRepository(session).record(
usage=UsageRecord(
organization_id=org_a,
unit=RateUnit.SANDBOX_CPU_SECOND,
quantity=10.0,
unit_price=0.1,
total_price=1.0,
)
)
await session.commit()
assert balance_after == 19.0
assert await _balance(session_factory, org_a) == 19.0
async def test_another_organizations_wallet_is_untouched(two_orgs):
session_factory, (org_a, org_b) = two_orgs
await fund_org(session_factory, org_a, 50.0)
await fund_org(session_factory, org_b, 50.0)
async with session_factory() as session:
await UsageLedgerRepository(session).record(
usage=UsageRecord(
organization_id=org_a,
unit=RateUnit.TOOL_CALL,
quantity=1.0,
total_price=5.0,
)
)
await session.commit()
assert await _balance(session_factory, org_a) == 45.0
assert await _balance(session_factory, org_b) == 50.0
async with session_factory() as session:
b_debits = await CreditLedgerRepository(session).history(
org_b, kinds=(CreditKind.USAGE,)
)
assert b_debits == []
async def test_a_rolled_back_session_leaves_neither_the_usage_row_nor_the_debit(two_orgs):
session_factory, (org_a, _org_b) = two_orgs
await fund_org(session_factory, org_a, 100.0)
async with session_factory() as session:
await UsageLedgerRepository(session).record(
usage=UsageRecord(
organization_id=org_a,
unit=RateUnit.TOOL_CALL,
quantity=1.0,
total_price=7.0,
)
)
await session.rollback()
assert await _usage_ids(session_factory, org_a) == []
assert await _balance(session_factory, org_a) == 100.0
async with session_factory() as session:
debits = await CreditLedgerRepository(session).history(
org_a, kinds=(CreditKind.USAGE,)
)
assert debits == []