Files
chat_backend/llm_be/monetization/views.py
T
westfarn 4fc2b87bb4
Deploy Beta / unit-tests (push) Successful in 10s
Unit Tests / test (push) Successful in 9s
Deploy Beta / docker (push) Successful in 23s
Deploy Beta / deploy-beta (push) Successful in 1m19s
Grant mobile plans from verified App Store and Play purchases (#78) (#79)
## Summary
- Mobile access is granted by `POST /api/finance/store/confirm/` after the app finishes a StoreKit or Play Billing purchase.
- App Store signed transactions are checked against Apple Root CA G3. Play purchase tokens are checked with the Play Developer API.
- The RevenueCat webhook no longer grants a plan. Stripe Checkout and the Stripe webhook stay for the website.
- A purchase account token must match the signed-in user, and a transaction already linked to another account is rejected. Access ends when the verified period end passes unless the app confirms again.

Client: ai_ml_operations/chat_web_app#122

## Config
- `APPLE_IAP_BUNDLE_ID` (default `ai.hesychia.chat`)
- `GOOGLE_PLAY_PACKAGE_NAME`
- `GOOGLE_PLAY_SERVICE_ACCOUNT_JSON` (JSON or a file path)
- Plan `revenuecat_product_id` or `REVENUECAT_PRODUCT_PLAN_MAP` maps the store product to a plan

## Test plan
- [x] `python manage.py test monetization.tests.test_store_purchases monetization.tests.test_revenuecat monetization.tests.test_plans_quotas`
- [ ] Confirm an App Store sandbox transaction and a Play license-test purchase against a configured backend
- [ ] Confirm a token already linked to another user is rejected
- [ ] Confirm `GET /subscription/` returns `store_account_token` and flips a lapsed store sub to canceled

Reviewed-on: #79
2026-10-19 04:37:49 -07:00

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)