"""The platform owner's credit service: top-ups, the manual history and the balances page. What these tests defend: * an owner credit moves the wallet by the amount and writes one history row that names the owner who made it and the reason * a zero, negative or oversized amount, and a blank or too-short reason, are refused before any wallet or history row is written * an unknown organization is a 404, never a silently created wallet * the history shows manual credit only (no usage debits) and only one organization's rows * the balances page lists every organization with its own balance, paused task count and last credit date; a usage debit is not a credit * the starting credit is granted only when the setting is positive """ from __future__ import annotations from types import SimpleNamespace import pytest import pytest_asyncio from app.application.services.credit_service import MAX_OWNER_CREDIT, CreditService from app.core.errors import NotFoundError, ValidationError from app.domain.base import new_id from app.domain.billing.models import CreditKind from app.domain.workforce.pipeline import Stage, TaskPipelineState from app.infrastructure.db.models import OrganizationRow, TaskRow, UserRow from app.infrastructure.repositories.billing import CreditWalletRepository from app.infrastructure.repositories.credit import CreditLedgerRepository from app.infrastructure.repositories.engine import PipelineStateRepository from tests.billing_helpers import fund_org VALID_REASON = "شارژ دستی مالک" async def _new_org(session, label: str) -> str: """An organization with its own owner and no wallet yet.""" user = UserRow(email=f"credit-svc-{new_id()[:8]}@example.com", password_hash="x") session.add(user) await session.flush() org = OrganizationRow( name=f"سازمان {label}", slug=f"credit-svc-{new_id()[:8]}", owner_user_id=user.id ) session.add(org) await session.flush() return org.id @pytest_asyncio.fixture async def two_orgs(session_factory): async with session_factory() as session: org_a = await _new_org(session, "الف") org_b = await _new_org(session, "ب") await session.commit() return session_factory, (org_a, org_b) async def _balance(session_factory, organization_id: str) -> float: async with session_factory() as session: wallet = await CreditWalletRepository(session).get_for_org(organization_id) return wallet.balance if wallet else 0.0 async def _add_task_at(session_factory, organization_id: str, stage: Stage) -> None: async with session_factory() as session: task = TaskRow(organization_id=organization_id, title="کار آزمایشی") session.add(task) await session.flush() await PipelineStateRepository(session).create( TaskPipelineState(task_id=task.id, organization_id=organization_id, stage=stage) ) await session.commit() async def test_an_owner_credit_moves_the_balance_and_writes_one_row(two_orgs): session_factory, (org_a, _org_b) = two_orgs actor = new_id() async with session_factory() as session: credit = await CreditService(session).owner_credit( organization_id=org_a, amount=40.0, reason=VALID_REASON, actor_user_id=actor ) await session.commit() assert credit.kind is CreditKind.OWNER_CREDIT assert credit.amount == 40.0 assert credit.balance_after == 40.0 assert await _balance(session_factory, org_a) == 40.0 async with session_factory() as session: rows = await CreditLedgerRepository(session).history(org_a) assert len(rows) == 1 assert rows[0].reason == VALID_REASON assert rows[0].actor_user_id == actor @pytest.mark.parametrize("amount", [0.0, -5.0, MAX_OWNER_CREDIT + 1]) async def test_a_zero_negative_or_oversized_amount_is_refused(two_orgs, amount): session_factory, (org_a, _org_b) = two_orgs async with session_factory() as session: with pytest.raises(ValidationError): await CreditService(session).owner_credit( organization_id=org_a, amount=amount, reason=VALID_REASON, actor_user_id=new_id() ) async with session_factory() as session: assert await CreditLedgerRepository(session).history(org_a) == [] assert await _balance(session_factory, org_a) == 0.0 async def test_exactly_the_maximum_amount_is_accepted(two_orgs): session_factory, (org_a, _org_b) = two_orgs async with session_factory() as session: credit = await CreditService(session).owner_credit( organization_id=org_a, amount=MAX_OWNER_CREDIT, reason=VALID_REASON, actor_user_id=new_id(), ) await session.commit() assert credit.balance_after == MAX_OWNER_CREDIT @pytest.mark.parametrize("reason", ["", " ", "ab", "x" * 501]) async def test_a_blank_short_or_long_reason_is_refused(two_orgs, reason): session_factory, (org_a, _org_b) = two_orgs async with session_factory() as session: with pytest.raises(ValidationError) as caught: await CreditService(session).owner_credit( organization_id=org_a, amount=10.0, reason=reason, actor_user_id=new_id() ) assert caught.value.message == "دلیل افزودن اعتبار لازم است." assert await _balance(session_factory, org_a) == 0.0 async def test_an_unknown_organization_is_not_found(session_factory): async with session_factory() as session: with pytest.raises(NotFoundError) as caught: await CreditService(session).owner_credit( organization_id=new_id(), amount=10.0, reason=VALID_REASON, actor_user_id=new_id(), ) assert caught.value.status_code == 404 async def test_the_history_lists_manual_credit_for_one_organization_only(two_orgs): session_factory, (org_a, org_b) = two_orgs await fund_org(session_factory, org_a, 50.0) async with session_factory() as session: service = CreditService(session) await service.owner_credit( organization_id=org_a, amount=10.0, reason=VALID_REASON, actor_user_id=new_id() ) await service.owner_credit( organization_id=org_b, amount=7.0, reason="شارژ سازمان ب", actor_user_id=new_id() ) await CreditLedgerRepository(session).debit_for_usage( organization_id=org_a, usage_event_id=new_id(), amount=4.0 ) await session.commit() async with session_factory() as session: history = await CreditService(session).history(org_a) newest = await CreditService(session).history(org_a, limit=1) # The usage debit is not in the history; the other organization's credit is not either. assert [t.kind for t in history] == [CreditKind.OWNER_CREDIT, CreditKind.ADJUSTMENT] assert [t.reason for t in history] == [VALID_REASON, "test funding"] assert [t.kind for t in newest] == [CreditKind.OWNER_CREDIT] async def test_the_balances_list_each_organization_with_its_own_numbers(two_orgs): session_factory, (org_a, org_b) = two_orgs async with session_factory() as session: org_c = await _new_org(session, "پ") await session.commit() async with session_factory() as session: await CreditService(session).owner_credit( organization_id=org_a, amount=30.0, reason=VALID_REASON, actor_user_id=new_id() ) await session.commit() async with session_factory() as session: # A usage debit takes the wallet below zero; it is not a credit. await CreditLedgerRepository(session).debit_for_usage( organization_id=org_b, usage_event_id=new_id(), amount=2.0 ) await session.commit() await _add_task_at(session_factory, org_a, Stage.PAUSED) await _add_task_at(session_factory, org_a, Stage.PAUSED) await _add_task_at(session_factory, org_b, Stage.PENDING_IMPLEMENTATION) async with session_factory() as session: rows = {row.organization_id: row for row in await CreditService(session).balances()} funded, debited, empty = rows[org_a], rows[org_b], rows[org_c] assert funded.name == "سازمان الف" assert funded.balance == 30.0 assert funded.paused_tasks == 2 assert funded.last_credit_at is not None assert debited.name == "سازمان ب" assert debited.balance == -2.0 assert debited.paused_tasks == 0 assert debited.last_credit_at is None # An organization with no wallet is listed at zero, not left out. assert empty.balance == 0.0 assert empty.currency == "USD" assert empty.paused_tasks == 0 assert empty.last_credit_at is None async def test_the_starting_credit_is_granted_only_when_the_setting_is_positive( two_orgs, monkeypatch ): session_factory, (org_a, org_b) = two_orgs monkeypatch.setattr( "app.application.services.credit_service.settings", SimpleNamespace(billing_starting_credit=0.0), ) async with session_factory() as session: assert await CreditService(session).grant_starting_credit(org_a) is None await session.commit() assert await _balance(session_factory, org_a) == 0.0 monkeypatch.setattr( "app.application.services.credit_service.settings", SimpleNamespace(billing_starting_credit=3.0), ) async with session_factory() as session: granted = await CreditService(session).grant_starting_credit(org_b) await session.commit() assert granted is not None assert granted.kind is CreditKind.STARTING_CREDIT assert granted.balance_after == 3.0