- 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
219 lines
7.8 KiB
Python
219 lines
7.8 KiB
Python
"""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 == []
|