This is Part 2 of a 3-part series. We wire MockCard into the checkout built in Part 1 — no real gateway account required.
Series: Part 1 — The Store · Part 2 — MockCard Integration · Part 3 — Webhooks and Edge Cases
In Part 1 we built a store with a clean order lifecycle. The checkout form collects card details and hits /api/checkout, which creates an order in PENDING_PAYMENT and returns an order ID. Nothing is charged.
Part 2 finishes the payment flow. We call MockCard from the API route, handle every response type — approval, decline, 3DS — and move the order to the right status. By the end, you can test every payment scenario your store will ever see. No Stripe account. No test cards mailed to you. No waiting for a gateway sandbox to respond.
The MockCard Client
One file that wraps the API. Everything the checkout route needs to call MockCard lives here.
// lib/mockcard.ts
export type MockCardScenario =
| "success"
| "insufficient_funds"
| "do_not_honor"
| "expired_card"
| "incorrect_cvv"
| "3ds_challenge"
| "network_timeout";
export interface MockCardRequest {
amount: number; // in smallest currency unit (paise)
currency: string; // "INR", "USD", etc.
scenario: MockCardScenario; // you control this explicitly — the whole point
card_number: string;
expiry: string; // "MM/YY"
cvv: string;
name: string;
webhook_url?: string; // if provided, MockCard delivers a webhook on your behalf
}
export interface MockCardSuccess {
status: "success";
payment_intent_id: string; // "pi_mock_xxx" — store this, correlate with webhook
amount: number;
currency: string;
webhook_event: unknown;
}
export interface MockCardDecline {
status: "declined";
error: {
code: string; // "insufficient_funds", "do_not_honor", etc.
message: string;
};
}
export interface MockCard3DS {
status: "requires_action";
redirect_url: string;
}
export type MockCardResult = MockCardSuccess | MockCardDecline | MockCard3DS;
const MOCKCARD_API = "https://www.mockcard.io/api/v1/generate";
export async function charge(req: MockCardRequest): Promise<MockCardResult> {
const res = await fetch(MOCKCARD_API, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${process.env.MOCKCARD_API_KEY}`,
},
body: JSON.stringify(req),
});
if (!res.ok) {
throw new Error(`MockCard API error: ${res.status}`);
}
return res.json();
}
The key difference from a real gateway: you pass scenario explicitly. There is no magic card number that triggers a decline. You decide what to test, you pass the scenario, MockCard produces exactly that outcome. This is the design — it makes tests deterministic and readable.
How to Select Scenarios in Development
In production there is only one scenario: whatever the real bank decides. In development and testing you control it directly.
The simplest approach is a dev-only query param that the checkout page reads and passes through to the API route:
// app/checkout/page.tsx — read scenario from URL in dev only
"use client";
import { useSearchParams } from "next/navigation";
export default function CheckoutPage() {
const searchParams = useSearchParams();
// Only available in development — ignored in production
const testScenario = process.env.NODE_ENV === "development"
? (searchParams.get("scenario") as MockCardScenario | null)
: null;
// ... rest of the page
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
const res = await fetch("/api/checkout", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ form, items, total, testScenario }),
});
// ...
};
}
Then in your API route, use testScenario in dev and default to "success" in production:
// app/api/checkout/route.ts — scenario selection
const scenario: MockCardScenario =
process.env.NODE_ENV !== "production" && body.testScenario
? body.testScenario
: "success"; // production always sends "success" — real outcome comes from the bank
Now you can test every outcome from the browser without changing code:
http://localhost:3000/checkout?scenario=insufficient_funds
http://localhost:3000/checkout?scenario=3ds_challenge
http://localhost:3000/checkout?scenario=network_timeout
In tests you pass the scenario directly in the request body — no browser involved:
// test/checkout.test.ts
const res = await fetch("/api/checkout", {
method: "POST",
body: JSON.stringify({
form: { name: "Test", email: "t@example.com", cardNumber: "4111111111111111", expiry: "12/29", cvv: "123" },
items: [{ id: "prod_1", qty: 1 }],
total: 79900,
testScenario: "insufficient_funds", // MockCard: exact scenario
}),
});
This is cleaner than mapping card numbers to scenarios. The test is self-documenting: you can read it and know exactly what outcome is being tested without looking up a card number table.
Updated Checkout API Route
// app/api/checkout/route.ts
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { getProduct } from "@/lib/products";
import { charge, MockCardScenario } from "@/lib/mockcard";
export async function POST(req: NextRequest) {
const { form, items, total, testScenario } = await req.json();
// ── 1. Server-side total validation (same as Part 1) ──────────────────
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 });
}
if (serverTotal !== total) {
return NextResponse.json({ error: { message: "Price mismatch" } }, { status: 400 });
}
// ── 2. Create order in PENDING_PAYMENT ────────────────────────────────
const order = await prisma.order.create({
data: {
email: form.email,
name: form.name,
items: orderItems,
total: serverTotal,
status: "PENDING_PAYMENT",
},
});
// ── 3. Resolve scenario — dev/test can override, production always "success" ──
const scenario: MockCardScenario =
process.env.NODE_ENV !== "production" && testScenario
? testScenario
: "success";
// ── 4. Call MockCard ──────────────────────────────────────────────────
let result;
try {
result = await charge({
amount: serverTotal,
currency: "INR",
scenario,
card_number: form.cardNumber,
expiry: form.expiry,
cvv: form.cvv,
name: form.name,
webhook_url: `${process.env.NEXT_PUBLIC_APP_URL}/api/webhook/stripe`,
});
} catch {
return NextResponse.json({ error: { message: "Payment service unavailable. Please try again." } }, { status: 503 });
}
// ── 5. Handle each outcome ────────────────────────────────────────────
if (result.status === "success") {
await prisma.order.update({
where: { id: order.id },
data: {
status: "PENDING_WEBHOOK",
paymentIntentId: result.payment_intent_id,
},
});
return NextResponse.json({ success: true, orderId: order.id });
}
if (result.status === "declined") {
await prisma.order.update({
where: { id: order.id },
data: { status: "FAILED" },
});
return NextResponse.json({ error: { code: result.error.code, message: result.error.message } });
}
if (result.status === "requires_action") {
await prisma.order.update({
where: { id: order.id },
data: { paymentIntentId: result.redirect_url },
});
return NextResponse.json({ requiresAction: true, redirectUrl: result.redirect_url, orderId: order.id });
}
return NextResponse.json({ error: { message: "Unexpected payment response" } }, { status: 500 });
}
Decline Messages in the Checkout Form
// app/checkout/page.tsx — replace the error handling branch
const DECLINE_MESSAGES: Record<string, string> = {
insufficient_funds: "Your card has insufficient funds.",
do_not_honor: "Your card was declined by your bank. Please contact your issuer.",
expired_card: "Your card has expired. Please use a different card.",
incorrect_cvv: "The security code is incorrect. Please check and try again.",
network_timeout: "The payment network timed out. Please try again in a moment.",
};
if (data.error) {
setError(
DECLINE_MESSAGES[data.error.code] ??
data.error.message ??
"Payment failed. Please try again."
);
}
The 3DS Return URL
When requiresAction is returned, the browser redirects to MockCard's 3DS simulation page. After the user completes or abandons the challenge, MockCard redirects back to your return_url.
// app/api/3ds-return/route.ts
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
export async function GET(req: NextRequest) {
const { searchParams } = new URL(req.url);
const orderId = searchParams.get("order_id");
const outcome = searchParams.get("outcome"); // "success" | "abandoned"
if (!orderId) {
return NextResponse.redirect(new URL("/", req.url));
}
if (outcome === "abandoned") {
await prisma.order.update({
where: { id: orderId },
data: { status: "CANCELLED" },
});
return NextResponse.redirect(new URL(`/orders/${orderId}?status=cancelled`, req.url));
}
await prisma.order.update({
where: { id: orderId },
data: { status: "PENDING_WEBHOOK" },
});
return NextResponse.redirect(new URL(`/orders/${orderId}?status=pending`, req.url));
}
What to Test Right Now
Every scenario is one URL parameter away in development:
| URL parameter | Outcome |
|---|---|
?scenario=success |
Approved → PENDING_WEBHOOK |
?scenario=insufficient_funds |
Declined → FAILED |
?scenario=expired_card |
Declined → FAILED |
?scenario=incorrect_cvv |
Declined → FAILED |
?scenario=do_not_honor |
Declined → FAILED |
?scenario=3ds_challenge |
Redirect → PENDING_WEBHOOK or CANCELLED |
?scenario=network_timeout |
503 → order stays PENDING_PAYMENT |
No card number lookup table. No ambiguity about which card triggers which error. The test scenario is explicit in the URL, explicit in the request, and explicit in the test file.
Where We Are
The checkout now:
- Calls MockCard at
https://www.mockcard.io/api/v1/generateand handles approval, decline, and 3DS - Updates order status atomically before responding to the browser
- Shows the right decline message to the user
- Stores
paymentIntentIdon the order — ready for webhook correlation in Part 3 - Uses explicit scenarios in dev and test — no magic card numbers, no lookup tables
The one thing still missing: orders in PENDING_WEBHOOK stay there forever. The webhook handler that closes the loop is what Part 3 builds — along with the idempotency and chaos scenario tests that make the whole thing production-ready.