"""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