All posts
nextjstypescriptecommercepaymentsmockcardseries

Wiring MockCard Into Your Next.js Checkout (Part 2 of 3)

April 9, 20267 min read

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/generate and handles approval, decline, and 3DS
  • Updates order status atomically before responding to the browser
  • Shows the right decline message to the user
  • Stores paymentIntentId on 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.

Continue to Part 3 →