Files
chat_backend/llm_be/monetization/tests/test_revenuecat.py
T
westfarn 4fc2b87bb4
Deploy Beta / unit-tests (push) Successful in 10s
Unit Tests / test (push) Successful in 9s
Deploy Beta / docker (push) Successful in 23s
Deploy Beta / deploy-beta (push) Successful in 1m19s
Grant mobile plans from verified App Store and Play purchases (#78) (#79)
## 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
2026-10-19 04:37:49 -07:00

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)