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,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
|
||||
Reference in New Issue
Block a user