## Summary - Rename `finance` → **`monetization`** Django app (keep `finance_*` tables via `label = "finance"`) - Add `services/stripe.py` + `services/revenuecat.py`; RevenueCat webhook upserts **subscription + Invoice/Payment** (billing history parity with Stripe) - Mount `/api/monetization/` + keep `/api/finance/` alias - Extend `Source`/`Provider` with `revenuecat`; product→plan mapping via `revenuecat_product_id` / `REVENUECAT_PRODUCT_PLAN_MAP` Closes #68. Companion to [chat_web_app#100](ai_ml_operations/chat_web_app#100). ## Test plan - [x] `manage.py test monetization.tests` (54 OK) - [x] Smoke `chat_backend.tests.test_views_documents` + `test_oauth` - [ ] Deploy: set `REVENUECAT_WEBHOOK_SECRET`; point RC webhook at `/api/finance/webhooks/revenuecat/` - [ ] Map store product IDs on `SubscriptionPlan.revenuecat_product_id` (or env JSON map) - [ ] Sandbox INITIAL_PURCHASE → subscription `source=revenuecat` + invoice in `/finance/invoices/`Reviewed-on: #69
179 lines
6.7 KiB
Python
179 lines
6.7 KiB
Python
"""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()
|
|
)
|