"""Phase 7.6 — Wallet Engine tests.""" from __future__ import annotations import pytest from app.events.publisher import get_event_publisher from app.events.types import LoyaltyEventType from app.tests.conftest import TENANT_A, tenant_headers async def _open_wallet(client, *, suffix: str, currency: str = "IRR") -> dict: program = await client.post( "/api/v1/programs", json={"code": f"wal{suffix}", "name": f"Wallet {suffix}"}, headers=tenant_headers(TENANT_A), ) assert program.status_code == 201, program.text program_id = program.json()["id"] member = await client.post( "/api/v1/members", json={"program_id": program_id, "display_name": f"Member {suffix}", "enroll": True}, headers=tenant_headers(TENANT_A), ) assert member.status_code == 201, member.text member_id = member.json()["id"] wallet = await client.post( "/api/v1/wallets", json={ "program_id": program_id, "member_id": member_id, "currency_code": currency, }, headers=tenant_headers(TENANT_A), ) assert wallet.status_code == 201, wallet.text return { "program_id": program_id, "member_id": member_id, "wallet_id": wallet.json()["id"], "account_number": wallet.json()["account_number"], } @pytest.mark.asyncio async def test_open_credit_and_balance(client): ctx = await _open_wallet(client, suffix="oc1") credit = await client.post( f"/api/v1/wallets/{ctx['wallet_id']}/credit", json={"amount": 1000, "reason": "top-up", "idempotency_key": "c1"}, headers=tenant_headers(TENANT_A), ) assert credit.status_code == 200, credit.text assert credit.json()["balance_after"] == 1000 assert credit.json()["entry_type"] == "credit" again = await client.post( f"/api/v1/wallets/{ctx['wallet_id']}/credit", json={"amount": 1000, "reason": "top-up", "idempotency_key": "c1"}, headers=tenant_headers(TENANT_A), ) assert again.status_code == 200 assert again.json()["id"] == credit.json()["id"] balance = await client.get( f"/api/v1/wallets/{ctx['wallet_id']}/balance", headers=tenant_headers(TENANT_A), ) assert balance.status_code == 200 assert balance.json()["balance"] == 1000 assert balance.json()["currency_code"] == "IRR" events = {e.event_type for e in get_event_publisher().published} assert LoyaltyEventType.WALLET_OPENED.value in events assert LoyaltyEventType.WALLET_CREDITED.value in events @pytest.mark.asyncio async def test_debit_insufficient_funds_rejected(client): ctx = await _open_wallet(client, suffix="db1") await client.post( f"/api/v1/wallets/{ctx['wallet_id']}/credit", json={"amount": 200}, headers=tenant_headers(TENANT_A), ) debit = await client.post( f"/api/v1/wallets/{ctx['wallet_id']}/debit", json={"amount": 50, "reason": "purchase"}, headers=tenant_headers(TENANT_A), ) assert debit.status_code == 200, debit.text assert debit.json()["balance_after"] == 150 over = await client.post( f"/api/v1/wallets/{ctx['wallet_id']}/debit", json={"amount": 1000}, headers=tenant_headers(TENANT_A), ) assert over.status_code == 409 assert over.json()["error"]["code"] == "wallet_insufficient_funds" events = {e.event_type for e in get_event_publisher().published} assert LoyaltyEventType.WALLET_DEBITED.value in events @pytest.mark.asyncio async def test_adjust_and_ledger_list(client): ctx = await _open_wallet(client, suffix="adj1") await client.post( f"/api/v1/wallets/{ctx['wallet_id']}/credit", json={"amount": 500}, headers=tenant_headers(TENANT_A), ) adjust = await client.post( f"/api/v1/wallets/{ctx['wallet_id']}/adjust", json={"amount": -50, "reason": "correction"}, headers=tenant_headers(TENANT_A), ) assert adjust.status_code == 200, adjust.text assert adjust.json()["balance_after"] == 450 zero_adjust = await client.post( f"/api/v1/wallets/{ctx['wallet_id']}/adjust", json={"amount": 0, "reason": "noop"}, headers=tenant_headers(TENANT_A), ) assert zero_adjust.status_code == 422 assert zero_adjust.json()["error"]["code"] == "invalid_wallet_adjust_amount" page = await client.get( f"/api/v1/wallets/{ctx['wallet_id']}/ledger", params={"limit": 10}, headers=tenant_headers(TENANT_A), ) assert page.status_code == 200 body = page.json() assert body["total"] >= 2 assert len(body["items"]) >= 2 events = {e.event_type for e in get_event_publisher().published} assert LoyaltyEventType.WALLET_ADJUSTED.value in events @pytest.mark.asyncio async def test_transfer_between_wallets_same_currency(client): source = await _open_wallet(client, suffix="tr1a") target = await _open_wallet(client, suffix="tr1b") await client.post( f"/api/v1/wallets/{source['wallet_id']}/credit", json={"amount": 300}, headers=tenant_headers(TENANT_A), ) transfer = await client.post( f"/api/v1/wallets/{source['wallet_id']}/transfer", json={"target_wallet_id": target["wallet_id"], "amount": 120, "reason": "p2p"}, headers=tenant_headers(TENANT_A), ) assert transfer.status_code == 200, transfer.text body = transfer.json() assert body["source_entry"]["entry_type"] == "transfer_out" assert body["source_entry"]["amount"] == -120 assert body["source_entry"]["balance_after"] == 180 assert body["target_entry"]["entry_type"] == "transfer_in" assert body["target_entry"]["amount"] == 120 assert body["target_entry"]["balance_after"] == 120 source_balance = await client.get( f"/api/v1/wallets/{source['wallet_id']}/balance", headers=tenant_headers(TENANT_A), ) assert source_balance.json()["balance"] == 180 target_balance = await client.get( f"/api/v1/wallets/{target['wallet_id']}/balance", headers=tenant_headers(TENANT_A), ) assert target_balance.json()["balance"] == 120 events = {e.event_type for e in get_event_publisher().published} assert LoyaltyEventType.WALLET_TRANSFERRED.value in events @pytest.mark.asyncio async def test_transfer_rejects_different_currency(client): source = await _open_wallet(client, suffix="cur1a", currency="IRR") target = await _open_wallet(client, suffix="cur1b", currency="USD") await client.post( f"/api/v1/wallets/{source['wallet_id']}/credit", json={"amount": 500}, headers=tenant_headers(TENANT_A), ) resp = await client.post( f"/api/v1/wallets/{source['wallet_id']}/transfer", json={"target_wallet_id": target["wallet_id"], "amount": 10}, headers=tenant_headers(TENANT_A), ) assert resp.status_code == 422 assert resp.json()["error"]["code"] == "wallet_currency_mismatch" @pytest.mark.asyncio async def test_transfer_rejects_same_wallet(client): ctx = await _open_wallet(client, suffix="same1") await client.post( f"/api/v1/wallets/{ctx['wallet_id']}/credit", json={"amount": 500}, headers=tenant_headers(TENANT_A), ) resp = await client.post( f"/api/v1/wallets/{ctx['wallet_id']}/transfer", json={"target_wallet_id": ctx["wallet_id"], "amount": 10}, headers=tenant_headers(TENANT_A), ) assert resp.status_code == 422 assert resp.json()["error"]["code"] == "wallet_transfer_same_account" @pytest.mark.asyncio async def test_transfer_idempotency_returns_same_entries(client): source = await _open_wallet(client, suffix="idem1a") target = await _open_wallet(client, suffix="idem1b") await client.post( f"/api/v1/wallets/{source['wallet_id']}/credit", json={"amount": 500}, headers=tenant_headers(TENANT_A), ) first = await client.post( f"/api/v1/wallets/{source['wallet_id']}/transfer", json={ "target_wallet_id": target["wallet_id"], "amount": 75, "idempotency_key": "tr-idem-1", }, headers=tenant_headers(TENANT_A), ) assert first.status_code == 200, first.text second = await client.post( f"/api/v1/wallets/{source['wallet_id']}/transfer", json={ "target_wallet_id": target["wallet_id"], "amount": 75, "idempotency_key": "tr-idem-1", }, headers=tenant_headers(TENANT_A), ) assert second.status_code == 200, second.text assert second.json()["source_entry"]["id"] == first.json()["source_entry"]["id"] assert second.json()["target_entry"]["id"] == first.json()["target_entry"]["id"] balance = await client.get( f"/api/v1/wallets/{source['wallet_id']}/balance", headers=tenant_headers(TENANT_A), ) assert balance.json()["balance"] == 425 @pytest.mark.asyncio async def test_frozen_wallet_rejects_mutations(client): ctx = await _open_wallet(client, suffix="frz1") await client.post( f"/api/v1/wallets/{ctx['wallet_id']}/credit", json={"amount": 200}, headers=tenant_headers(TENANT_A), ) frozen = await client.patch( f"/api/v1/wallets/{ctx['wallet_id']}", json={"status": "frozen"}, headers=tenant_headers(TENANT_A), ) assert frozen.status_code == 200, frozen.text assert frozen.json()["status"] == "frozen" resp = await client.post( f"/api/v1/wallets/{ctx['wallet_id']}/credit", json={"amount": 10}, headers=tenant_headers(TENANT_A), ) assert resp.status_code == 409 assert resp.json()["error"]["code"] == "wallet_account_frozen" @pytest.mark.asyncio async def test_closed_wallet_rejects_mutations(client): ctx = await _open_wallet(client, suffix="cls1") await client.post( f"/api/v1/wallets/{ctx['wallet_id']}/credit", json={"amount": 200}, headers=tenant_headers(TENANT_A), ) closed = await client.post( f"/api/v1/wallets/{ctx['wallet_id']}/delete", headers=tenant_headers(TENANT_A), ) assert closed.status_code == 200, closed.text assert closed.json()["status"] == "closed" assert closed.json()["is_deleted"] is True resp = await client.post( f"/api/v1/wallets/{ctx['wallet_id']}/debit", json={"amount": 10}, headers=tenant_headers(TENANT_A), ) assert resp.status_code == 404 events = {e.event_type for e in get_event_publisher().published} assert LoyaltyEventType.WALLET_CLOSED.value in events @pytest.mark.asyncio async def test_duplicate_wallet_for_member_program_rejected(client): ctx = await _open_wallet(client, suffix="dup1") dup = await client.post( "/api/v1/wallets", json={"program_id": ctx["program_id"], "member_id": ctx["member_id"]}, headers=tenant_headers(TENANT_A), ) assert dup.status_code == 409 assert dup.json()["error"]["code"] == "wallet_account_exists" @pytest.mark.asyncio async def test_list_wallets(client): ctx = await _open_wallet(client, suffix="ls1") listed = await client.get("/api/v1/wallets", headers=tenant_headers(TENANT_A)) assert listed.status_code == 200 assert any(w["id"] == ctx["wallet_id"] for w in listed.json())