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:
mjalmoudy94
2026-10-14 23:19:05 +03:30
parent fef5a9deab
commit d532929189
117 changed files with 13119 additions and 640 deletions
+236
View File
@@ -0,0 +1,236 @@
"""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