Monetization app + RevenueCat webhooks (store IAP ledger) (#69)
## 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:
@@ -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")
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
)
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user