Add finance app with Stripe Checkout subscriptions (#21) (#23)
Unit Tests / test (push) Successful in 10s
Unit Tests / test (push) Successful in 10s
## Summary - Closes #21 — new Django `finance` app with Stripe as payment provider - Subscription price defaults to **$10 USD / month** via `SUBSCRIPTION_PRICE_AMOUNT_CENTS = 1000` in `settings.py` (env-overridable) - Persists **Invoice** and **Payment** rows; both registered in Django admin (with payment inline on invoices) - Checkout Session API redirects users to Stripe hosted payment; webhook verifies signatures and upserts ledger idempotently ## API - `POST /api/finance/checkout/` — JWT auth → `{ checkout_url, session_id }` - `GET /api/finance/invoices/` / `GET /api/finance/payments/` — own records - `POST /api/finance/webhooks/stripe/` — Stripe signature-verified webhook ## Config Documented in `.env.example` / `.env.prod.example`: `STRIPE_SECRET_KEY`, `STRIPE_PUBLISHABLE_KEY`, `STRIPE_WEBHOOK_SECRET`, optional `STRIPE_PRICE_ID`, `FRONTEND_BASE_URL` ## Test plan - [x] `uv run python manage.py test finance` (17 tests) - [ ] Set Stripe test keys locally; create checkout session; complete payment in Stripe test mode - [ ] Confirm Invoice/Payment appear in `/admin/` - [ ] Point Stripe webhook to `/api/finance/webhooks/stripe/` and verify `checkout.session.completed` / `invoice.paid`Reviewed-on: #23
This commit was merged in pull request #23.
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 finance.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("finance.services.stripe_service.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("finance.services.stripe_service.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 finance.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,159 @@
|
||||
"""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 finance.models import Invoice, Payment
|
||||
from finance.services.webhooks import (
|
||||
dispatch_stripe_event,
|
||||
handle_checkout_session_completed,
|
||||
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)
|
||||
|
||||
|
||||
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("finance.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("finance.views.dispatch_stripe_event")
|
||||
@patch("finance.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