- 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
137 lines
5.5 KiB
Python
137 lines
5.5 KiB
Python
"""Prepaid credit: the platform owner's top-ups, the history and the balances.
|
|
|
|
An organization's wallet is only ever moved through ``CreditLedgerRepository``, so every
|
|
change has a history row. This service is the owner's side of that ledger: it credits one
|
|
organization by hand, reads its manual history, lists every organization's balance for the
|
|
owner's credit page, and grants the optional starting credit to a new organization. Usage
|
|
debits are not written here; ``UsageLedgerRepository.record`` writes them.
|
|
|
|
Only the platform owner may call the write methods and the cross-organization ``balances``.
|
|
The routers enforce that; this service does not look at who is calling.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
from datetime import datetime
|
|
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.application.ports.realtime import emit_wallet_changed
|
|
from app.core.config import settings
|
|
from app.core.errors import NotFoundError, ValidationError
|
|
from app.domain.billing.models import CreditKind, CreditTransaction
|
|
from app.domain.workforce.pipeline import Stage
|
|
from app.infrastructure.repositories.billing import CreditWalletRepository
|
|
from app.infrastructure.repositories.credit import CreditLedgerRepository
|
|
from app.infrastructure.repositories.engine import PipelineStateRepository
|
|
from app.infrastructure.repositories.identity import OrganizationRepository
|
|
|
|
#: The most one owner action may credit at once. A bigger top-up is made in steps.
|
|
MAX_OWNER_CREDIT = 100_000.0
|
|
|
|
#: The reason is required and stored with the history row (at most 500 characters).
|
|
_REASON_MIN = 3
|
|
_REASON_MAX = 500
|
|
|
|
#: The entries a history page shows: what someone did to the wallet, not usage debits.
|
|
_MANUAL_KINDS = (CreditKind.OWNER_CREDIT, CreditKind.STARTING_CREDIT, CreditKind.ADJUSTMENT)
|
|
|
|
|
|
@dataclass(slots=True, kw_only=True)
|
|
class OrgBalance:
|
|
"""One organization's row on the platform owner's credit page."""
|
|
|
|
organization_id: str
|
|
name: str
|
|
slug: str
|
|
balance: float
|
|
currency: str
|
|
paused_tasks: int
|
|
last_credit_at: datetime | None
|
|
|
|
|
|
class CreditService:
|
|
def __init__(self, session: AsyncSession) -> None:
|
|
self.session = session
|
|
self.ledger = CreditLedgerRepository(session)
|
|
self.wallets = CreditWalletRepository(session)
|
|
self.orgs = OrganizationRepository(session)
|
|
self.states = PipelineStateRepository(session)
|
|
|
|
async def owner_credit(
|
|
self,
|
|
*,
|
|
organization_id: str,
|
|
amount: float,
|
|
reason: str,
|
|
actor_user_id: str,
|
|
) -> CreditTransaction:
|
|
"""Credit one organization's wallet on the platform owner's word."""
|
|
if await self.orgs.get(organization_id) is None:
|
|
raise NotFoundError("سازمان یافت نشد.")
|
|
if not 0 < amount <= MAX_OWNER_CREDIT:
|
|
raise ValidationError(
|
|
f"مقدار اعتبار باید بیشتر از صفر و حداکثر {MAX_OWNER_CREDIT:g} باشد."
|
|
)
|
|
clean_reason = reason.strip()
|
|
if not _REASON_MIN <= len(clean_reason) <= _REASON_MAX:
|
|
raise ValidationError("دلیل افزودن اعتبار لازم است.")
|
|
|
|
transaction = await self.ledger.apply(
|
|
organization_id=organization_id,
|
|
amount=amount,
|
|
kind=CreditKind.OWNER_CREDIT,
|
|
reason=clean_reason,
|
|
actor_user_id=actor_user_id,
|
|
)
|
|
# Live push: the sidebar credit badge follows the wallet as it moves.
|
|
await emit_wallet_changed(
|
|
organization_id=organization_id, balance=transaction.balance_after
|
|
)
|
|
return transaction
|
|
|
|
async def history(self, organization_id: str, *, limit: int = 50) -> list[CreditTransaction]:
|
|
"""One organization's manual credit history, newest first. Usage debits are left out."""
|
|
return await self.ledger.history(organization_id, kinds=_MANUAL_KINDS, limit=limit)
|
|
|
|
async def balances(self) -> list[OrgBalance]:
|
|
"""Every organization's balance, paused task count and last credit date.
|
|
|
|
Cross-organization on purpose: only the platform owner's credit page may call it.
|
|
A missing wallet is shown as zero.
|
|
"""
|
|
paused = await self.states.count_at_by_org(Stage.PAUSED)
|
|
last_credit = await self.ledger.last_credit_at_by_org()
|
|
rows: list[OrgBalance] = []
|
|
for org in await self.orgs.list_all():
|
|
wallet = await self.wallets.get_for_org(org.id)
|
|
rows.append(
|
|
OrgBalance(
|
|
organization_id=org.id,
|
|
name=org.name,
|
|
slug=org.slug,
|
|
balance=wallet.balance if wallet else 0.0,
|
|
currency=wallet.currency if wallet else "USD",
|
|
paused_tasks=paused.get(org.id, 0),
|
|
last_credit_at=last_credit.get(org.id),
|
|
)
|
|
)
|
|
return rows
|
|
|
|
async def grant_starting_credit(self, organization_id: str) -> CreditTransaction | None:
|
|
"""Give a new organization the configured starting credit, if one is set.
|
|
|
|
``BILLING_STARTING_CREDIT`` defaults to 0, so by default nothing is granted and the
|
|
owner credits new organizations by hand.
|
|
"""
|
|
amount = float(getattr(settings, "billing_starting_credit", 0.0))
|
|
if amount <= 0:
|
|
return None
|
|
return await self.ledger.apply(
|
|
organization_id=organization_id,
|
|
amount=amount,
|
|
kind=CreditKind.STARTING_CREDIT,
|
|
reason="اعتبار آغازین",
|
|
)
|