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..1813afa8 --- /dev/null +++ b/build-battle/merchant-console/src/app/api/cards/[id]/route.ts @@ -0,0 +1,40 @@ +import { transitionCard } from "@/data/cards" +import { CARD_STATUSES } from "@/lib/cards" +import { CardStatus } from "@/data/types" +import { NextRequest, NextResponse } from "next/server" + +/** + * Moves a card through the status state machine: active ⇄ frozen, either to + * cancelled, and cancelled is terminal. The transition is guarded here, not + * only in the UI. + */ +export async function PATCH( + request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + const { id } = await params + + let body: unknown + try { + body = await request.json() + } catch { + return NextResponse.json( + { error: "Request body must be JSON." }, + { status: 400 }, + ) + } + + const status = (body as { status?: unknown } | null)?.status + if (!CARD_STATUSES.includes(status as CardStatus)) { + return NextResponse.json( + { error: "Status must be active, frozen, or cancelled." }, + { status: 400 }, + ) + } + + const result = transitionCard(id, status as CardStatus) + if ("error" in result) { + return NextResponse.json({ error: result.error }, { status: result.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..e05c7cae --- /dev/null +++ b/build-battle/merchant-console/src/app/api/cards/route.ts @@ -0,0 +1,34 @@ +import { createCard, listCards } from "@/data/cards" +import { parseCardInput } from "@/lib/cards" +import { NextRequest, NextResponse } from "next/server" + +/** Every issued card, masked. Card records never carry a full number. */ +export function GET() { + return NextResponse.json({ rows: listCards() }) +} + +/** + * Issues a virtual card (NWP-201). The body is validated against allowlists + * before it reaches the store, and the full number is returned here and + * nowhere else. A repeated requestId returns the existing card without the + * number, so a double submit cannot mint two cards. + */ +export async function POST(request: NextRequest) { + let body: unknown + try { + body = await request.json() + } catch { + return NextResponse.json( + { error: "Request body must be JSON." }, + { status: 400 }, + ) + } + + const parsed = parseCardInput(body) + if ("error" in parsed) { + return NextResponse.json({ error: parsed.error }, { status: 400 }) + } + + const { card, number, created } = createCard(parsed.input) + return NextResponse.json({ card, number }, { status: created ? 201 : 200 }) +} 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..c7a8b910 --- /dev/null +++ b/build-battle/merchant-console/src/app/cards/[id]/page.tsx @@ -0,0 +1,141 @@ +import { Divider } from "@/components/Divider" +import { StatusBadge } from "@/components/ui/payments/StatusBadge" +import { cardById } from "@/data/cards" +import { merchantById } from "@/data/merchants" +import { CardEvent } from "@/data/types" +import { CATEGORY_LABELS, maskCard } from "@/lib/cards" +import { formatInZone } from "@/lib/dates" +import { formatMoney } from "@/lib/money" +import Link from "next/link" +import { notFound } from "next/navigation" +import { CardActions } from "../card-actions" +import { SpendBar } from "../spend-bar" + +export const dynamic = "force-dynamic" + +const EVENT_LABELS: Record = { + issued: "Card issued", + frozen: "Frozen", + unfrozen: "Unfrozen", + cancelled: "Cancelled", +} + +export default async function CardDetail({ + params, +}: { + params: Promise<{ id: string }> +}) { + const { id } = await params + const card = cardById(id) + if (!card) notFound() + + const merchant = merchantById(card.merchantId)! + + return ( +
+ + ← All cards + + +
+

+ {card.nickname} +

+ + {maskCard(card.last4)} + + +
+

{card.id}

+ +
+ +
+ + + +

+ Spend +

+
+ +
+ + + +
+ + {merchant.name} + {merchant.country} + + {CATEGORY_LABELS[card.category]} + + {maskCard(card.last4)} + + + + {formatMoney(card.limit, card.currency)} + + + + {card.createdAt} + + + {formatInZone(card.createdAt, merchant.timezone)} + +
+ + + +

+ History +

+
    + {card.events.map((event, index) => ( +
  1. +
  2. + ))} +
+
+ ) +} + +function Field({ + label, + children, +}: { + label: string + children: React.ReactNode +}) { + return ( +
+
{label}
+
+ {children} +
+
+ ) +} 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..2904a61a --- /dev/null +++ b/build-battle/merchant-console/src/app/cards/card-actions.tsx @@ -0,0 +1,128 @@ +"use client" + +import { Button } from "@/components/Button" +import { CardStatus } from "@/data/types" +import { canTransition } from "@/lib/cards" +import { useRouter } from "next/navigation" +import { useState, useTransition } from "react" + +/** + * Freeze, unfreeze, and cancel a card in place. Each action goes through the + * guarded PATCH route and then refreshes the server-rendered page data + * without a full reload. Cancel asks for confirmation first, because it is + * terminal. + */ +export function CardActions({ + cardId, + status, + nickname, +}: { + cardId: string + status: CardStatus + nickname: string +}) { + const router = useRouter() + const [pending, startTransition] = useTransition() + const [busy, setBusy] = useState(false) + const [confirmingCancel, setConfirmingCancel] = useState(false) + const [error, setError] = useState(null) + + if (status === "cancelled") { + return No actions + } + + const transition = async (to: CardStatus) => { + setBusy(true) + setError(null) + try { + const response = await fetch(`/api/cards/${cardId}`, { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ status: to }), + }) + if (!response.ok) { + const body = (await response.json().catch(() => null)) as { + error?: string + } | null + setError(body?.error ?? "The card could not be updated. Try again.") + return + } + setConfirmingCancel(false) + startTransition(() => router.refresh()) + } catch { + setError( + "Could not reach the server. Check your connection and try again.", + ) + } finally { + setBusy(false) + } + } + + const working = busy || pending + + if (confirmingCancel) { + return ( +
+ + Cancel {nickname}? This cannot be undone. + + + + {error && ( +

+ {error} +

+ )} +
+ ) + } + + const next: CardStatus = status === "active" ? "frozen" : "active" + + return ( +
+ {canTransition(status, next) && ( + + )} + + {error && ( +

+ {error} +

+ )} +
+ ) +} diff --git a/build-battle/merchant-console/src/app/cards/issue-card-dialog.tsx b/build-battle/merchant-console/src/app/cards/issue-card-dialog.tsx new file mode 100644 index 00000000..9e309314 --- /dev/null +++ b/build-battle/merchant-console/src/app/cards/issue-card-dialog.tsx @@ -0,0 +1,286 @@ +"use client" + +import { Button } from "@/components/Button" +import { + Drawer, + DrawerBody, + DrawerClose, + 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 { Card, CardCategory, Currency } from "@/data/types" +import { CATEGORY_LABELS } from "@/lib/cards" +import { formatMoney } from "@/lib/money" +import { Plus } from "lucide-react" +import { useRouter } from "next/navigation" +import { useEffect, useRef, useState } from "react" + +type MerchantOption = { id: string; name: string; currency: Currency } + +type Issued = { card: Card; number: string | null } + +const LABEL = "text-sm font-medium text-gray-900 dark:text-gray-50" + +/** + * Issue a virtual card. /api/cards validates every field again; the number + * is shown once and dropped from state when the dialog closes. + */ +export function IssueCardDialog({ + merchants, + categories, +}: { + merchants: MerchantOption[] + categories: readonly CardCategory[] +}) { + const router = useRouter() + const [open, setOpen] = useState(false) + const [nickname, setNickname] = useState("") + const [merchantId, setMerchantId] = useState("") + const [category, setCategory] = useState("any") + const [limit, setLimit] = useState("") + const [submitting, setSubmitting] = useState(false) + const [error, setError] = useState(null) + const [issued, setIssued] = useState(null) + // One id per attempt; the server returns the same card if it sees it twice. + const requestId = useRef(null) + const successHeading = useRef(null) + + const merchant = merchants.find((m) => m.id === merchantId) + // Currency is the merchant's; the server verifies this too. + const currency = merchant?.currency + + useEffect(() => { + if (issued) successHeading.current?.focus() + }, [issued]) + + const reset = () => { + setNickname("") + setMerchantId("") + setCategory("any") + setLimit("") + setError(null) + setIssued(null) + setSubmitting(false) + requestId.current = null + } + + const onOpenChange = (next: boolean) => { + setOpen(next) + // Closing wipes the revealed number; there is no way to see it again. + if (!next) reset() + } + + const submit = async (event: React.FormEvent) => { + event.preventDefault() + if (submitting) return + setSubmitting(true) + setError(null) + requestId.current ??= globalThis.crypto.randomUUID() + + try { + const response = await fetch("/api/cards", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + nickname, + merchantId, + category, + limit, + currency, + requestId: requestId.current, + }), + }) + const body = (await response.json().catch(() => null)) as + (Issued & { error?: undefined }) | { error: string } | null + if (!response.ok || !body || "error" in body) { + setError(body?.error ?? "The card could not be issued. Try again.") + return + } + setIssued(body) + router.refresh() + } catch { + setError( + "Could not reach the server. Check your connection and try again.", + ) + } finally { + setSubmitting(false) + } + } + + return ( + + + + + {/* Centered modal rather than the Drawer's default side panel. */} + + {issued ? ( + <> + + + + Card issued + + + + Copy the number now. It is shown once and cannot be recovered. + + + +
+
Nickname
+
+ {issued.card.nickname} +
+
Card number
+
+ {issued.number + ? issued.number.replace(/(\d{4})(?=\d)/g, "$1 ") + : `•••• ${issued.card.last4}`} +
+
Spend limit
+
+ {formatMoney(issued.card.limit, issued.card.currency)} +
+
+
+ + + + + + + ) : ( +
+ + Issue a virtual card + + Single-merchant, virtual, with a limit from the start. + + + +
+ + setNickname(event.target.value)} + placeholder="e.g. Google Ads" + maxLength={40} + autoComplete="off" + className="mt-1.5" + /> +
+ +
+ + +
+ +
+ + +
+ +
+ + setLimit(event.target.value)} + placeholder="250.00" + autoComplete="off" + className="mt-1.5" + /> +

+ {merchant + ? `${merchant.name} settles in ${merchant.currency}.` + : "The currency follows the merchant."} +

+
+ + {error && ( +

+ {error} +

+ )} +
+ + + + + + +
+ )} +
+
+ ) +} 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..b57509af --- /dev/null +++ b/build-battle/merchant-console/src/app/cards/page.tsx @@ -0,0 +1,116 @@ +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeaderCell, + TableRoot, + TableRow, +} from "@/components/Table" +import { StatusBadge } from "@/components/ui/payments/StatusBadge" +import { listCards } from "@/data/cards" +import { merchantById, merchants } from "@/data/merchants" +import { CARD_CATEGORIES, CATEGORY_LABELS, maskCard } from "@/lib/cards" +import { formatDate } from "@/lib/dates" +import { formatMoney } from "@/lib/money" +import Link from "next/link" +import { CardActions } from "./card-actions" +import { IssueCardDialog } from "./issue-card-dialog" + +export const dynamic = "force-dynamic" + +export default function CardsPage() { + const rows = listCards() + + return ( +
+
+
+

+ Virtual cards +

+

+ Single-merchant cards issued from the console. Numbers are shown + once, at issue. +

+
+ ({ + id: m.id, + name: m.name, + currency: m.currency, + }))} + categories={CARD_CATEGORIES} + /> +
+ + + + + + Card + Merchant + Category + Number + Limit + Status + Created + Actions + + + + {rows.length === 0 && ( + + +

+ No cards issued yet +

+

+ Issue a card to give a merchant a limit for vendor + subscriptions, ad spend, or contractor tools. +

+
+
+ )} + {rows.map((card) => { + const merchant = merchantById(card.merchantId) + return ( + + + + {card.nickname} + + + {merchant?.name} + + {CATEGORY_LABELS[card.category]} + + + {maskCard(card.last4)} + + + {formatMoney(card.limit, card.currency)} + + + + + {formatDate(card.createdAt)} + + + + + ) + })} +
+
+
+
+ ) +} diff --git a/build-battle/merchant-console/src/app/cards/spend-bar.tsx b/build-battle/merchant-console/src/app/cards/spend-bar.tsx new file mode 100644 index 00000000..34ff0639 --- /dev/null +++ b/build-battle/merchant-console/src/app/cards/spend-bar.tsx @@ -0,0 +1,78 @@ +import { Currency } from "@/data/types" +import { spendLevel, spendPercent } from "@/lib/cards" +import { formatMoney } from "@/lib/money" +import { cx } from "@/lib/utils" + +// Tailwind only sees literal class names, so the width is bucketed to 10%. +const WIDTHS = [ + "w-0", + "w-[10%]", + "w-[20%]", + "w-[30%]", + "w-[40%]", + "w-1/2", + "w-[60%]", + "w-[70%]", + "w-[80%]", + "w-[90%]", + "w-full", +] as const + +const FILL = { + ok: "bg-blue-500 dark:bg-blue-500", + warn: "bg-amber-500 dark:bg-amber-400", + over: "bg-red-500 dark:bg-red-500", +} as const + +/** Spend against the limit. Amber past 80%, red once the limit is reached. */ +export function SpendBar({ + spent, + limit, + currency, +}: { + spent: number + limit: number + currency: Currency +}) { + const percent = spendPercent(spent, limit) + const level = spendLevel(percent) + + return ( +
+
+ + {formatMoney(spent, currency)} spent + + + {percent}% of {formatMoney(limit, currency)} + +
+
+
+
+ {level === "warn" && ( +

+ Past 80% of the limit. +

+ )} + {level === "over" && ( +

+ Limit reached. New charges will decline. +

+ )} +
+ ) +} 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/ui/navigation/AppSidebar.tsx b/build-battle/merchant-console/src/components/ui/navigation/AppSidebar.tsx index f5e1345b..a4e3d9fc 100644 --- a/build-battle/merchant-console/src/components/ui/navigation/AppSidebar.tsx +++ b/build-battle/merchant-console/src/components/ui/navigation/AppSidebar.tsx @@ -16,7 +16,13 @@ import { } from "@/components/Sidebar" import { cx, focusRing } from "@/lib/utils" import { RiArrowDownSFill } from "@remixicon/react" -import { Banknote, CreditCard, House, ShieldAlert } from "lucide-react" +import { + Banknote, + CreditCard, + House, + ShieldAlert, + WalletCards, +} from "lucide-react" import * as React from "react" import { Logo } from "../../../../public/Logo" import { UserProfile } from "./UserProfile" @@ -48,6 +54,12 @@ const navigation = [ icon: Banknote, notifications: false as const, }, + { + name: "Cards", + href: siteConfig.baseLinks.cards, + icon: WalletCards, + notifications: false as const, + }, ] as const export function AppSidebar({ ...props }: React.ComponentProps) { diff --git a/build-battle/merchant-console/src/components/ui/navigation/Breadcrumbs.tsx b/build-battle/merchant-console/src/components/ui/navigation/Breadcrumbs.tsx index 89481ad4..e3edad0a 100644 --- a/build-battle/merchant-console/src/components/ui/navigation/Breadcrumbs.tsx +++ b/build-battle/merchant-console/src/components/ui/navigation/Breadcrumbs.tsx @@ -9,6 +9,7 @@ const LABELS: Record = { payments: "Payments", disputes: "Disputes", payouts: "Payouts", + cards: "Cards", } export function Breadcrumbs() { diff --git a/build-battle/merchant-console/src/components/ui/payments/StatusBadge.tsx b/build-battle/merchant-console/src/components/ui/payments/StatusBadge.tsx index 20e5ff26..9b065a67 100644 --- a/build-battle/merchant-console/src/components/ui/payments/StatusBadge.tsx +++ b/build-battle/merchant-console/src/components/ui/payments/StatusBadge.tsx @@ -1,8 +1,13 @@ import { Badge } from "@/components/Badge" -import { DisputeStatus, PaymentStatus, PayoutStatus } from "@/data/types" +import { + CardStatus, + DisputeStatus, + PaymentStatus, + PayoutStatus, +} from "@/data/types" import { cx } from "@/lib/utils" -type AnyStatus = PaymentStatus | DisputeStatus | PayoutStatus +type AnyStatus = PaymentStatus | DisputeStatus | PayoutStatus | CardStatus const LABELS: Record = { authorized: "Authorized", @@ -17,6 +22,9 @@ const LABELS: Record = { paid: "Paid", in_transit: "In transit", pending: "Pending", + active: "Active", + frozen: "Frozen", + cancelled: "Cancelled", } const DOTS: Record = { @@ -32,9 +40,15 @@ const DOTS: Record = { paid: "bg-emerald-600 dark:bg-emerald-400", in_transit: "bg-blue-500 dark:bg-blue-500", pending: "bg-gray-500 dark:bg-gray-500", + active: "bg-emerald-600 dark:bg-emerald-400", + frozen: "bg-blue-500 dark:bg-blue-500", + cancelled: "bg-gray-500 dark:bg-gray-500", } -const VARIANTS: Record = { +const VARIANTS: Record< + AnyStatus, + "default" | "neutral" | "success" | "error" | "warning" +> = { authorized: "default", captured: "success", refunded: "neutral", @@ -47,6 +61,9 @@ const VARIANTS: Record b.createdAt.localeCompare(a.createdAt)) +} + +export function cardById(id: string): Card | null { + return store.cards.find((card) => card.id === id) ?? null +} + +/** + * Issues a card. A repeated request id returns the card it already created, + * so a double submit or a retry after a timeout cannot mint two cards; the + * number is not returned a second time. + */ +export function createCard(input: CardInput): { + card: Card + number: string | null + created: boolean +} { + if (input.requestId) { + const existing = store.cards.find((c) => c.requestId === input.requestId) + if (existing) return { card: existing, number: null, created: false } + } + + const number = generateCardNumber() + // Cards are never deleted, so the store length is a monotonic sequence and + // survives dev-server module reloads, unlike a module-level counter. + const seq = store.cards.length + 1 + const now = new Date().toISOString() + const card: Card = { + id: `card_${pad(seq)}`, + nickname: input.nickname, + merchantId: input.merchantId, + category: input.category, + limit: input.limit, + spent: 0, + currency: input.currency, + last4: number.slice(-4), + numberRef: `cardref_${pad(seq)}`, + status: "active", + requestId: input.requestId, + createdAt: now, + events: [{ type: "issued", at: now }], + } + store.cards.push(card) + return { card, number, created: true } +} + +const EVENT_FOR: Record< + Exclude, + "frozen" | "cancelled" +> = { frozen: "frozen", cancelled: "cancelled" } + +/** Moves a card through the state machine, or explains why it cannot. */ +export function transitionCard( + id: string, + to: CardStatus, +): { card: Card } | { error: string; status: 404 | 409 } { + const card = cardById(id) + if (!card) return { error: "Card not found.", status: 404 } + if (card.status === "cancelled") { + return { error: "A cancelled card cannot be changed.", status: 409 } + } + if (!canTransition(card.status, to)) { + return { error: `Card is already ${card.status}.`, status: 409 } + } + + card.status = to + card.events.push({ + type: to === "active" ? "unfrozen" : EVENT_FOR[to], + at: new Date().toISOString(), + }) + return { card } +} diff --git a/build-battle/merchant-console/src/data/generate.ts b/build-battle/merchant-console/src/data/generate.ts index 2887ba8c..a6b22b80 100644 --- a/build-battle/merchant-console/src/data/generate.ts +++ b/build-battle/merchant-console/src/data/generate.ts @@ -1,5 +1,6 @@ import { merchants } from "./merchants" import { + Card, Currency, Dispute, Payment, @@ -52,7 +53,7 @@ const REASON_CODES = [ "13.7 Cancelled Merchandise", ] -const pad = (n: number, width = 6) => String(n).padStart(width, "0") +export const pad = (n: number, width = 6) => String(n).padStart(width, "0") /** The anchor date. Fixed, so "the last 30 days" is stable across runs. */ export const GENERATED_AT = new Date("2026-08-13T00:00:00.000Z") @@ -86,7 +87,8 @@ export function generate() { createdAt.setUTCHours(between(0, 23), between(0, 59), between(0, 59), 0) const status = statusFor() - const method = rand() < 0.82 ? "card" : rand() < 0.6 ? "wallet" : "bank_transfer" + const method = + rand() < 0.82 ? "card" : rand() < 0.6 ? "wallet" : "bank_transfer" const amount = between(450, 480_00) const payment: Payment = { @@ -97,7 +99,9 @@ export function generate() { status, method, cardBrand: - method === "card" ? pick(["visa", "mastercard", "amex"] as const) : null, + method === "card" + ? pick(["visa", "mastercard", "amex"] as const) + : null, last4: method === "card" ? String(between(1000, 9999)) : null, createdAt: createdAt.toISOString(), description: pick(DESCRIPTIONS), @@ -123,7 +127,9 @@ export function generate() { } if (status === "disputed") { - const openedAt = new Date(createdAt.getTime() + between(2, 10) * 86_400_000) + const openedAt = new Date( + createdAt.getTime() + between(2, 10) * 86_400_000, + ) disputes.push({ id: `dp_${pad(++disputeSeq)}`, paymentId: payment.id, @@ -148,7 +154,50 @@ export function generate() { } const payouts = generatePayouts(payments) - return { payments, refunds, disputes, payouts } + return { payments, refunds, disputes, payouts, cards: generateCards() } +} + +/** + * Two fixture cards so the spend bar and a frozen row are visible before + * anyone issues one. Spend here is fixture data; cards issued at runtime start + * at zero and stay there. Only the last four is kept. + */ +function generateCards(): Card[] { + const issuedAt = new Date(GENERATED_AT) + issuedAt.setUTCDate(issuedAt.getUTCDate() - 12) + const at = issuedAt.toISOString() + const base = { last4: "4242", requestId: null, createdAt: at } + return [ + { + ...base, + id: `card_${pad(1)}`, + numberRef: `cardref_${pad(1)}`, + nickname: "Figma seats", + merchantId: "mch_04", + category: "software", + limit: 40000, + spent: 36000, + currency: "GBP", + status: "active", + events: [{ type: "issued", at }], + }, + { + ...base, + id: `card_${pad(2)}`, + numberRef: `cardref_${pad(2)}`, + nickname: "Contractor — Berlin", + merchantId: "mch_05", + category: "contractors", + limit: 120000, + spent: 15000, + currency: "EUR", + status: "frozen", + events: [ + { type: "issued", at }, + { type: "frozen", at }, + ], + }, + ] } function generatePayouts(payments: Payment[]): Payout[] { diff --git a/build-battle/merchant-console/src/data/queries.ts b/build-battle/merchant-console/src/data/queries.ts index cc4ca009..262160ff 100644 --- a/build-battle/merchant-console/src/data/queries.ts +++ b/build-battle/merchant-console/src/data/queries.ts @@ -77,8 +77,8 @@ export function sortPayments( const factor = direction === "asc" ? 1 : -1 return [...payments].sort((a, b) => { if (sort === "amount") { - // Sort by the formatted amount so the order matches what the table shows. - return String(a.amount).localeCompare(String(b.amount)) * factor + // Amounts are integer minor units; compare them as numbers. + return (a.amount - b.amount) * factor } return a.createdAt.localeCompare(b.createdAt) * factor }) diff --git a/build-battle/merchant-console/src/data/store.ts b/build-battle/merchant-console/src/data/store.ts index ba71d950..7f029b88 100644 --- a/build-battle/merchant-console/src/data/store.ts +++ b/build-battle/merchant-console/src/data/store.ts @@ -1,6 +1,6 @@ import { generate } from "./generate" import { merchants } from "./merchants" -import { Dispute, Payment, Payout, Refund } from "./types" +import { Card, Dispute, Payment, Payout, Refund } from "./types" /** * In-memory store. @@ -19,6 +19,7 @@ interface Store { refunds: Refund[] disputes: Dispute[] payouts: Payout[] + cards: Card[] } declare global { @@ -27,8 +28,8 @@ declare global { } function createStore(): Store { - const { payments, refunds, disputes, payouts } = generate() - return { merchants, payments, refunds, disputes, payouts } + const { payments, refunds, disputes, payouts, cards } = generate() + return { merchants, payments, refunds, disputes, payouts, cards } } export const store: Store = globalThis.__northwindStore ?? createStore() diff --git a/build-battle/merchant-console/src/data/types.ts b/build-battle/merchant-console/src/data/types.ts index 6697e576..9ecd8961 100644 --- a/build-battle/merchant-console/src/data/types.ts +++ b/build-battle/merchant-console/src/data/types.ts @@ -1,16 +1,48 @@ export type Currency = "USD" | "EUR" | "GBP" export type PaymentStatus = - | "authorized" - | "captured" - | "refunded" - | "failed" - | "disputed" + "authorized" | "captured" | "refunded" | "failed" | "disputed" export type DisputeStatus = "needs_response" | "under_review" | "won" | "lost" export type PayoutStatus = "paid" | "in_transit" | "pending" +export type CardStatus = "active" | "frozen" | "cancelled" + +/** Merchant category the card is locked to at issue time. */ +export type CardCategory = + "any" | "advertising" | "software" | "contractors" | "travel" | "office" + +export interface CardEvent { + type: "issued" | "frozen" | "unfrozen" | "cancelled" + /** ISO 8601, always UTC. */ + at: string +} + +/** + * A virtual card. The full number is never stored: only the last four and an + * opaque reference survive creation. + */ +export interface Card { + id: string + nickname: string + merchantId: string + category: CardCategory + /** Integer minor units. Never a float. */ + limit: number + /** Integer minor units, same currency as the limit. */ + spent: number + currency: Currency + last4: string + numberRef: string + status: CardStatus + /** Client-supplied idempotency key; a reuse returns the existing card. */ + requestId: string | null + /** ISO 8601, always UTC. */ + createdAt: string + events: CardEvent[] +} + export interface Merchant { id: string name: string diff --git a/build-battle/merchant-console/src/lib/cards.test.ts b/build-battle/merchant-console/src/lib/cards.test.ts new file mode 100644 index 00000000..09ef9465 --- /dev/null +++ b/build-battle/merchant-console/src/lib/cards.test.ts @@ -0,0 +1,185 @@ +import { describe, expect, it } from "vitest" +import { + MAX_LIMIT_MINOR, + canTransition, + generateCardNumber, + isLuhnValid, + luhnCheckDigit, + maskCard, + parseCardInput, + spendLevel, + spendPercent, +} from "./cards" + +/** + * Numbers live on the 4242 test BIN with a real check digit, cancelled is + * terminal, and the server rejects what the ticket says it must. + */ + +describe("luhnCheckDigit", () => { + it("completes the canonical test number", () => { + expect(luhnCheckDigit("424242424242424")).toBe(2) + }) + + it("matches the classic Luhn vector", () => { + expect(luhnCheckDigit("7992739871")).toBe(3) + }) +}) + +describe("isLuhnValid", () => { + it("accepts a valid number and rejects a one-digit change", () => { + expect(isLuhnValid("4242424242424242")).toBe(true) + expect(isLuhnValid("4242424242424241")).toBe(false) + }) + + it("rejects non-digits and empty input", () => { + expect(isLuhnValid("4242 4242")).toBe(false) + expect(isLuhnValid("")).toBe(false) + }) +}) + +describe("generateCardNumber", () => { + it("is 16 digits on the 4242 test BIN with a valid check digit, every time", () => { + for (let i = 0; i < 100; i++) { + const number = generateCardNumber() + expect(number).toMatch(/^4242\d{12}$/) + expect(isLuhnValid(number)).toBe(true) + } + }) + + it("is not a constant", () => { + const numbers = new Set(Array.from({ length: 50 }, generateCardNumber)) + expect(numbers.size).toBeGreaterThan(1) + }) +}) + +describe("maskCard", () => { + it("shows only the last four", () => { + expect(maskCard("4242")).toBe("•••• 4242") + }) +}) + +describe("canTransition", () => { + it.each([ + ["active", "frozen", true], + ["frozen", "active", true], + ["active", "cancelled", true], + ["frozen", "cancelled", true], + ["cancelled", "active", false], + ["cancelled", "frozen", false], + ["active", "active", false], + ["frozen", "frozen", false], + ["cancelled", "cancelled", false], + ] as const)("%s → %s is %s", (from, to, allowed) => { + expect(canTransition(from, to)).toBe(allowed) + }) +}) + +describe("parseCardInput", () => { + const valid = { + nickname: " Google Ads ", + merchantId: "mch_01", + limit: "250.00", + currency: "USD", + } + + it("converts the limit to minor units once and trims the nickname", () => { + const result = parseCardInput(valid) + expect(result).toEqual({ + input: { + nickname: "Google Ads", + merchantId: "mch_01", + category: "any", + limit: 25000, + currency: "USD", + requestId: null, + }, + }) + }) + + it("treats 250 and 250.00 as the same limit", () => { + const a = parseCardInput({ ...valid, limit: "250" }) + const b = parseCardInput({ ...valid, limit: "250.00" }) + expect(a).toEqual(b) + }) + + it.each([ + ["missing merchant", { merchantId: "" }, "Choose a merchant."], + ["unknown merchant", { merchantId: "mch_99" }, "Unknown merchant."], + ["zero limit", { limit: "0" }, "Spend limit must be greater than zero."], + ["negative limit", { limit: "-5" }, "Enter a spend limit like 250.00."], + ["non-decimal limit", { limit: "abc" }, "Enter a spend limit like 250.00."], + // A number would be ambiguous between cents and dollars; only strings. + ["numeric limit", { limit: 25000 }, "Enter a spend limit like 250.00."], + [ + "one cent over the ceiling", + { limit: "50000.01" }, + "Spend limit cannot exceed 5,000,000 minor units.", + ], + [ + "currency outside the allowlist", + { currency: "JPY" }, + "Currency must be USD, EUR, or GBP.", + ], + [ + "lowercase currency", + { currency: "usd" }, + "Currency must be USD, EUR, or GBP.", + ], + // mch_04 settles in GBP. + [ + "currency differing from the merchant", + { merchantId: "mch_04" }, + "Halcyon Studio settles in GBP; the card must use the same currency.", + ], + ["blank nickname", { nickname: " " }, "Give the card a nickname."], + [ + "overlong nickname", + { nickname: "x".repeat(41) }, + "Nickname must be 40 characters or fewer.", + ], + [ + "unknown category", + { category: "gambling" }, + "Unknown merchant category.", + ], + ])("rejects a %s", (_, patch, error) => { + expect(parseCardInput({ ...valid, ...patch })).toEqual({ error }) + }) + + it("accepts exactly 5,000,000 minor units and a matching merchant currency", () => { + expect(parseCardInput({ ...valid, limit: "50000" })).toHaveProperty( + "input.limit", + MAX_LIMIT_MINOR, + ) + expect( + parseCardInput({ + ...valid, + merchantId: "mch_04", + currency: "GBP", + category: "software", + }), + ).toMatchObject({ input: { currency: "GBP", category: "software" } }) + }) + + it("never throws on a non-object body", () => { + expect(parseCardInput(null)).toHaveProperty("error") + expect(parseCardInput("x")).toHaveProperty("error") + expect(parseCardInput([])).toHaveProperty("error") + }) +}) + +describe("spendPercent and spendLevel", () => { + it("computes a clamped whole-number ratio", () => { + expect(spendPercent(0, 10000)).toBe(0) + expect(spendPercent(8000, 10000)).toBe(80) + expect(spendPercent(12000, 10000)).toBe(100) + expect(spendPercent(0, 0)).toBe(0) + }) + + it("turns amber past 80%, not at it", () => { + expect(spendLevel(80)).toBe("ok") + expect(spendLevel(81)).toBe("warn") + expect(spendLevel(100)).toBe("over") + }) +}) diff --git a/build-battle/merchant-console/src/lib/cards.ts b/build-battle/merchant-console/src/lib/cards.ts new file mode 100644 index 00000000..34871312 --- /dev/null +++ b/build-battle/merchant-console/src/lib/cards.ts @@ -0,0 +1,183 @@ +import { merchantById } from "@/data/merchants" +import { CardCategory, CardStatus, Currency } from "@/data/types" +import { parseAmountToMinorUnits } from "./money" + +/** + * Pure card rules. No store access and no Node-only imports, because client + * components read the transition table from here. + */ + +export const TEST_BIN = "4242" +export const CARD_NUMBER_LENGTH = 16 + +export const CARD_STATUSES = ["active", "frozen", "cancelled"] as const +export const CARD_CATEGORIES = [ + "any", + "advertising", + "software", + "contractors", + "travel", + "office", +] as const +export const CURRENCIES = ["USD", "EUR", "GBP"] as const + +/** Ticket NWP-201: the largest limit a card may carry, in minor units. */ +export const MAX_LIMIT_MINOR = 5_000_000 +export const MAX_NICKNAME_LENGTH = 40 + +export const CATEGORY_LABELS: Record = { + any: "Any category", + advertising: "Advertising", + software: "Software", + contractors: "Contractors", + travel: "Travel", + office: "Office supplies", +} + +/** Luhn check digit for a partial number (every digit except the last). */ +export function luhnCheckDigit(partial: string): number { + let sum = 0 + // Walk right to left; doubling starts on the rightmost digit of the partial + // because the check digit will occupy the final position. + for ( + let i = partial.length - 1, double = true; + i >= 0; + i--, double = !double + ) { + let digit = Number(partial[i]) + if (double) { + digit *= 2 + if (digit > 9) digit -= 9 + } + sum += digit + } + return (10 - (sum % 10)) % 10 +} + +export function isLuhnValid(number: string): boolean { + if (!/^\d{2,}$/.test(number)) return false + const partial = number.slice(0, -1) + return luhnCheckDigit(partial) === Number(number[number.length - 1]) +} + +/** 16 digits on the 4242 test BIN with a valid check digit (CSPRNG body). */ +export function generateCardNumber(): string { + const bodyLength = CARD_NUMBER_LENGTH - TEST_BIN.length - 1 + const random = globalThis.crypto.getRandomValues(new Uint32Array(bodyLength)) + const partial = TEST_BIN + Array.from(random, (n) => n % 10).join("") + return partial + luhnCheckDigit(partial) +} + +export function maskCard(last4: string): string { + return `•••• ${last4}` +} + +/** The state machine. `cancelled` has no exits. */ +export const TRANSITIONS: Record = { + active: ["frozen", "cancelled"], + frozen: ["active", "cancelled"], + cancelled: [], +} + +export function canTransition(from: CardStatus, to: CardStatus): boolean { + return TRANSITIONS[from].includes(to) +} + +export interface CardInput { + nickname: string + merchantId: string + category: CardCategory + /** Integer minor units, converted once from the client's string. */ + limit: number + currency: Currency + requestId: string | null +} + +type ParseResult = { input: CardInput } | { error: string } + +/** + * Validates POST /api/cards. Every field is allowlisted; the limit arrives as + * a string and is converted to minor units exactly once, here. + */ +export function parseCardInput(body: unknown): ParseResult { + if (typeof body !== "object" || body === null || Array.isArray(body)) { + return { error: "Request body must be a JSON object." } + } + const raw = body as Record + + const nickname = typeof raw.nickname === "string" ? raw.nickname.trim() : "" + if (nickname.length === 0) { + return { error: "Give the card a nickname." } + } + if (nickname.length > MAX_NICKNAME_LENGTH) { + return { + error: `Nickname must be ${MAX_NICKNAME_LENGTH} characters or fewer.`, + } + } + + if (typeof raw.merchantId !== "string" || raw.merchantId.length === 0) { + return { error: "Choose a merchant." } + } + const merchant = merchantById(raw.merchantId) + if (!merchant) { + return { error: "Unknown merchant." } + } + + if (!CURRENCIES.includes(raw.currency as Currency)) { + return { error: "Currency must be USD, EUR, or GBP." } + } + const currency = raw.currency as Currency + if (currency !== merchant.currency) { + return { + error: `${merchant.name} settles in ${merchant.currency}; the card must use the same currency.`, + } + } + + if (typeof raw.limit !== "string") { + return { error: "Enter a spend limit like 250.00." } + } + const limit = parseAmountToMinorUnits(raw.limit) + if (limit === null) { + return { error: "Enter a spend limit like 250.00." } + } + if (limit <= 0) { + return { error: "Spend limit must be greater than zero." } + } + if (limit > MAX_LIMIT_MINOR) { + return { error: "Spend limit cannot exceed 5,000,000 minor units." } + } + + const category = raw.category === undefined ? "any" : raw.category + if (!CARD_CATEGORIES.includes(category as CardCategory)) { + return { error: "Unknown merchant category." } + } + + const requestId = + typeof raw.requestId === "string" && raw.requestId.length > 0 + ? raw.requestId + : null + + return { + input: { + nickname, + merchantId: merchant.id, + category: category as CardCategory, + limit, + currency, + requestId, + }, + } +} + +/** Bar ratio, clamped to 100. Both inputs are minor units of one currency. */ +export function spendPercent(spent: number, limit: number): number { + if (limit <= 0) return 0 + return Math.min(100, Math.floor((spent * 100) / limit)) +} + +/** Bar colour band. The ticket says amber past 80%. */ +export function spendLevel(percent: number): "ok" | "warn" | "over" { + if (percent >= 100) return "over" + if (percent > 80) return "warn" + return "ok" +} diff --git a/docs/specs/NWP-201-issue-cards.md b/docs/specs/NWP-201-issue-cards.md new file mode 100644 index 00000000..924603ee --- /dev/null +++ b/docs/specs/NWP-201-issue-cards.md @@ -0,0 +1,70 @@ +# SPEC · NWP-201 — Issue virtual cards from the console + +> Written before any code. Generated with `/spec`, then edited by a human. +> Load it as context when you build: `@docs/specs/NWP-201-issue-cards.md` + +**Ticket:** [NWP-201](../tickets/NWP-201.md) +**Author:** Gabriel Amaral +**Status:** done + +## Problem + +Ops issues virtual cards by messaging the platform team, who create them by hand. It takes hours, happens 12–20 times a week, and last month two cards went out with the wrong limit. Marcus wants issue, list, and detail in the console today. + +## Current state + +Paths are under `build-battle/merchant-console/`. + +- `src/data/store.ts` — in-memory store on `globalThis`; no `cards` slice. Restart `next dev` after adding one. +- `src/data/generate.ts` — seed is generated TypeScript, not JSON as `CLAUDE.md` says; `pad()` builds ids. Card fixtures go here. +- `src/data/types.ts` — `Currency = "USD" | "EUR" | "GBP"` already is the allowlist. No `Card` type. +- `src/data/merchants.ts` — `merchantById()` returns `undefined` when unknown; each merchant has a `currency` nothing checks yet. +- `src/lib/money.ts` — `parseAmountToMinorUnits` (boundary converter), `formatMoney`. `src/lib/dates.ts` — `formatInZone`. +- `src/app/api/payments/export/route.ts` — the route pattern: `as const` allowlist, `{ value } | { error }` validator, `NextResponse.json({ error }, { status: 400 })`. No POST handler exists anywhere. +- `src/app/payments/page.tsx`, `[id]/page.tsx` — server components reading the store; inline empty state; `Field` grid and timeline. Next 15 `params` are promises. +- `src/app/payments/export-dialog.tsx` — form dialog on `src/components/Drawer.tsx`; there is no `Dialog.tsx` despite `.claude/rules/components.md`. +- `src/components/ui/payments/StatusBadge.tsx` — three `Record` maps to extend. +- `src/data/queries.ts:81` — defect: amounts sorted with `String().localeCompare`. One-line fix in passing. + +## Domain rules + +| Rule | Source | What breaks if ignored | +| --- | --- | --- | +| "Money is integer minor units. `$250.00` is `25000`." | `CLAUDE.md`, ticket rule 1 | The wrong-limit bug ops is escaping | +| "Never persist or display a full card number after creation." | ticket rule 2, `.claude/rules/cards.md` | A PAN in the store or a payload | +| "`active ⇄ frozen`, either to `cancelled`, and `cancelled` is terminal. Guard on the server." | ticket rule 3, `cards.md` | A cancelled card comes back | +| "Every generated number starts `4242` with a valid Luhn check digit. Generate on the server." | ticket rule 4, `cards.md` | Something resembling a real PAN | +| Reject missing merchant, limit ≤ 0, limit > 5,000,000, currency ∉ USD/EUR/GBP, on the server | ticket core 6, `api-routes.md` | Client-only enforcement | +| Labelled inputs, named dialog, focus handled, Escape closes; written empty and error states | `components.md` | Unusable by keyboard; blank tables | + +## Approach + +Add a `cards` slice to the store, a pure `src/lib/cards.ts` (Luhn generator on the 4242 BIN, transition table, `parseCardInput` converting the limit string once via `parseAmountToMinorUnits`), `POST /api/cards` (the only response carrying a full number) and `PATCH /api/cards/[id]` (guarded transitions). `/cards` and `/cards/[id]` are server components like `/payments`; the issue form is a client dialog cloned from `export-dialog.tsx` that shows the number once and wipes it on close. Beyond the ticket: currency must match the merchant, a client `requestId` makes issue idempotent, every transition is recorded on the card, and cancel needs a confirm. + +**Considered and rejected:** generating the number in the browser (`cards.md` calls it a bug); a module-level cards array (resets on HMR, unlike the `globalThis` store). + +## File map + +| File | Add or change | Why | +| --- | --- | --- | +| `src/data/types.ts` | change | `Card`, `CardStatus`, `CardCategory`, `CardEvent` | +| `src/data/generate.ts` | change | export `pad`, two fixture cards | +| `src/data/store.ts` | change | `cards` slice | +| `src/data/cards.ts` | add | `listCards`, `cardById`, `createCard`, `transitionCard` | +| `src/lib/cards.ts`, `src/lib/cards.test.ts` | add | Luhn, generator, mask, transitions, `parseCardInput`, spend percent, tests | +| `src/app/api/cards/route.ts`, `src/app/api/cards/[id]/route.ts` | add | GET/POST issue, PATCH status | +| `src/app/cards/page.tsx`, `[id]/page.tsx`, `issue-card-dialog.tsx`, `card-actions.tsx`, `spend-bar.tsx` | add | list, detail, form, freeze/unfreeze/cancel, progress bar | +| `StatusBadge.tsx`, `siteConfig.ts`, `AppSidebar.tsx`, `Breadcrumbs.tsx` | change | card statuses, navigation | +| `src/data/queries.ts` | change | numeric amount sort | + +## Plan + +1. **Types, store, lib, tests** — done when `npm test` is green with `cards.test.ts`. +2. **Routes** — done when curl shows 201 with a `4242…` Luhn-valid number, 400 per rejection, 409 for `cancelled → active`, no 16-digit string in `GET /api/cards`. +3. **Nav, list, dialog, detail** — done when a card issued in the browser appears masked in the list and opens in detail. +4. **Stretch** — done when freeze/unfreeze changes the badge without navigation, the 90% fixture shows amber, cancel confirms then offers no actions. +5. **Ship** — lint, test, `/ship-ready`, PR. + +## Out of scope + +Persistence (NWP-203), auth, real issuer calls, editing a limit (NWP-202). `spent` is 0 at issue and stays 0; fixtures carry spend only so bar states are visible.