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