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