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:
@@ -0,0 +1,163 @@
|
||||
"""The prepaid credit ledger: each balance change is one atomic update and one history row.
|
||||
|
||||
What these tests defend:
|
||||
|
||||
* a credit then a debit leave the balance their history rows record
|
||||
* the same usage row debits a wallet once, however many times it is written
|
||||
* debiting one organization never moves another organization's wallet
|
||||
* a missing wallet is created at zero, then credited
|
||||
* the history is newest first and always scoped to one organization
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest_asyncio
|
||||
|
||||
from app.domain.base import new_id
|
||||
from app.domain.billing.models import CreditKind
|
||||
from app.infrastructure.db.models import OrganizationRow, UserRow
|
||||
from app.infrastructure.repositories.billing import CreditWalletRepository
|
||||
from app.infrastructure.repositories.credit import CreditLedgerRepository
|
||||
from tests.billing_helpers import fund_org
|
||||
|
||||
|
||||
@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"ledger-{new_id()[:8]}@example.com", password_hash="x")
|
||||
session.add(user)
|
||||
await session.flush()
|
||||
org = OrganizationRow(
|
||||
name=f"سازمان {label}", slug=f"ledger-{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 test_a_credit_then_a_debit_leave_the_balance_their_history_records(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:
|
||||
debit = await CreditLedgerRepository(session).debit_for_usage(
|
||||
organization_id=org_a, usage_event_id=new_id(), amount=30.0
|
||||
)
|
||||
await session.commit()
|
||||
assert debit is not None
|
||||
assert debit.kind is CreditKind.USAGE
|
||||
assert debit.amount == -30.0
|
||||
assert debit.balance_after == 70.0
|
||||
assert await _balance(session_factory, org_a) == 70.0
|
||||
|
||||
async with session_factory() as session:
|
||||
history = await CreditLedgerRepository(session).history(org_a)
|
||||
assert [t.kind for t in history] == [CreditKind.USAGE, CreditKind.ADJUSTMENT]
|
||||
assert [t.balance_after for t in history] == [70.0, 100.0]
|
||||
|
||||
|
||||
async def test_the_same_usage_row_debits_the_wallet_once(two_orgs):
|
||||
session_factory, (org_a, _org_b) = two_orgs
|
||||
await fund_org(session_factory, org_a, 100.0)
|
||||
usage_id = new_id()
|
||||
|
||||
async with session_factory() as session:
|
||||
repo = CreditLedgerRepository(session)
|
||||
first = await repo.debit_for_usage(
|
||||
organization_id=org_a, usage_event_id=usage_id, amount=25.0
|
||||
)
|
||||
second = await repo.debit_for_usage(
|
||||
organization_id=org_a, usage_event_id=usage_id, amount=25.0
|
||||
)
|
||||
free = await repo.debit_for_usage(
|
||||
organization_id=org_a, usage_event_id=new_id(), amount=0.0
|
||||
)
|
||||
await session.commit()
|
||||
assert first is not None and first.balance_after == 75.0
|
||||
assert second is None
|
||||
assert free is None
|
||||
assert await _balance(session_factory, org_a) == 75.0
|
||||
|
||||
async with session_factory() as session:
|
||||
usage_rows = await CreditLedgerRepository(session).history(
|
||||
org_a, kinds=(CreditKind.USAGE,)
|
||||
)
|
||||
assert len(usage_rows) == 1
|
||||
assert usage_rows[0].usage_event_id == usage_id
|
||||
|
||||
|
||||
async def test_debiting_one_organization_never_moves_another(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 CreditLedgerRepository(session).debit_for_usage(
|
||||
organization_id=org_a, usage_event_id=new_id(), amount=20.0
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
assert await _balance(session_factory, org_a) == 30.0
|
||||
assert await _balance(session_factory, org_b) == 50.0
|
||||
async with session_factory() as session:
|
||||
b_history = await CreditLedgerRepository(session).history(org_b)
|
||||
assert [t.kind for t in b_history] == [CreditKind.ADJUSTMENT]
|
||||
|
||||
|
||||
async def test_a_missing_wallet_is_created_at_zero_then_credited(two_orgs):
|
||||
session_factory, (org_a, _org_b) = two_orgs
|
||||
async with session_factory() as session:
|
||||
assert await CreditWalletRepository(session).get_for_org(org_a) is None
|
||||
|
||||
async with session_factory() as session:
|
||||
credit = await CreditLedgerRepository(session).apply(
|
||||
organization_id=org_a,
|
||||
amount=25.0,
|
||||
kind=CreditKind.OWNER_CREDIT,
|
||||
reason="first credit",
|
||||
)
|
||||
await session.commit()
|
||||
assert credit.balance_after == 25.0
|
||||
assert await _balance(session_factory, org_a) == 25.0
|
||||
|
||||
|
||||
async def test_history_is_newest_first_scoped_and_capped(two_orgs):
|
||||
session_factory, (org_a, org_b) = two_orgs
|
||||
async with session_factory() as session:
|
||||
repo = CreditLedgerRepository(session)
|
||||
await repo.apply(
|
||||
organization_id=org_a, amount=10.0, kind=CreditKind.STARTING_CREDIT, reason="start"
|
||||
)
|
||||
await repo.apply(
|
||||
organization_id=org_a, amount=40.0, kind=CreditKind.OWNER_CREDIT, reason="owner"
|
||||
)
|
||||
# Org B never received credit: a usage debit alone is not a credit.
|
||||
await repo.debit_for_usage(organization_id=org_b, usage_event_id=new_id(), amount=1.0)
|
||||
await session.commit()
|
||||
|
||||
async with session_factory() as session:
|
||||
repo = CreditLedgerRepository(session)
|
||||
everything = await repo.history(org_a)
|
||||
credits_only = await repo.history(org_a, kinds=(CreditKind.OWNER_CREDIT,))
|
||||
newest_one = await repo.history(org_a, limit=1)
|
||||
org_b_history = await repo.history(org_b)
|
||||
last_credit = await repo.last_credit_at_by_org()
|
||||
|
||||
assert [t.reason for t in everything] == ["owner", "start"]
|
||||
assert [t.reason for t in credits_only] == ["owner"]
|
||||
assert [t.reason for t in newest_one] == ["owner"]
|
||||
assert [t.kind for t in org_b_history] == [CreditKind.USAGE]
|
||||
assert set(last_credit) == {org_a}
|
||||
assert last_credit[org_a] is not None
|
||||
Reference in New Issue
Block a user