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