Add monetization app with RevenueCat webhooks alongside Stripe.
Rename finance → monetization (keep finance_* tables via app label), add RevenueCat webhook + ledger upserts so store IAP syncs subscriptions and billing history like Stripe. Companion to chat_web_app#100 / #68.
This commit is contained in:
@@ -0,0 +1,178 @@
|
||||
"""Tests for RevenueCat webhook auth, ledger upserts, and plan assignment."""
|
||||
|
||||
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 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)
|
||||
|
||||
|
||||
@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()
|
||||
)
|
||||
Reference in New Issue
Block a user