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

257 lines
9.4 KiB
Python

from django.http import Http404
from rest_framework import mixins, status, viewsets
from rest_framework.decorators import action
from rest_framework.permissions import AllowAny, IsAuthenticated
from rest_framework.response import Response
from infrasynth.api.idempotency import idempotent
from infrasynth.features.services import FeatureService
from infrasynth.shared.exceptions import NotFoundError, ValidationAppError
from infrasynth.tenancy.context import get_current_tenant
from .filters import (
AppFilter,
EntitlementFilter,
InvoiceFilter,
PaymentGatewayFilter,
PaymentTransactionFilter,
PlanFilter,
SubscriptionFilter,
)
from .models import (
App,
Entitlement,
Invoice,
PaymentGateway,
PaymentTransaction,
Plan,
Subscription,
)
from .serializers import (
AppSerializer,
CheckoutSerializer,
EntitlementSerializer,
InvoiceSerializer,
PaymentGatewaySerializer,
PaymentTransactionSerializer,
PlanSerializer,
SubscribeSerializer,
SubscriptionSerializer,
)
class _BillingFeatureMixin:
def initial(self, request, *args, **kwargs):
if not FeatureService().is_enabled("billing", user=getattr(request, "user", None)):
raise Http404()
super().initial(request, *args, **kwargs)
class PaymentGatewayViewSet(_BillingFeatureMixin, viewsets.ModelViewSet):
queryset = PaymentGateway.objects.all()
serializer_class = PaymentGatewaySerializer
permission_classes = [IsAuthenticated]
filterset_class = PaymentGatewayFilter
def get_queryset(self):
return PaymentGateway.objects.all()
class AppViewSet(mixins.ListModelMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet):
queryset = App.objects.filter(is_active=True)
serializer_class = AppSerializer
filterset_class = AppFilter
permission_classes = [AllowAny]
def get_queryset(self):
return App.objects.filter(is_active=True).order_by("slug")
class PlanViewSet(mixins.ListModelMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet):
queryset = Plan.objects.filter(is_active=True)
serializer_class = PlanSerializer
filterset_class = PlanFilter
permission_classes = [AllowAny]
lookup_field = "slug"
def get_queryset(self):
return Plan.objects.filter(is_active=True).select_related("app", "gateway").order_by("app__slug", "slug")
class EntitlementViewSet(mixins.ListModelMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet):
serializer_class = EntitlementSerializer
permission_classes = [IsAuthenticated]
filterset_class = EntitlementFilter
def get_queryset(self):
# Entitlement.objects is tenant-scoped by TenantManager (fail closed).
return Entitlement.objects.select_related("app", "plan").order_by("app__slug")
@action(detail=False, methods=["get"], url_path=r"by-app/(?P<app_slug>[^/.]+)")
def by_app(self, request, app_slug=None):
entitlement = Entitlement.objects.filter(app__slug=app_slug).select_related("app", "plan").first()
if entitlement is None:
raise NotFoundError("No entitlement for this app.")
return Response(EntitlementSerializer(entitlement).data)
class SubscriptionViewSet(_BillingFeatureMixin, viewsets.ModelViewSet):
serializer_class = SubscriptionSerializer
permission_classes = [IsAuthenticated]
filterset_class = SubscriptionFilter
def get_queryset(self):
return Subscription.objects.select_related("plan", "plan__app", "gateway", "entitlement").order_by("-id")
@action(detail=False, methods=["post"])
@idempotent
def checkout(self, request):
ser = CheckoutSerializer(data=request.data)
ser.is_valid(raise_exception=True)
tenant = get_current_tenant()
plan = (
Plan.objects.filter(app__slug=ser.validated_data["app"], slug=ser.validated_data["plan"], is_active=True)
.select_related("gateway", "app")
.first()
)
if plan is None:
raise NotFoundError("Plan not found.")
from .services import BillingService
try:
result, gateway = BillingService().create_checkout_session(
plan,
tenant,
request.user,
success_url=ser.validated_data.get("success_url"),
cancel_url=ser.validated_data.get("cancel_url"),
)
except ValueError as exc:
raise ValidationAppError(str(exc), code="VALIDATION_CHECKOUT_FAILED") from exc
return Response(
{
"checkout_url": result.checkout_url,
"session_id": result.session_id,
"client_secret": result.client_secret,
"gateway": gateway.slug,
"plan_slug": plan.slug,
"app_slug": plan.app.slug,
}
)
@action(detail=False, methods=["post"])
def subscribe(self, request):
ser = SubscribeSerializer(data=request.data)
ser.is_valid(raise_exception=True)
tenant = get_current_tenant()
plan = (
Plan.objects.filter(slug=ser.validated_data["plan_slug"], is_active=True).select_related("gateway").first()
)
if plan is None:
raise NotFoundError("Plan not found.")
from .services import BillingService
try:
result, gateway = BillingService().create_checkout_session(
plan,
tenant,
request.user,
success_url=ser.validated_data.get("success_url"),
cancel_url=ser.validated_data.get("cancel_url"),
)
except ValueError as exc:
raise ValidationAppError(str(exc), code="VALIDATION_CHECKOUT_FAILED") from exc
return Response(
{
"checkout_url": result.checkout_url,
"session_id": result.session_id,
"client_secret": result.client_secret,
"gateway": gateway.slug,
"plan_slug": plan.slug,
}
)
@action(detail=True, methods=["post"])
def cancel(self, request, pk=None):
subscription = self.get_object()
from .services import BillingService
BillingService().cancel_subscription(subscription)
return Response(SubscriptionSerializer(subscription).data)
class InvoiceViewSet(_BillingFeatureMixin, viewsets.ModelViewSet):
serializer_class = InvoiceSerializer
permission_classes = [IsAuthenticated]
filterset_class = InvoiceFilter
def get_queryset(self):
return Invoice.objects.select_related("subscription", "gateway", "pdf_file").order_by("-id")
class PaymentTransactionViewSet(_BillingFeatureMixin, viewsets.ReadOnlyModelViewSet):
serializer_class = PaymentTransactionSerializer
permission_classes = [IsAuthenticated]
filterset_class = PaymentTransactionFilter
def get_queryset(self):
return PaymentTransaction.objects.select_related("invoice", "gateway").order_by("-id")
class WebhookViewSet(viewsets.GenericViewSet):
permission_classes = [AllowAny]
@action(detail=False, methods=["post"])
def receive(self, request, provider=None):
from django.utils.module_loading import import_string
from infrasynth.api.webhooks import assert_fresh_webhook
from infrasynth.shared.settings_utils import get_setting
gateway_slug = provider or request.data.get("gateway_slug") or request.query_params.get("gateway_slug")
gateway = (
PaymentGateway.objects.filter(slug=gateway_slug, is_active=True).first()
if gateway_slug
else PaymentGateway.objects.filter(is_active=True).first()
)
if gateway is None:
return Response({"detail": "No active payment gateway found."}, status=status.HTTP_404_NOT_FOUND)
# Replay protection (API-STANDARD §10): reject events older than 5 min.
tolerance = int(get_setting("INFRASYNTH_BILLING", "WEBHOOK_TOLERANCE_SECONDS", 300))
timestamp = (
request.data.get("created") or request.data.get("timestamp") or request.headers.get("X-Webhook-Timestamp")
)
if timestamp is not None:
try:
assert_fresh_webhook(timestamp, tolerance_seconds=tolerance)
except Exception as exc: # noqa: BLE001
raise ValidationAppError(str(exc), code="VALIDATION_WEBHOOK_STALE") from exc
try:
gateway_cls = import_string(gateway.gateway_class)
gateway_instance = gateway_cls(gateway.config)
result = gateway_instance.handle_webhook(payload=request.data, headers=dict(request.headers))
except Exception as exc: # noqa: BLE001
raise ValidationAppError(f"Webhook processing failed: {exc}", code="VALIDATION_WEBHOOK_FAILED") from exc
if not result.is_handled:
return Response({"status": "ignored", "event_type": result.event_type})
payload = result.data if isinstance(result.data, dict) else {}
event_id = str(request.data.get("id") or request.headers.get("X-Event-Id") or "") or None
from .services import BillingService
outcome = BillingService().process_webhook_event(
gateway,
result.event_type,
payload,
event_id=event_id,
)
return Response(
{
"status": "processed",
"event_type": result.event_type,
"billing": outcome,
}
)