Skip to content

feat: replace Snipcart with Stripe Checkout + custom cookie cart - #84

Draft
khou22 wants to merge 7 commits into
mainfrom
feat/stripe-checkout
Draft

feat: replace Snipcart with Stripe Checkout + custom cookie cart#84
khou22 wants to merge 7 commits into
mainfrom
feat/stripe-checkout

Conversation

@khou22

@khou22 khou22 commented Jul 7, 2026

Copy link
Copy Markdown
Owner

Summary

Removes Snipcart entirely and replaces it with a self-owned cart (cookie-backed) and Stripe Checkout. No monthly/per-order platform fees, no vendor-hosted cart UI, and no secrets in client code. Inventory and order fulfillment stay manual via the Stripe Dashboard, which suits the low volume of the print store.

Motivation

  • Drop Snipcart's fees and hosted-cart lock-in.
  • Own the cart UX so it matches the rest of the site.
  • Keep the system as simple as possible — no admin dashboard, no order database. The Stripe Dashboard is the order history; a notification email is the fulfillment trigger.

Architecture

flowchart LR
  A[ProductDetails: Add to cart] --> B[Cart cookie]
  B --> C[/photography/cart page/]
  C --> D[POST /api/checkout]
  D --> E[Server re-derives prices from photoPricing]
  E --> F[Stripe Checkout Session]
  F --> G[Redirect to Stripe hosted page]
  G --> H[Payment complete]
  H --> I[Stripe webhook: checkout.session.completed]
  I --> J[Verify signature]
  J --> K[EmailServiceFactory → order email]
  K --> L[Manual fulfillment via Stripe Dashboard]
Loading

Key principle: the client never sends prices. The cart cookie stores only { photoID, variantId, qty }; the server re-derives the real price from photoPricing at checkout time, so cart tampering is a non-issue.

What changed

Added

  • Cookie-backed cartCartProvider / useCart (src/components/organisms/Cart/) storing only { photoID, variantId, qty } in a 30-day cookie, with validation + quantity clamping on read.
  • Cart page (/photography/cart) — line items with thumbnails, quantity controls, remove, subtotal, and a Checkout button.
  • Checkout success page (/photography/cart/success) — clears the cart on arrival.
  • POST /api/checkout — validates items against photoPricing (rejects unknown photos / out-of-stock variants with 400), builds Stripe line items with inline price_data, collects a US shipping address, and applies an optional Dashboard-configured shipping rate via STRIPE_SHIPPING_RATE_ID.
  • POST /api/stripe/webhook — verifies the Stripe signature, fetches line items, and emails the full order (items, customer, ship-to) via the existing EmailServiceFactory. Returns 5xx on email failure so Stripe automatically retries.
  • getPrintProduct (src/utils/printProduct.ts, renamed from snipcart.ts) — provider-neutral product metadata.
  • Design doc at docs-internal/stripe-checkout-design.md.

Removed

  • Snipcart script/CSS loader, the Snipcart CartButton, the pricing.json order-validation route, getSnipcartPublicKey, and the NEXT_PUBLIC_SNIPCART_KEY env var.

Changed

  • ProductDetails "Add to cart" now uses the cart hook + a sonner toast. The existing PostHog add_to_cart event is preserved, and a new begin_checkout event was added on the cart page.

Dependencies

  • Added stripe. Email libraries (mailgun.js, form-data) were already installed and are reused via EmailServiceFactory — no new email deps.

Security

  • Secret keys (STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET, mail keys) are server-only; never NEXT_PUBLIC_.
  • Prices are re-derived server-side, so client cart tampering cannot change what is charged.
  • The webhook verifies the Stripe signature before acting on any event.
  • Out-of-stock variants are rejected at checkout.
  • .env is committed with empty placeholder values (the checked-in example); real values live only in .env.local and Vercel.

Vercel compatibility

