- Backend: ConnectionManager, Redis-backed bus with in-process fallback, channel authorization, and typed emit helpers wired into workers (ai_runtime, sandbox_gateway), workforce/sandbox/billing/audit/error services. - Frontend: single shared WS client with backoff + heartbeat, RealtimeProvider, useLiveChannel/useLiveEvent hooks, live indicators on task detail, manager chat, dashboard, traces, sandbox terminal, admin errors, and billing. - Contracts: WsEvent/WsServerFrame/WsClientFrame types mirroring the port. All 255 backend tests pass; web typecheck and build pass.
196 lines
7.3 KiB
Python
196 lines
7.3 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 (
|
|
CreditWallet,
|
|
Invoice,
|
|
Payment,
|
|
PaymentStatus,
|
|
Subscription,
|
|
)
|
|
from app.infrastructure.repositories.audit import UsageLedgerRepository
|
|
from app.infrastructure.repositories.billing import (
|
|
CreditWalletRepository,
|
|
InvoiceRepository,
|
|
PaymentRepository,
|
|
PlanRepository,
|
|
SubscriptionRepository,
|
|
)
|
|
|
|
#: 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
|
|
|
|
# -- 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.
|
|
|
|
``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.
|
|
"""
|
|
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:
|
|
wallet = await self.wallets.adjust(
|
|
organization_id=organization_id, delta=payment.amount
|
|
)
|
|
final = await self.payments.set_status(payment_id, PaymentStatus.SUCCEEDED)
|
|
await emit_wallet_changed(
|
|
organization_id=organization_id, balance=wallet.balance
|
|
)
|
|
else:
|
|
final = await self.payments.set_status(payment_id, PaymentStatus.FAILED)
|
|
return final or payment
|