"""Billing service: checkouts, subscriptions, invoices (``ENTITLEMENTS.md``).""" from __future__ import annotations import logging from datetime import timedelta from django.utils import timezone from django.utils.module_loading import import_string from infrasynth.shared.enums import EntitlementStatus, InvoiceStatus, SubscriptionStatus from infrasynth.shared.settings_utils import get_setting from .entitlements import EntitlementService from .models import App, Entitlement, Invoice, PaymentGateway, PaymentTransaction, Plan, Subscription from .signals import ( entitlement_changed, invoice_generated, subscription_cancelled, subscription_created, ) logger = logging.getLogger(__name__) __all__ = ["BillingService", "EntitlementService"] class BillingService: """Public API for plans, checkouts, subscriptions, and invoices.""" def create_checkout_session( self, plan: Plan, tenant, user=None, success_url: str | None = None, cancel_url: str | None = None, ): """Creates a checkout session in the plan's payment gateway.""" gateway = plan.gateway or PaymentGateway.objects.filter(is_active=True).first() if gateway is None: raise ValueError("No active payment gateway configured for this plan.") gateway_instance = self._get_gateway(gateway) return gateway_instance.create_checkout_session( plan, user, tenant=tenant, success_url=success_url, cancel_url=cancel_url, ), gateway def create_subscription( self, plan: Plan, tenant, gateway: PaymentGateway | None, external_id: str = "", ) -> Subscription: """Creates a subscription record (and its entitlement) after checkout.""" now = timezone.now() interval_days = 365 if plan.interval == "yearly" else 30 trial_end = now + timedelta(days=plan.trial_days) if plan.trial_days else None subscription = Subscription.all_objects.create( tenant=tenant, plan=plan, gateway=gateway, external_id=external_id, status=(SubscriptionStatus.TRIALING if trial_end and trial_end > now else SubscriptionStatus.ACTIVE), current_period_start=now, current_period_end=now + timedelta(days=interval_days), trial_end=trial_end, ) entitlement, _ = Entitlement.all_objects.get_or_create( tenant=tenant, app=plan.app, defaults={ "plan": plan, "status": EntitlementStatus.TRIALING if trial_end else EntitlementStatus.ACTIVE, "current_period_end": subscription.current_period_end, "source": gateway.slug if gateway else "manual", }, ) entitlement.plan = plan entitlement.status = EntitlementStatus.TRIALING if trial_end and trial_end > now else EntitlementStatus.ACTIVE entitlement.current_period_end = subscription.current_period_end entitlement.save() subscription.entitlement = entitlement subscription.save(update_fields=["entitlement"]) EntitlementService().invalidate(tenant, plan.app.slug) entitlement_changed.send( sender=Entitlement, tenant_id=str(tenant.pk), app=plan.app.slug, status=entitlement.status, ) subscription_created.send( sender=Subscription, tenant_id=str(tenant.pk), plan_slug=plan.slug, app_slug=plan.app.slug, gateway=gateway.slug if gateway else "", external_id=external_id, ) return subscription def cancel_subscription(self, subscription: Subscription) -> bool: gateway = subscription.gateway if gateway: try: gateway_instance = self._get_gateway(gateway) gateway_instance.cancel_subscription(subscription) except Exception: # noqa: BLE001 logger.exception("Gateway cancellation failed for subscription %s", subscription.pk) subscription.status = SubscriptionStatus.CANCELLED subscription.cancelled_at = timezone.now() subscription.cancel_at_period_end = False subscription.save() if subscription.entitlement_id: entitlement = subscription.entitlement if entitlement is not None: entitlement.status = EntitlementStatus.CANCELLED entitlement.save(update_fields=["status"]) EntitlementService().invalidate(subscription.tenant, entitlement.app.slug) subscription_cancelled.send( sender=Subscription, tenant_id=str(subscription.tenant_id), plan_slug=subscription.plan.slug if subscription.plan else "", reason="user_requested", ) return True def sync_subscription(self, subscription: Subscription) -> dict: gateway = subscription.gateway if gateway is None: return {} gateway_instance = self._get_gateway(gateway) data = gateway_instance.sync_subscription(subscription) for field in ( "status", "current_period_start", "current_period_end", "cancel_at_period_end", "cancelled_at", "trial_end", "metadata", ): if field in data and data[field] is not None: setattr(subscription, field, data[field]) subscription.save() return data def generate_invoice( self, subscription: Subscription, amount: int | None = None, line_items: list | None = None, ) -> Invoice: """Creates an invoice (minor units) and triggers PDF generation.""" plan = subscription.plan currency = plan.price_currency if plan else get_setting("INFRASYNTH_BILLING", "DEFAULT_CURRENCY", "USD") if amount is None: amount = plan.price_amount if plan else 0 amount = int(amount) tax_percentage = get_setting("INFRASYNTH_BILLING", "TAX_PERCENTAGE", 0) tax_name = get_setting("INFRASYNTH_BILLING", "TAX_NAME", "") tax_amount = int(amount * (int(tax_percentage) / 100)) invoice = Invoice.all_objects.create( tenant=subscription.tenant, subscription=subscription, gateway=subscription.gateway, invoice_number=self._next_invoice_number(subscription.tenant), amount=amount, currency=currency, tax_amount=tax_amount, tax_name=tax_name, status=InvoiceStatus.OPEN, due_date=timezone.now() + timedelta(days=get_setting("INFRASYNTH_BILLING", "GRACE_PERIOD_DAYS", 5)), line_items=line_items or [ { "description": plan.name if plan else "Subscription", "amount": amount, "quantity": 1, } ], ) invoice_generated.send( sender=Invoice, tenant_id=str(subscription.tenant_id), invoice_id=invoice.id, amount=invoice.amount, ) from .invoice_generator import generate_invoice_pdf generate_invoice_pdf.delay(invoice.id, str(subscription.tenant_id)) return invoice # --- entitlement lifecycle (ENTITLEMENTS.md §5) ------------------------- def mark_past_due(self, entitlement: Entitlement) -> None: entitlement.status = EntitlementStatus.PAST_DUE metadata = dict(entitlement.metadata or {}) metadata.setdefault("past_due_since", timezone.now().isoformat()) entitlement.metadata = metadata entitlement.save(update_fields=["status", "metadata"]) EntitlementService().invalidate(entitlement.tenant, entitlement.app.slug) def enter_grace(self, entitlement: Entitlement) -> None: entitlement.status = EntitlementStatus.GRACE entitlement.save(update_fields=["status"]) EntitlementService().invalidate(entitlement.tenant, entitlement.app.slug) def suspend_entitlement(self, entitlement: Entitlement) -> None: entitlement.status = EntitlementStatus.SUSPENDED entitlement.save(update_fields=["status"]) EntitlementService().invalidate(entitlement.tenant, entitlement.app.slug) def reinstate_entitlement(self, entitlement: Entitlement) -> None: entitlement.status = EntitlementStatus.ACTIVE entitlement.save(update_fields=["status"]) EntitlementService().invalidate(entitlement.tenant, entitlement.app.slug) # --- helpers ------------------------------------------------------------ def _next_invoice_number(self, tenant) -> str: prefix = get_setting("INFRASYNTH_BILLING", "INVOICE_NUMBER_PREFIX", "INV-") year = timezone.now().year full_prefix = f"{prefix}{year}-" count = Invoice.all_objects.filter(tenant=tenant, invoice_number__startswith=full_prefix).count() return f"{full_prefix}{count + 1:06d}" def _get_gateway(self, gateway: PaymentGateway): gateway_cls = import_string(gateway.gateway_class) return gateway_cls(gateway.config) # --- inbound webhooks (ENTITLEMENTS.md §6) ------------------------------ def process_webhook_event(self, gateway, event_type: str, data: dict, *, event_id: str | None = None) -> dict: """Idempotently applies a verified provider event to billing state. Returns a small result dict describing what happened. Provider payloads differ, so this extracts the common fields heuristically; a provider can pre-normalize in its ``handle_webhook``. """ from django.core.cache import cache from infrasynth.tenancy.models import Tenant from .signals import payment_failed, payment_succeeded data = data if isinstance(data, dict) else {} tenant_id = self._extract_tenant_id(data) tenant = Tenant.objects.filter(pk=tenant_id).first() if tenant_id else None if event_id: idem_key = f"tenant:{tenant_id or 'unknown'}:billing:webhook:{event_id}" if cache.get(idem_key): return {"status": "duplicate", "event_id": event_id} cache.set(idem_key, True, 60 * 60 * 24) lower = event_type.lower() is_paid = any(token in lower for token in ("paid", "succeeded", "approved", "activated")) is_failed = any(token in lower for token in ("failed", "declined", "past_due", "rejected")) subscription = self._find_subscription(data, tenant) entitlement = subscription.entitlement if subscription and subscription.entitlement_id else None if entitlement is None and tenant is not None: entitlement = Entitlement.all_objects.filter(tenant=tenant).order_by("-id").first() invoice = self._find_invoice(data, tenant) amount, currency = self._extract_amount(data) transaction = None if tenant is not None: transaction = PaymentTransaction.all_objects.create( tenant=tenant, gateway=gateway, invoice=invoice, external_id=str(data.get("id") or data.get("payment_id") or ""), amount=amount, currency=currency, status=event_type, metadata=data, ) result: dict = {"status": "processed", "event_type": event_type} if is_paid and entitlement is not None: entitlement.status = EntitlementStatus.ACTIVE entitlement.metadata = {**(entitlement.metadata or {}), "failed_payments": 0} entitlement.save(update_fields=["status", "metadata"]) EntitlementService().invalidate(tenant, entitlement.app.slug) if tenant is not None and tenant.status != Tenant.Status.ACTIVE: tenant.status = Tenant.Status.ACTIVE tenant.suspended_at = None tenant.save(update_fields=["status", "suspended_at"]) if invoice is not None and invoice.status != InvoiceStatus.PAID: invoice.status = InvoiceStatus.PAID invoice.paid_at = timezone.now() invoice.save(update_fields=["status", "paid_at"]) payment_succeeded.send( sender=PaymentTransaction, tenant_id=str(tenant.pk) if tenant else None, invoice_id=invoice.id if invoice else None, amount=amount, ) result["action"] = "reinstated" elif is_failed and entitlement is not None: metadata = dict(entitlement.metadata or {}) metadata["failed_payments"] = int(metadata.get("failed_payments", 0)) + 1 entitlement.status = EntitlementStatus.PAST_DUE entitlement.metadata = metadata entitlement.save(update_fields=["status", "metadata"]) EntitlementService().invalidate(tenant, entitlement.app.slug) payment_failed.send( sender=PaymentTransaction, tenant_id=str(tenant.pk) if tenant else None, invoice_id=invoice.id if invoice else None, error=event_type, ) result["action"] = "past_due" if transaction is not None: result["transaction_id"] = transaction.id return result @staticmethod def _extract_tenant_id(data: dict): metadata = data.get("metadata") or {} if isinstance(metadata, dict) and metadata.get("tenant_id"): return metadata["tenant_id"] return data.get("tenant_id") or data.get("tenantId") @staticmethod def _find_subscription(data: dict, tenant): external_id = data.get("subscription") or data.get("preapproval_id") or data.get("subscription_id") if external_id: found = Subscription.all_objects.filter(external_id=str(external_id)).first() if found is not None: return found if tenant is not None: return Subscription.all_objects.filter(tenant=tenant).order_by("-id").first() return None @staticmethod def _find_invoice(data: dict, tenant): external_id = data.get("invoice") or data.get("invoice_id") if external_id: found = Invoice.all_objects.filter(external_id=str(external_id)).first() if found is not None: return found if tenant is not None: return Invoice.all_objects.filter(tenant=tenant, status=InvoiceStatus.OPEN).order_by("-id").first() return None @staticmethod def _extract_amount(data: dict) -> tuple[int, str]: raw = ( data.get("amount") or data.get("amount_paid") or data.get("amount_in_cents") or data.get("transaction_amount") or 0 ) try: amount = int(raw) except (TypeError, ValueError): amount = 0 currency = str(data.get("currency") or data.get("currency_id") or "USD").upper() return amount, currency def get_app(app_slug: str) -> App: return App.objects.get(slug=app_slug, is_active=True)