Grant mobile plans from verified App Store and Play purchases (#78).
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:
1 parent
bb5c43a4e7
commit
74e8a4ad71
14 files changed
+1141
-119
No files matched your search
@@ -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)
|
||||
Reference in new issue
Block a user