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
384 lines
15 KiB
Python
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)
|