Grant mobile plans from verified App Store and Play purchases (#78).
CI / test (pull_request) Successful in 10s
Unit Tests / test (pull_request) Successful in 10s

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.
This commit is contained in:
westfarn committed 2026-10-19 06:14:27 -05:00
1 parent bb5c43a4e7
commit 74e8a4ad71
14 files changed
+1141 -119

No files matched your search

+50 -51
View File
@@ -12,6 +12,7 @@ from monetization.serializers import (
InvoiceSerializer,
PaymentSerializer,
PortalSessionSerializer,
StorePurchaseConfirmSerializer,
SubscriptionPlanSerializer,
)
from monetization.services.plans import (
@@ -20,10 +21,13 @@ from monetization.services.plans import (
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.store_purchases import (
StoreNotConfiguredError,
StorePurchaseRejected,
StoreVerificationError,
confirm_store_purchase,
expire_lapsed_store_subscription,
store_account_token,
)
from monetization.services.stripe import (
StripeNotConfiguredError,
@@ -194,6 +198,7 @@ class SubscriptionMeView(APIView):
sub = request.user.subscription
except UserSubscription.DoesNotExist:
sub = None
sub = expire_lapsed_store_subscription(sub)
usage = get_usage_snapshot(request.user)
payload = {
@@ -213,11 +218,52 @@ class SubscriptionMeView(APIView):
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."""
@@ -266,50 +312,3 @@ class StripeWebhookView(APIView):
)
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)