Grant mobile plans from verified App Store and Play purchases (#78).
The app confirms a StoreKit signed transaction or Play purchase token. The backend checks it with Apple or Google before writing the ledger, and the RevenueCat webhook no longer grants access.
This commit is contained in:
1 parent
bb5c43a4e7
commit
74e8a4ad71
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