Each src/app/api/* route deploys as a serverless function — the webhook is just an inbound HTTPS POST, exactly like the existing /api/contact endpoint. The handler reads the raw body via request.text() before parsing, so signature verification works. If the function ever errors, Stripe retries for up to 3 days. Note: production Deployment Protection must stay off, or it will block Stripe's POSTs.

Implementation detail: in Stripe SDK v22 the shipping address lives at session.collected_information.shipping_details (not the older session.shipping_details); the webhook uses the current path.

Testing

  • pnpm lint — clean
  • pnpm test — 19 passed
  • pnpm build — succeeds; new routes registered (/api/checkout ƒ, /api/stripe/webhook ƒ, /photography/cart, /photography/cart/success)

🚀 Pre-launch checklist

1. Stripe Dashboard (test mode first)

  • Create / log in to Stripe account
  • Copy test secret key (sk_test_…) for local testing
  • (Optional) Create a shipping rate (Product catalog → Shipping rates), copy its shr_… ID

2. Local test run

  • Add to .env.local (gitignored):
    STRIPE_SECRET_KEY=sk_test_... STRIPE_WEBHOOK_SECRET= # filled in by the next step STRIPE_SHIPPING_RATE_ID= # optional, shr_... (test-mode rate) ORDER_NOTIFICATION_EMAIL=you@example.com MAILGUN_API_KEY=... # already have MAILGUN_DOMAIN=... # already have
  • brew install stripe/stripe-cli/stripe && stripe login
  • stripe listen --forward-to localhost:3000/api/stripe/webhook
    → copy the printed whsec_… into STRIPE_WEBHOOK_SECRET in .env.local
  • pnpm dev, then run the end-to-end flow:
    • Add prints to cart from a photo page (toast + cart badge update)
    • Cart page: change qty, remove item, subtotal correct
    • Checkout with test card 4242 4242 4242 4242 (any date/CVC/ZIP)
    • Redirected to success page, cart cleared
    • Order email received with items + shipping address
    • Order visible in Stripe Dashboard (test mode)

3. Vercel (production)

  • Add env vars (Settings → Environment Variables, Production):
    STRIPE_SECRET_KEY=sk_live_... STRIPE_WEBHOOK_SECRET= # filled in by the next section STRIPE_SHIPPING_RATE_ID= # optional, must be a LIVE-mode rate ORDER_NOTIFICATION_EMAIL=you@example.com
  • Confirm MAILGUN_API_KEY / MAILGUN_DOMAIN are already set in Vercel
  • Confirm Deployment Protection is off for production
  • Merge this PR → deploy

4. Stripe Dashboard (live mode)

  • Switch to live mode, add webhook endpoint:
    https://<your-domain>/api/stripe/webhook
    → listen for events: checkout.session.completed, checkout.session.async_payment_succeeded, checkout.session.async_payment_failed
    pin the endpoint's API version to the SDK's pinned version (2026-06-24.dahlia) instead of the account default
  • Copy that endpoint's signing secret (whsec_…) into STRIPE_WEBHOOK_SECRET on Vercel → redeploy
  • (Optional) Recreate the shipping rate in live mode, update STRIPE_SHIPPING_RATE_ID

5. Live smoke test

  • Buy the cheapest print ($8 4x6) with a real card
  • Order email arrives, order shows in Stripe Dashboard
  • Refund yourself from the Dashboard

6. Cleanup

  • Cancel Snipcart subscription 🎉

🔍 Post-review hardening (follow-up commits)

A rigorous audit of the initial commit surfaced six improvements, each in its own commit:

  1. Webhook correctness — re-retrieves the session via the SDK (event payload shape follows the endpoint's API version, not the SDK's, so shipping details could silently go missing), only emails when payment_status === "paid", handles checkout.session.async_payment_succeeded (delayed methods like ACH fire completed while still unpaid), and expands line-item products so the exact photoID appears in the order email.
  2. Dev-aware redirect URLssuccess_url/cancel_url use the request origin in dev so local test checkouts return to localhost instead of production.
  3. Input hardening/api/checkout rejects non-integer/< 1 quantities and carts over Stripe's 100-line-item cap with proper 400s (previously a NaN quantity surfaced as a 500).
  4. Pricing data fix — the 12x18-photo-paper variant's display name said "16x20"; corrected to "12x18" (id + dimensions both agree).
  5. UX polish — checkout button un-sticks when returning from Stripe via Back (bfcache), server 400 messages surface in the error toast, cart cookie gets Secure over https.
  6. Docs — design doc updated with the API-version pinning guidance and async payment events (also reflected in checklist §4 above).

- Add cookie-backed cart (CartProvider/useCart) storing only
  {photoID, variantId, qty}; prices always re-derived server-side
- New cart page + checkout success page under /photography/cart
- POST /api/checkout validates items against photoPricing and creates
  a Stripe Checkout Session (inline price_data, US shipping address,
  optional Dashboard-configured STRIPE_SHIPPING_RATE_ID)
- POST /api/stripe/webhook verifies signature and emails order details
  via existing EmailServiceFactory (email-only order tracking)
- Replace Snipcart add-to-cart in ProductDetails with cart hook +
  sonner toast (PostHog add_to_cart event preserved)
- Remove Snipcart script/CSS, CartButton, pricing.json validation
  route, getSnipcartPublicKey, and NEXT_PUBLIC_SNIPCART_KEY
- Add design doc at docs-internal/stripe-checkout-design.md

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@khou22
khou22 marked this pull request as draft July 7, 2026 05:00
khou22 and others added 6 commits July 23, 2026 10:07
…oad shape

- Re-retrieve the Checkout Session via the SDK instead of trusting the
  event payload: event shapes follow the webhook endpoint's configured
  API version, so collected_information (shipping) could silently be
  missing if the endpoint was created on an older version
- Only send the order email when payment_status === "paid" — delayed
  payment methods fire checkout.session.completed while still unpaid
- Handle checkout.session.async_payment_succeeded so async payments
  still trigger the fulfillment email; log async_payment_failed
- Expand line item products so the exact photoID appears in the order
  email (display names alone can be ambiguous for fulfillment)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
success_url/cancel_url were always built from the production siteUrl,
so local test checkouts redirected to khou22.com — the localhost cart
cookie was never cleared and the success page couldn't be tested.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A malformed qty (string, NaN, missing) previously flowed through
Math.round into a NaN quantity, which Stripe rejected — surfacing as a
500 instead of a 400. Reject non-integer or < 1 quantities and carts
over Stripe's 100-line-item cap with proper 400s.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The variant's id and dimensions are both 12x18 but the customer-facing
name said "16x20" — which would have printed on Stripe line items and
receipts for the wrong size.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Reset the checkout button when the page is restored from the bfcache
  (Back from Stripe left it stuck on "Redirecting…" in Safari/Firefox)
- Surface server 400 validation messages (eg. out-of-stock variant) in
  the error toast instead of a generic failure message
- Set the Secure attribute on the cart cookie over https

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant