hoangsonww / hoangsonww/MERN-Stack-Ecommerce-App
Epic: Real Payments & Robust Order Lifecycle (Stripe + Idempotency + Webhooks)
- Dominant language
- JavaScript
- Stars
- 126
- Forks
- 66
- PR merge metrics
- No merged PRs in 30d
Description
## 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)
```js
// 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)
```js
// 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)
```jsx
// 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 (
Pay
);
}
export default function CheckoutPage() {
// call /api/checkout/payment-intent first to get clientSecret & orderId
// then render:
return (
);
}
```
---
## 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
* [ ] Backend: pricing service & validation (Zod/Joi).
* [ ] Backend: idempotent `payment-intent` endpoint.
* [ ] Backend: inventory reservation service (TTL + cron cleaner).
* [ ] Backend: webhook route + signature verify + replay guard.
* [ ] Backend: order model + state machine + audit events.
* [ ] Frontend: integrate Payment Element; error/3DS UX.
* [ ] Frontend: order success/failure pages wired to real states.
* [ ] Emails: stub provider + templates (success/refund).
* [ ] DevOps: Vault secrets, webhook URL, CI envs.
* [ ] Docs: `docs/payments.md` with test cards & runbook.
* [ ] Tests: unit, integration, E2E.
**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).
---
Contributor guide
Assessment
This issue has not been assessed yet.