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

101 lines
3.9 KiB
Python

"""``EntitlementService`` — in-process commercial enforcement (``ENTITLEMENTS.md`` §4)."""
from __future__ import annotations
from typing import Any
from django.core.cache import cache
from infrasynth.shared.settings_utils import get_setting
from .models import Entitlement
__all__ = ["EntitlementService"]
_ENTITLED_STATUSES = {"trialing", "active", "past_due", "grace"}
_TENANT_OK_STATUSES = {"active", "trialing"}
class EntitlementService:
"""The only gate for commercial access. Feature flags never replace it."""
def __init__(self) -> None:
self.ttl = int(get_setting("INFRASYNTH_BILLING", "ENTITLEMENT_CACHE_TTL_SECONDS", 60))
def _cache_key(self, tenant: Any, app_slug: str) -> str:
return f"tenant:{tenant.pk}:entitlements:{app_slug}"
def get(self, tenant: Any, app_slug: str) -> Entitlement | None:
if tenant is None:
return None
key = self._cache_key(tenant, app_slug)
cached = cache.get(key)
if cached is not None:
return cached if isinstance(cached, Entitlement) else None
entitlement = (
Entitlement.all_objects.filter(tenant=tenant, app__slug=app_slug).select_related("plan", "app").first()
)
cache.set(key, entitlement, self.ttl)
return entitlement
def is_entitled(self, tenant: Any, app_slug: str, *, feature: str | None = None) -> bool:
if tenant is None:
return False
# Tenant status gates before entitlement state (ENTITLEMENTS.md §4).
if getattr(tenant, "status", None) not in _TENANT_OK_STATUSES:
return False
entitlement = self.get(tenant, app_slug)
if entitlement is None:
return False
if entitlement.status not in _ENTITLED_STATUSES:
return False
if feature is None:
return True
return bool(self._features(entitlement).get(feature, False))
@staticmethod
def _features(entitlement: Entitlement) -> dict:
"""``plan.features`` merged under any entitlement-level override."""
features: dict = {}
if entitlement.plan:
features.update(entitlement.plan.features or {})
override = (entitlement.metadata or {}).get("features")
if isinstance(override, dict):
features.update(override)
return features
def check_limit(self, tenant: Any, app_slug: str, limit: str, current: int) -> bool:
"""True when ``current`` is within the plan's ``limit`` (None = unlimited)."""
entitlement = self.get(tenant, app_slug)
if entitlement is None or entitlement.plan is None:
return False
max_value = (entitlement.plan.limits or {}).get(limit)
if max_value is None:
return True
return current < int(max_value)
def require_limit(self, tenant: Any, app_slug: str, limit: str, current: int) -> None:
"""Raises :class:`EntitlementError` when the limit would be exceeded."""
if self.check_limit(tenant, app_slug, limit, current):
return
from infrasynth.shared.exceptions import ENTITLEMENT_LIMIT_REACHED, EntitlementError
entitlement = self.get(tenant, app_slug)
max_value = (entitlement.plan.limits or {}).get(limit) if entitlement and entitlement.plan else None
raise EntitlementError(
code=ENTITLEMENT_LIMIT_REACHED,
app=app_slug,
limit=limit,
current=current,
max=max_value,
)
def invalidate(self, tenant: Any, app_slug: str | None = None) -> None:
if tenant is None:
return
if app_slug is not None:
cache.delete(self._cache_key(tenant, app_slug))
return
slugs = Entitlement.all_objects.filter(tenant=tenant).values_list("app__slug", flat=True)
for slug in set(slugs):
cache.delete(self._cache_key(tenant, slug))