infrasynth-backend-kit/infrasynth/billing/services.py
jcv-dev 551b42eab5 feat: production-hardening pass across the kit
Close the gaps between the documented contract (API-STANDARD, TENANCY,
ENTITLEMENTS) and the implementation, and remove committed build artifacts.

Security:
- verify + process inbound webhooks (HMAC/handler verify, size limit,
  timestamp tolerance, idempotency via InboundEvent.external_id)
- real 2FA login flow (pre-auth challenge; tokens only after verify/recovery)
- wire HybridPermission into security/audit views; add API-key rotate and
  users/<id>/permissions|roles endpoints
- tenant-scoped throttling on by default; webhook replay protection
- verify MercadoPago webhook signatures
- login brute-force guard, configurable password policy, real ALTCHA PoW

Correctness:
- apply verified billing webhooks idempotently (subscription/entitlement/
  invoice/PaymentTransaction); scheduled payment lifecycle jobs
- capture audit update diffs automatically; add audit retention purge
- working notification retries, per-channel rate limits, log retention
- pluggable virus scanner, upload-size limit, pipeline toggle
- feature rollout %/environment targeting; settings-driven registrations
- workflow guards (instance cap, route depth, self-assignment, clone on re-entry)
- wire every previously-dead INFRASYNTH_* setting; drop truly dead ones

Delivery:
- README + CHANGELOG; CI format check + coverage gate
- keep test media out of the tree; untrack .coverage, __pycache__,
  egg-info, docs/ and invoice artifacts
2026-09-24 10:41:21 -05:00

384 lines
15 KiB
Python

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