Grant mobile plans from verified App Store and Play purchases (#78) (#79)
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

## 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:
westfarn committed 2026-10-19 04:37:49 -07:00
1 parent bb5c43a4e7
commit 4fc2b87bb4
14 files changed
+1141 -119

No files matched your search

+1 -51
View File
@@ -2,10 +2,7 @@
from __future__ import annotations
from django.test import TestCase, override_settings
from django.urls import reverse
from rest_framework import status
from rest_framework.test import APIClient
from django.test import TestCase
from chat_backend.tests.factories import make_user
from monetization.models import Invoice, Payment, SubscriptionPlan, UserSubscription
@@ -129,50 +126,3 @@ class RevenueCatDispatchTests(TestCase):
self.assertEqual(sub.status, UserSubscription.Status.CANCELED)
@override_settings(REVENUECAT_WEBHOOK_SECRET="test-rc-secret")
class RevenueCatWebhookViewTests(TestCase):
def setUp(self):
seed_subscription_plans(update_existing=False)
self.client = APIClient()
self.url = reverse("finance_revenuecat_webhook")
self.user = make_user(email="rcview@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_missing_secret_config(self):
with override_settings(REVENUECAT_WEBHOOK_SECRET=""):
response = self.client.post(
self.url, {"event": _rc_event()}, format="json"
)
self.assertEqual(response.status_code, status.HTTP_503_SERVICE_UNAVAILABLE)
def test_unauthorized(self):
response = self.client.post(
self.url,
{"event": _rc_event(app_user_id=str(self.user.pk))},
format="json",
HTTP_AUTHORIZATION="Bearer wrong",
)
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
def test_success(self):
response = self.client.post(
self.url,
{"api_version": "1.0", "event": _rc_event(app_user_id=str(self.user.pk))},
format="json",
HTTP_AUTHORIZATION="Bearer test-rc-secret",
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertTrue(
UserSubscription.objects.filter(
user=self.user,
source=UserSubscription.Source.REVENUECAT,
status=UserSubscription.Status.ACTIVE,
).exists()
)
self.assertTrue(
Invoice.objects.filter(
user=self.user, provider=Invoice.Provider.REVENUECAT
).exists()
)
@@ -0,0 +1,336 @@
"""StoreKit and Play Billing confirmation. No store webhooks."""
from __future__ import annotations
import base64
from datetime import datetime, timedelta, timezone as dt_timezone
from unittest.mock import patch
import jwt
from cryptography import x509
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.x509.oid import NameOID
from django.test import TestCase, override_settings
from django.urls import reverse
from django.utils import timezone
from rest_framework import status
from rest_framework.test import APITestCase
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.store_purchases import (
StoreNotConfiguredError,
StorePurchaseRejected,
confirm_store_purchase,
store_account_token,
)
_APP_STORE_OID = x509.ObjectIdentifier("1.2.840.113635.100.6.11.1")
_PRODUCT = "hesychia_founders_monthly"
def _cert(*, subject_cn, subject_key, issuer_cert, issuer_key, is_ca, app_store=False):
now = datetime.now(dt_timezone.utc)
name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, subject_cn)])
issuer_name = issuer_cert.subject if issuer_cert is not None else name
builder = (
x509.CertificateBuilder()
.subject_name(name)
.issuer_name(issuer_name)
.public_key(subject_key.public_key())
.serial_number(x509.random_serial_number())
.not_valid_before(now - timedelta(days=1))
.not_valid_after(now + timedelta(days=30))
)
if is_ca:
builder = builder.add_extension(
x509.BasicConstraints(ca=True, path_length=None),
critical=True,
)
if app_store:
builder = builder.add_extension(
x509.UnrecognizedExtension(_APP_STORE_OID, b"\x05\x00"),
critical=False,
)
signer = issuer_key if issuer_key is not None else subject_key
return builder.sign(signer, hashes.SHA256())
def _chain():
root_key = ec.generate_private_key(ec.SECP256R1())
leaf_key = ec.generate_private_key(ec.SECP256R1())
root = _cert(
subject_cn="Test Apple Root",
subject_key=root_key,
issuer_cert=None,
issuer_key=None,
is_ca=True,
)
leaf = _cert(
subject_cn="Test StoreKit",
subject_key=leaf_key,
issuer_cert=root,
issuer_key=root_key,
is_ca=False,
app_store=True,
)
root_pem = root.public_bytes(serialization.Encoding.PEM).decode("ascii")
return leaf_key, leaf, root, root_pem
def _b64_der(cert) -> str:
der = cert.public_bytes(serialization.Encoding.DER)
return base64.b64encode(der).decode("ascii")
def _signed_transaction(leaf_key, leaf, root, claims):
return jwt.encode(
claims,
leaf_key,
algorithm="ES256",
headers={"x5c": [_b64_der(leaf), _b64_der(root)]},
)
def _future_ms(days=30) -> int:
moment = datetime.now(dt_timezone.utc) + timedelta(days=days)
return int(moment.timestamp() * 1000)
class AppleConfirmTests(TestCase):
def setUp(self):
seed_subscription_plans(update_existing=False)
self.user = make_user(email="apple@example.com")
founders = SubscriptionPlan.objects.get(slug="founders")
founders.revenuecat_product_id = _PRODUCT
founders.save(update_fields=["revenuecat_product_id", "last_modified"])
self.leaf_key, self.leaf, self.root, self.root_pem = _chain()
def _claims(self, **overrides):
base = {
"bundleId": "ai.hesychia.chat",
"productId": _PRODUCT,
"transactionId": "2000000000001",
"originalTransactionId": "2000000000001",
"appAccountToken": store_account_token(self.user.pk),
"purchaseDate": _future_ms(days=-1),
"expiresDate": _future_ms(days=30),
"price": 10000,
"currency": "USD",
}
base.update(overrides)
return base
def _confirm(self, claims):
token = _signed_transaction(self.leaf_key, self.leaf, self.root, claims)
with override_settings(
APPLE_IAP_BUNDLE_ID="ai.hesychia.chat",
APPLE_IAP_ROOT_PEM=self.root_pem,
):
return confirm_store_purchase(
self.user,
{
"store": "app_store",
"product_id": _PRODUCT,
"transaction_id": claims["transactionId"],
"signed_transaction": token,
},
)
def test_signed_transaction_grants_plan(self):
sub = self._confirm(self._claims())
self.assertEqual(sub.source, UserSubscription.Source.APP_STORE)
self.assertEqual(sub.status, UserSubscription.Status.ACTIVE)
self.assertEqual(sub.plan.slug, "founders")
self.assertEqual(sub.revenuecat_original_transaction_id, "2000000000001")
invoice = Invoice.objects.get(user=self.user)
self.assertEqual(invoice.provider, Invoice.Provider.APP_STORE)
self.assertEqual(invoice.amount_paid, 1000)
self.assertEqual(invoice.revenuecat_store, "APP_STORE")
payment = Payment.objects.get(user=self.user)
self.assertEqual(payment.provider, Payment.Provider.APP_STORE)
self.assertEqual(payment.status, Payment.Status.SUCCEEDED)
def test_repeat_confirm_is_idempotent(self):
claims = self._claims()
self._confirm(claims)
self._confirm(claims)
self.assertEqual(Invoice.objects.filter(user=self.user).count(), 1)
self.assertEqual(Payment.objects.filter(user=self.user).count(), 1)
def test_wrong_account_token_rejected(self):
with self.assertRaises(StorePurchaseRejected):
self._confirm(self._claims(appAccountToken="00000000-0000-0000-0000-000000000000"))
def test_untrusted_chain_rejected(self):
token = _signed_transaction(
self.leaf_key, self.leaf, self.root, self._claims()
)
with override_settings(APPLE_IAP_BUNDLE_ID="ai.hesychia.chat", APPLE_IAP_ROOT_PEM=""):
with self.assertRaises(StorePurchaseRejected):
confirm_store_purchase(
self.user,
{
"store": "app_store",
"product_id": _PRODUCT,
"signed_transaction": token,
},
)
def test_revoked_transaction_cancels(self):
sub = self._confirm(self._claims(revocationDate=_future_ms(days=-1)))
self.assertEqual(sub.status, UserSubscription.Status.CANCELED)
self.assertFalse(Payment.objects.filter(user=self.user).exists())
class PlayConfirmTests(TestCase):
def setUp(self):
seed_subscription_plans(update_existing=False)
self.user = make_user(email="play@example.com")
founders = SubscriptionPlan.objects.get(slug="founders")
founders.revenuecat_product_id = _PRODUCT
founders.save(update_fields=["revenuecat_product_id", "last_modified"])
def _body(self, **overrides):
expiry = (datetime.now(dt_timezone.utc) + timedelta(days=30)).strftime(
"%Y-%m-%dT%H:%M:%SZ"
)
body = {
"subscriptionState": "SUBSCRIPTION_STATE_ACTIVE",
"lineItems": [
{
"productId": _PRODUCT,
"expiryTime": expiry,
"latestSuccessfulOrderId": "GPA.1234-5678",
"autoRenewingPlan": {
"autoRenewEnabled": True,
"recurringPrice": {
"currencyCode": "USD",
"units": "10",
"nanos": 0,
},
},
}
],
"externalAccountIdentifiers": {
"obfuscatedExternalAccountId": store_account_token(self.user.pk),
},
}
body.update(overrides)
return body
@patch("monetization.services.store_purchases.fetch_google_subscription")
def test_play_token_grants_plan(self, mock_fetch):
mock_fetch.return_value = self._body()
sub = confirm_store_purchase(
self.user,
{
"store": "play_store",
"product_id": _PRODUCT,
"purchase_token": "token-abc",
"transaction_id": "GPA.1234-5678",
},
)
self.assertEqual(sub.source, UserSubscription.Source.PLAY_STORE)
self.assertEqual(sub.status, UserSubscription.Status.ACTIVE)
self.assertEqual(sub.revenuecat_original_transaction_id, "token-abc")
self.assertFalse(sub.cancel_at_period_end)
invoice = Invoice.objects.get(user=self.user)
self.assertEqual(invoice.provider, Invoice.Provider.PLAY_STORE)
self.assertEqual(invoice.amount_paid, 1000)
@patch("monetization.services.store_purchases.fetch_google_subscription")
def test_canceled_but_unexpired_keeps_access(self, mock_fetch):
mock_fetch.return_value = self._body(
subscriptionState="SUBSCRIPTION_STATE_CANCELED"
)
sub = confirm_store_purchase(
self.user,
{
"store": "play_store",
"product_id": _PRODUCT,
"purchase_token": "token-cancel",
},
)
self.assertEqual(sub.status, UserSubscription.Status.ACTIVE)
self.assertTrue(sub.cancel_at_period_end)
@patch("monetization.services.store_purchases.fetch_google_subscription")
def test_token_already_linked_to_someone_else(self, mock_fetch):
other = make_user(email="other@example.com")
UserSubscription.objects.create(
user=other,
plan=SubscriptionPlan.objects.get(slug="founders"),
status=UserSubscription.Status.ACTIVE,
source=UserSubscription.Source.PLAY_STORE,
revenuecat_original_transaction_id="token-taken",
)
mock_fetch.return_value = self._body()
with self.assertRaises(StorePurchaseRejected):
confirm_store_purchase(
self.user,
{
"store": "play_store",
"product_id": _PRODUCT,
"purchase_token": "token-taken",
},
)
def test_missing_play_credentials(self):
with override_settings(GOOGLE_PLAY_SERVICE_ACCOUNT_JSON=""):
with self.assertRaises(StoreNotConfiguredError):
confirm_store_purchase(
self.user,
{
"store": "play_store",
"product_id": _PRODUCT,
"purchase_token": "token-abc",
},
)
class StoreConfirmApiTests(APITestCase):
def setUp(self):
seed_subscription_plans(update_existing=False)
self.user = make_user(email="api@example.com")
self.client.force_authenticate(user=self.user)
self.url = reverse("finance_store_confirm")
def test_requires_auth(self):
self.client.force_authenticate(user=None)
response = self.client.post(self.url, {}, format="json")
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
@patch("monetization.views.confirm_store_purchase")
def test_rejected_purchase_is_400(self, mock_confirm):
mock_confirm.side_effect = StorePurchaseRejected("nope")
response = self.client.post(
self.url,
{
"store": "play_store",
"product_id": _PRODUCT,
"purchase_token": "token",
},
format="json",
)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
def test_lapsed_store_period_ends_on_read(self):
plan = SubscriptionPlan.objects.get(slug="founders")
UserSubscription.objects.create(
user=self.user,
plan=plan,
status=UserSubscription.Status.ACTIVE,
source=UserSubscription.Source.APP_STORE,
current_period_end=timezone.now() - timedelta(days=1),
)
response = self.client.get(reverse("finance_subscription_me"))
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data["status"], "canceled")
self.assertTrue(response.data["needs_checkout"])
self.assertEqual(
response.data["store_account_token"],
store_account_token(self.user.pk),
)