from django.http import Http404 from rest_framework import mixins, status, viewsets from rest_framework.decorators import action from rest_framework.permissions import AllowAny from rest_framework.response import Response from infrasynth.api.idempotency import idempotent from infrasynth.features.services import FeatureService from infrasynth.security.permissions import IsAuthenticatedAndPermitted 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 = [IsAuthenticatedAndPermitted] 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 = [IsAuthenticatedAndPermitted] 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[^/.]+)") 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 = [IsAuthenticatedAndPermitted] 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 = [IsAuthenticatedAndPermitted] 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 = [IsAuthenticatedAndPermitted] 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, } )