Files
smart-org/apps/api/tests/test_credit_ledger.py
T
mjalmoudy94 d532929189 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
2026-10-14 23:19:05 +03:30

164 lines
6.4 KiB
Python

"""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