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