infrasynth-backend-kit/infrasynth/billing/tasks.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

156 lines
5.3 KiB
Python

"""Scheduled billing lifecycle jobs (``ENTITLEMENTS.md`` §5).
All tasks iterate tenants explicitly and bind ``current_tenant`` per row; none
runs "globally" against tenant-owned tables (``TENANCY.md`` §7).
"""
from __future__ import annotations
import logging
from datetime import timedelta
from celery import shared_task
from django.utils import timezone
from infrasynth.shared.enums import EntitlementStatus, SubscriptionStatus
from infrasynth.shared.settings_utils import get_setting
from infrasynth.tenancy.context import tenant_context
logger = logging.getLogger(__name__)
def _with_tenant(tenant):
return tenant_context(tenant)
@shared_task(name="infrasynth.billing.sync_subscriptions")
def sync_subscriptions():
"""Pulls the latest state from each active gateway subscription."""
from .models import Subscription
from .services import BillingService
service = BillingService()
synced = 0
subs = Subscription.all_objects.filter(
status__in=[
SubscriptionStatus.ACTIVE,
SubscriptionStatus.PAST_DUE,
SubscriptionStatus.TRIALING,
],
gateway__isnull=False,
).select_related("gateway", "entitlement")
for sub in subs:
try:
with _with_tenant(sub.tenant):
service.sync_subscription(sub)
synced += 1
except Exception: # noqa: BLE001
logger.exception("Failed to sync subscription %s", sub.pk)
return synced
@shared_task(name="infrasynth.billing.advance_entitlement_lifecycle")
def advance_entitlement_lifecycle():
"""Moves past-due entitlements to suspended once the grace window elapses."""
from infrasynth.tenancy.models import Tenant
from .models import Entitlement
from .services import BillingService
grace_days = int(get_setting("INFRASYNTH_BILLING", "GRACE_PERIOD_DAYS", 5))
max_retries = int(get_setting("INFRASYNTH_BILLING", "MAX_RETRY_FAILED_PAYMENTS", 3))
service = BillingService()
suspended = 0
candidates = Entitlement.all_objects.filter(
status__in=[EntitlementStatus.PAST_DUE, EntitlementStatus.GRACE]
).select_related("tenant", "app")
now = timezone.now()
for entitlement in candidates:
metadata = dict(entitlement.metadata or {})
since_raw = metadata.get("past_due_since")
if not since_raw:
metadata["past_due_since"] = now.isoformat()
entitlement.metadata = metadata
entitlement.save(update_fields=["metadata"])
continue
from datetime import datetime
try:
since = datetime.fromisoformat(since_raw)
except ValueError:
continue
attempts = int(metadata.get("failed_payments", 0))
if now - since < timedelta(days=grace_days) and attempts < max_retries:
continue
with _with_tenant(entitlement.tenant):
service.suspend_entitlement(entitlement)
tenant = entitlement.tenant
if tenant.status != Tenant.Status.SUSPENDED:
tenant.status = Tenant.Status.SUSPENDED
tenant.suspended_at = now
tenant.save(update_fields=["status", "suspended_at"])
suspended += 1
return suspended
@shared_task(name="infrasynth.billing.expire_entitlements")
def expire_entitlements():
"""Expires cancelled-at-period-end entitlements whose period has ended."""
from .models import Entitlement
from .services import EntitlementService
now = timezone.now()
expired = 0
candidates = Entitlement.all_objects.filter(
status__in=[EntitlementStatus.ACTIVE, EntitlementStatus.TRIALING, EntitlementStatus.CANCELLED]
).select_related("tenant", "app")
for entitlement in candidates:
if not entitlement.cancel_at_period_end:
continue
if entitlement.current_period_end and entitlement.current_period_end > now:
continue
entitlement.status = EntitlementStatus.EXPIRED
entitlement.save(update_fields=["status"])
with _with_tenant(entitlement.tenant):
EntitlementService().invalidate(entitlement.tenant, entitlement.app.slug)
expired += 1
return expired
@shared_task(name="infrasynth.billing.generate_renewal_invoices")
def generate_renewal_invoices():
"""Creates an open invoice ahead of each subscription renewal."""
from django.db.models import Q
from infrasynth.shared.enums import InvoiceStatus
from .models import Invoice, Subscription
from .services import BillingService
days_ahead = int(get_setting("INFRASYNTH_BILLING", "INVOICE_GENERATION_DAYS_BEFORE_RENEWAL", 3))
now = timezone.now()
horizon = now + timedelta(days=days_ahead)
service = BillingService()
created = 0
subs = Subscription.all_objects.filter(
status=SubscriptionStatus.ACTIVE,
current_period_end__lte=horizon,
current_period_end__gt=now,
).select_related("tenant", "plan")
for sub in subs:
already = (
Invoice.all_objects.filter(
subscription=sub,
status__in=[InvoiceStatus.DRAFT, InvoiceStatus.OPEN],
)
.filter(Q(due_date__isnull=True) | Q(due_date__gte=now))
.exists()
)
if already:
continue
with _with_tenant(sub.tenant):
service.generate_invoice(sub)
created += 1
return created