- infrasynth.configs: typed multi-tenant config store (registry, service, secrets, cache) + public config_changed/config_reset signals and API - emit the declared-but-dead signals (features flags/overrides, scheduler task_completed/task_failed, tenancy tenant_updated, audit model_changed) and per-model audit field exclusions - security: permission catalog (security_permission), Django-style model-derived AutoPermission, PermissionRegistry, RoleAssignment, global-or-tenant Grant/Revoke, catalog API - consolidate the permission surface: PermissionRegistry only (drop the settings dict), IsAuthenticatedAndPermitted aliases HybridPermission, require_permission replaced by required_permissions + require_all - packaging: add [build-system]; add Forgejo publish workflow (.forgejo)
258 lines
9.5 KiB
Python
258 lines
9.5 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
|
|
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<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 = [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,
|
|
}
|
|
)
|