This is Part 1 of a 3-part series. We build a Next.js ecommerce checkout from scratch — no real payment gateway involved until Part 2.
Series: Part 1 — The Store · Part 2 — MockCard Integration · Part 3 — Webhooks and Edge Cases
Here is the uncomfortable truth about building a payment flow: most developers write the checkout UI, paste in a Stripe integration tutorial, and ship it. The tests mock the gateway. The webhook handler is untested. The edge cases — duplicate deliveries, 3DS abandonment, silent capture failures — are discovered by users, not engineers.
This series builds a checkout from first principles and tests it properly. No gateway account required. By the end of Part 3 you will have a payment flow that handles every failure mode before it happens in production.
Part 1 is the store itself. We build products, a cart, and an order system with the right status lifecycle. Part 2 wires in MockCard. Part 3 handles webhooks and the chaos.
The Data Model
Before a single line of UI, define the order lifecycle. This is the most important decision in the series — get it wrong and the whole thing falls apart.
// prisma/schema.prisma
model Order {
id String @id @default(cuid())
email String
name String
items Json // [{ productId, name, qty, unitPrice }]
total Int // in paise (smallest unit)
status OrderStatus @default(PENDING_PAYMENT)
paymentIntentId String? @unique // stored when payment is submitted
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model ProcessedEvent {
id String @id // webhook event.id — used for idempotency in Part 3
processedAt DateTime @default(now())
}
enum OrderStatus {
PENDING_PAYMENT // order created, nothing charged yet
PENDING_WEBHOOK // payment submitted, waiting for gateway confirmation
FULFILLED // webhook received, order shipped / access granted
FAILED // payment declined
CANCELLED // 3DS abandoned or explicit cancellation
}
Two things worth noting:
PENDING_WEBHOOK is not the same as PENDING_PAYMENT. When the user submits payment, the charge is initiated but not confirmed. The confirmation arrives via webhook. Orders that never leave PENDING_WEBHOOK are captured-but-unconfirmed — this is exactly the limbo state we test in Part 3.
ProcessedEvent is not optional. Real gateways send the same webhook more than once. Without a record of which events you have already processed, a retry becomes a second fulfilment. We build this from day one, not as an afterthought.
npx prisma migrate dev --name init
Products
For this series we use static products. In production these come from a database or CMS — the checkout logic is identical.
// lib/products.ts
export interface Product {
id: string;
name: string;
price: number; // paise
image: string;
sku: string;
}
export const PRODUCTS: Product[] = [
{ id: "prod_1", name: "MockCard T-Shirt", price: 79900, image: "/products/tshirt.jpg", sku: "MC-TS-001" },
{ id: "prod_2", name: "Developer Mug", price: 49900, image: "/products/mug.jpg", sku: "MC-MUG-01" },
{ id: "prod_3", name: "Testing Handbook", price: 99900, image: "/products/book.jpg", sku: "MC-BK-001" },
];
export function getProduct(id: string): Product | undefined {
return PRODUCTS.find((p) => p.id === id);
}
Cart State
A React context that survives navigation. Nothing fancy — just what the checkout page needs to read.
// lib/cart-context.tsx
"use client";
import { createContext, useContext, useState, useCallback } from "react";
import type { Product } from "./products";
interface CartItem extends Product { qty: number }
interface CartContextValue {
items: CartItem[];
add: (product: Product) => void;
remove: (id: string) => void;
clear: () => void;
total: number;
itemCount: number;
}
const CartContext = createContext<CartContextValue | null>(null);
export function CartProvider({ children }: { children: React.ReactNode }) {
const [items, setItems] = useState<CartItem[]>([]);
const add = useCallback((product: Product) => {
setItems((prev) => {
const existing = prev.find((i) => i.id === product.id);
if (existing) return prev.map((i) => i.id === product.id ? { ...i, qty: i.qty + 1 } : i);
return [...prev, { ...product, qty: 1 }];
});
}, []);
const remove = useCallback((id: string) => {
setItems((prev) => prev.filter((i) => i.id !== id));
}, []);
const clear = useCallback(() => setItems([]), []);
const total = items.reduce((sum, i) => sum + i.price * i.qty, 0);
const itemCount = items.reduce((sum, i) => sum + i.qty, 0);
return (
<CartContext.Provider value={{ items, add, remove, clear, total, itemCount }}>
{children}
</CartContext.Provider>
);
}
export function useCart() {
const ctx = useContext(CartContext);
if (!ctx) throw new Error("useCart must be used inside CartProvider");
return ctx;
}
The Checkout Page
Two sections: order summary on the left, customer + card details on the right. The card fields collect data but in Part 2 these are sent to MockCard, not a real gateway — so no PCI scope issues during development.
// app/checkout/page.tsx
"use client";
import { useState } from "react";
import { useCart } from "@/lib/cart-context";
import { useRouter } from "next/navigation";
export default function CheckoutPage() {
const { items, total, clear } = useCart();
const router = useRouter();
const [form, setForm] = useState({
name: "", email: "", address: "",
// These are sent to MockCard in Part 2 — not a real gateway
cardNumber: "", expiry: "", cvv: "",
});
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
setError(null);
const res = await fetch("/api/checkout", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ form, items, total }),
});
const data = await res.json();
setLoading(false);
if (data.success) {
clear();
router.push(`/orders/${data.orderId}?status=pending`);
} else if (data.requiresAction) {
// 3DS redirect — handled in Part 2
window.location.href = data.redirectUrl;
} else {
setError(data.error?.message ?? "Payment failed. Please try again.");
}
};
if (items.length === 0) {
return <div className="p-8 text-center">Your cart is empty.</div>;
}
return (
<main className="max-w-4xl mx-auto p-6 grid grid-cols-1 md:grid-cols-2 gap-8">
{/* Order summary */}
<div className="space-y-4">
<h2 className="font-semibold text-lg">Order Summary</h2>
{items.map((item) => (
<div key={item.id} className="flex justify-between text-sm">
<span>{item.name} × {item.qty}</span>
<span>₹{((item.price * item.qty) / 100).toFixed(2)}</span>
</div>
))}
<div className="border-t pt-3 font-semibold flex justify-between">
<span>Total</span>
<span>₹{(total / 100).toFixed(2)}</span>
</div>
</div>
{/* Payment form */}
<form onSubmit={handleSubmit} className="space-y-4">
<h2 className="font-semibold text-lg">Payment Details</h2>
<input required placeholder="Full name" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} className="w-full border rounded p-2 text-sm" />
<input required placeholder="Email" value={form.email} onChange={(e) => setForm({ ...form, email: e.target.value })} className="w-full border rounded p-2 text-sm" type="email" />
<input required placeholder="Address" value={form.address} onChange={(e) => setForm({ ...form, address: e.target.value })} className="w-full border rounded p-2 text-sm" />
<input required placeholder="Card number" value={form.cardNumber} onChange={(e) => setForm({ ...form, cardNumber: e.target.value })} className="w-full border rounded p-2 text-sm font-mono" maxLength={16} />
<div className="grid grid-cols-2 gap-3">
<input required placeholder="MM/YY" value={form.expiry} onChange={(e) => setForm({ ...form, expiry: e.target.value })} className="border rounded p-2 text-sm" />
<input required placeholder="CVV" value={form.cvv} onChange={(e) => setForm({ ...form, cvv: e.target.value })} className="border rounded p-2 text-sm" maxLength={4} />
</div>
{error && (
<div className="bg-red-50 border border-red-200 rounded p-3 text-sm text-red-700">
{error}
</div>
)}
<button
type="submit"
disabled={loading}
className="w-full bg-black text-white py-3 rounded font-medium hover:bg-gray-800 disabled:opacity-50"
>
{loading ? "Processing…" : `Pay ₹${(total / 100).toFixed(2)}`}
</button>
</form>
</main>
);
}
The Checkout API Route
This route creates the order record in PENDING_PAYMENT status. In Part 2 it calls MockCard. Right now it just validates the input and persists the order — the payment logic comes next.
// app/api/checkout/route.ts
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { getProduct } from "@/lib/products";
export async function POST(req: NextRequest) {
const { form, items, total } = await req.json();
// Validate every item exists and total is correct server-side
let serverTotal = 0;
const orderItems = [];
for (const item of items) {
const product = getProduct(item.id);
if (!product) {
return NextResponse.json({ error: { message: "Invalid product" } }, { status: 400 });
}
serverTotal += product.price * item.qty;
orderItems.push({ productId: product.id, name: product.name, qty: item.qty, unitPrice: product.price });
}
// Never trust the client total
if (serverTotal !== total) {
return NextResponse.json({ error: { message: "Price mismatch" } }, { status: 400 });
}
// Create order in PENDING_PAYMENT — nothing is charged yet
const order = await prisma.order.create({
data: {
email: form.email,
name: form.name,
items: orderItems,
total: serverTotal,
status: "PENDING_PAYMENT",
},
});
// Part 2: call MockCard here and update order to PENDING_WEBHOOK on success
// For now, return the order ID so we can test the UI flow
return NextResponse.json({ success: true, orderId: order.id });
}
Order Status Page
// app/orders/[id]/page.tsx
import { prisma } from "@/lib/prisma";
import { notFound } from "next/navigation";
const STATUS_LABELS = {
PENDING_PAYMENT: { label: "Awaiting payment", color: "text-amber-600" },
PENDING_WEBHOOK: { label: "Payment processing", color: "text-blue-600" },
FULFILLED: { label: "Order confirmed", color: "text-green-600" },
FAILED: { label: "Payment failed", color: "text-red-600" },
CANCELLED: { label: "Order cancelled", color: "text-zinc-500" },
};
export default async function OrderPage({ params }: { params: { id: string } }) {
const order = await prisma.order.findUnique({ where: { id: params.id } });
if (!order) notFound();
const { label, color } = STATUS_LABELS[order.status];
const items = order.items as { name: string; qty: number; unitPrice: number }[];
return (
<main className="max-w-xl mx-auto p-8 space-y-6">
<div>
<p className="text-sm text-gray-500">Order #{order.id.slice(0, 8)}</p>
<p className={`text-xl font-semibold mt-1 ${color}`}>{label}</p>
</div>
<div className="border rounded p-4 space-y-2">
{items.map((item, i) => (
<div key={i} className="flex justify-between text-sm">
<span>{item.name} × {item.qty}</span>
<span>₹{((item.unitPrice * item.qty) / 100).toFixed(2)}</span>
</div>
))}
<div className="border-t pt-2 font-semibold flex justify-between">
<span>Total</span>
<span>₹{(order.total / 100).toFixed(2)}</span>
</div>
</div>
{order.status === "PENDING_WEBHOOK" && (
<p className="text-sm text-gray-500">
Your payment was received. We are confirming with the payment processor.
This usually takes a few seconds.
</p>
)}
</main>
);
}
Where We Are
You now have:
- A Prisma schema with the right order lifecycle —
PENDING_PAYMENT → PENDING_WEBHOOK → FULFILLED - A cart with client state that survives navigation
- A checkout form that collects card details
- A server-side route that validates order totals and creates the order record
- An order status page that renders every state clearly
Nothing is charged yet. The checkout form collects card details but nothing calls a gateway. This is intentional — in Part 2 we wire in MockCard, which means you can test every payment outcome (approvals, all five decline types, 3DS challenge) without a real gateway account or a real card.
The separation matters: the store logic is complete and testable on its own before any payment code exists.