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:
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user