The app confirms a StoreKit signed transaction or Play purchase token. The backend checks it with Apple or Google before writing the ledger, and the RevenueCat webhook no longer grants access.
315 lines
11 KiB
Python
315 lines
11 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 monetization.models import Invoice, Payment, SubscriptionPlan, UserSubscription
|
|
from monetization.serializers import (
|
|
CheckoutSessionSerializer,
|
|
InvoiceSerializer,
|
|
PaymentSerializer,
|
|
PortalSessionSerializer,
|
|
StorePurchaseConfirmSerializer,
|
|
SubscriptionPlanSerializer,
|
|
)
|
|
from monetization.services.plans import (
|
|
needs_checkout,
|
|
plan_to_dict,
|
|
seed_subscription_plans,
|
|
)
|
|
from monetization.services.quotas import get_usage_snapshot
|
|
from monetization.services.store_purchases import (
|
|
StoreNotConfiguredError,
|
|
StorePurchaseRejected,
|
|
StoreVerificationError,
|
|
confirm_store_purchase,
|
|
expire_lapsed_store_subscription,
|
|
store_account_token,
|
|
)
|
|
from monetization.services.stripe import (
|
|
StripeNotConfiguredError,
|
|
create_billing_portal_session,
|
|
create_checkout_session,
|
|
dispatch_stripe_event,
|
|
resolve_stripe_customer_id,
|
|
)
|
|
|
|
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:
|
|
sub = request.user.subscription
|
|
except UserSubscription.DoesNotExist:
|
|
sub = None
|
|
if (
|
|
sub is not None
|
|
and sub.is_active
|
|
and sub.source == UserSubscription.Source.BACKER
|
|
):
|
|
return Response(
|
|
{
|
|
"detail": (
|
|
"This account has complimentary Backer access and "
|
|
"does not require payment."
|
|
),
|
|
"needs_checkout": False,
|
|
},
|
|
status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
|
|
try:
|
|
session, plan = create_checkout_session(
|
|
user=request.user,
|
|
success_url=serializer.validated_data.get("success_url"),
|
|
cancel_url=serializer.validated_data.get("cancel_url"),
|
|
plan_slug=serializer.validated_data.get("plan_slug"),
|
|
)
|
|
except ValueError as exc:
|
|
return Response({"detail": str(exc)}, status=status.HTTP_400_BAD_REQUEST)
|
|
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": plan.currency or settings.SUBSCRIPTION_PRICE_CURRENCY,
|
|
"amount_due": plan.price_cents,
|
|
"amount_paid": 0,
|
|
"stripe_customer_id": getattr(session, "customer", None) or "",
|
|
"description": plan.name,
|
|
},
|
|
)
|
|
|
|
return Response(
|
|
{
|
|
"checkout_url": session.url,
|
|
"session_id": session.id,
|
|
"plan_slug": plan.slug,
|
|
},
|
|
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 PlanListView(APIView):
|
|
"""Public-facing plan catalog (only `is_public` rows by default)."""
|
|
|
|
permission_classes = (permissions.AllowAny,)
|
|
authentication_classes = ()
|
|
|
|
def get(self, request):
|
|
seed_subscription_plans(update_existing=False)
|
|
include_all = (
|
|
request.user
|
|
and request.user.is_authenticated
|
|
and request.user.is_staff
|
|
and request.query_params.get("all") == "1"
|
|
)
|
|
qs = SubscriptionPlan.objects.all()
|
|
if not include_all:
|
|
qs = qs.filter(is_public=True)
|
|
return Response(SubscriptionPlanSerializer(qs, many=True).data)
|
|
|
|
|
|
class SubscriptionMeView(APIView):
|
|
"""Current user's plan, checkout need, and usage snapshot (#16/#17/#36)."""
|
|
|
|
def get(self, request):
|
|
seed_subscription_plans(update_existing=False)
|
|
try:
|
|
sub = request.user.subscription
|
|
except UserSubscription.DoesNotExist:
|
|
sub = None
|
|
sub = expire_lapsed_store_subscription(sub)
|
|
|
|
usage = get_usage_snapshot(request.user)
|
|
payload = {
|
|
"plan": plan_to_dict(sub.plan) if sub and sub.plan_id else None,
|
|
"status": sub.status if sub else UserSubscription.Status.NONE,
|
|
"source": sub.source if sub else UserSubscription.Source.NONE,
|
|
"needs_checkout": needs_checkout(request.user),
|
|
"stripe_subscription_id": (
|
|
sub.stripe_subscription_id if sub else ""
|
|
),
|
|
"revenuecat_original_transaction_id": (
|
|
sub.revenuecat_original_transaction_id if sub else ""
|
|
),
|
|
"cancel_at_period_end": bool(sub.cancel_at_period_end) if sub else False,
|
|
"current_period_end": (
|
|
sub.current_period_end.isoformat()
|
|
if sub and sub.current_period_end
|
|
else None
|
|
),
|
|
"store_account_token": store_account_token(request.user.pk),
|
|
"usage": usage.to_dict(),
|
|
}
|
|
return Response(payload)
|
|
|
|
|
|
class ConfirmStorePurchaseView(APIView):
|
|
"""Verify a StoreKit or Play Billing purchase and grant the plan.
|
|
|
|
The app calls this after the native store sheet succeeds. There is no
|
|
store webhook.
|
|
"""
|
|
|
|
def post(self, request):
|
|
serializer = StorePurchaseConfirmSerializer(data=request.data)
|
|
serializer.is_valid(raise_exception=True)
|
|
try:
|
|
sub = confirm_store_purchase(request.user, serializer.validated_data)
|
|
except StorePurchaseRejected as exc:
|
|
return Response({"detail": str(exc)}, status=status.HTTP_400_BAD_REQUEST)
|
|
except StoreNotConfiguredError as exc:
|
|
return Response(
|
|
{"detail": str(exc)},
|
|
status=status.HTTP_503_SERVICE_UNAVAILABLE,
|
|
)
|
|
except StoreVerificationError as exc:
|
|
logger.exception("Store purchase verification failed")
|
|
return Response({"detail": str(exc)}, status=status.HTTP_502_BAD_GATEWAY)
|
|
|
|
return Response(
|
|
{
|
|
"source": sub.source,
|
|
"status": sub.status,
|
|
"plan_slug": sub.plan.slug if sub.plan_id else None,
|
|
"cancel_at_period_end": bool(sub.cancel_at_period_end),
|
|
"current_period_end": (
|
|
sub.current_period_end.isoformat()
|
|
if sub.current_period_end
|
|
else None
|
|
),
|
|
"store_account_token": store_account_token(request.user.pk),
|
|
},
|
|
status=status.HTTP_200_OK,
|
|
)
|
|
|
|
|
|
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)
|