Monetization app + RevenueCat webhooks (store IAP ledger) (#69)
## Summary - Rename `finance` → **`monetization`** Django app (keep `finance_*` tables via `label = "finance"`) - Add `services/stripe.py` + `services/revenuecat.py`; RevenueCat webhook upserts **subscription + Invoice/Payment** (billing history parity with Stripe) - Mount `/api/monetization/` + keep `/api/finance/` alias - Extend `Source`/`Provider` with `revenuecat`; product→plan mapping via `revenuecat_product_id` / `REVENUECAT_PRODUCT_PLAN_MAP` Closes #68. Companion to [chat_web_app#100](ai_ml_operations/chat_web_app#100). ## Test plan - [x] `manage.py test monetization.tests` (54 OK) - [x] Smoke `chat_backend.tests.test_views_documents` + `test_oauth` - [ ] Deploy: set `REVENUECAT_WEBHOOK_SECRET`; point RC webhook at `/api/finance/webhooks/revenuecat/` - [ ] Map store product IDs on `SubscriptionPlan.revenuecat_product_id` (or env JSON map) - [ ] Sandbox INITIAL_PURCHASE → subscription `source=revenuecat` + invoice in `/finance/invoices/`Reviewed-on: #69
This commit was merged in pull request #69.
This commit is contained in:
@@ -0,0 +1,315 @@
|
||||
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,
|
||||
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.revenuecat import (
|
||||
RevenueCatWebhookAuthError,
|
||||
dispatch_revenuecat_event,
|
||||
verify_revenuecat_authorization,
|
||||
)
|
||||
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
|
||||
|
||||
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
|
||||
),
|
||||
"usage": usage.to_dict(),
|
||||
}
|
||||
return Response(payload)
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
class RevenueCatWebhookView(APIView):
|
||||
"""Verify RevenueCat Authorization and upsert subscription + ledger rows."""
|
||||
|
||||
permission_classes = (permissions.AllowAny,)
|
||||
authentication_classes = ()
|
||||
|
||||
def post(self, request):
|
||||
webhook_secret = settings.REVENUECAT_WEBHOOK_SECRET
|
||||
if not webhook_secret:
|
||||
logger.error("REVENUECAT_WEBHOOK_SECRET is not configured")
|
||||
return Response(
|
||||
{"detail": "Webhook secret not configured"},
|
||||
status=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
)
|
||||
|
||||
try:
|
||||
verify_revenuecat_authorization(
|
||||
authorization_header=request.META.get("HTTP_AUTHORIZATION"),
|
||||
expected_secret=webhook_secret,
|
||||
)
|
||||
except RevenueCatWebhookAuthError as exc:
|
||||
return Response(
|
||||
{"detail": str(exc)},
|
||||
status=status.HTTP_401_UNAUTHORIZED,
|
||||
)
|
||||
|
||||
payload = request.data
|
||||
if not isinstance(payload, dict):
|
||||
return Response(
|
||||
{"detail": "Invalid payload"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
try:
|
||||
dispatch_revenuecat_event(payload)
|
||||
except Exception:
|
||||
event = payload.get("event") if isinstance(payload, dict) else {}
|
||||
event_id = event.get("id") if isinstance(event, dict) else None
|
||||
logger.exception("Error handling RevenueCat event %s", event_id)
|
||||
return Response(
|
||||
{"detail": "Webhook handler error"},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
)
|
||||
|
||||
return Response({"received": True}, status=status.HTTP_200_OK)
|
||||
Reference in New Issue
Block a user