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
This commit is contained in:
@@ -0,0 +1,161 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user