Monetization app + RevenueCat webhooks (store IAP ledger) (#69)
Deploy Beta / unit-tests (push) Successful in 11s
Unit Tests / test (push) Successful in 11s
Deploy Beta / docker (push) Successful in 21s
Deploy Beta / deploy-beta (push) Successful in 50s

## 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
This commit was merged in pull request #69.
This commit is contained in:
2026-08-04 03:40:15 -07:00
parent 2aeb95136a
commit e1e086a474
44 changed files with 965 additions and 102 deletions
+122
View File
@@ -0,0 +1,122 @@
"""Tests for Stripe Checkout Session API (mocked Stripe SDK)."""
from unittest.mock import MagicMock, patch
from django.test import override_settings
from django.urls import reverse
from rest_framework import status
from rest_framework.test import APITestCase
from chat_backend.tests.factories import make_company, make_user
from monetization.models import Invoice
class CreateCheckoutSessionViewTestCase(APITestCase):
def setUp(self):
self.company = make_company()
self.user = make_user(company=self.company)
self.client.force_authenticate(user=self.user)
self.url = reverse("finance_checkout")
@override_settings(
STRIPE_SECRET_KEY="sk_test_fake",
SUBSCRIPTION_PRICE_AMOUNT_CENTS=1000,
SUBSCRIPTION_PRICE_CURRENCY="usd",
SUBSCRIPTION_PRICE_INTERVAL="month",
SUBSCRIPTION_PRODUCT_NAME="Chat Subscription",
STRIPE_PRICE_ID="",
STRIPE_CHECKOUT_SUCCESS_URL="http://localhost:3000/ok",
STRIPE_CHECKOUT_CANCEL_URL="http://localhost:3000/cancel",
)
@patch("monetization.services.stripe.stripe.checkout.Session.create")
def test_creates_checkout_session_and_draft_invoice(self, mock_create):
mock_session = MagicMock()
mock_session.id = "cs_test_abc"
mock_session.url = "https://checkout.stripe.com/c/pay/cs_test_abc"
mock_session.customer = None
mock_create.return_value = mock_session
response = self.client.post(self.url, {}, format="json")
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
self.assertEqual(
response.data["checkout_url"],
"https://checkout.stripe.com/c/pay/cs_test_abc",
)
self.assertEqual(response.data["session_id"], "cs_test_abc")
mock_create.assert_called_once()
kwargs = mock_create.call_args.kwargs
self.assertEqual(kwargs["mode"], "subscription")
line_item = kwargs["line_items"][0]
self.assertEqual(line_item["price_data"]["unit_amount"], 1000)
self.assertEqual(line_item["price_data"]["currency"], "usd")
self.assertEqual(
line_item["price_data"]["recurring"]["interval"], "month"
)
self.assertEqual(kwargs["metadata"]["user_id"], str(self.user.pk))
invoice = Invoice.objects.get(stripe_checkout_session_id="cs_test_abc")
self.assertEqual(invoice.user, self.user)
self.assertEqual(invoice.company, self.company)
self.assertEqual(invoice.amount_due, 1000)
self.assertEqual(invoice.status, Invoice.Status.OPEN)
@override_settings(STRIPE_SECRET_KEY="", STRIPE_PRICE_ID="")
def test_missing_stripe_key_returns_503(self):
response = self.client.post(self.url, {}, format="json")
self.assertEqual(response.status_code, status.HTTP_503_SERVICE_UNAVAILABLE)
def test_unauthenticated_rejected(self):
self.client.force_authenticate(user=None)
response = self.client.post(self.url, {}, format="json")
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
@override_settings(
STRIPE_SECRET_KEY="sk_test_fake",
STRIPE_PRICE_ID="price_abc123",
STRIPE_CHECKOUT_SUCCESS_URL="http://localhost:3000/ok",
STRIPE_CHECKOUT_CANCEL_URL="http://localhost:3000/cancel",
)
@patch("monetization.services.stripe.stripe.checkout.Session.create")
def test_uses_stripe_price_id_when_set(self, mock_create):
mock_session = MagicMock()
mock_session.id = "cs_test_price"
mock_session.url = "https://checkout.stripe.com/c/pay/cs_test_price"
mock_session.customer = None
mock_create.return_value = mock_session
response = self.client.post(self.url, {}, format="json")
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
line_item = mock_create.call_args.kwargs["line_items"][0]
self.assertEqual(line_item, {"price": "price_abc123", "quantity": 1})
class InvoicePaymentListViewTestCase(APITestCase):
def setUp(self):
self.company = make_company()
self.user = make_user(company=self.company)
self.other = make_user(
email="other@test.com",
username="other@test.com",
company=self.company,
)
self.client.force_authenticate(user=self.user)
Invoice.objects.create(
user=self.user,
company=self.company,
amount_due=1000,
stripe_checkout_session_id="cs_mine",
)
Invoice.objects.create(
user=self.other,
company=self.company,
amount_due=1000,
stripe_checkout_session_id="cs_other",
)
def test_list_own_invoices_only(self):
response = self.client.get(reverse("finance_invoices"))
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(len(response.data), 1)
self.assertEqual(response.data[0]["stripe_checkout_session_id"], "cs_mine")
+66
View File
@@ -0,0 +1,66 @@
"""Tests for finance Invoice / Payment models and admin registration."""
from django.contrib import admin
from django.test import TestCase
from chat_backend.tests.factories import make_company, make_user
from monetization.models import Invoice, Payment
class InvoicePaymentModelTestCase(TestCase):
def setUp(self):
self.company = make_company()
self.user = make_user(company=self.company)
def test_create_invoice_and_payment(self):
invoice = Invoice.objects.create(
user=self.user,
company=self.company,
status=Invoice.Status.OPEN,
amount_due=1000,
currency="usd",
stripe_checkout_session_id="cs_test_1",
)
payment = Payment.objects.create(
user=self.user,
company=self.company,
invoice=invoice,
amount=1000,
currency="usd",
status=Payment.Status.PENDING,
stripe_payment_intent_id="pi_test_1",
)
self.assertEqual(invoice.provider, Invoice.Provider.STRIPE)
self.assertEqual(payment.invoice_id, invoice.pk)
self.assertEqual(Invoice.objects.count(), 1)
self.assertEqual(Payment.objects.count(), 1)
def test_mark_payment_succeeded(self):
payment = Payment.objects.create(
user=self.user,
amount=1000,
stripe_payment_intent_id="pi_test_2",
)
payment.mark_succeeded()
payment.refresh_from_db()
self.assertEqual(payment.status, Payment.Status.SUCCEEDED)
self.assertIsNotNone(payment.paid_at)
def test_unique_stripe_checkout_session_id(self):
Invoice.objects.create(
user=self.user,
stripe_checkout_session_id="cs_unique",
amount_due=1000,
)
with self.assertRaises(Exception):
Invoice.objects.create(
user=self.user,
stripe_checkout_session_id="cs_unique",
amount_due=1000,
)
class FinanceAdminRegistrationTestCase(TestCase):
def test_invoice_and_payment_registered(self):
self.assertIn(Invoice, admin.site._registry)
self.assertIn(Payment, admin.site._registry)
@@ -0,0 +1,296 @@
"""Tests for multi-plan catalog, Backer whitelist, quotas, and subscription API."""
from datetime import timedelta
from unittest.mock import MagicMock, patch
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.models import PromptMetric
from chat_backend.tests.factories import make_company, make_conversation, make_user
from monetization.models import BackerEmail, SubscriptionPlan, UserSubscription
from monetization.services.plans import (
assign_plan,
needs_checkout,
seed_subscription_plans,
try_redeem_backer_email,
)
from monetization.services.quotas import (
FeatureNotAllowed,
QuotaExceeded,
assert_feature_allowed,
assert_within_quotas,
get_usage_snapshot,
)
class PlanCatalogTestCase(TestCase):
def test_seed_creates_expected_plans(self):
plans = {p.slug: p for p in seed_subscription_plans()}
self.assertEqual(
set(plans),
{"founders", "standard", "pro", "business", "backer"},
)
self.assertTrue(plans["founders"].is_public)
self.assertTrue(plans["founders"].is_selectable)
self.assertEqual(plans["founders"].price_cents, 1000)
self.assertEqual(plans["founders"].prompt_quota_per_window, 300)
self.assertFalse(plans["standard"].is_public)
self.assertEqual(plans["standard"].price_cents, 1500)
self.assertEqual(plans["standard"].prompt_quota_per_window, 100)
self.assertFalse(plans["standard"].allows_image_generation)
self.assertEqual(plans["pro"].price_cents, 4000)
self.assertEqual(plans["pro"].prompt_quota_per_window, 200)
self.assertTrue(plans["pro"].allows_image_generation)
self.assertEqual(plans["business"].price_cents, 9900)
self.assertEqual(plans["business"].prompt_quota_per_window, 300)
self.assertEqual(plans["backer"].price_cents, 0)
self.assertFalse(plans["backer"].is_selectable)
self.assertTrue(plans["backer"].allows_all_future_features)
def test_seed_allows_rag_matrix(self):
"""#43: RAG is gated per-plan — standard is the only tier without it."""
plans = {p.slug: p for p in seed_subscription_plans()}
self.assertTrue(plans["founders"].allows_rag)
self.assertFalse(plans["standard"].allows_rag)
self.assertTrue(plans["pro"].allows_rag)
self.assertTrue(plans["business"].allows_rag)
self.assertTrue(plans["backer"].allows_rag)
def test_allows_feature_recognizes_rag_aliases(self):
plans = {p.slug: p for p in seed_subscription_plans()}
self.assertTrue(plans["pro"].allows_feature("rag"))
self.assertTrue(plans["pro"].allows_feature("document_rag"))
self.assertFalse(plans["standard"].allows_feature("rag"))
self.assertFalse(plans["standard"].allows_feature("document_rag"))
class BackerRedeemTestCase(TestCase):
def setUp(self):
seed_subscription_plans()
self.company = make_company()
def test_redeem_assigns_backer_and_skips_checkout(self):
BackerEmail.objects.create(email="backer@example.com")
user = make_user(email="backer@example.com", company=self.company)
sub = try_redeem_backer_email(user)
self.assertIsNotNone(sub)
self.assertEqual(sub.plan.slug, "backer")
self.assertEqual(sub.source, UserSubscription.Source.BACKER)
self.assertFalse(needs_checkout(user))
entry = BackerEmail.objects.get(email="backer@example.com")
self.assertIsNotNone(entry.redeemed_at)
self.assertEqual(entry.redeemed_user_id, user.pk)
def test_redeem_is_one_shot(self):
BackerEmail.objects.create(email="once@example.com")
user = make_user(email="once@example.com", company=self.company)
self.assertIsNotNone(try_redeem_backer_email(user))
self.assertIsNone(try_redeem_backer_email(user))
@override_settings(ENFORCE_SUBSCRIPTION_GATES=True)
class QuotaGateTestCase(TestCase):
def setUp(self):
seed_subscription_plans()
self.company = make_company()
self.user = make_user(company=self.company)
self.plan = SubscriptionPlan.objects.get(slug="standard")
assign_plan(
self.user,
plan=self.plan,
source=UserSubscription.Source.ADMIN,
)
self.conversation = make_conversation(user=self.user)
def _add_metrics(self, count, *, tokens_in=None, tokens_out=None):
now = timezone.now()
for i in range(count):
PromptMetric.objects.create(
prompt_id=1000 + i,
conversation_id=self.conversation.id,
start_time=now,
prompt_length=10,
tokens_in=tokens_in,
tokens_out=tokens_out,
has_file=False,
model_name="test",
)
def test_prompt_quota_blocks(self):
self._add_metrics(100)
with self.assertRaises(QuotaExceeded) as ctx:
assert_within_quotas(self.user)
self.assertEqual(ctx.exception.code, "prompt_quota_exceeded")
def test_feature_gate_blocks_image_on_standard(self):
with self.assertRaises(FeatureNotAllowed) as ctx:
assert_feature_allowed(self.user, "image_generation")
self.assertEqual(ctx.exception.code, "feature_not_allowed")
def test_pro_allows_image(self):
pro = SubscriptionPlan.objects.get(slug="pro")
assign_plan(
self.user, plan=pro, source=UserSubscription.Source.ADMIN
)
assert_feature_allowed(self.user, "image_generation")
def test_business_allows_rag(self):
business = SubscriptionPlan.objects.get(slug="business")
assign_plan(self.user, plan=business, source=UserSubscription.Source.ADMIN)
assert_feature_allowed(self.user, "rag")
def test_feature_gate_blocks_rag_on_standard(self):
with self.assertRaises(FeatureNotAllowed) as ctx:
assert_feature_allowed(self.user, "rag")
self.assertEqual(ctx.exception.code, "feature_not_allowed")
def test_pro_allows_rag(self):
pro = SubscriptionPlan.objects.get(slug="pro")
assign_plan(
self.user, plan=pro, source=UserSubscription.Source.ADMIN
)
assert_feature_allowed(self.user, "rag")
def test_token_quota_blocks_when_reported(self):
self.plan.monthly_token_quota = 50
self.plan.prompt_quota_per_window = 1000
self.plan.save()
self._add_metrics(1, tokens_in=30, tokens_out=30)
with self.assertRaises(QuotaExceeded) as ctx:
assert_within_quotas(self.user)
self.assertEqual(ctx.exception.code, "token_quota_exceeded")
def test_null_tokens_do_not_fabricate_zero_usage(self):
self._add_metrics(3, tokens_in=None, tokens_out=None)
usage = get_usage_snapshot(self.user)
self.assertIsNone(usage.tokens_in_period)
self.assertIsNone(usage.tokens_out_period)
self.assertIsNone(usage.tokens_total_period)
self.assertGreaterEqual(usage.turns_missing_token_usage, 3)
class PlanListAndSubscriptionApiTestCase(APITestCase):
def setUp(self):
seed_subscription_plans()
self.company = make_company()
self.user = make_user(company=self.company)
def test_public_plans_only_founders(self):
url = reverse("finance_plans")
response = self.client.get(url)
self.assertEqual(response.status_code, status.HTTP_200_OK)
slugs = [row["slug"] for row in response.data]
self.assertEqual(slugs, ["founders"])
def test_subscription_me_includes_usage_nulls(self):
self.client.force_authenticate(user=self.user)
founders = SubscriptionPlan.objects.get(slug="founders")
assign_plan(
self.user,
plan=founders,
source=UserSubscription.Source.STRIPE,
)
url = reverse("finance_subscription_me")
response = self.client.get(url)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data["plan"]["slug"], "founders")
self.assertFalse(response.data["needs_checkout"])
self.assertIsNone(response.data["usage"]["tokens_in_period"])
self.assertEqual(response.data["usage"]["prompt_quota"], 300)
class CheckoutUsesFoundersPlanTestCase(APITestCase):
def setUp(self):
seed_subscription_plans()
self.company = make_company()
self.user = make_user(company=self.company)
self.client.force_authenticate(user=self.user)
self.url = reverse("finance_checkout")
@override_settings(
STRIPE_SECRET_KEY="sk_test_fake",
SUBSCRIPTION_PRICE_AMOUNT_CENTS=1000,
SUBSCRIPTION_PRICE_CURRENCY="usd",
SUBSCRIPTION_PRICE_INTERVAL="month",
SUBSCRIPTION_PRODUCT_NAME="Founders",
STRIPE_PRICE_ID="",
STRIPE_CHECKOUT_SUCCESS_URL="http://localhost:3000/ok",
STRIPE_CHECKOUT_CANCEL_URL="http://localhost:3000/cancel",
)
@patch("monetization.services.stripe.stripe.checkout.Session.create")
def test_checkout_defaults_to_founders(self, mock_create):
mock_session = MagicMock()
mock_session.id = "cs_test_founders"
mock_session.url = "https://checkout.stripe.com/c/pay/cs_test_founders"
mock_session.customer = None
mock_create.return_value = mock_session
response = self.client.post(self.url, {}, format="json")
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
self.assertEqual(response.data["plan_slug"], "founders")
kwargs = mock_create.call_args.kwargs
self.assertEqual(kwargs["metadata"]["plan_slug"], "founders")
self.assertEqual(
kwargs["line_items"][0]["price_data"]["unit_amount"], 1000
)
def test_backer_cannot_checkout(self):
backer = SubscriptionPlan.objects.get(slug="backer")
assign_plan(
self.user, plan=backer, source=UserSubscription.Source.BACKER
)
response = self.client.post(self.url, {}, format="json")
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertFalse(response.data["needs_checkout"])
class TokenSerializerApiTestCase(APITestCase):
def setUp(self):
self.company = make_company()
self.user = make_user(company=self.company)
self.client.force_authenticate(user=self.user)
self.conversation = make_conversation(user=self.user, title="Tok")
def test_conversation_tokens_null_when_unreported(self):
PromptMetric.objects.create(
prompt_id=1,
conversation_id=self.conversation.id,
start_time=timezone.now(),
prompt_length=5,
tokens_in=None,
tokens_out=None,
has_file=False,
model_name="t",
)
response = self.client.get(reverse("conversations"))
self.assertEqual(response.status_code, status.HTTP_200_OK)
row = next(r for r in response.data if r["id"] == self.conversation.id)
self.assertIsNone(row["tokens_in"])
self.assertIsNone(row["tokens_out"])
def test_conversation_tokens_sum_when_reported(self):
for tin, tout, pid in ((10, 20, 1), (5, None, 2), (None, 7, 3)):
PromptMetric.objects.create(
prompt_id=pid,
conversation_id=self.conversation.id,
start_time=timezone.now(),
prompt_length=5,
tokens_in=tin,
tokens_out=tout,
has_file=False,
model_name="t",
)
response = self.client.get(reverse("conversations"))
row = next(r for r in response.data if r["id"] == self.conversation.id)
self.assertEqual(row["tokens_in"], 15)
self.assertEqual(row["tokens_out"], 27)
+102
View File
@@ -0,0 +1,102 @@
"""Tests for Stripe Customer Portal session API (mocked Stripe SDK)."""
from unittest.mock import MagicMock, patch
from django.test import override_settings
from django.urls import reverse
from rest_framework import status
from rest_framework.test import APITestCase
from chat_backend.tests.factories import make_company, make_user
from monetization.models import Invoice
class CreateBillingPortalSessionViewTestCase(APITestCase):
def setUp(self):
self.company = make_company()
self.user = make_user(company=self.company)
self.client.force_authenticate(user=self.user)
self.url = reverse("finance_portal")
def _create_invoice_with_customer(self, customer_id="cus_test_abc"):
return Invoice.objects.create(
user=self.user,
company=self.company,
amount_due=1000,
amount_paid=1000,
status=Invoice.Status.PAID,
stripe_checkout_session_id="cs_portal_test",
stripe_customer_id=customer_id,
stripe_subscription_id="sub_test_abc",
description="Chat Subscription",
)
@override_settings(
STRIPE_SECRET_KEY="sk_test_fake",
STRIPE_PORTAL_RETURN_URL="http://localhost:3000/account/",
)
@patch("monetization.services.stripe.stripe.billing_portal.Session.create")
def test_creates_portal_session(self, mock_create):
self._create_invoice_with_customer()
mock_session = MagicMock()
mock_session.url = "https://billing.stripe.com/p/session/test_portal"
mock_create.return_value = mock_session
response = self.client.post(self.url, {}, format="json")
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
self.assertEqual(
response.data["portal_url"],
"https://billing.stripe.com/p/session/test_portal",
)
mock_create.assert_called_once_with(
customer="cus_test_abc",
return_url="http://localhost:3000/account/",
)
@override_settings(
STRIPE_SECRET_KEY="sk_test_fake",
STRIPE_PORTAL_RETURN_URL="http://localhost:3000/account/",
)
@patch("monetization.services.stripe.stripe.billing_portal.Session.create")
def test_accepts_custom_return_url(self, mock_create):
self._create_invoice_with_customer()
mock_session = MagicMock()
mock_session.url = "https://billing.stripe.com/p/session/custom"
mock_create.return_value = mock_session
response = self.client.post(
self.url,
{"return_url": "http://localhost:3000/account/#billing"},
format="json",
)
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
self.assertEqual(
mock_create.call_args.kwargs["return_url"],
"http://localhost:3000/account/#billing",
)
@override_settings(STRIPE_SECRET_KEY="sk_test_fake")
def test_no_stripe_customer_returns_400(self):
Invoice.objects.create(
user=self.user,
company=self.company,
amount_due=1000,
stripe_checkout_session_id="cs_no_customer",
stripe_customer_id="",
)
response = self.client.post(self.url, {}, format="json")
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertIn("No Stripe customer", response.data["detail"])
@override_settings(STRIPE_SECRET_KEY="")
def test_missing_stripe_key_returns_503(self):
self._create_invoice_with_customer()
response = self.client.post(self.url, {}, format="json")
self.assertEqual(response.status_code, status.HTTP_503_SERVICE_UNAVAILABLE)
def test_unauthenticated_rejected(self):
self.client.force_authenticate(user=None)
response = self.client.post(self.url, {}, format="json")
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
@@ -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()
)
+238
View File
@@ -0,0 +1,238 @@
"""Tests for Stripe webhook verification and ledger upserts."""
from unittest.mock import patch
from django.test import override_settings
from django.urls import reverse
from rest_framework import status
from rest_framework.test import APITestCase
from chat_backend.tests.factories import make_company, make_user
from chat_backend.models import UserAuthEvent
from monetization.models import Invoice, Payment, UserSubscription
from monetization.services.plans import assign_plan_from_stripe, seed_subscription_plans
from monetization.services.webhooks import (
dispatch_stripe_event,
handle_checkout_session_completed,
handle_customer_subscription_deleted,
handle_customer_subscription_updated,
handle_invoice_paid,
handle_invoice_payment_failed,
)
class WebhookHandlerUnitTestCase(APITestCase):
def setUp(self):
self.company = make_company()
self.user = make_user(company=self.company)
def test_checkout_session_completed_creates_invoice_and_payment(self):
session = {
"id": "cs_test_completed",
"metadata": {"user_id": str(self.user.pk)},
"customer": "cus_123",
"subscription": "sub_123",
"payment_intent": "pi_123",
"payment_status": "paid",
"amount_total": 1000,
"currency": "usd",
"customer_email": self.user.email,
}
invoice = handle_checkout_session_completed(session)
self.assertIsNotNone(invoice)
self.assertEqual(invoice.status, Invoice.Status.PAID)
self.assertEqual(invoice.amount_paid, 1000)
self.assertEqual(invoice.stripe_subscription_id, "sub_123")
payment = Payment.objects.get(stripe_payment_intent_id="pi_123")
self.assertEqual(payment.status, Payment.Status.SUCCEEDED)
self.assertEqual(payment.invoice_id, invoice.pk)
def test_checkout_session_completed_is_idempotent(self):
session = {
"id": "cs_test_idem",
"metadata": {"user_id": str(self.user.pk)},
"payment_status": "paid",
"amount_total": 1000,
"currency": "usd",
"payment_intent": "pi_idem",
}
handle_checkout_session_completed(session)
handle_checkout_session_completed(session)
self.assertEqual(
Invoice.objects.filter(stripe_checkout_session_id="cs_test_idem").count(),
1,
)
self.assertEqual(
Payment.objects.filter(stripe_payment_intent_id="pi_idem").count(),
1,
)
def test_invoice_paid_upserts(self):
stripe_invoice = {
"id": "in_paid_1",
"metadata": {"user_id": str(self.user.pk)},
"customer": "cus_1",
"subscription": "sub_1",
"amount_due": 1000,
"amount_paid": 1000,
"currency": "usd",
"status": "paid",
"payment_intent": "pi_paid_1",
"charge": "ch_paid_1",
"period_start": 1_700_000_000,
"period_end": 1_700_259_200,
"hosted_invoice_url": "https://invoice.stripe.com/i/test",
"status_transitions": {"paid_at": 1_700_000_100},
}
invoice = handle_invoice_paid(stripe_invoice)
self.assertEqual(invoice.status, Invoice.Status.PAID)
self.assertEqual(invoice.stripe_invoice_id, "in_paid_1")
payment = Payment.objects.get(stripe_payment_intent_id="pi_paid_1")
self.assertEqual(payment.stripe_charge_id, "ch_paid_1")
self.assertEqual(payment.status, Payment.Status.SUCCEEDED)
def test_invoice_payment_failed(self):
stripe_invoice = {
"id": "in_fail_1",
"metadata": {"user_id": str(self.user.pk)},
"amount_due": 1000,
"amount_paid": 0,
"currency": "usd",
"payment_intent": "pi_fail_1",
}
invoice = handle_invoice_payment_failed(stripe_invoice)
self.assertEqual(invoice.status, Invoice.Status.PAYMENT_FAILED)
payment = Payment.objects.get(stripe_payment_intent_id="pi_fail_1")
self.assertEqual(payment.status, Payment.Status.FAILED)
def test_dispatch_ignores_unknown_events(self):
result = dispatch_stripe_event(
{"type": "customer.created", "data": {"object": {}}}
)
self.assertIsNone(result)
def test_checkout_assigns_plan_from_metadata(self):
seed_subscription_plans(update_existing=False)
session = {
"id": "cs_test_plan_meta",
"metadata": {"user_id": str(self.user.pk), "plan_slug": "founders"},
"customer": "cus_meta",
"subscription": "sub_meta",
"payment_intent": "pi_meta",
"payment_status": "paid",
"amount_total": 1000,
"currency": "usd",
}
handle_checkout_session_completed(session)
sub = UserSubscription.objects.get(user=self.user)
self.assertEqual(sub.plan.slug, "founders")
self.assertEqual(sub.source, UserSubscription.Source.STRIPE)
self.assertEqual(sub.status, UserSubscription.Status.ACTIVE)
started = UserAuthEvent.objects.get(
user=self.user,
event_type=UserAuthEvent.EventType.SUBSCRIPTION_STARTED,
)
self.assertIn("founders", started.detail)
def test_subscription_updated_sets_cancel_at_period_end(self):
seed_subscription_plans(update_existing=False)
assign_plan_from_stripe(
self.user,
plan_slug="founders",
stripe_subscription_id="sub_cancel",
)
result = handle_customer_subscription_updated(
{
"id": "sub_cancel",
"status": "active",
"cancel_at_period_end": True,
"current_period_end": 1_700_259_200,
"metadata": {"user_id": str(self.user.pk), "plan_slug": "founders"},
}
)
self.assertIsNotNone(result)
sub = UserSubscription.objects.get(user=self.user)
self.assertTrue(sub.cancel_at_period_end)
self.assertEqual(sub.status, UserSubscription.Status.ACTIVE)
self.assertIsNotNone(sub.current_period_end)
updated = UserAuthEvent.objects.filter(
user=self.user,
event_type=UserAuthEvent.EventType.SUBSCRIPTION_UPDATED,
).latest("created")
self.assertIn("cancel_at_period_end=True", updated.detail)
def test_subscription_deleted_marks_canceled(self):
seed_subscription_plans(update_existing=False)
assign_plan_from_stripe(
self.user,
plan_slug="founders",
stripe_subscription_id="sub_gone",
)
result = handle_customer_subscription_deleted(
{
"id": "sub_gone",
"status": "canceled",
"current_period_end": 1_700_259_200,
"metadata": {"user_id": str(self.user.pk)},
}
)
self.assertIsNotNone(result)
sub = UserSubscription.objects.get(user=self.user)
self.assertEqual(sub.status, UserSubscription.Status.CANCELED)
self.assertFalse(sub.cancel_at_period_end)
updated = UserAuthEvent.objects.filter(
user=self.user,
event_type=UserAuthEvent.EventType.SUBSCRIPTION_UPDATED,
).latest("created")
self.assertIn("status=canceled", updated.detail)
class StripeWebhookViewTestCase(APITestCase):
def setUp(self):
self.url = reverse("finance_stripe_webhook")
self.company = make_company()
self.user = make_user(company=self.company)
@override_settings(STRIPE_WEBHOOK_SECRET="")
def test_missing_webhook_secret_returns_503(self):
response = self.client.post(
self.url,
data=b"{}",
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_503_SERVICE_UNAVAILABLE)
@override_settings(STRIPE_WEBHOOK_SECRET="whsec_test")
@patch("monetization.views.stripe.Webhook.construct_event")
def test_invalid_signature_returns_400(self, mock_construct):
import stripe
mock_construct.side_effect = stripe.SignatureVerificationError(
"bad sig", "sig_header"
)
response = self.client.post(
self.url,
data=b"{}",
content_type="application/json",
HTTP_STRIPE_SIGNATURE="t=1,v1=bad",
)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
@override_settings(STRIPE_WEBHOOK_SECRET="whsec_test")
@patch("monetization.views.dispatch_stripe_event")
@patch("monetization.views.stripe.Webhook.construct_event")
def test_valid_event_dispatched(self, mock_construct, mock_dispatch):
mock_construct.return_value = {
"id": "evt_1",
"type": "checkout.session.completed",
"data": {"object": {"id": "cs_x"}},
}
response = self.client.post(
self.url,
data=b'{"id":"evt_1"}',
content_type="application/json",
HTTP_STRIPE_SIGNATURE="t=1,v1=good",
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertTrue(response.data["received"])
mock_dispatch.assert_called_once()