"""Prepaid billing (D-B): only the platform owner moves an organization's credit. What these tests defend: * an organization owner and an organization admin (who holds ``billing.manage``) get 403 from ``add-credit`` and from ``settle``, and the wallet does not move * a member keeps reading the billing summary (``billing.view``) and gets 403 on ``add-credit`` * the platform owner's ``add-credit`` moves the wallet and writes one ``adjustment`` history row with the reason ``billing/add-credit``; a clawback that would leave the wallet below zero is refused and changes nothing * the platform owner can settle a pending payment, and a succeeded payment credits the wallet """ from __future__ import annotations from httpx import AsyncClient from app.domain.identity.models import Role from app.infrastructure.db.models import MembershipRow from tests.conftest import auth_header, signup OWNER_ONLY = "اعتبار سازمان را فقط مالک سامانه افزایش می‌دهد." def _payload(name: str, slug: str) -> dict: return { "org_name": name, "slug": slug, "app_name": f"app-{slug}", "app_type": "web_app", "language": "node", "framework": "next", "git_source": "platform", } async def _create_org(client: AsyncClient, token: str, name: str, slug: str) -> str: response = await client.post( "/api/setup/complete", json=_payload(name, slug), headers=auth_header(token) ) assert response.status_code == 201, response.text return response.json()["organization"]["id"] async def _join(session_factory, organization_id: str, user_id: str, role: Role) -> None: async with session_factory() as session: session.add( MembershipRow(organization_id=organization_id, user_id=user_id, role=role.value) ) await session.commit() async def _pending_payment(client: AsyncClient, token: str, organization_id: str) -> str: response = await client.post( f"/api/organizations/{organization_id}/billing/checkout", json={"amount": 30.0}, headers=auth_header(token), ) assert response.status_code == 201, response.text return response.json()["payment_id"] async def test_an_organization_owner_and_admin_cannot_add_credit_or_settle( client, manager_token, session_factory, seeded ): org_id = await _create_org(client, manager_token, "سازمان الف", "bill-owner") admin = await signup(client, "billing-admin@example.com") await _join(session_factory, org_id, admin["user"]["id"], Role.ADMIN) payment_id = await _pending_payment(client, manager_token, org_id) billing = f"/api/organizations/{org_id}/billing" for token in (manager_token, admin["access_token"]): headers = auth_header(token) credit = await client.post(f"{billing}/add-credit", json={"amount": 50.0}, headers=headers) assert credit.status_code == 403, credit.text assert credit.json()["error"]["message"] == OWNER_ONLY settle = await client.post( f"{billing}/payments/{payment_id}/settle", json={"succeeded": True}, headers=headers, ) assert settle.status_code == 403, settle.text assert settle.json()["error"]["message"] == OWNER_ONLY summary = await client.get(f"{billing}/summary", headers=auth_header(manager_token)) assert summary.status_code == 200, summary.text assert summary.json()["balance"] == 0.0 async def test_a_member_reads_the_summary_but_cannot_add_credit( client, manager_token, session_factory, seeded ): org_id = await _create_org(client, manager_token, "سازمان ب", "bill-member") member = await signup(client, "billing-member@example.com") await _join(session_factory, org_id, member["user"]["id"], Role.MEMBER) billing = f"/api/organizations/{org_id}/billing" headers = auth_header(member["access_token"]) summary = await client.get(f"{billing}/summary", headers=headers) assert summary.status_code == 200, summary.text assert summary.json()["balance"] == 0.0 credit = await client.post(f"{billing}/add-credit", json={"amount": 5.0}, headers=headers) assert credit.status_code == 403, credit.text assert credit.json()["error"]["message"] == OWNER_ONLY async def test_the_platform_owner_adds_and_claws_back_credit_as_adjustments( client, platform_owner_token, seeded ): org_id = await _create_org(client, platform_owner_token, "سازمان پ", "bill-platform") owner = auth_header(platform_owner_token) billing = f"/api/organizations/{org_id}/billing" credit = await client.post(f"{billing}/add-credit", json={"amount": 25.0}, headers=owner) assert credit.status_code == 200, credit.text assert credit.json()["balance"] == 25.0 assert credit.json()["low_balance"] is False clawback = await client.post(f"{billing}/add-credit", json={"amount": -10.0}, headers=owner) assert clawback.status_code == 200, clawback.text assert clawback.json()["balance"] == 15.0 refused = await client.post(f"{billing}/add-credit", json={"amount": -50.0}, headers=owner) assert refused.status_code == 422, refused.text assert refused.json()["error"]["message"] == "اعتبار کافی برای این کسر وجود ندارد." zero = await client.post(f"{billing}/add-credit", json={"amount": 0.0}, headers=owner) assert zero.status_code == 422, zero.text # The refused clawback rolled back: the balance is still 15. summary = await client.get(f"{billing}/summary", headers=owner) assert summary.status_code == 200, summary.text assert summary.json()["balance"] == 15.0 history = await client.get(f"/api/admin/orgs/{org_id}/credit-history", headers=owner) assert history.status_code == 200, history.text assert [(h["kind"], h["amount"], h["reason"]) for h in history.json()] == [ ("adjustment", -10.0, "billing/add-credit"), ("adjustment", 25.0, "billing/add-credit"), ] async def test_the_platform_owner_settles_a_payment_and_the_wallet_is_credited( client, platform_owner_token, seeded ): org_id = await _create_org(client, platform_owner_token, "سازمان ت", "bill-settle") owner = auth_header(platform_owner_token) billing = f"/api/organizations/{org_id}/billing" payment_id = await _pending_payment(client, platform_owner_token, org_id) settled = await client.post( f"{billing}/payments/{payment_id}/settle", json={"succeeded": True}, headers=owner ) assert settled.status_code == 200, settled.text assert settled.json()["status"] == "succeeded" summary = await client.get(f"{billing}/summary", headers=owner) assert summary.status_code == 200, summary.text assert summary.json()["balance"] == 30.0