feat: shop PaymentIntent flow — shop_buy + shop_confirm + stripe_webhook — Chunk 3 of [[project-wallet-shop-expansion]]. Three-endpoint split per the locked Stripe design: webhook is authoritative for fulfillment (resilient to 3DS, browser closes, network drops); sync /shop/confirm is a best-effort UX speedup (fulfills immediately when Stripe.js confirms client-side, no waiting for webhook delivery); both call Purchase.fulfill() which is idempotent — whichever lands first wins, the other becomes a no-op via the status==SUCCEEDED guard. **POST /dashboard/wallet/shop/buy** (form-encoded shop_item_slug): looks up active ShopItem (404 if missing/inactive); enforces max_owned via is_available_for(user) (409 if cap hit, eg already-owned BAND); requires a saved PaymentMethod (402 otherwise — picks most-recent via order_by('-pk').first() per the open-Q note in the scope doc); creates Stripe PaymentIntent (amount=item.price_cents, currency=usd, customer=user.stripe_customer_id, payment_method=pm.stripe_pm_id, automatic_payment_methods={enabled, allow_redirects=never} for in-window 3DS); creates Purchase w. pi.id; backfills pi.metadata.purchase_id via PaymentIntent.modify so the webhook handler can resolve back to the row; returns {client_secret, purchase_id} JSON for Stripe.js confirmCardPayment. **POST /dashboard/wallet/shop/confirm** (form-encoded purchase_id): retrieves PI from Stripe, if status=='succeeded' calls purchase.fulfill(); returns {status} JSON. 404 if the purchase doesn't belong to request.user. Idempotent — re-firing after fulfill is a safe no-op. **POST /stripe/webhook** (csrf_exempt, mounted at root /stripe/webhook so the URL stays stable across app-routing refactors w. Stripe's dashboard config): verifies signature via stripe.Webhook.construct_event against STRIPE_WEBHOOK_SECRET env var (400 on mismatch — Stripe won't retry on 4xx, only 5xx); on payment_intent.succeeded looks up Purchase by metadata.purchase_id w. fall-back to stripe_payment_intent_id (both unique). Unknown event types are no-op 200 (Stripe sends charge.dispute.created etc. + would retry indefinitely on 5xx). New STRIPE_WEBHOOK_SECRET = os.environ.get(...) setting; user swaps it on staging+prod per the live-mode env-var-only decision. TDD — 17 ITs in test_shop_views.py across 3 classes: ShopBuyViewTest (7 cases — login required, success path creates PI + Purchase w. correct shape, PI.create called w. correct args, unknown slug 404, inactive item 404, max_owned 409, no PM 402); ShopConfirmViewTest (5 cases — login required, succeeded PI triggers fulfill, processing PI leaves PENDING, idempotent on already-SUCCEEDED, other user's purchase 404); StripeWebhookViewTest (5 cases — sig mismatch 400, succeeded event triggers fulfill, unknown event type 2xx no-op, duplicate delivery idempotent, unknown purchase_id 2xx no-op). All Stripe API calls mocked via mock.patch('apps.dashboard.views.stripe'). 1208 IT/UT green
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
337
src/apps/dashboard/tests/integrated/test_shop_views.py
Normal file
337
src/apps/dashboard/tests/integrated/test_shop_views.py
Normal file
@@ -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)
|
||||
@@ -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'),
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user