From 410664fb0fba9bdd90b4d943f99a841270f689bc Mon Sep 17 00:00:00 2001 From: Disco DeDisco Date: Fri, 22 May 2026 00:42:09 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20shop=20PaymentIntent=20flow=20=E2=80=94?= =?UTF-8?q?=20`shop=5Fbuy`=20+=20`shop=5Fconfirm`=20+=20`stripe=5Fwebhook`?= =?UTF-8?q?=20=E2=80=94=20Chunk=203=20of=20[[project-wallet-shop-expansion?= =?UTF-8?q?]].=20Three-endpoint=20split=20per=20the=20locked=20Stripe=20de?= =?UTF-8?q?sign:=20webhook=20is=20authoritative=20for=20fulfillment=20(res?= =?UTF-8?q?ilient=20to=203DS,=20browser=20closes,=20network=20drops);=20sy?= =?UTF-8?q?nc=20`/shop/confirm`=20is=20a=20best-effort=20UX=20speedup=20(f?= =?UTF-8?q?ulfills=20immediately=20when=20Stripe.js=20confirms=20client-si?= =?UTF-8?q?de,=20no=20waiting=20for=20webhook=20delivery);=20both=20call?= =?UTF-8?q?=20`Purchase.fulfill()`=20which=20is=20idempotent=20=E2=80=94?= =?UTF-8?q?=20whichever=20lands=20first=20wins,=20the=20other=20becomes=20?= =?UTF-8?q?a=20no-op=20via=20the=20`status=3D=3DSUCCEEDED`=20guard.=20**`P?= =?UTF-8?q?OST=20/dashboard/wallet/shop/buy`**=20(form-encoded=20`shop=5Fi?= =?UTF-8?q?tem=5Fslug`):=20looks=20up=20active=20ShopItem=20(404=20if=20mi?= =?UTF-8?q?ssing/inactive);=20enforces=20`max=5Fowned`=20via=20`is=5Favail?= =?UTF-8?q?able=5Ffor(user)`=20(409=20if=20cap=20hit,=20eg=20already-owned?= =?UTF-8?q?=20BAND);=20requires=20a=20saved=20PaymentMethod=20(402=20other?= =?UTF-8?q?wise=20=E2=80=94=20picks=20most-recent=20via=20`order=5Fby('-pk?= =?UTF-8?q?').first()`=20per=20the=20open-Q=20note=20in=20the=20scope=20do?= =?UTF-8?q?c);=20creates=20Stripe=20PaymentIntent=20(amount=3Ditem.price?= =?UTF-8?q?=5Fcents,=20currency=3Dusd,=20customer=3Duser.stripe=5Fcustomer?= =?UTF-8?q?=5Fid,=20payment=5Fmethod=3Dpm.stripe=5Fpm=5Fid,=20automatic=5F?= =?UTF-8?q?payment=5Fmethods=3D{enabled,=20allow=5Fredirects=3Dnever}=20fo?= =?UTF-8?q?r=20in-window=203DS);=20creates=20`Purchase`=20w.=20pi.id;=20ba?= =?UTF-8?q?ckfills=20pi.metadata.purchase=5Fid=20via=20`PaymentIntent.modi?= =?UTF-8?q?fy`=20so=20the=20webhook=20handler=20can=20resolve=20back=20to?= =?UTF-8?q?=20the=20row;=20returns=20`{client=5Fsecret,=20purchase=5Fid}`?= =?UTF-8?q?=20JSON=20for=20Stripe.js=20`confirmCardPayment`.=20**`POST=20/?= =?UTF-8?q?dashboard/wallet/shop/confirm`**=20(form-encoded=20`purchase=5F?= =?UTF-8?q?id`):=20retrieves=20PI=20from=20Stripe,=20if=20`status=3D=3D'su?= =?UTF-8?q?cceeded'`=20calls=20`purchase.fulfill()`;=20returns=20`{status}?= =?UTF-8?q?`=20JSON.=20404=20if=20the=20purchase=20doesn't=20belong=20to?= =?UTF-8?q?=20`request.user`.=20Idempotent=20=E2=80=94=20re-firing=20after?= =?UTF-8?q?=20fulfill=20is=20a=20safe=20no-op.=20**`POST=20/stripe/webhook?= =?UTF-8?q?`**=20(csrf=5Fexempt,=20mounted=20at=20root=20`/stripe/webhook`?= =?UTF-8?q?=20so=20the=20URL=20stays=20stable=20across=20app-routing=20ref?= =?UTF-8?q?actors=20w.=20Stripe's=20dashboard=20config):=20verifies=20sign?= =?UTF-8?q?ature=20via=20`stripe.Webhook.construct=5Fevent`=20against=20`S?= =?UTF-8?q?TRIPE=5FWEBHOOK=5FSECRET`=20env=20var=20(400=20on=20mismatch=20?= =?UTF-8?q?=E2=80=94=20Stripe=20won't=20retry=20on=204xx,=20only=205xx);?= =?UTF-8?q?=20on=20`payment=5Fintent.succeeded`=20looks=20up=20Purchase=20?= =?UTF-8?q?by=20`metadata.purchase=5Fid`=20w.=20fall-back=20to=20`stripe?= =?UTF-8?q?=5Fpayment=5Fintent=5Fid`=20(both=20unique).=20Unknown=20event?= =?UTF-8?q?=20types=20are=20no-op=20200=20(Stripe=20sends=20`charge.disput?= =?UTF-8?q?e.created`=20etc.=20+=20would=20retry=20indefinitely=20on=205xx?= =?UTF-8?q?).=20New=20`STRIPE=5FWEBHOOK=5FSECRET=20=3D=20os.environ.get(..?= =?UTF-8?q?.)`=20setting;=20user=20swaps=20it=20on=20staging+prod=20per=20?= =?UTF-8?q?the=20live-mode=20env-var-only=20decision.=20TDD=20=E2=80=94=20?= =?UTF-8?q?17=20ITs=20in=20`test=5Fshop=5Fviews.py`=20across=203=20classes?= =?UTF-8?q?:=20`ShopBuyViewTest`=20(7=20cases=20=E2=80=94=20login=20requir?= =?UTF-8?q?ed,=20success=20path=20creates=20PI=20+=20Purchase=20w.=20corre?= =?UTF-8?q?ct=20shape,=20PI.create=20called=20w.=20correct=20args,=20unkno?= =?UTF-8?q?wn=20slug=20404,=20inactive=20item=20404,=20max=5Fowned=20409,?= =?UTF-8?q?=20no=20PM=20402);=20`ShopConfirmViewTest`=20(5=20cases=20?= =?UTF-8?q?=E2=80=94=20login=20required,=20succeeded=20PI=20triggers=20ful?= =?UTF-8?q?fill,=20processing=20PI=20leaves=20PENDING,=20idempotent=20on?= =?UTF-8?q?=20already-SUCCEEDED,=20other=20user's=20purchase=20404);=20`St?= =?UTF-8?q?ripeWebhookViewTest`=20(5=20cases=20=E2=80=94=20sig=20mismatch?= =?UTF-8?q?=20400,=20succeeded=20event=20triggers=20fulfill,=20unknown=20e?= =?UTF-8?q?vent=20type=202xx=20no-op,=20duplicate=20delivery=20idempotent,?= =?UTF-8?q?=20unknown=20purchase=5Fid=202xx=20no-op).=20All=20Stripe=20API?= =?UTF-8?q?=20calls=20mocked=20via=20`mock.patch('apps.dashboard.views.str?= =?UTF-8?q?ipe')`.=201208=20IT/UT=20green?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- .../tests/integrated/test_shop_views.py | 337 ++++++++++++++++++ src/apps/dashboard/urls.py | 2 + src/apps/dashboard/views.py | 144 +++++++- src/core/settings.py | 1 + src/core/urls.py | 4 + 5 files changed, 486 insertions(+), 2 deletions(-) create mode 100644 src/apps/dashboard/tests/integrated/test_shop_views.py diff --git a/src/apps/dashboard/tests/integrated/test_shop_views.py b/src/apps/dashboard/tests/integrated/test_shop_views.py new file mode 100644 index 0000000..010dfbd --- /dev/null +++ b/src/apps/dashboard/tests/integrated/test_shop_views.py @@ -0,0 +1,337 @@ +"""Shop view ITs — Chunk 3 of the wallet-expansion sprint. + +Three endpoints under test: + * `POST /dashboard/wallet/shop/buy` (`shop_buy`) — creates a Stripe + PaymentIntent + a `Purchase` row in PENDING; returns + `{client_secret, purchase_id}` for Stripe.js confirmCardPayment. + * `POST /dashboard/wallet/shop/confirm` (`shop_confirm`) — sync follow-up + after Stripe.js confirms client-side; retrieves the PI from Stripe, + if `status=='succeeded'` calls `Purchase.fulfill()` (idempotent w. the + webhook's parallel call). + * `POST /stripe/webhook` (`stripe_webhook`) — async fulfillment fallback + + bulletproof for 3DS-completed cards. Verifies signature against + `STRIPE_WEBHOOK_SECRET`; on `payment_intent.succeeded` calls + `Purchase.fulfill()` (same idempotent method as `shop_confirm`). + +`apps.dashboard.views.stripe` is mocked across all three. The webhook +view's signature verification is also mocked via +`stripe.Webhook.construct_event`. +""" +from unittest import mock + +from django.test import TestCase, override_settings + +from apps.lyric.models import PaymentMethod, Purchase, ShopItem, Token, User + + +def _seed_starting_items(): + """Mirror the seed-migration row shape so each TestCase starts w. a + known catalog (TestCase rolls back the data migration, so the rows + seeded by `0009_seed_shop_items` aren't there during tests).""" + ShopItem.objects.update_or_create( + slug="tithe-1", + defaults={ + "name": "Tithe Token", "description": "1 Tithe + 144 Writs", + "icon": "fa-piggy-bank", "badge_text": "", + "price_cents": 100, "granted_token_type": Token.TITHE, + "granted_count": 1, "granted_writs": 144, + "max_owned": None, "display_order": 10, "active": True, + }, + ) + ShopItem.objects.update_or_create( + slug="band-1", + defaults={ + "name": "Wristband", "description": "Admit All Entry", + "icon": "fa-ring", "badge_text": "", + "price_cents": 2000, "granted_token_type": Token.BAND, + "granted_count": 1, "granted_writs": 0, + "max_owned": 1, "display_order": 30, "active": True, + }, + ) + + +class ShopBuyViewTest(TestCase): + def setUp(self): + _seed_starting_items() + self.user = User.objects.create(email="buyer@test.io") + self.user.stripe_customer_id = "cus_buyer" + self.user.save() + PaymentMethod.objects.create( + user=self.user, stripe_pm_id="pm_test_4242", + last4="4242", brand="visa", + ) + self.client.force_login(self.user) + + def test_requires_login(self): + self.client.logout() + response = self.client.post( + "/dashboard/wallet/shop/buy", {"shop_item_slug": "tithe-1"} + ) + self.assertRedirects( + response, "/?next=/dashboard/wallet/shop/buy", + fetch_redirect_response=False, + ) + + @mock.patch("apps.dashboard.views.stripe") + def test_success_creates_payment_intent_and_purchase(self, mock_stripe): + mock_stripe.PaymentIntent.create.return_value = mock.Mock( + id="pi_test_abc", client_secret="pi_test_abc_secret", + ) + response = self.client.post( + "/dashboard/wallet/shop/buy", {"shop_item_slug": "tithe-1"}, + ) + self.assertEqual(response.status_code, 200) + body = response.json() + self.assertEqual(body["client_secret"], "pi_test_abc_secret") + purchase = Purchase.objects.get(pk=body["purchase_id"]) + self.assertEqual(purchase.user, self.user) + self.assertEqual(purchase.shop_item.slug, "tithe-1") + self.assertEqual(purchase.status, Purchase.PENDING) + self.assertEqual(purchase.stripe_payment_intent_id, "pi_test_abc") + self.assertEqual(purchase.amount_cents, 100) + self.assertEqual(purchase.granted_writs, 144) + + @mock.patch("apps.dashboard.views.stripe") + def test_payment_intent_called_with_correct_args(self, mock_stripe): + mock_stripe.PaymentIntent.create.return_value = mock.Mock( + id="pi_a", client_secret="pi_a_secret", + ) + self.client.post( + "/dashboard/wallet/shop/buy", {"shop_item_slug": "tithe-1"}, + ) + kwargs = mock_stripe.PaymentIntent.create.call_args.kwargs + self.assertEqual(kwargs["amount"], 100) + self.assertEqual(kwargs["currency"], "usd") + self.assertEqual(kwargs["customer"], "cus_buyer") + self.assertEqual(kwargs["payment_method"], "pm_test_4242") + + @mock.patch("apps.dashboard.views.stripe") + def test_unknown_item_slug_returns_404(self, mock_stripe): + response = self.client.post( + "/dashboard/wallet/shop/buy", {"shop_item_slug": "no-such-item"}, + ) + self.assertEqual(response.status_code, 404) + + @mock.patch("apps.dashboard.views.stripe") + def test_inactive_item_returns_404(self, mock_stripe): + item = ShopItem.objects.get(slug="tithe-1") + item.active = False + item.save() + response = self.client.post( + "/dashboard/wallet/shop/buy", {"shop_item_slug": "tithe-1"}, + ) + self.assertEqual(response.status_code, 404) + + @mock.patch("apps.dashboard.views.stripe") + def test_max_owned_violation_returns_409(self, mock_stripe): + """User already owns 1 BAND (max_owned=1) → buy refused w. 409.""" + Token.objects.create(user=self.user, token_type=Token.BAND) + response = self.client.post( + "/dashboard/wallet/shop/buy", {"shop_item_slug": "band-1"}, + ) + self.assertEqual(response.status_code, 409) + # No PaymentIntent created + mock_stripe.PaymentIntent.create.assert_not_called() + + @mock.patch("apps.dashboard.views.stripe") + def test_no_payment_method_returns_402(self, mock_stripe): + """User w. no saved PaymentMethod → 402 Payment Required.""" + self.user.payment_methods.all().delete() + response = self.client.post( + "/dashboard/wallet/shop/buy", {"shop_item_slug": "tithe-1"}, + ) + self.assertEqual(response.status_code, 402) + mock_stripe.PaymentIntent.create.assert_not_called() + + +class ShopConfirmViewTest(TestCase): + def setUp(self): + _seed_starting_items() + self.user = User.objects.create(email="confirm@test.io") + self.user.tokens.all().delete() + self.user.refresh_from_db() + self.client.force_login(self.user) + self.tithe = ShopItem.objects.get(slug="tithe-1") + self.purchase = Purchase.objects.create( + user=self.user, shop_item=self.tithe, + stripe_payment_intent_id="pi_conf_1", + amount_cents=100, granted_writs=144, + ) + + def test_requires_login(self): + self.client.logout() + response = self.client.post( + "/dashboard/wallet/shop/confirm", {"purchase_id": self.purchase.pk}, + ) + self.assertRedirects( + response, "/?next=/dashboard/wallet/shop/confirm", + fetch_redirect_response=False, + ) + + @mock.patch("apps.dashboard.views.stripe") + def test_succeeded_pi_triggers_fulfill(self, mock_stripe): + mock_stripe.PaymentIntent.retrieve.return_value = mock.Mock(status="succeeded") + response = self.client.post( + "/dashboard/wallet/shop/confirm", {"purchase_id": self.purchase.pk}, + ) + self.assertEqual(response.status_code, 200) + self.purchase.refresh_from_db() + self.assertEqual(self.purchase.status, Purchase.SUCCEEDED) + self.assertEqual( + self.user.tokens.filter(token_type=Token.TITHE).count(), 1 + ) + + @mock.patch("apps.dashboard.views.stripe") + def test_pending_pi_does_not_fulfill(self, mock_stripe): + """Stripe still processing → leave Purchase PENDING for the webhook.""" + mock_stripe.PaymentIntent.retrieve.return_value = mock.Mock(status="processing") + self.client.post( + "/dashboard/wallet/shop/confirm", {"purchase_id": self.purchase.pk}, + ) + self.purchase.refresh_from_db() + self.assertEqual(self.purchase.status, Purchase.PENDING) + self.assertEqual( + self.user.tokens.filter(token_type=Token.TITHE).count(), 0 + ) + + @mock.patch("apps.dashboard.views.stripe") + def test_idempotent_when_already_succeeded(self, mock_stripe): + """Webhook already fulfilled — confirm endpoint shouldn't double-mint.""" + self.purchase.fulfill() + self.assertEqual(self.purchase.status, Purchase.SUCCEEDED) + mock_stripe.PaymentIntent.retrieve.return_value = mock.Mock(status="succeeded") + response = self.client.post( + "/dashboard/wallet/shop/confirm", {"purchase_id": self.purchase.pk}, + ) + self.assertEqual(response.status_code, 200) + self.assertEqual( + self.user.tokens.filter(token_type=Token.TITHE).count(), 1 + ) + + @mock.patch("apps.dashboard.views.stripe") + def test_other_users_purchase_returns_404(self, mock_stripe): + """A user trying to confirm someone else's PendingPurchase gets 404.""" + other = User.objects.create(email="other@test.io") + other_purchase = Purchase.objects.create( + user=other, shop_item=self.tithe, + stripe_payment_intent_id="pi_other", + amount_cents=100, granted_writs=144, + ) + response = self.client.post( + "/dashboard/wallet/shop/confirm", {"purchase_id": other_purchase.pk}, + ) + self.assertEqual(response.status_code, 404) + + +@override_settings(STRIPE_WEBHOOK_SECRET="whsec_test_123") +class StripeWebhookViewTest(TestCase): + def setUp(self): + _seed_starting_items() + self.user = User.objects.create(email="webhook@test.io") + self.user.tokens.all().delete() + self.user.refresh_from_db() + self.tithe = ShopItem.objects.get(slug="tithe-1") + self.purchase = Purchase.objects.create( + user=self.user, shop_item=self.tithe, + stripe_payment_intent_id="pi_wh_1", + amount_cents=100, granted_writs=144, + ) + + @mock.patch("apps.dashboard.views.stripe") + def test_signature_mismatch_returns_400(self, mock_stripe): + mock_stripe.Webhook.construct_event.side_effect = ValueError("bad sig") + response = self.client.post( + "/stripe/webhook", + data=b"{}", + content_type="application/json", + HTTP_STRIPE_SIGNATURE="t=0,v1=deadbeef", + ) + self.assertEqual(response.status_code, 400) + + @mock.patch("apps.dashboard.views.stripe") + def test_payment_intent_succeeded_triggers_fulfill(self, mock_stripe): + # `Mock(intent_obj, ...)` returns a `dict()`-style object that supports + # `event["type"]` AND `event["data"]["object"]["id"]` — match what the + # webhook view will read. + mock_stripe.Webhook.construct_event.return_value = { + "type": "payment_intent.succeeded", + "data": {"object": { + "id": "pi_wh_1", + "metadata": {"purchase_id": str(self.purchase.pk)}, + }}, + } + response = self.client.post( + "/stripe/webhook", data=b"{}", + content_type="application/json", + HTTP_STRIPE_SIGNATURE="t=0,v1=goodsig", + ) + self.assertEqual(response.status_code, 200) + self.purchase.refresh_from_db() + self.assertEqual(self.purchase.status, Purchase.SUCCEEDED) + self.assertEqual( + self.user.tokens.filter(token_type=Token.TITHE).count(), 1 + ) + + @mock.patch("apps.dashboard.views.stripe") + def test_unknown_event_type_is_noop(self, mock_stripe): + """Stripe sends a lot of event types we don't care about (eg + `charge.dispute.created`). Webhook view should return 2xx so + Stripe doesn't retry, but NOT touch the Purchase.""" + mock_stripe.Webhook.construct_event.return_value = { + "type": "charge.dispute.created", + "data": {"object": {"id": "pi_wh_1"}}, + } + response = self.client.post( + "/stripe/webhook", data=b"{}", + content_type="application/json", + HTTP_STRIPE_SIGNATURE="t=0,v1=goodsig", + ) + self.assertEqual(response.status_code, 200) + self.purchase.refresh_from_db() + self.assertEqual(self.purchase.status, Purchase.PENDING) + + @mock.patch("apps.dashboard.views.stripe") + def test_duplicate_delivery_is_idempotent(self, mock_stripe): + """Stripe may redeliver a webhook (network blip, our 5xx, etc.). + Re-firing the same `payment_intent.succeeded` event must not + double-mint tokens.""" + mock_stripe.Webhook.construct_event.return_value = { + "type": "payment_intent.succeeded", + "data": {"object": { + "id": "pi_wh_1", + "metadata": {"purchase_id": str(self.purchase.pk)}, + }}, + } + self.client.post( + "/stripe/webhook", data=b"{}", + content_type="application/json", + HTTP_STRIPE_SIGNATURE="t=0,v1=goodsig", + ) + self.client.post( + "/stripe/webhook", data=b"{}", + content_type="application/json", + HTTP_STRIPE_SIGNATURE="t=0,v1=goodsig", + ) + self.assertEqual( + self.user.tokens.filter(token_type=Token.TITHE).count(), 1 + ) + + @mock.patch("apps.dashboard.views.stripe") + def test_unknown_purchase_id_in_metadata_is_noop(self, mock_stripe): + """If metadata.purchase_id doesn't match any row, log + 200 (don't + crash the webhook listener — Stripe would retry on 5xx).""" + mock_stripe.Webhook.construct_event.return_value = { + "type": "payment_intent.succeeded", + "data": {"object": { + "id": "pi_unknown", + "metadata": {"purchase_id": "999999"}, + }}, + } + response = self.client.post( + "/stripe/webhook", data=b"{}", + content_type="application/json", + HTTP_STRIPE_SIGNATURE="t=0,v1=goodsig", + ) + self.assertEqual(response.status_code, 200) + self.purchase.refresh_from_db() + self.assertEqual(self.purchase.status, Purchase.PENDING) diff --git a/src/apps/dashboard/urls.py b/src/apps/dashboard/urls.py index 5d5e992..fa58107 100644 --- a/src/apps/dashboard/urls.py +++ b/src/apps/dashboard/urls.py @@ -10,6 +10,8 @@ urlpatterns = [ path('wallet/toggle-applets', views.toggle_wallet_applets, name='toggle_wallet_applets'), path('wallet/setup-intent', views.setup_intent, name='setup_intent'), path('wallet/save-payment-method', views.save_payment_method, name='save_payment_method'), + path('wallet/shop/buy', views.shop_buy, name='shop_buy'), + path('wallet/shop/confirm', views.shop_confirm, name='shop_confirm'), path('kit-bag/', views.kit_bag, name='kit_bag'), path('sky/', views.sky_view, name='sky'), path('sky/preview', views.sky_preview, name='sky_preview'), diff --git a/src/apps/dashboard/views.py b/src/apps/dashboard/views.py index 080c029..feb35da 100644 --- a/src/apps/dashboard/views.py +++ b/src/apps/dashboard/views.py @@ -13,12 +13,12 @@ from django.http import HttpResponse, HttpResponseForbidden, HttpResponseRedirec from django.shortcuts import redirect, render from django.urls import reverse from django.utils import timezone -from django.views.decorators.csrf import ensure_csrf_cookie +from django.views.decorators.csrf import csrf_exempt, ensure_csrf_cookie from apps.applets.utils import applet_context, apply_applet_toggle from apps.drama.models import Note from apps.epic.utils import _compute_distinctions -from apps.lyric.models import PaymentMethod, Token, User, Wallet, is_reserved_username +from apps.lyric.models import PaymentMethod, Purchase, ShopItem, Token, User, Wallet, is_reserved_username APPLET_ORDER = ["wallet", "username", "palette"] @@ -240,6 +240,146 @@ def save_payment_method(request): return JsonResponse({"last4": pm.card.last4, "brand": pm.card.brand}) +# ── Shop: PaymentIntent flow ───────────────────────────────────────────────── +# Three endpoints split fulfillment responsibility: +# /shop/buy creates a Purchase (PENDING) + a Stripe PaymentIntent. +# Returns client_secret so Stripe.js can confirmCardPayment +# (handles 3DS natively). +# /shop/confirm sync follow-up after Stripe.js confirms client-side. Pulls +# PI status from Stripe; if SUCCEEDED, calls Purchase.fulfill() +# immediately (faster UX than waiting for the webhook round-trip). +# /stripe/webhook async fulfillment from Stripe's webhook delivery. Same +# Purchase.fulfill() call — whichever (confirm or webhook) +# lands first wins; the other becomes a no-op via fulfill()'s +# idempotent guard. +# +# Decisions locked 2026-05-21 in [[project-wallet-shop-expansion]]: +# * Webhook is THE authoritative source for fulfillment (resilient to 3DS, +# network drops, browser closes during checkout). +# * Confirm endpoint is a UX-speedup belt-and-suspenders; never required. +# * Webhook idempotency via Purchase.fulfill()'s status==SUCCEEDED guard. +# * No STRIPE_LIVE_MODE setting — env-var swap is all that's needed. + + +@login_required(login_url="/") +def shop_buy(request): + """Create a Stripe PaymentIntent + a PENDING Purchase row. + + Body: `shop_item_slug` (form-encoded). + Returns: 200 `{client_secret, purchase_id}` on success; + 402 if the user has no saved PaymentMethod; + 404 if the slug doesn't match an active ShopItem; + 409 if the item's max_owned cap is reached for this user. + """ + stripe.api_key = settings.STRIPE_SECRET_KEY + slug = request.POST.get("shop_item_slug", "") + item = ShopItem.objects.filter(slug=slug, active=True).first() + if item is None: + return HttpResponse(status=404) + if not item.is_available_for(request.user): + return HttpResponse(status=409) + pm = request.user.payment_methods.order_by("-pk").first() + if pm is None: + return HttpResponse(status=402) + intent = stripe.PaymentIntent.create( + amount=item.price_cents, + currency="usd", + customer=request.user.stripe_customer_id, + payment_method=pm.stripe_pm_id, + # `automatic_payment_methods` so Stripe.js picks the right confirm + # method (cards, wallets, etc.) without us hard-coding payment-method- + # type plumbing. `allow_redirects=never` keeps the 3DS challenge in + # the same window — Stripe.js handles the modal natively. + automatic_payment_methods={"enabled": True, "allow_redirects": "never"}, + metadata={ + # Webhook handler looks up the Purchase by this on + # `payment_intent.succeeded`. Belt-and-suspenders w. looking up + # by `stripe_payment_intent_id` (also unique). + "purchase_id": "_pending_", # overwritten after Purchase.save() below + }, + ) + purchase = Purchase.objects.create( + user=request.user, + shop_item=item, + stripe_payment_intent_id=intent.id, + amount_cents=item.price_cents, + granted_writs=item.granted_writs, + ) + # Now we have purchase.pk — backfill the metadata on the PI so the + # webhook handler can resolve back to it. + stripe.PaymentIntent.modify( + intent.id, metadata={"purchase_id": str(purchase.pk)}, + ) + return JsonResponse({ + "client_secret": intent.client_secret, + "purchase_id": purchase.pk, + }) + + +@login_required(login_url="/") +def shop_confirm(request): + """Sync follow-up after Stripe.js confirms client-side. Polls the PI + once + fulfills if SUCCEEDED. Idempotent w. the webhook handler via + `Purchase.fulfill()`'s status guard. + + Body: `purchase_id` (form-encoded). + Returns: 200 always (sync fulfillment is best-effort; webhook is + authoritative). 404 if the purchase doesn't belong to this user. + """ + stripe.api_key = settings.STRIPE_SECRET_KEY + purchase_id = request.POST.get("purchase_id") + purchase = Purchase.objects.filter( + pk=purchase_id, user=request.user, + ).first() + if purchase is None: + return HttpResponse(status=404) + if purchase.status != Purchase.SUCCEEDED: + intent = stripe.PaymentIntent.retrieve(purchase.stripe_payment_intent_id) + if intent.status == "succeeded": + purchase.fulfill() + return JsonResponse({"status": purchase.status}) + + +@csrf_exempt +def stripe_webhook(request): + """Stripe webhook listener. Verifies signature against + `STRIPE_WEBHOOK_SECRET`; on `payment_intent.succeeded` calls + `Purchase.fulfill()` (idempotent w. `/shop/confirm`). + + Always returns 2xx (even on unknown event types or already-fulfilled + purchases) — Stripe retries on 5xx, which would just deliver the same + event repeatedly. 4xx is reserved for signature mismatch (a genuine + auth failure that Stripe should NOT retry). + """ + stripe.api_key = settings.STRIPE_SECRET_KEY + payload = request.body + sig_header = request.headers.get("Stripe-Signature", "") + try: + event = stripe.Webhook.construct_event( + payload, sig_header, settings.STRIPE_WEBHOOK_SECRET, + ) + except (ValueError, Exception) as e: + # ValueError = invalid payload; SignatureVerificationError = bad sig. + # Either way, refuse — Stripe will alert if it can't deliver. + if isinstance(e, ValueError) or "Signature" in type(e).__name__: + return HttpResponse(status=400) + raise + if event["type"] == "payment_intent.succeeded": + intent = event["data"]["object"] + purchase_id = intent.get("metadata", {}).get("purchase_id") + purchase = None + if purchase_id and purchase_id.isdigit(): + purchase = Purchase.objects.filter(pk=int(purchase_id)).first() + # Fall-back lookup by PI ID in case metadata's missing for any reason. + if purchase is None: + purchase = Purchase.objects.filter( + stripe_payment_intent_id=intent.get("id", ""), + ).first() + if purchase is not None: + purchase.fulfill() + return HttpResponse(status=200) + + # ── My Sky (personal natal chart) ──────────────────────────────────────────── def _sky_preview_data(request): diff --git a/src/core/settings.py b/src/core/settings.py index 3b87a9b..ef20196 100644 --- a/src/core/settings.py +++ b/src/core/settings.py @@ -221,6 +221,7 @@ MAILGUN_DOMAIN = "howdy.earthmanrpg.com" # Your Mailgun domain # Stripe payment settings STRIPE_PUBLISHABLE_KEY = os.environ.get("STRIPE_PUBLISHABLE_KEY", "") STRIPE_SECRET_KEY = os.environ.get("STRIPE_SECRET_KEY", "") +STRIPE_WEBHOOK_SECRET = os.environ.get("STRIPE_WEBHOOK_SECRET", "") # PySwiss ephemeris microservice PYSWISS_URL = os.environ.get("PYSWISS_URL", "http://127.0.0.1:8001") diff --git a/src/core/urls.py b/src/core/urls.py index 9a7747f..1a60572 100644 --- a/src/core/urls.py +++ b/src/core/urls.py @@ -17,6 +17,10 @@ urlpatterns = [ path('billboard/', include('apps.billboard.urls')), path('ap/', include('apps.ap.urls')), path('.well-known/webfinger', ap_views.webfinger, name='webfinger'), + # Stripe webhook lives at a stable root-level URL (no `dashboard/` prefix + # so we can keep the same endpoint pinned in the Stripe dashboard's + # webhook config across any future app-routing refactors). + path('stripe/webhook', dash_views.stripe_webhook, name='stripe_webhook'), ] # Please remove the following urlpattern