Files
smart-org/apps/api/tests/test_billing_prepaid.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

162 lines
6.7 KiB
Python

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