diff --git a/build-battle/merchant-console/src/app/api/cards/[id]/route.ts b/build-battle/merchant-console/src/app/api/cards/[id]/route.ts
new file mode 100644
index 00000000..d994a05a
--- /dev/null
+++ b/build-battle/merchant-console/src/app/api/cards/[id]/route.ts
@@ -0,0 +1,22 @@
+import { cardById, parseCardStatus, transitionCard } from "@/data/cards"
+import { NextRequest, NextResponse } from "next/server"
+
+type Context = { params: Promise<{ id: string }> }
+
+export async function GET(_request: NextRequest, { params }: Context) {
+ const card = cardById((await params).id)
+ if (!card) return NextResponse.json({ error: "Card not found." }, { status: 404 })
+ return NextResponse.json({ card })
+}
+
+/** Status changes only; limit edits are NWP-202. */
+export async function PATCH(request: NextRequest, { params }: Context) {
+ const parsed = parseCardStatus(await request.json().catch(() => null))
+ if (!parsed.ok) return NextResponse.json({ error: parsed.error }, { status: 400 })
+ const result = transitionCard((await params).id, parsed.value)
+ if (!result.ok) {
+ const status = result.reason === "not_found" ? 404 : 409
+ return NextResponse.json({ error: result.error }, { status })
+ }
+ return NextResponse.json({ card: result.card })
+}
diff --git a/build-battle/merchant-console/src/app/api/cards/route.ts b/build-battle/merchant-console/src/app/api/cards/route.ts
new file mode 100644
index 00000000..8c961b21
--- /dev/null
+++ b/build-battle/merchant-console/src/app/api/cards/route.ts
@@ -0,0 +1,24 @@
+import { issueCardOnce, listCards, parseIdempotencyKey, parseIssueCard } from "@/data/cards"
+import { NextRequest, NextResponse } from "next/server"
+
+/** Stored cards carry the last four only, never a number. */
+export function GET() {
+ return NextResponse.json({ cards: listCards() })
+}
+
+/** The one response with a full number. A replayed key gets 409 without it. */
+export async function POST(request: NextRequest) {
+ const key = parseIdempotencyKey(request.headers.get("idempotency-key"))
+ if (!key.ok) return NextResponse.json({ error: key.error }, { status: 400 })
+ const parsed = parseIssueCard(await request.json().catch(() => null))
+ if (!parsed.ok) return NextResponse.json({ error: parsed.error }, { status: 400 })
+
+ const result = issueCardOnce(parsed.value, key.value)
+ if (result.replayed) {
+ const error = "This card was already issued. Its number is not shown again."
+ return NextResponse.json({ error, card: result.card }, { status: 409 })
+ }
+
+ const { card, number } = result
+ return NextResponse.json({ card, number }, { status: 201, headers: { "cache-control": "no-store" } })
+}
diff --git a/build-battle/merchant-console/src/app/cards/[id]/not-found.tsx b/build-battle/merchant-console/src/app/cards/[id]/not-found.tsx
new file mode 100644
index 00000000..01dbc5e1
--- /dev/null
+++ b/build-battle/merchant-console/src/app/cards/[id]/not-found.tsx
@@ -0,0 +1,13 @@
+import Link from "next/link"
+
+export default function CardNotFound() {
+ return (
+
+
This card does not exist
+
+ Check the link, or find it in the list.
+ Cards issued before the console last restarted are not kept.
+
+
+ )
+}
diff --git a/build-battle/merchant-console/src/app/cards/[id]/page.tsx b/build-battle/merchant-console/src/app/cards/[id]/page.tsx
new file mode 100644
index 00000000..31233a9d
--- /dev/null
+++ b/build-battle/merchant-console/src/app/cards/[id]/page.tsx
@@ -0,0 +1,89 @@
+import { Divider } from "@/components/Divider"
+import { StatusBadge } from "@/components/ui/payments/StatusBadge"
+import { cardById } from "@/data/cards"
+import { merchantById } from "@/data/merchants"
+import { CARD_CATEGORY_LABELS, maskCardNumber, spendProgress } from "@/lib/cards"
+import { formatInZone } from "@/lib/dates"
+import { formatMoney } from "@/lib/money"
+import { cx } from "@/lib/utils"
+import Link from "next/link"
+import { notFound } from "next/navigation"
+
+export const dynamic = "force-dynamic"
+
+const EVENT_LABELS = { issued: "Card issued", frozen: "Frozen", unfrozen: "Unfrozen", cancelled: "Cancelled" }
+const heading = "mt-6 text-sm font-semibold text-gray-900 dark:text-gray-50"
+
+export default async function CardDetail({ params }: { params: Promise<{ id: string }> }) {
+ const card = cardById((await params).id)
+ if (!card) notFound()
+
+ const merchant = merchantById(card.merchantId)!
+ const money = (minor: number) => formatMoney(minor, card.currency)
+ const { percent, nearLimit } = spendProgress(card.spent, card.spendLimit)
+ const fields: [label: string, value: string, mono?: boolean][] = [
+ ["Merchant", `${merchant.name} · ${merchant.country}`],
+ ["Currency", card.currency],
+ ["Category lock", card.categoryLock ? CARD_CATEGORY_LABELS[card.categoryLock] : "None: any category"],
+ ["Spend limit", money(card.spendLimit)],
+ ["Spent", money(card.spent)],
+ ["Remaining", money(Math.max(0, card.spendLimit - card.spent))],
+ ["Reference", card.reference, true],
+ [`Created (${merchant.timezone})`, formatInZone(card.createdAt, merchant.timezone)],
+ ]
+
+ return (
+
+
+ ← All cards
+
+
+
{card.nickname}
+
+
+
+ {maskCardNumber(card.last4)} · {card.id}
+
+
+
+
+ {fields.map(([label, value, mono]) => (
+
+
- {label}
+ -
+ {value}
+
+
+ ))}
+
+
+
Spend
+
+ {money(card.spent)} of {money(card.spendLimit)} spent
+ {percent}% used
+
+
+ {card.spent === 0 && (
+
+ No spend recorded. The console is not connected to a card network yet, so spend stays at
+ zero until authorizations exist.
+
+ )}
+
+
History
+
+ {card.events.map((event, index) => (
+ -
+
{EVENT_LABELS[event.type]}
+ {formatInZone(event.at, merchant.timezone)}
+
+ ))}
+
+
+ )
+}
diff --git a/build-battle/merchant-console/src/app/cards/card-actions.tsx b/build-battle/merchant-console/src/app/cards/card-actions.tsx
new file mode 100644
index 00000000..282b1300
--- /dev/null
+++ b/build-battle/merchant-console/src/app/cards/card-actions.tsx
@@ -0,0 +1,78 @@
+"use client"
+
+import { Button } from "@/components/Button"
+import type { CardStatus } from "@/data/types"
+import { useRouter } from "next/navigation"
+import { useState } from "react"
+
+/** Freeze, unfreeze and a two-step cancel. */
+export function CardActions(props: { id: string; nickname: string; status: CardStatus }) {
+ const router = useRouter()
+ const [pending, setPending] = useState(false)
+ const [confirming, setConfirming] = useState(false)
+ const [error, setError] = useState(null)
+
+ if (props.status === "cancelled") {
+ return —
+ }
+
+ const update = async (status: CardStatus) => {
+ setPending(true)
+ setError(null)
+ try {
+ const response = await fetch(`/api/cards/${props.id}`, {
+ method: "PATCH",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ status }),
+ })
+ if (!response.ok) {
+ const body = await response.json().catch(() => null)
+ return setError(body?.error ?? "The card could not be updated. Try again.")
+ }
+ setConfirming(false)
+ router.refresh()
+ } catch {
+ setError("The card could not be updated. Check your connection and try again.")
+ } finally {
+ setPending(false)
+ }
+ }
+
+ const action = (
+ label: string,
+ name: string,
+ onClick: () => void,
+ variant: "secondary" | "ghost" | "destructive" = "secondary",
+ ) => (
+
+ )
+
+ return (
+
+
+ {confirming ? (
+ <>
+ {action("Keep card", "Keep", () => setConfirming(false))}
+ {action("Confirm cancel", "Confirm cancel", () => update("cancelled"), "destructive")}
+ >
+ ) : (
+ <>
+ {props.status === "active"
+ ? action("Freeze", "Freeze", () => update("frozen"))
+ : action("Unfreeze", "Unfreeze", () => update("active"))}
+ {action("Cancel card", "Cancel card", () => setConfirming(true), "ghost")}
+ >
+ )}
+
+ {error &&
{error}
}
+
+ )
+}
diff --git a/build-battle/merchant-console/src/app/cards/issue-card-drawer.tsx b/build-battle/merchant-console/src/app/cards/issue-card-drawer.tsx
new file mode 100644
index 00000000..604e5e4f
--- /dev/null
+++ b/build-battle/merchant-console/src/app/cards/issue-card-drawer.tsx
@@ -0,0 +1,226 @@
+"use client"
+
+import { Button } from "@/components/Button"
+import {
+ Drawer,
+ DrawerBody,
+ DrawerContent,
+ DrawerDescription,
+ DrawerFooter,
+ DrawerHeader,
+ DrawerTitle,
+ DrawerTrigger,
+} from "@/components/Drawer"
+import { Input } from "@/components/Input"
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/Select"
+import type { Currency } from "@/data/types"
+import { CARD_CATEGORIES, CARD_CATEGORY_LABELS } from "@/lib/cards"
+import { parseAmountToMinorUnits } from "@/lib/money"
+import { Plus } from "lucide-react"
+import { useRouter } from "next/navigation"
+import { useState } from "react"
+
+/** Radix Select has no empty value, so "no lock" is a sentinel. */
+const NO_LOCK = "none"
+const EMPTY = { nickname: "", merchantId: "", limit: "", category: NO_LOCK }
+
+function Field(props: { id: string; label: string; note?: string | null; error?: boolean; children: React.ReactNode }) {
+ return (
+
+
+ {props.children}
+ {props.note && (
+
+ {props.note}
+
+ )}
+
+ )
+}
+
+function Choice(props: {
+ id: string
+ value: string
+ onChange: (value: string) => void
+ options: [value: string, label: string][]
+ placeholder?: string
+ noted?: boolean
+}) {
+ return (
+
+ )
+}
+
+export function IssueCardDrawer(props: {
+ merchants: { id: string; name: string; currency: Currency }[]
+ maxNicknameLength: number
+}) {
+ const router = useRouter()
+ const [open, setOpen] = useState(false)
+ const [form, setForm] = useState(EMPTY)
+ const [limitError, setLimitError] = useState(null)
+ const [error, setError] = useState(null)
+ const [submitting, setSubmitting] = useState(false)
+ // The number lives here only until the drawer closes.
+ const [issued, setIssued] = useState<{ nickname: string; number: string } | null>(null)
+ // One key per form: a retry or double click issues at most one card.
+ const [idempotencyKey, setIdempotencyKey] = useState(() => crypto.randomUUID())
+
+ const set = (changes: Partial) => setForm((f) => ({ ...f, ...changes }))
+ const currency = props.merchants.find((m) => m.id === form.merchantId)?.currency
+
+ const onOpenChange = (next: boolean) => {
+ // Closing mid-request would drop the one response that carries the number.
+ if (!next && submitting) return
+ setOpen(next)
+ if (next) return
+ if (issued) router.refresh()
+ setForm(EMPTY)
+ setLimitError(null)
+ setError(null)
+ setIssued(null)
+ setIdempotencyKey(crypto.randomUUID())
+ }
+
+ const submit = async (event: React.FormEvent) => {
+ event.preventDefault()
+ if (submitting) return
+ setError(null)
+ setLimitError(null)
+ if (!form.nickname.trim()) return setError("Nickname is required.")
+ if (!currency) return setError("Choose a merchant.")
+ // Converted once, at the boundary; the server re-validates.
+ const spendLimit = parseAmountToMinorUnits(form.limit)
+ if (!spendLimit) return setLimitError("Enter an amount like 250.00, greater than zero.")
+
+ setSubmitting(true)
+ try {
+ const { nickname, merchantId, category } = form
+ const response = await fetch("/api/cards", {
+ method: "POST",
+ headers: { "content-type": "application/json", "idempotency-key": idempotencyKey },
+ body: JSON.stringify({
+ nickname,
+ merchantId,
+ spendLimit,
+ currency,
+ categoryLock: category === NO_LOCK ? null : category,
+ }),
+ })
+ const body = await response.json().catch(() => null)
+ if (response.ok && body?.number) setIssued({ nickname: body.card.nickname, number: body.number })
+ else setError(body?.error ?? "The card could not be issued. Try again.")
+ } catch {
+ setError("The card could not be issued. Check your connection and try again.")
+ } finally {
+ setSubmitting(false)
+ }
+ }
+
+ return (
+
+
+
+
+
+
+ {issued ? "Card issued" : "Issue card"}
+ {issued ? "Copy the number now." : "One merchant, one limit."}
+
+
+ {issued ? (
+ <>
+
+ {issued.nickname}
+
+
Card number
+
+ {issued.number.replace(/(\d{4})(?=\d)/g, "$1 ")}
+
+
+
+ The full number is shown only this once.
+
+
+
+
+
+ >
+ ) : (
+
+ )}
+
+
+ )
+}
diff --git a/build-battle/merchant-console/src/app/cards/page.tsx b/build-battle/merchant-console/src/app/cards/page.tsx
new file mode 100644
index 00000000..dde5156b
--- /dev/null
+++ b/build-battle/merchant-console/src/app/cards/page.tsx
@@ -0,0 +1,92 @@
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeaderCell,
+ TableRoot,
+ TableRow,
+} from "@/components/Table"
+import { StatusBadge } from "@/components/ui/payments/StatusBadge"
+import { listCards, MAX_NICKNAME_LENGTH } from "@/data/cards"
+import { merchantById, merchants } from "@/data/merchants"
+import { CARD_CATEGORY_LABELS, maskCardNumber } from "@/lib/cards"
+import { formatDate } from "@/lib/dates"
+import { formatMoney } from "@/lib/money"
+import Link from "next/link"
+import { CardActions } from "./card-actions"
+import { IssueCardDrawer } from "./issue-card-drawer"
+
+export const dynamic = "force-dynamic"
+
+const COLUMNS = ["Card", "Merchant", "Number", "Category", "Spend limit", "Status", "Created", ""]
+
+export default function CardsPage() {
+ const cards = listCards()
+
+ return (
+
+
+
Virtual cards
+
+
+
+
+
+
+
+ {COLUMNS.map((column) => (
+
+ {column || Actions}
+
+ ))}
+
+
+
+ {cards.length === 0 && (
+
+
+ No cards issued yet
+
+ Use Issue card to create a virtual card for a merchant.
+
+
+
+ )}
+ {cards.map((card) => (
+
+
+
+ {card.nickname}
+
+
+ {merchantById(card.merchantId)?.name}
+ {maskCardNumber(card.last4)}
+
+ {card.categoryLock ? CARD_CATEGORY_LABELS[card.categoryLock] : "Any"}
+
+
+ {formatMoney(card.spendLimit, card.currency)}
+
+
+
+
+ {formatDate(card.createdAt)}
+
+
+
+
+ ))}
+
+
+
+
+
+ {cards.length} {cards.length === 1 ? "card" : "cards"}
+
+
+ )
+}
diff --git a/build-battle/merchant-console/src/app/siteConfig.ts b/build-battle/merchant-console/src/app/siteConfig.ts
index c59e5da2..08c5d3d7 100644
--- a/build-battle/merchant-console/src/app/siteConfig.ts
+++ b/build-battle/merchant-console/src/app/siteConfig.ts
@@ -7,6 +7,7 @@ export const siteConfig = {
payments: "/payments",
disputes: "/disputes",
payouts: "/payouts",
+ cards: "/cards",
},
}
diff --git a/build-battle/merchant-console/src/components/Drawer.tsx b/build-battle/merchant-console/src/components/Drawer.tsx
index fc9a5539..85114676 100644
--- a/build-battle/merchant-console/src/components/Drawer.tsx
+++ b/build-battle/merchant-console/src/components/Drawer.tsx
@@ -112,6 +112,7 @@ const DrawerHeader = React.forwardRef<