Summary
Replace the simulated checkout with production-ready payments and a resilient order workflow:
- Stripe Payments (Payment Element or hosted Checkout)
- 3-D Secure (SCA) support
- Idempotent server APIs
- Signed webhooks for post-payment fulfillment
- Inventory reservation + rollback
- Order states with audit trail, email receipts, refunds
This preserves the existing UX while making the system deployable in real environments.
Why
- Users expect real purchases, not a mock flow.
- Idempotency + webhooks prevent double charges & out-of-sync orders.
- Proper PCI/SCA handling via Stripe Elements keeps us SAQ-A compliant (no raw card data on our servers).
- Inventory reservation reduces oversells during spikes.
Scope
Backend (Node/Express)
- Add
POST /api/checkout/payment-intent to create PaymentIntents using idempotency keys.
- Add
POST /api/webhooks/stripe to handle payment_intent.succeeded/processing/failed and charge.refunded.
- Add order states:
PENDING → PAID → FULFILLING → FULFILLED (with CANCELED, FAILED, REFUNDED).
- Inventory reservation: reserve stock when creating the PaymentIntent (TTL ~15 min), confirm on success, release on failure/timeout.
- Email receipts (stub now; provider later).
Frontend (React/MUI)
- Integrate @stripe/stripe-js + @stripe/react-stripe-js Payment Element.
- Preserve the current checkout form (billing/shipping), then mount Stripe element for card entry.
- Show SCA modal flows automatically; reflect real order states on success/failure.
Security / Ops
- Verify Stripe webhook signatures.
- Rate-limit checkout endpoints.
- Move secrets to
.env (local) and Vault (prod) — the repo already has vault/.
- Add Lighthouse/AXE accessibility budget for the checkout page.
Acceptance Criteria
- ✅ Card payment completes end-to-end in test mode with SCA (3DS) prompts.
- ✅ Orders transition through states; DB persists
paymentIntentId, events[] audit entries.
- ✅ Idempotent: repeated client submits do not double-charge or duplicate orders.
- ✅ Inventory is reserved at intent create, decremented on success, restored on failure/timeout.
- ✅ Webhook processing is signed and replay-protected.
- ✅ Unified error UX on the client (declines, 3DS failure, network errors).
- ✅ Unit tests for pricing, tax/shipping calculation, idempotency, webhook handler.
- ✅ Docs updated: env vars, test cards, refund flow.
API Sketch
Create Payment Intent (server)
// backend/routes/checkout.js
import express from "express";
import Stripe from "stripe";
import { requireAuth } from "../middleware/auth.js";
import { reserveInventory, confirmInventory, releaseInventory } from "../services/inventory.js";
import { createOrder, updateOrderStatus, appendOrderEvent } from "../services/orders.js";
const router = express.Router();
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, { apiVersion: "2023-10-16" });
router.post("/payment-intent", requireAuth, async (req, res, next) => {
try {
const { items, shippingAddress, email, idempotencyKey } = req.body;
// 1) Price server-side
const { amount, currency, lineItems } = await priceCart(items);
// 2) Reserve inventory (TTL ~15 min)
const reservationId = await reserveInventory(lineItems);
// 3) Create pending order
const order = await createOrder({
userId: req.user.id,
email,
amount,
currency,
lineItems,
shippingAddress,
state: "PENDING",
reservationId
});
// 4) Create/confirm PaymentIntent (client will confirm with Payment Element)
const intent = await stripe.paymentIntents.create({
amount, currency,
automatic_payment_methods: { enabled: true },
metadata: { orderId: order._id, reservationId },
receipt_email: email
}, { idempotencyKey });
await appendOrderEvent(order._id, "PAYMENT_INTENT_CREATED", { intentId: intent.id });
res.json({ clientSecret: intent.client_secret, orderId: order._id });
} catch (err) {
next(err);
}
});
export default router;
Webhook (server)
// backend/routes/stripeWebhook.js
import express from "express";
import Stripe from "stripe";
import { confirmInventory, releaseInventory } from "../services/inventory.js";
import { updateOrderStatus, appendOrderEvent, findOrderByPaymentIntent } from "../services/orders.js";
const router = express.Router();
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, { apiVersion: "2023-10-16" });
const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET;
router.post("/webhooks/stripe",
express.raw({ type: "application/json" }),
async (req, res) => {
let event;
try {
const sig = req.headers["stripe-signature"];
event = stripe.webhooks.constructEvent(req.body, sig, endpointSecret);
} catch (err) {
return res.status(400).send(`Webhook Error: ${err.message}`);
}
const pi = event.data.object;
const order = await findOrderByPaymentIntent(pi.id);
switch (event.type) {
case "payment_intent.succeeded":
await confirmInventory(order.reservationId);
await updateOrderStatus(order._id, "PAID");
await appendOrderEvent(order._id, "PAYMENT_SUCCEEDED", { id: pi.id });
break;
case "payment_intent.payment_failed":
await releaseInventory(order.reservationId);
await updateOrderStatus(order._id, "FAILED");
await appendOrderEvent(order._id, "PAYMENT_FAILED", { id: pi.id });
break;
case "charge.refunded":
await updateOrderStatus(order._id, "REFUNDED");
await appendOrderEvent(order._id, "REFUNDED", { charge: event.data.object.id });
break;
default:
break;
}
res.json({ received: true });
}
);
export default router;
Frontend (Payment Element)
// src/pages/Checkout.jsx
import {loadStripe} from '@stripe/stripe-js';
import {Elements, PaymentElement, useStripe, useElements} from '@stripe/react-stripe-js';
const stripePromise = loadStripe(import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY);
function CheckoutForm({ orderId, clientSecret }) {
const stripe = useStripe();
const elements = useElements();
const onSubmit = async (e) => {
e.preventDefault();
const { error } = await stripe.confirmPayment({
elements,
confirmParams: { return_url: `${window.location.origin}/order-success?orderId=${orderId}` }
});
// Show error on-page if any
};
return (
<form onSubmit={onSubmit}>
<PaymentElement />
<button disabled={!stripe}>Pay</button>
</form>
);
}
export default function CheckoutPage() {
// call /api/checkout/payment-intent first to get clientSecret & orderId
// then render:
return (
<Elements stripe={stripePromise} options={{ clientSecret }}>
<CheckoutForm orderId={orderId} clientSecret={clientSecret}/>
</Elements>
);
}
Data Model Changes
-
orders:
state: enum (PENDING, PAID, FULFILLING, FULFILLED, CANCELED, FAILED, REFUNDED)
paymentIntentId, reservationId
events[]: { ts, type, payload }
-
inventoryReservations:
reservationId, items[], expiresAt, status (HELD, CONFIRMED, RELEASED)
Environment Variables
STRIPE_SECRET_KEY=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...
VITE_STRIPE_PUBLISHABLE_KEY=pk_test_...
# existing:
MONGO_URI=...
JWT_SECRET=...
Store secrets in Vault for prod; .env only for local.
Test Plan
- Unit: pricing calc, idempotency key reuse, inventory hold/confirm/release.
- Integration: mock Stripe SDK; simulate webhook signatures; order state transitions.
- E2E (Playwright/Cypress): full checkout success, 3DS challenge flow, decline path, retry, refresh after submit (no double charge).
- Load: burst 50 concurrent checkouts → no duplicate orders; inventory stays consistent.
Tasks
Assignee: @hoangsonww
Milestone: v1.2.0
Related: #openapi.yaml (update), docs/, vault/
Nice to have (later):
- Hosted Stripe Checkout as a fallback for quicker go-live.
- Saved payment methods for logged-in users (link tokens).
- Admin refunds & partial refunds.
- Tax/shipping provider integrations (modular pricing engine).
Summary
Replace the simulated checkout with production-ready payments and a resilient order workflow:
This preserves the existing UX while making the system deployable in real environments.
Why
Scope
Backend (Node/Express)
POST /api/checkout/payment-intentto create PaymentIntents using idempotency keys.POST /api/webhooks/stripeto handlepayment_intent.succeeded/processing/failedandcharge.refunded.PENDING → PAID → FULFILLING → FULFILLED(withCANCELED,FAILED,REFUNDED).Frontend (React/MUI)
Security / Ops
.env(local) and Vault (prod) — the repo already hasvault/.Acceptance Criteria
paymentIntentId,events[]audit entries.API Sketch
Create Payment Intent (server)
Webhook (server)
Frontend (Payment Element)
Data Model Changes
orders:state: enum (PENDING,PAID,FULFILLING,FULFILLED,CANCELED,FAILED,REFUNDED)paymentIntentId,reservationIdevents[]:{ ts, type, payload }inventoryReservations:reservationId,items[],expiresAt,status(HELD,CONFIRMED,RELEASED)Environment Variables
Store secrets in Vault for prod;
.envonly for local.Test Plan
Tasks
payment-intentendpoint.docs/payments.mdwith test cards & runbook.Assignee: @hoangsonww
Milestone:
v1.2.0Related: #openapi.yaml (update),
docs/,vault/Nice to have (later):