نسخهٔ وب مستقل — پلتفرم نیروی کار هوش مصنوعی
مخزن اختصاصی برنامهٔ وب؛ محتوای پوشهٔ web در ریشهٔ مخزن قرار گرفت. تاریخچهٔ پروژهٔ ربات (agy-bot) در این مخزن وجود ندارد و هیچ وابستگی runtime به آن نیست.
This commit is contained in:
@@ -0,0 +1,184 @@
|
||||
"""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.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("اعتبار کافی برای این کسر وجود ندارد.")
|
||||
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:
|
||||
await self.wallets.adjust(organization_id=organization_id, delta=payment.amount)
|
||||
final = await self.payments.set_status(payment_id, PaymentStatus.SUCCEEDED)
|
||||
else:
|
||||
final = await self.payments.set_status(payment_id, PaymentStatus.FAILED)
|
||||
return final or payment
|
||||
Reference in New Issue
Block a user