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
156 lines
5.3 KiB
Python
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
|