## 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
129 lines
4.7 KiB
Python
129 lines
4.7 KiB
Python
"""Tests for RevenueCat webhook auth, ledger upserts, and plan assignment."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from django.test import TestCase
|
|
|
|
from chat_backend.tests.factories import make_user
|
|
from monetization.models import Invoice, Payment, SubscriptionPlan, UserSubscription
|
|
from monetization.services.plans import seed_subscription_plans
|
|
from monetization.services.revenuecat import (
|
|
RevenueCatWebhookAuthError,
|
|
dispatch_revenuecat_event,
|
|
verify_revenuecat_authorization,
|
|
)
|
|
|
|
|
|
def _rc_event(**overrides):
|
|
base = {
|
|
"id": "rc_evt_1",
|
|
"type": "INITIAL_PURCHASE",
|
|
"app_user_id": "1",
|
|
"product_id": "hesychia_founders_monthly",
|
|
"store": "PLAY_STORE",
|
|
"price": 10.0,
|
|
"currency": "USD",
|
|
"purchased_at_ms": 1_700_000_000_000,
|
|
"expiration_at_ms": 1_702_592_000_000,
|
|
"transaction_id": "GPA.1234",
|
|
"original_transaction_id": "GPA.1234",
|
|
}
|
|
base.update(overrides)
|
|
return base
|
|
|
|
|
|
class RevenueCatAuthTests(TestCase):
|
|
def test_bearer_token_ok(self):
|
|
verify_revenuecat_authorization(
|
|
authorization_header="Bearer secret-token",
|
|
expected_secret="secret-token",
|
|
)
|
|
|
|
def test_raw_token_ok(self):
|
|
verify_revenuecat_authorization(
|
|
authorization_header="secret-token",
|
|
expected_secret="secret-token",
|
|
)
|
|
|
|
def test_bad_token(self):
|
|
with self.assertRaises(RevenueCatWebhookAuthError):
|
|
verify_revenuecat_authorization(
|
|
authorization_header="Bearer nope",
|
|
expected_secret="secret-token",
|
|
)
|
|
|
|
|
|
class RevenueCatDispatchTests(TestCase):
|
|
def setUp(self):
|
|
seed_subscription_plans(update_existing=False)
|
|
self.user = make_user(email="rc@example.com")
|
|
founders = SubscriptionPlan.objects.get(slug="founders")
|
|
founders.revenuecat_product_id = "hesychia_founders_monthly"
|
|
founders.save(update_fields=["revenuecat_product_id", "last_modified"])
|
|
|
|
def test_initial_purchase_assigns_plan_and_ledger(self):
|
|
event = _rc_event(app_user_id=str(self.user.pk))
|
|
dispatch_revenuecat_event({"api_version": "1.0", "event": event})
|
|
|
|
sub = UserSubscription.objects.get(user=self.user)
|
|
self.assertEqual(sub.source, UserSubscription.Source.REVENUECAT)
|
|
self.assertEqual(sub.status, UserSubscription.Status.ACTIVE)
|
|
self.assertEqual(sub.plan.slug, "founders")
|
|
self.assertEqual(sub.revenuecat_original_transaction_id, "GPA.1234")
|
|
|
|
invoice = Invoice.objects.get(revenuecat_event_id="rc_evt_1")
|
|
self.assertEqual(invoice.provider, Invoice.Provider.REVENUECAT)
|
|
self.assertEqual(invoice.status, Invoice.Status.PAID)
|
|
self.assertEqual(invoice.amount_paid, 1000)
|
|
self.assertEqual(invoice.revenuecat_store, "PLAY_STORE")
|
|
self.assertIn("PLAY_STORE", invoice.description)
|
|
|
|
payment = Payment.objects.get(revenuecat_transaction_id="GPA.1234")
|
|
self.assertEqual(payment.provider, Payment.Provider.REVENUECAT)
|
|
self.assertEqual(payment.status, Payment.Status.SUCCEEDED)
|
|
self.assertEqual(payment.invoice_id, invoice.pk)
|
|
|
|
def test_idempotent_replay(self):
|
|
event = _rc_event(app_user_id=str(self.user.pk))
|
|
dispatch_revenuecat_event({"event": event})
|
|
dispatch_revenuecat_event({"event": event})
|
|
self.assertEqual(Invoice.objects.filter(user=self.user).count(), 1)
|
|
self.assertEqual(Payment.objects.filter(user=self.user).count(), 1)
|
|
|
|
def test_cancellation_sets_cancel_at_period_end(self):
|
|
dispatch_revenuecat_event(
|
|
{"event": _rc_event(app_user_id=str(self.user.pk))}
|
|
)
|
|
dispatch_revenuecat_event(
|
|
{
|
|
"event": _rc_event(
|
|
id="rc_evt_cancel",
|
|
type="CANCELLATION",
|
|
app_user_id=str(self.user.pk),
|
|
transaction_id="GPA.999",
|
|
)
|
|
}
|
|
)
|
|
sub = UserSubscription.objects.get(user=self.user)
|
|
self.assertTrue(sub.cancel_at_period_end)
|
|
self.assertEqual(sub.status, UserSubscription.Status.ACTIVE)
|
|
|
|
def test_expiration_cancels(self):
|
|
dispatch_revenuecat_event(
|
|
{"event": _rc_event(app_user_id=str(self.user.pk))}
|
|
)
|
|
dispatch_revenuecat_event(
|
|
{
|
|
"event": _rc_event(
|
|
id="rc_evt_exp",
|
|
type="EXPIRATION",
|
|
app_user_id=str(self.user.pk),
|
|
transaction_id="GPA.exp",
|
|
)
|
|
}
|
|
)
|
|
sub = UserSubscription.objects.get(user=self.user)
|
|
self.assertEqual(sub.status, UserSubscription.Status.CANCELED)
|
|
|
|
|