- 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
207 lines
7.9 KiB
Python
207 lines
7.9 KiB
Python
"""Billing use cases.
|
|
|
|
Design rule, stated once and enforced here: **the internal usage ledger is the
|
|
source of truth.** A payment provider only moves money; it never decides what a
|
|
tenant owes. That is why ``summary`` computes totals from ``UsageLedgerRepository``
|
|
and not from any external invoice.
|
|
|
|
Credit is spent, not checked-then-spent: the debit is a single adjustment, so two
|
|
concurrent runs cannot both pass a stale affordability check.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import datetime
|
|
|
|
from app.application.ports import PaymentProviderPort
|
|
from app.application.ports.realtime import emit_wallet_changed
|
|
from app.core.errors import NotFoundError, PermissionDeniedError, ValidationError
|
|
from app.domain.billing.models import (
|
|
CreditKind,
|
|
CreditWallet,
|
|
Invoice,
|
|
Payment,
|
|
PaymentStatus,
|
|
Subscription,
|
|
)
|
|
from app.infrastructure.repositories.audit import UsageLedgerRepository
|
|
from app.infrastructure.repositories.billing import (
|
|
CreditWalletRepository,
|
|
InvoiceRepository,
|
|
PaymentRepository,
|
|
PlanRepository,
|
|
SubscriptionRepository,
|
|
)
|
|
from app.infrastructure.repositories.credit import CreditLedgerRepository
|
|
|
|
#: A tenant may not buy an arbitrary amount of credit in one action.
|
|
MIN_TOPUP = 1.0
|
|
MAX_TOPUP = 10_000.0
|
|
|
|
|
|
class BillingService:
|
|
def __init__(
|
|
self,
|
|
*,
|
|
wallets: CreditWalletRepository,
|
|
invoices: InvoiceRepository,
|
|
payments: PaymentRepository,
|
|
plans: PlanRepository,
|
|
subscriptions: SubscriptionRepository,
|
|
ledger: UsageLedgerRepository,
|
|
payment_provider: PaymentProviderPort,
|
|
) -> None:
|
|
self.wallets = wallets
|
|
self.invoices = invoices
|
|
self.payments = payments
|
|
self.plans = plans
|
|
self.subscriptions = subscriptions
|
|
self.ledger = ledger
|
|
self.payment_provider = payment_provider
|
|
# Settled payments are credited through the credit ledger, in the wallets' session.
|
|
self.credit_ledger = CreditLedgerRepository(wallets.session)
|
|
|
|
# -- reads ---------------------------------------------------------------
|
|
|
|
async def wallet_for(self, organization_id: str) -> CreditWallet:
|
|
return await self.wallets.ensure(organization_id)
|
|
|
|
async def summary(
|
|
self,
|
|
*,
|
|
organization_id: str,
|
|
since: datetime | None = None,
|
|
until: datetime | None = None,
|
|
) -> dict:
|
|
wallet = await self.wallets.ensure(organization_id)
|
|
breakdown = await self.ledger.breakdown(
|
|
organization_id=organization_id, since=since, until=until
|
|
)
|
|
return {
|
|
"balance": wallet.balance,
|
|
"currency": wallet.currency,
|
|
"low_balance": wallet.is_low,
|
|
"low_balance_threshold": wallet.low_balance_threshold,
|
|
"total_spend": breakdown.total_price,
|
|
"by_unit": breakdown.by_unit,
|
|
"by_employee": breakdown.by_employee,
|
|
"by_project": breakdown.by_project,
|
|
}
|
|
|
|
async def list_usage_events(
|
|
self, *, organization_id: str, limit: int = 100, offset: int = 0
|
|
) -> list:
|
|
return await self.ledger.list_events(
|
|
organization_id=organization_id, limit=limit, offset=offset
|
|
)
|
|
|
|
async def list_invoices(self, organization_id: str) -> list[Invoice]:
|
|
return await self.invoices.list_for_org(organization_id)
|
|
|
|
async def list_payments(self, organization_id: str) -> list[Payment]:
|
|
return await self.payments.list_for_org(organization_id)
|
|
|
|
async def current_subscription(self, organization_id: str) -> Subscription | None:
|
|
return await self.subscriptions.get_for_org(organization_id)
|
|
|
|
# -- writes --------------------------------------------------------------
|
|
|
|
async def add_credit(
|
|
self, *, organization_id: str, amount: float, allow_negative: bool = False
|
|
) -> CreditWallet:
|
|
"""Grant (or, for a platform owner, claw back) credit.
|
|
|
|
Superseded by the owner-only ``CreditService``, which writes the credit ledger.
|
|
|
|
``allow_negative`` is reserved for the platform owner console; an org
|
|
admin may only ever add credit.
|
|
"""
|
|
if amount == 0:
|
|
raise ValidationError("مقدار اعتبار نمیتواند صفر باشد.")
|
|
if amount < 0 and not allow_negative:
|
|
raise PermissionDeniedError("کسر اعتبار فقط توسط مالک سامانه انجام میشود.")
|
|
if not allow_negative and amount > MAX_TOPUP:
|
|
raise ValidationError(f"حداکثر افزایش اعتبار در هر عملیات {MAX_TOPUP} است.")
|
|
|
|
wallet = await self.wallets.adjust(organization_id=organization_id, delta=amount)
|
|
if wallet.balance < 0:
|
|
# Refuse to leave a wallet in debt; undo and surface the conflict.
|
|
await self.wallets.adjust(organization_id=organization_id, delta=-amount)
|
|
raise ValidationError("اعتبار کافی برای این کسر وجود ندارد.")
|
|
# Live push: the sidebar credit badge and the billing page follow the
|
|
# wallet as it moves, whoever moved it.
|
|
await emit_wallet_changed(
|
|
organization_id=organization_id, balance=wallet.balance
|
|
)
|
|
return wallet
|
|
|
|
async def start_checkout(
|
|
self,
|
|
*,
|
|
organization_id: str,
|
|
amount: float,
|
|
success_url: str,
|
|
cancel_url: str,
|
|
) -> tuple[Payment, str]:
|
|
"""Create a pending payment plus the provider's hosted checkout URL."""
|
|
if amount < MIN_TOPUP:
|
|
raise ValidationError(f"حداقل مبلغ شارژ {MIN_TOPUP} است.")
|
|
if amount > MAX_TOPUP:
|
|
raise ValidationError(f"حداکثر مبلغ شارژ {MAX_TOPUP} است.")
|
|
|
|
wallet = await self.wallets.ensure(organization_id)
|
|
payment = await self.payments.create(
|
|
Payment(
|
|
organization_id=organization_id,
|
|
amount=amount,
|
|
status=PaymentStatus.PENDING,
|
|
provider="mock",
|
|
)
|
|
)
|
|
session = await self.payment_provider.create_checkout(
|
|
organization_id=organization_id,
|
|
amount=amount,
|
|
currency=wallet.currency,
|
|
success_url=success_url,
|
|
cancel_url=cancel_url,
|
|
)
|
|
updated = await self.payments.set_status(
|
|
payment.id, PaymentStatus.PENDING, external_id=session.external_id
|
|
)
|
|
return (updated or payment), session.url
|
|
|
|
async def settle_payment(
|
|
self, *, organization_id: str, payment_id: str, succeeded: bool
|
|
) -> Payment:
|
|
"""Mark a payment final and credit the wallet when it succeeded.
|
|
|
|
This is the only path that turns money into credit, so it is idempotent
|
|
with respect to status: a payment that is already final is returned
|
|
untouched rather than credited twice. The credit is one ``adjustment`` row in
|
|
the credit ledger, written in this session, so it shows in the owner's credit
|
|
history and updates ``last_credit_at``.
|
|
"""
|
|
payment = next(
|
|
(p for p in await self.payments.list_for_org(organization_id) if p.id == payment_id),
|
|
None,
|
|
)
|
|
if payment is None:
|
|
raise NotFoundError("پرداخت یافت نشد.")
|
|
if payment.status != PaymentStatus.PENDING:
|
|
return payment
|
|
|
|
if succeeded:
|
|
credit = await self.credit_ledger.apply(
|
|
organization_id=organization_id,
|
|
amount=payment.amount,
|
|
kind=CreditKind.ADJUSTMENT,
|
|
reason="billing/settle-payment",
|
|
)
|
|
final = await self.payments.set_status(payment_id, PaymentStatus.SUCCEEDED)
|
|
await emit_wallet_changed(
|
|
organization_id=organization_id, balance=credit.balance_after
|
|
)
|
|
else:
|
|
final = await self.payments.set_status(payment_id, PaymentStatus.FAILED)
|
|
return final or payment
|