## 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
This commit was merged in pull request #79.
This commit is contained in:
1 parent
bb5c43a4e7
commit
4fc2b87bb4
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