Expose POST /api/finance/portal/ so authenticated users can open Stripe's hosted portal for plan changes, payment methods, and cancellations (chat_web_app#33 companion).
180 lines
6.0 KiB
Python
180 lines
6.0 KiB
Python
import logging
|
|
|
|
import stripe
|
|
from django.conf import settings
|
|
from rest_framework import permissions, status
|
|
from rest_framework.response import Response
|
|
from rest_framework.views import APIView
|
|
|
|
from finance.models import Invoice, Payment
|
|
from finance.serializers import (
|
|
CheckoutSessionSerializer,
|
|
InvoiceSerializer,
|
|
PaymentSerializer,
|
|
PortalSessionSerializer,
|
|
)
|
|
from finance.services.stripe_service import (
|
|
StripeNotConfiguredError,
|
|
create_billing_portal_session,
|
|
create_checkout_session,
|
|
resolve_stripe_customer_id,
|
|
)
|
|
from finance.services.webhooks import dispatch_stripe_event
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class CreateCheckoutSessionView(APIView):
|
|
"""Create a Stripe Checkout Session and return the hosted redirect URL."""
|
|
|
|
def post(self, request):
|
|
serializer = CheckoutSessionSerializer(data=request.data)
|
|
serializer.is_valid(raise_exception=True)
|
|
|
|
try:
|
|
session = create_checkout_session(
|
|
user=request.user,
|
|
success_url=serializer.validated_data.get("success_url"),
|
|
cancel_url=serializer.validated_data.get("cancel_url"),
|
|
)
|
|
except StripeNotConfiguredError as exc:
|
|
return Response(
|
|
{"detail": str(exc)},
|
|
status=status.HTTP_503_SERVICE_UNAVAILABLE,
|
|
)
|
|
except stripe.StripeError as exc:
|
|
logger.exception("Stripe Checkout Session creation failed")
|
|
return Response(
|
|
{"detail": str(getattr(exc, "user_message", None) or exc)},
|
|
status=status.HTTP_502_BAD_GATEWAY,
|
|
)
|
|
|
|
# Persist a draft invoice keyed by checkout session for admin visibility
|
|
# before the webhook fires.
|
|
Invoice.objects.update_or_create(
|
|
stripe_checkout_session_id=session.id,
|
|
defaults={
|
|
"user": request.user,
|
|
"company": request.user.company,
|
|
"provider": Invoice.Provider.STRIPE,
|
|
"status": Invoice.Status.OPEN,
|
|
"currency": settings.SUBSCRIPTION_PRICE_CURRENCY,
|
|
"amount_due": settings.SUBSCRIPTION_PRICE_AMOUNT_CENTS,
|
|
"amount_paid": 0,
|
|
"stripe_customer_id": getattr(session, "customer", None) or "",
|
|
"description": settings.SUBSCRIPTION_PRODUCT_NAME,
|
|
},
|
|
)
|
|
|
|
return Response(
|
|
{
|
|
"checkout_url": session.url,
|
|
"session_id": session.id,
|
|
},
|
|
status=status.HTTP_201_CREATED,
|
|
)
|
|
|
|
|
|
class CreateBillingPortalSessionView(APIView):
|
|
"""Create a Stripe Customer Portal session and return the hosted URL."""
|
|
|
|
def post(self, request):
|
|
serializer = PortalSessionSerializer(data=request.data)
|
|
serializer.is_valid(raise_exception=True)
|
|
|
|
customer_id = resolve_stripe_customer_id(user=request.user)
|
|
if not customer_id:
|
|
return Response(
|
|
{
|
|
"detail": (
|
|
"No Stripe customer found for this account. "
|
|
"Complete Checkout first to manage billing."
|
|
)
|
|
},
|
|
status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
|
|
try:
|
|
session = create_billing_portal_session(
|
|
customer_id=customer_id,
|
|
return_url=serializer.validated_data.get("return_url"),
|
|
)
|
|
except StripeNotConfiguredError as exc:
|
|
return Response(
|
|
{"detail": str(exc)},
|
|
status=status.HTTP_503_SERVICE_UNAVAILABLE,
|
|
)
|
|
except stripe.StripeError as exc:
|
|
logger.exception("Stripe Billing Portal Session creation failed")
|
|
return Response(
|
|
{"detail": str(getattr(exc, "user_message", None) or exc)},
|
|
status=status.HTTP_502_BAD_GATEWAY,
|
|
)
|
|
|
|
return Response(
|
|
{"portal_url": session.url},
|
|
status=status.HTTP_201_CREATED,
|
|
)
|
|
|
|
|
|
class InvoiceListView(APIView):
|
|
def get(self, request):
|
|
invoices = Invoice.objects.filter(user=request.user)
|
|
return Response(InvoiceSerializer(invoices, many=True).data)
|
|
|
|
|
|
class PaymentListView(APIView):
|
|
def get(self, request):
|
|
payments = Payment.objects.filter(user=request.user)
|
|
return Response(PaymentSerializer(payments, many=True).data)
|
|
|
|
|
|
class StripeWebhookView(APIView):
|
|
"""Verify Stripe signatures and upsert local invoice/payment rows."""
|
|
|
|
permission_classes = (permissions.AllowAny,)
|
|
authentication_classes = ()
|
|
|
|
def post(self, request):
|
|
payload = request.body
|
|
sig_header = request.META.get("HTTP_STRIPE_SIGNATURE", "")
|
|
webhook_secret = settings.STRIPE_WEBHOOK_SECRET
|
|
|
|
if not webhook_secret:
|
|
logger.error("STRIPE_WEBHOOK_SECRET is not configured")
|
|
return Response(
|
|
{"detail": "Webhook secret not configured"},
|
|
status=status.HTTP_503_SERVICE_UNAVAILABLE,
|
|
)
|
|
|
|
try:
|
|
event = stripe.Webhook.construct_event(
|
|
payload=payload,
|
|
sig_header=sig_header,
|
|
secret=webhook_secret,
|
|
)
|
|
except ValueError:
|
|
return Response(
|
|
{"detail": "Invalid payload"},
|
|
status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
except stripe.SignatureVerificationError:
|
|
return Response(
|
|
{"detail": "Invalid signature"},
|
|
status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
|
|
if hasattr(event, "to_dict"):
|
|
event = event.to_dict()
|
|
|
|
try:
|
|
dispatch_stripe_event(event)
|
|
except Exception:
|
|
logger.exception("Error handling Stripe event %s", event.get("id"))
|
|
return Response(
|
|
{"detail": "Webhook handler error"},
|
|
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
)
|
|
|
|
return Response({"received": True}, status=status.HTTP_200_OK)
|