Skip to content

Epic: Real Payments & Robust Order Lifecycle (Stripe + Idempotency + Webhooks) #24

@hoangsonww

Description

@hoangsonww

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

  • 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).

Metadata

Metadata

Assignees

Labels

dependenciesPull requests that update a dependency filedocumentationImprovements or additions to documentationenhancementNew feature or requestgood first issueGood for newcomershelp wantedExtra attention is neededquestionFurther information is requested

Projects

Status

Backlog

Relationships

None yet

Development

No branches or pull requests

Issue actions