-
Notifications
You must be signed in to change notification settings - Fork 39
Open
Labels
dependenciesPull requests that update a dependency filePull requests that update a dependency filedocumentationImprovements or additions to documentationImprovements or additions to documentationenhancementNew feature or requestNew feature or requestgood first issueGood for newcomersGood for newcomershelp wantedExtra attention is neededExtra attention is neededquestionFurther information is requestedFurther information is requested
Milestone
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-intentto create PaymentIntents using idempotency keys. - Add
POST /api/webhooks/stripeto handlepayment_intent.succeeded/processing/failedandcharge.refunded. - Add order states:
PENDING → PAID → FULFILLING → FULFILLED(withCANCELED,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 hasvault/. - 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,reservationIdevents[]:{ 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-intentendpoint. - 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.mdwith 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).
Metadata
Metadata
Assignees
Labels
dependenciesPull requests that update a dependency filePull requests that update a dependency filedocumentationImprovements or additions to documentationImprovements or additions to documentationenhancementNew feature or requestNew feature or requestgood first issueGood for newcomersGood for newcomershelp wantedExtra attention is neededExtra attention is neededquestionFurther information is requestedFurther information is requested
Projects
Status
Backlog