## 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
@@ -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),
|
||||
)
|
||||
Reference in new issue
Block a user