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,112 @@
|
||||
"""A settled payment is credited through the credit ledger, so the owner's history shows it.
|
||||
|
||||
What these tests defend:
|
||||
|
||||
* settling a pending payment as succeeded writes exactly one ``adjustment`` history row with the
|
||||
reason ``billing/settle-payment`` and the payment's amount, the wallet goes up by that amount
|
||||
once, and the organization's ``last_credit_at`` is set
|
||||
* settling the same payment again changes nothing: the payment is already final, so neither the
|
||||
wallet nor the history moves
|
||||
* a failed payment credits nothing and writes no history row
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from app.application.container import build_billing_service
|
||||
from app.domain.base import new_id
|
||||
from app.domain.billing.models import CreditKind, Payment, PaymentStatus
|
||||
from app.domain.identity.models import PlatformRole
|
||||
from app.infrastructure.db.models import OrganizationRow, UserRow
|
||||
from app.infrastructure.repositories.billing import CreditWalletRepository, PaymentRepository
|
||||
from app.infrastructure.repositories.credit import CreditLedgerRepository
|
||||
|
||||
SETTLE_REASON = "billing/settle-payment"
|
||||
|
||||
|
||||
async def _org_with_pending_payment(
|
||||
session_factory, slug: str, amount: float
|
||||
) -> tuple[str, str]:
|
||||
"""Create an organization and one pending payment; return (organization id, payment id)."""
|
||||
async with session_factory() as session:
|
||||
user = UserRow(
|
||||
id=new_id(),
|
||||
email=f"{slug}@example.com",
|
||||
password_hash="x",
|
||||
name="t",
|
||||
platform_role=PlatformRole.MANAGER.value,
|
||||
)
|
||||
session.add(user)
|
||||
await session.flush()
|
||||
org = OrganizationRow(id=new_id(), name=slug, slug=slug, owner_user_id=user.id)
|
||||
session.add(org)
|
||||
await session.flush()
|
||||
payment = await PaymentRepository(session).create(
|
||||
Payment(organization_id=org.id, amount=amount)
|
||||
)
|
||||
await session.commit()
|
||||
return org.id, payment.id
|
||||
|
||||
|
||||
async def _settle(
|
||||
session_factory, organization_id: str, payment_id: str, *, succeeded: bool
|
||||
) -> Payment:
|
||||
"""Settle through the service the owner's endpoint calls, in its own transaction."""
|
||||
async with session_factory() as session:
|
||||
payment = await build_billing_service(session).settle_payment(
|
||||
organization_id=organization_id, payment_id=payment_id, succeeded=succeeded
|
||||
)
|
||||
await session.commit()
|
||||
return payment
|
||||
|
||||
|
||||
async def _balance_and_history(session_factory, organization_id: str):
|
||||
"""The wallet balance and every credit history row of one organization."""
|
||||
async with session_factory() as session:
|
||||
wallet = await CreditWalletRepository(session).get_for_org(organization_id)
|
||||
history = await CreditLedgerRepository(session).history(organization_id)
|
||||
return (wallet.balance if wallet else 0.0), history
|
||||
|
||||
|
||||
async def test_a_settled_payment_writes_one_adjustment_row_and_credits_once(session_factory):
|
||||
organization_id, payment_id = await _org_with_pending_payment(
|
||||
session_factory, "settle-once", 30.0
|
||||
)
|
||||
|
||||
payment = await _settle(session_factory, organization_id, payment_id, succeeded=True)
|
||||
|
||||
assert payment.status == PaymentStatus.SUCCEEDED
|
||||
balance, history = await _balance_and_history(session_factory, organization_id)
|
||||
assert balance == 30.0
|
||||
assert [(t.kind, t.amount, t.balance_after, t.reason) for t in history] == [
|
||||
(CreditKind.ADJUSTMENT, 30.0, 30.0, SETTLE_REASON)
|
||||
]
|
||||
async with session_factory() as session:
|
||||
last_credit_at = await CreditLedgerRepository(session).last_credit_at_by_org()
|
||||
assert organization_id in last_credit_at
|
||||
|
||||
|
||||
async def test_settling_the_same_payment_twice_does_not_credit_twice(session_factory):
|
||||
organization_id, payment_id = await _org_with_pending_payment(
|
||||
session_factory, "settle-twice", 30.0
|
||||
)
|
||||
await _settle(session_factory, organization_id, payment_id, succeeded=True)
|
||||
|
||||
again = await _settle(session_factory, organization_id, payment_id, succeeded=True)
|
||||
|
||||
assert again.status == PaymentStatus.SUCCEEDED
|
||||
balance, history = await _balance_and_history(session_factory, organization_id)
|
||||
assert balance == 30.0
|
||||
assert len(history) == 1
|
||||
|
||||
|
||||
async def test_a_failed_payment_credits_nothing(session_factory):
|
||||
organization_id, payment_id = await _org_with_pending_payment(
|
||||
session_factory, "settle-failed", 30.0
|
||||
)
|
||||
|
||||
failed = await _settle(session_factory, organization_id, payment_id, succeeded=False)
|
||||
|
||||
assert failed.status == PaymentStatus.FAILED
|
||||
balance, history = await _balance_and_history(session_factory, organization_id)
|
||||
assert balance == 0.0
|
||||
assert history == []
|
||||
Reference in New Issue
Block a user