diff --git a/NWP-201-issue-cards.md b/NWP-201-issue-cards.md new file mode 100644 index 0000000..cf4f658 --- /dev/null +++ b/NWP-201-issue-cards.md @@ -0,0 +1,93 @@ +# NWP-201 · Issue virtual cards from the console — implementation spec + +Ticket: `docs/tickets/NWP-201.md` · Branch: `NWP-201-issue-cards` + +## Current state + +There is no cards code in this repository today. `src/app/` has `overview/`, +`payments/`, `disputes/`, and `payouts/`; there is no `cards/` route and no +`src/app/api/cards/`. `merchant-console/CLAUDE.md` says as much: "Cards is +NWP-201 and does not exist yet." + +What already exists and must be reused rather than rebuilt: + +| Existing | Where | Why it matters here | +| --- | --- | --- | +| In-memory store on `globalThis` | `src/data/store.ts:34` | Cards join it as another array. No DB — persistence is NWP-203 | +| Merchants, each with a `currency` | `src/data/merchants.ts:7` | Every merchant already declares USD/GBP/EUR. A card's currency must agree | +| `formatMoney(minorUnits, currency)` | `src/lib/money.ts:15` | The only place a decimal point appears. Do not write a second formatter | +| `Currency` union, `Merchant`, `Payment` | `src/data/types.ts:1` | `Card` extends this file rather than starting a new one | +| Payment query builder | `src/data/queries.ts:45` | Spend is derived from real payments through this, not invented | +| Drawer (Radix dialog) | `src/components/Drawer.tsx` | There is no `Dialog.tsx` despite what `.claude/rules/components.md` implies | +| Table, Button, Input, Select, Badge | `src/components/` | The list and form are built from these | + +## Domain rules this must respect + +From `merchant-console/CLAUDE.md` and `.claude/rules/`: + +1. **Money is integer minor units.** A $250.00 limit is `25000`. Formatted once, + at the edge, next to its currency code. +2. **Storage and bucketing are UTC.** `createdAt` is an ISO UTC string, matching + `Payment.createdAt`. +3. **Validate on the server.** Merchant, limit, currency, and status transitions + are checked in the route handler against an allowlist. Client checks are a + convenience, never the enforcement. +4. **Card numbers.** Generated server-side on the `4242` BIN with a valid Luhn + check digit. The full number is returned exactly once, in the creation + response, and is never stored on the record. +5. **Status is a state machine.** `active ⇄ frozen`, either to `cancelled`, + and `cancelled` is terminal. + +## Files this will touch + +Server first, in this order. Nothing in the UI is written until the route is +verified by test and by curl. + +| # | File | Change | +| --- | --- | --- | +| 1 | `src/data/types.ts` | Add `Card`, `CardStatus`, `CardStatusEvent` | +| 2 | `src/lib/cards.ts` | **new** — Luhn generator on the 4242 BIN, `maskCard`, `canTransition`, limit/currency validation | +| 3 | `src/lib/cards.test.ts` | **new** — Luhn validity, BIN prefix, uniqueness, masking, every legal and illegal transition, validation boundaries | +| 4 | `src/data/store.ts` | Add `cards: Card[]` to the store | +| 5 | `src/data/cards.ts` | Card queries: list, by id, spend derived from real payments | +| 6 | `src/app/api/cards/route.ts` | `GET` list · `POST` issue, validated, reveal-once | +| 7 | `src/app/api/cards/[id]/route.ts` | `GET` detail · `PATCH` guarded status transition | +| 8 | `src/app/cards/page.tsx` | `/cards` list: nickname, merchant, masked number, limit, status, created | +| 9 | `src/app/cards/issue-dialog.tsx` | Issue form + one-time reveal on success | +| 10 | `src/app/cards/card-actions.tsx` | Freeze/unfreeze and cancel-with-confirm, no full reload | +| 11 | `src/app/cards/[id]/page.tsx` | Detail: full record, spend against limit, audit trail | +| 12 | `src/components/ui/navigation/AppSidebar.tsx` | Add the Cards link | + +## Decisions taken deliberately + +- **Currency is derived from the merchant, and the server verifies it.** + `merchants.ts` already knows each merchant's currency, so an ops user cannot + issue a GBP card against a EUR merchant. The form derives it; the route + rejects a mismatch with 422 rather than trusting the form. +- **Spend is honest.** `spent` is not a random number. It is derived from + captured payments for that merchant through the existing query builder, and + the derivation is stated on the detail page. A card issued today against a + merchant with no captured payments shows 0 and a bar at 0%. +- **Issue is idempotent.** The client generates a request id per open form; the + route stores it on the card and returns the existing card on a repeat rather + than minting a second. A double-click cannot create two cards. +- **The full number never lands on the record.** `Card` has `last4` and + `numberRef` only. The full PAN exists solely in the POST response body. +- **Audit trail.** Every status change appends `{ from, to, at }` to the card + and is rendered on the detail page. + +## How this will be verified + +- `npm test` — unit tests on the generator, the mask, the state machine, and + the validators. Each would fail without the change. +- `curl` against `POST /api/cards` for each rejection: missing merchant, zero, + negative, over 5,000,000, bad currency, currency/merchant mismatch, and a + replayed request id. +- `curl` against `PATCH` for `cancelled → active`, which must be refused. +- Browser: issue a card, confirm the number appears once, reload and confirm it + is masked, freeze and unfreeze without a reload, cancel behind a confirm. + +## Out of scope + +Persistence, auth, real network calls, and editing a limit after issue — per +the ticket. No database, no ORM, no migration. 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 0000000..d1a3ea3 --- /dev/null +++ b/build-battle/merchant-console/src/app/api/cards/[id]/route.ts @@ -0,0 +1,68 @@ +import { applyCardStatus, cardById, spendForCard } from "@/data/cards" +import { canTransition, isCardStatus } from "@/lib/cards" +import { NextRequest, NextResponse } from "next/server" + +/** + * GET — one card, masked. The full number is never available here. + * PATCH — a guarded status change. + * + * The state machine is enforced server-side: active ⇄ frozen, either to + * cancelled, and cancelled is terminal. A client that asks for a forbidden + * transition is refused whatever its UI allowed. + */ + +export async function GET( + _request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + const { id } = await params + const card = cardById(id) + if (!card) { + return NextResponse.json({ message: "Card not found." }, { status: 404 }) + } + return NextResponse.json({ card: { ...card, spent: spendForCard(card) } }) +} + +export async function PATCH( + request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + const { id } = await params + const card = cardById(id) + if (!card) { + return NextResponse.json({ message: "Card not found." }, { status: 404 }) + } + + let body: unknown + try { + body = await request.json() + } catch { + return NextResponse.json( + { message: "Expected a JSON body." }, + { status: 400 }, + ) + } + + const status = (body as Record)?.status + if (!isCardStatus(status)) { + return NextResponse.json( + { message: "Status must be active, frozen, or cancelled." }, + { status: 422 }, + ) + } + + if (!canTransition(card.status, status)) { + return NextResponse.json( + { + message: + card.status === "cancelled" + ? "A cancelled card is cancelled for good." + : `A ${card.status} card cannot become ${status}.`, + }, + { status: 409 }, + ) + } + + applyCardStatus(card, status, new Date().toISOString()) + return NextResponse.json({ card: { ...card, spent: spendForCard(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 0000000..8791d40 --- /dev/null +++ b/build-battle/merchant-console/src/app/api/cards/route.ts @@ -0,0 +1,97 @@ +import { + addCard, + cardByRequestId, + listCards, + nextCardId, + spendForCard, +} from "@/data/cards" +import { merchantById } from "@/data/merchants" +import { Card } from "@/data/types" +import { generateCardNumber, lastFour, validateIssue } from "@/lib/cards" +import { NextRequest, NextResponse } from "next/server" + +/** + * GET — every issued card, masked. + * POST — issue one. + * + * The client is not trusted. Nickname, merchant, limit, currency, and category + * are validated here; the merchant's own currency is the authority on what a + * card may be denominated in. + * + * The full number appears in the POST response and nowhere else. It is not + * written to the store, so no later read can return it. + */ + +export function GET() { + return NextResponse.json({ + cards: listCards().map((card) => ({ + ...card, + spent: spendForCard(card), + })), + }) +} + +export async function POST(request: NextRequest) { + let body: unknown + try { + body = await request.json() + } catch { + return NextResponse.json( + { message: "Expected a JSON body." }, + { status: 400 }, + ) + } + + const input = (body ?? {}) as Record + + // Idempotency: the same request id must never mint a second card. A + // double-clicked form, or a retry after a timeout, gets the original back. + const requestId = + typeof input.requestId === "string" && input.requestId + ? input.requestId + : null + if (requestId) { + const existing = cardByRequestId(requestId) + if (existing) { + return NextResponse.json( + { + card: { ...existing, spent: spendForCard(existing) }, + replayed: true, + }, + { status: 200 }, + ) + } + } + + const merchant = merchantById( + typeof input.merchantId === "string" ? input.merchantId : "", + ) + const validation = validateIssue(input, merchant?.currency) + if (!validation.ok) { + return NextResponse.json({ message: validation.message }, { status: 422 }) + } + + const number = generateCardNumber() + const now = new Date().toISOString() + const card: Card = { + id: nextCardId(), + nickname: validation.value.nickname, + merchantId: validation.value.merchantId, + spendLimit: validation.value.spendLimit, + currency: validation.value.currency, + status: "active", + last4: lastFour(number), + numberRef: `ref_${now}`, + categoryLock: validation.value.categoryLock, + createdAt: now, + history: [{ from: null, to: "active", at: now }], + requestId, + } + addCard(card) + + // The one and only time the full number is returned. + return NextResponse.json( + { card: { ...card, spent: spendForCard(card) }, number }, + { status: 201 }, + ) +} 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 0000000..b9df83e --- /dev/null +++ b/build-battle/merchant-console/src/app/cards/[id]/page.tsx @@ -0,0 +1,166 @@ +import { Divider } from "@/components/Divider" +import { CardStatusBadge } from "@/components/ui/cards/CardStatusBadge" +import { cardById, spendForCard } from "@/data/cards" +import { merchantById } from "@/data/merchants" +import { maskCardNumber } from "@/lib/cards" +import { formatDate } from "@/lib/dates" +import { formatMoney } from "@/lib/money" +import { cx } from "@/lib/utils" +import Link from "next/link" +import { notFound } from "next/navigation" +import { CardActions } from "../card-actions" + +const AMBER_AT = 80 + +export default async function CardDetailPage({ + params, +}: { + params: Promise<{ id: string }> +}) { + const { id } = await params + const card = cardById(id) + if (!card) notFound() + + const merchant = merchantById(card.merchantId) + const spent = spendForCard(card) + const percent = card.spendLimit > 0 ? (spent / card.spendLimit) * 100 : 0 + const shown = Math.min(100, percent) + const isAmber = percent >= AMBER_AT + + return ( +
+ + ← All cards + + +
+
+

+ {card.nickname} +

+

+ {maskCardNumber(card.last4)} +

+
+
+ + +
+
+ + + +
+
+

+ Spend against limit +

+

+ {Math.round(percent)}% +

+
+
+
+
+

+ {formatMoney(spent, card.currency)} of{" "} + {formatMoney(card.spendLimit, card.currency)} +

+

+ Derived from captured {card.currency} payments for {merchant?.name}{" "} + made since this card was issued. Not a stored figure. +

+
+ + + +
+ + + + + + +
+ + + +

+ History +

+
    + {card.history.map((event, index) => ( +
  1. + + {formatDate(event.at)} + + + {event.from === null + ? `Issued as ${event.to}` + : `${event.from} → ${event.to}`} + +
  2. + ))} +
+
+ ) +} + +function Field({ + label, + value, + mono, + capitalize, +}: { + label: string + value: string + mono?: boolean + capitalize?: boolean +}) { + return ( +
+
{label}
+
+ {value} +
+
+ ) +} 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 0000000..9da2281 --- /dev/null +++ b/build-battle/merchant-console/src/app/cards/card-actions.tsx @@ -0,0 +1,127 @@ +"use client" + +import { Button } from "@/components/Button" +import { + Drawer, + DrawerBody, + DrawerContent, + DrawerDescription, + DrawerFooter, + DrawerHeader, + DrawerTitle, +} from "@/components/Drawer" +import { CardStatus } from "@/data/types" +import { useRouter } from "next/navigation" +import { useState, useTransition } from "react" + +/** + * Freeze, unfreeze, and cancel. + * + * The status change goes through the guarded PATCH, so the server decides + * whether a transition is legal. Cancel sits behind a confirmation because it + * is terminal — nothing comes back from cancelled. + * + * router.refresh() re-renders the server component in place; there is no full + * page reload. + */ +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 [error, setError] = useState(null) + const [confirming, setConfirming] = useState(false) + + const change = async (next: CardStatus) => { + setBusy(true) + setError(null) + try { + const response = await fetch(`/api/cards/${cardId}`, { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ status: next }), + }) + if (!response.ok) { + const body = await response.json().catch(() => ({})) + setError(body.message ?? "That change was refused.") + return + } + setConfirming(false) + startTransition(() => router.refresh()) + } catch { + setError("Could not reach the server.") + } finally { + setBusy(false) + } + } + + if (status === "cancelled") { + return ( + + Cancelled — no further changes + + ) + } + + const working = busy || pending + + return ( +
+ + + + + {error && ( + + {error} + + )} + + + + + Cancel this card? + + {nickname} will stop working immediately. Cancelling is permanent + — the card cannot be reactivated afterwards. + + + + + + + + + +
+ ) +} diff --git a/build-battle/merchant-console/src/app/cards/issue-dialog.tsx b/build-battle/merchant-console/src/app/cards/issue-dialog.tsx new file mode 100644 index 0000000..e9919bf --- /dev/null +++ b/build-battle/merchant-console/src/app/cards/issue-dialog.tsx @@ -0,0 +1,279 @@ +"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 { Currency } from "@/data/types" +import { CATEGORY_LOCKS } from "@/lib/cards" +import { formatMoney, parseAmountToMinorUnits } from "@/lib/money" +import { Plus } from "lucide-react" +import { useRouter } from "next/navigation" +import { useState } from "react" + +type MerchantOption = { id: string; name: string; currency: Currency } + +/** + * Issue a virtual card. + * + * Currency is not a free choice — it is derived from the merchant, because + * merchants.ts already knows what each one trades in. The server verifies it + * anyway; this only stops ops from being asked a question with one answer. + * + * The full number is shown once, on success. It lives in component state for + * that one render and is dropped when the drawer closes. + */ +export function IssueCardDialog({ + merchants, +}: { + merchants: MerchantOption[] +}) { + const router = useRouter() + const [open, setOpen] = useState(false) + const [nickname, setNickname] = useState("") + const [merchantId, setMerchantId] = useState("") + const [limit, setLimit] = useState("") + const [categoryLock, setCategoryLock] = useState("none") + const [submitting, setSubmitting] = useState(false) + const [error, setError] = useState(null) + const [issued, setIssued] = useState<{ + number: string + last4: string + } | null>(null) + // One key per open form. A double-click reuses it, so the server returns the + // card it already made instead of minting a second. + const [requestId, setRequestId] = useState(() => crypto.randomUUID()) + + const merchant = merchants.find((m) => m.id === merchantId) + const currency = merchant?.currency + const minorUnits = parseAmountToMinorUnits(limit) + + const reset = () => { + setNickname("") + setMerchantId("") + setLimit("") + setCategoryLock("none") + setError(null) + setIssued(null) + setSubmitting(false) + setRequestId(crypto.randomUUID()) + } + + const onOpenChange = (next: boolean) => { + setOpen(next) + if (!next) { + reset() + router.refresh() + } + } + + const submit = async () => { + setSubmitting(true) + setError(null) + try { + const response = await fetch("/api/cards", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + nickname, + merchantId, + spendLimit: minorUnits, + currency, + categoryLock: categoryLock === "none" ? null : categoryLock, + requestId, + }), + }) + const body = await response.json() + if (!response.ok) { + setError(body.message ?? "Could not issue the card.") + return + } + setIssued({ number: body.number ?? "", last4: body.card.last4 }) + } catch { + setError("Could not reach the server. Nothing was issued.") + } finally { + setSubmitting(false) + } + } + + const canSubmit = + !submitting && + nickname.trim().length > 0 && + Boolean(merchantId) && + minorUnits !== null && + minorUnits > 0 + + return ( + + + + + + + + + {issued ? "Card issued" : "Issue a virtual card"} + + + {issued + ? "Copy the number now. It will not be shown again." + : "Single-merchant, virtual, with a limit from the moment it exists."} + + + + + {issued ? ( +
+
+

+ Shown once +

+

+ {issued.number.replace(/(.{4})/g, "$1 ").trim()} +

+

+ Everywhere else in the console this card is ••••{" "} + {issued.last4}. +

+
+
+ ) : ( + <> +
+ + setNickname(e.target.value)} + placeholder="Ad spend — Q3" + className="mt-2" + /> +
+ +
+ + +
+ +
+ + setLimit(e.target.value)} + placeholder="250.00" + className="mt-2" + aria-describedby="card-limit-hint" + /> +

+ {minorUnits !== null && currency + ? `${formatMoney(minorUnits, currency)} — stored as ${minorUnits} minor units` + : "Up to 5,000,000 minor units."} +

+
+ +
+ + +
+ + {error && ( +

+ {error} +

+ )} + + )} +
+ + + {issued ? ( + + ) : ( + <> + + + + )} + +
+
+ ) +} 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 0000000..ec3cde4 --- /dev/null +++ b/build-battle/merchant-console/src/app/cards/page.tsx @@ -0,0 +1,112 @@ +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeaderCell, + TableRoot, + TableRow, +} from "@/components/Table" +import { CardStatusBadge } from "@/components/ui/cards/CardStatusBadge" +import { listCards } from "@/data/cards" +import { merchantById, merchants } from "@/data/merchants" +import { 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 { IssueCardDialog } from "./issue-dialog" + +export default function CardsPage() { + const cards = listCards() + + return ( +
+
+
+

+ Virtual cards +

+

+ Single-merchant cards issued from the console. +

+
+ ({ + id: m.id, + name: m.name, + currency: m.currency, + }))} + /> +
+ + + + + + Nickname + Merchant + Number + + Spend limit + + Status + Created + + Actions + + + + + {cards.length === 0 && ( + + +

+ No cards issued yet +

+

+ Issue one and it will appear here. Cards live until the dev + server restarts. +

+
+
+ )} + {cards.map((card) => { + const merchant = merchantById(card.merchantId) + return ( + + + + {card.nickname} + + + {merchant?.name ?? card.merchantId} + + {maskCardNumber(card.last4)} + + + {formatMoney(card.spendLimit, card.currency)} + + + + + {formatDate(card.createdAt)} + + + + + ) + })} +
+
+
+
+ ) +} diff --git a/build-battle/merchant-console/src/app/siteConfig.ts b/build-battle/merchant-console/src/app/siteConfig.ts index c59e5da..626769d 100644 --- a/build-battle/merchant-console/src/app/siteConfig.ts +++ b/build-battle/merchant-console/src/app/siteConfig.ts @@ -5,6 +5,7 @@ export const siteConfig = { baseLinks: { overview: "/overview", payments: "/payments", + cards: "/cards", disputes: "/disputes", payouts: "/payouts", }, diff --git a/build-battle/merchant-console/src/components/ui/cards/CardStatusBadge.tsx b/build-battle/merchant-console/src/components/ui/cards/CardStatusBadge.tsx new file mode 100644 index 0000000..8cd6078 --- /dev/null +++ b/build-battle/merchant-console/src/components/ui/cards/CardStatusBadge.tsx @@ -0,0 +1,33 @@ +import { Badge } from "@/components/Badge" +import { CardStatus } from "@/data/types" +import { cx } from "@/lib/utils" + +const LABELS: Record = { + active: "Active", + frozen: "Frozen", + cancelled: "Cancelled", +} + +const DOTS: Record = { + 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 = { + active: "success", + frozen: "default", + cancelled: "neutral", +} + +export function CardStatusBadge({ status }: { status: CardStatus }) { + return ( + + + ) +} 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 f5e1345..ec714a6 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,7 @@ 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, Wallet } from "lucide-react" import * as React from "react" import { Logo } from "../../../../public/Logo" import { UserProfile } from "./UserProfile" @@ -36,6 +36,12 @@ const navigation = [ icon: CreditCard, notifications: false as const, }, + { + name: "Cards", + href: siteConfig.baseLinks.cards, + icon: Wallet, + notifications: false as const, + }, { name: "Disputes", href: siteConfig.baseLinks.disputes, diff --git a/build-battle/merchant-console/src/data/cards-seed.ts b/build-battle/merchant-console/src/data/cards-seed.ts new file mode 100644 index 0000000..ea8fcae --- /dev/null +++ b/build-battle/merchant-console/src/data/cards-seed.ts @@ -0,0 +1,69 @@ +import { generateCardNumber, lastFour } from "@/lib/cards" +import { Card } from "./types" + +/** + * A few cards that already exist, so the list is not empty on a cold start and + * the spend bar has something real to render. + * + * Their created dates are backdated deliberately: spend is derived from + * payments made after a card was issued, so a card issued a moment ago + * truthfully shows zero. These are old enough to have accrued some. + * + * Numbers are generated the same way the route generates them — 4242 BIN, + * valid Luhn — and only the last four is kept, exactly as at issue time. + */ + +const SEEDED: ReadonlyArray<{ + id: string + nickname: string + merchantId: string + spendLimit: number + currency: Card["currency"] + status: Card["status"] + categoryLock: string | null + createdAt: string +}> = [ + { + id: "card_0001", + nickname: "Ad spend — Q3", + merchantId: "mch_01", + spendLimit: 500000, + currency: "USD", + status: "active", + categoryLock: "advertising", + createdAt: "2026-07-02T09:00:00.000Z", + }, + { + id: "card_0002", + nickname: "Design contractors", + merchantId: "mch_04", + spendLimit: 120000, + currency: "GBP", + status: "frozen", + categoryLock: "contractors", + createdAt: "2026-07-18T14:30:00.000Z", + }, + { + id: "card_0003", + nickname: "Warehouse utilities", + merchantId: "mch_05", + spendLimit: 80000, + currency: "EUR", + status: "active", + categoryLock: "utilities", + createdAt: "2026-08-05T08:15:00.000Z", + }, +] + +export function seedCards(): Card[] { + return SEEDED.map((seed) => { + const number = generateCardNumber() + return { + ...seed, + last4: lastFour(number), + numberRef: `ref_${seed.id}`, + history: [{ from: null, to: seed.status, at: seed.createdAt }], + requestId: null, + } + }) +} diff --git a/build-battle/merchant-console/src/data/cards.ts b/build-battle/merchant-console/src/data/cards.ts new file mode 100644 index 0000000..07d9ec9 --- /dev/null +++ b/build-battle/merchant-console/src/data/cards.ts @@ -0,0 +1,66 @@ +import { sumMinorUnits } from "@/lib/money" +import { filterPayments } from "./queries" +import { store } from "./store" +import { Card, CardStatus } from "./types" + +/** + * Card reads and writes against the in-memory store. + * + * Spend is derived, never invented. It is the sum of captured card payments + * for the card's merchant made *after* the card was issued, which is why a + * card issued a moment ago honestly shows zero. + * + * Payment filtering goes through the builder in queries.ts. There is no second + * one here. + */ + +export function listCards(): Card[] { + return [...store.cards].sort((a, b) => b.createdAt.localeCompare(a.createdAt)) +} + +export function cardById(id: string): Card | null { + return store.cards.find((card) => card.id === id) ?? null +} + +export function cardByRequestId(requestId: string): Card | null { + return store.cards.find((card) => card.requestId === requestId) ?? null +} + +/** + * Spend against a card, in the card's currency. + * + * Only payments in the same currency are summed — mixing currencies produces a + * meaningless number even when it looks right. + */ +export function spendForCard(card: Card): number { + const payments = filterPayments({ + merchantId: card.merchantId, + status: "captured", + from: card.createdAt, + }) + return sumMinorUnits( + payments + .filter((payment) => payment.currency === card.currency) + .map((payment) => payment.amount), + ) +} + +export function nextCardId(): string { + const highest = store.cards.reduce((max, card) => { + const n = Number(card.id.replace("card_", "")) + return Number.isFinite(n) && n > max ? n : max + }, 0) + return `card_${String(highest + 1).padStart(4, "0")}` +} + +export function addCard(card: Card): Card { + store.cards.push(card) + return card +} + +/** Applies a transition that has already been checked with canTransition. */ +export function applyCardStatus(card: Card, to: CardStatus, at: string): Card { + card.history.push({ from: card.status, to, at }) + card.status = to + return card +} diff --git a/build-battle/merchant-console/src/data/queries.test.ts b/build-battle/merchant-console/src/data/queries.test.ts new file mode 100644 index 0000000..4da43a1 --- /dev/null +++ b/build-battle/merchant-console/src/data/queries.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from "vitest" +import { sortPayments } from "./queries" +import { Payment } from "./types" + +/** + * Amounts are integer minor units. Sorting them as text put 9000 after 10000, + * which made every amount-sorted page and every amount-sorted export wrong in + * a way that looked plausible. These pin the numeric order. + */ + +const base: Payment = { + id: "pay_0001", + merchantId: "mch_01", + amount: 0, + currency: "USD", + status: "captured", + method: "card", + cardBrand: "visa", + last4: "4242", + createdAt: "2026-03-14T10:15:00.000Z", + description: "Order", +} + +const withAmounts = (...amounts: number[]): Payment[] => + amounts.map((amount, i) => ({ ...base, id: `pay_${i}`, amount })) + +describe("sortPayments by amount", () => { + it("orders ascending by value, not by text", () => { + const sorted = sortPayments(withAmounts(10000, 9000, 250), "amount", "asc") + expect(sorted.map((p) => p.amount)).toEqual([250, 9000, 10000]) + }) + + it("orders descending by value", () => { + const sorted = sortPayments(withAmounts(250, 10000, 9000), "amount", "desc") + expect(sorted.map((p) => p.amount)).toEqual([10000, 9000, 250]) + }) + + it("keeps 9000 below 10000, the case text sorting got wrong", () => { + const sorted = sortPayments(withAmounts(10000, 9000), "amount", "asc") + expect(sorted[0].amount).toBe(9000) + }) + + it("does not mutate the array it was given", () => { + const input = withAmounts(10000, 250) + sortPayments(input, "amount", "asc") + expect(input.map((p) => p.amount)).toEqual([10000, 250]) + }) +}) + +describe("sortPayments by createdAt", () => { + it("still defaults to newest first", () => { + const older = { ...base, id: "old", createdAt: "2026-01-01T00:00:00.000Z" } + const newer = { ...base, id: "new", createdAt: "2026-06-01T00:00:00.000Z" } + expect(sortPayments([older, newer]).map((p) => p.id)).toEqual([ + "new", + "old", + ]) + }) +}) diff --git a/build-battle/merchant-console/src/data/queries.ts b/build-battle/merchant-console/src/data/queries.ts index cc4ca00..2eb8209 100644 --- a/build-battle/merchant-console/src/data/queries.ts +++ b/build-battle/merchant-console/src/data/queries.ts @@ -77,8 +77,10 @@ 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, so compare them as numbers. Comparing + // the stringified value sorted lexicographically, which put 9000 after + // 10000 and made every amount-sorted page subtly wrong. + 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 ba71d95..15fec2f 100644 --- a/build-battle/merchant-console/src/data/store.ts +++ b/build-battle/merchant-console/src/data/store.ts @@ -1,6 +1,7 @@ +import { seedCards } from "./cards-seed" 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 +20,7 @@ interface Store { refunds: Refund[] disputes: Dispute[] payouts: Payout[] + cards: Card[] } declare global { @@ -28,7 +30,7 @@ declare global { function createStore(): Store { const { payments, refunds, disputes, payouts } = generate() - return { merchants, payments, refunds, disputes, payouts } + return { merchants, payments, refunds, disputes, payouts, cards: seedCards() } } 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 6697e57..5126f61 100644 --- a/build-battle/merchant-console/src/data/types.ts +++ b/build-battle/merchant-console/src/data/types.ts @@ -71,6 +71,40 @@ export interface Payout { paymentIds: string[] } +export type CardStatus = "active" | "frozen" | "cancelled" + +/** One entry per status change, so the detail page can answer "what happened". */ +export interface CardStatusEvent { + from: CardStatus | null + to: CardStatus + /** ISO 8601, always UTC. */ + at: string +} + +export interface Card { + id: string + nickname: string + merchantId: string + /** Integer minor units. Never a float. */ + spendLimit: number + currency: Currency + status: CardStatus + /** + * Last four only. The full number is returned exactly once, by the creation + * response, and is never stored here. + */ + last4: string + /** Opaque handle for the generated number. Not the number itself. */ + numberRef: string + /** Optional merchant category lock, chosen at issue time. */ + categoryLock: string | null + /** ISO 8601, always UTC. */ + createdAt: string + history: CardStatusEvent[] + /** Idempotency key from the issuing client. Repeats return the same card. */ + requestId: string | null +} + export interface PaymentFilters { status?: PaymentStatus | "all" merchantId?: 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 0000000..970b0e1 --- /dev/null +++ b/build-battle/merchant-console/src/lib/cards.test.ts @@ -0,0 +1,171 @@ +import { describe, expect, it } from "vitest" +import { + CARD_LENGTH, + MAX_SPEND_LIMIT, + TEST_BIN, + canTransition, + generateCardNumber, + isLuhnValid, + lastFour, + luhnCheckDigit, + maskCardNumber, + validateIssue, +} from "./cards" + +/** + * A card number that fails Luhn, or that does not start with the test BIN, is + * the one defect in here that could look like a real PAN. These pin both. + */ + +describe("luhnCheckDigit", () => { + it("produces the digit that makes a known number valid", () => { + // 4242424242424242 is the canonical test number; its body checks to 2. + expect(luhnCheckDigit("424242424242424")).toBe(2) + }) + + it("agrees with isLuhnValid for any generated body", () => { + const body = "4242123456789" + expect(isLuhnValid(`${body}${luhnCheckDigit(body)}`)).toBe(true) + }) +}) + +describe("isLuhnValid", () => { + it("accepts a valid number and rejects a tampered one", () => { + expect(isLuhnValid("4242424242424242")).toBe(true) + expect(isLuhnValid("4242424242424243")).toBe(false) + }) + + it("rejects anything that is not all digits", () => { + expect(isLuhnValid("4242-4242-4242-4242")).toBe(false) + }) +}) + +describe("generateCardNumber", () => { + it("is 16 digits on the test BIN with a valid check digit", () => { + const number = generateCardNumber() + expect(number).toHaveLength(CARD_LENGTH) + expect(number.startsWith(TEST_BIN)).toBe(true) + expect(isLuhnValid(number)).toBe(true) + }) + + it("stays valid across many draws, not just a lucky one", () => { + for (let i = 0; i < 500; i++) { + const number = generateCardNumber() + expect(number.startsWith(TEST_BIN)).toBe(true) + expect(isLuhnValid(number)).toBe(true) + } + }) + + it("is not a hardcoded constant", () => { + const drawn = new Set( + Array.from({ length: 50 }, () => generateCardNumber()), + ) + expect(drawn.size).toBeGreaterThan(1) + }) + + it("is deterministic when given a deterministic source", () => { + const always = () => 0.5 + expect(generateCardNumber(always)).toBe(generateCardNumber(always)) + }) +}) + +describe("maskCardNumber", () => { + it("shows the last four and nothing else", () => { + const number = "4242123456781234" + const masked = maskCardNumber(lastFour(number)) + expect(masked).toBe("•••• 1234") + expect(masked).not.toContain(number.slice(0, 12)) + }) +}) + +describe("canTransition", () => { + it("allows active and frozen to swap", () => { + expect(canTransition("active", "frozen")).toBe(true) + expect(canTransition("frozen", "active")).toBe(true) + }) + + it("allows either to be cancelled", () => { + expect(canTransition("active", "cancelled")).toBe(true) + expect(canTransition("frozen", "cancelled")).toBe(true) + }) + + it("treats cancelled as terminal", () => { + expect(canTransition("cancelled", "active")).toBe(false) + expect(canTransition("cancelled", "frozen")).toBe(false) + expect(canTransition("cancelled", "cancelled")).toBe(false) + }) + + it("refuses a transition to the status it already has", () => { + expect(canTransition("active", "active")).toBe(false) + expect(canTransition("frozen", "frozen")).toBe(false) + }) +}) + +describe("validateIssue", () => { + const valid = { + nickname: "Ad spend", + merchantId: "mch_01", + spendLimit: 25000, + currency: "USD", + } + + it("accepts a well-formed request", () => { + const result = validateIssue(valid, "USD") + expect(result.ok).toBe(true) + if (result.ok) expect(result.value.spendLimit).toBe(25000) + }) + + it("rejects a missing merchant", () => { + expect(validateIssue({ ...valid, merchantId: "" }, "USD").ok).toBe(false) + expect(validateIssue({ ...valid, merchantId: undefined }, "USD").ok).toBe( + false, + ) + }) + + it("rejects a merchant that does not exist", () => { + expect(validateIssue(valid, undefined).ok).toBe(false) + }) + + it("rejects a zero or negative limit", () => { + expect(validateIssue({ ...valid, spendLimit: 0 }, "USD").ok).toBe(false) + expect(validateIssue({ ...valid, spendLimit: -1 }, "USD").ok).toBe(false) + }) + + it("rejects a limit above 5,000,000 minor units but accepts the boundary", () => { + expect( + validateIssue({ ...valid, spendLimit: MAX_SPEND_LIMIT }, "USD").ok, + ).toBe(true) + expect( + validateIssue({ ...valid, spendLimit: MAX_SPEND_LIMIT + 1 }, "USD").ok, + ).toBe(false) + }) + + it("rejects a float limit, because money is integer minor units", () => { + expect(validateIssue({ ...valid, spendLimit: 250.5 }, "USD").ok).toBe(false) + }) + + it("rejects a limit that arrived as a string", () => { + expect(validateIssue({ ...valid, spendLimit: "25000" }, "USD").ok).toBe( + false, + ) + }) + + it("rejects a currency outside USD, EUR, GBP", () => { + expect(validateIssue({ ...valid, currency: "JPY" }, "USD").ok).toBe(false) + }) + + it("rejects a currency the merchant does not trade in", () => { + const result = validateIssue({ ...valid, currency: "GBP" }, "EUR") + expect(result.ok).toBe(false) + if (!result.ok) expect(result.message).toContain("EUR") + }) + + it("rejects an unknown category lock but allows none", () => { + expect(validateIssue({ ...valid, categoryLock: "crypto" }, "USD").ok).toBe( + false, + ) + const none = validateIssue(valid, "USD") + expect(none.ok).toBe(true) + if (none.ok) expect(none.value.categoryLock).toBeNull() + }) +}) 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 0000000..2056bdd --- /dev/null +++ b/build-battle/merchant-console/src/lib/cards.ts @@ -0,0 +1,185 @@ +import { Currency, CardStatus } from "@/data/types" + +/** + * Virtual card issuing. + * + * Numbers are generated here, server-side, on the 4242 test BIN with a valid + * Luhn check digit. Nothing in this repository may resemble a real PAN. + * + * The full number is returned once by the creation route and never stored. + * Everything that persists carries the last four and an opaque reference. + */ + +export const TEST_BIN = "4242" +export const CARD_LENGTH = 16 +export const MAX_SPEND_LIMIT = 5_000_000 +export const CURRENCIES: readonly Currency[] = ["USD", "EUR", "GBP"] + +export const CATEGORY_LOCKS = [ + "advertising", + "software", + "travel", + "contractors", + "utilities", +] as const + +export type CategoryLock = (typeof CATEGORY_LOCKS)[number] + +/** + * Luhn check digit for a partial number. + * + * Doubles every second digit from the right of the final number, which — since + * the check digit occupies the rightmost slot — means doubling from the right + * of the body we are given. + */ +export function luhnCheckDigit(partial: string): number { + let sum = 0 + let double = true + for (let i = partial.length - 1; i >= 0; i--) { + let digit = partial.charCodeAt(i) - 48 + if (double) { + digit *= 2 + if (digit > 9) digit -= 9 + } + double = !double + sum += digit + } + return (10 - (sum % 10)) % 10 +} + +/** True when a complete number satisfies Luhn. */ +export function isLuhnValid(number: string): boolean { + if (!/^\d+$/.test(number)) return false + const body = number.slice(0, -1) + const check = number.charCodeAt(number.length - 1) - 48 + return luhnCheckDigit(body) === check +} + +/** + * A 16-digit number on the test BIN with a valid check digit. + * + * `random` is injectable so tests are deterministic; production passes nothing + * and gets Math.random. + */ +export function generateCardNumber(random: () => number = Math.random): string { + const middleLength = CARD_LENGTH - TEST_BIN.length - 1 + let middle = "" + for (let i = 0; i < middleLength; i++) { + middle += Math.floor(random() * 10).toString() + } + const body = `${TEST_BIN}${middle}` + return `${body}${luhnCheckDigit(body)}` +} + +/** Display form. The only shape a card number takes after creation. */ +export function maskCardNumber(last4: string): string { + return `•••• ${last4}` +} + +export function lastFour(number: string): string { + return number.slice(-4) +} + +/** + * Status transitions. + * + * active ⇄ frozen, either to cancelled, and cancelled is terminal. + */ +const TRANSITIONS: Record = { + active: ["frozen", "cancelled"], + frozen: ["active", "cancelled"], + cancelled: [], +} + +export function canTransition(from: CardStatus, to: CardStatus): boolean { + return TRANSITIONS[from].includes(to) +} + +export function isCardStatus(value: unknown): value is CardStatus { + return value === "active" || value === "frozen" || value === "cancelled" +} + +export type IssueInput = { + nickname?: unknown + merchantId?: unknown + spendLimit?: unknown + currency?: unknown + categoryLock?: unknown +} + +export type ValidIssue = { + nickname: string + merchantId: string + spendLimit: number + currency: Currency + categoryLock: CategoryLock | null +} + +export type IssueValidation = + | { ok: true; value: ValidIssue } + | { ok: false; message: string } + +/** + * Validate an issue request. The client is not trusted: this runs in the route + * handler, and every rejection returns a message safe to show a user. + * + * `merchantCurrency` is the currency the chosen merchant actually trades in. + * A card cannot be issued in a currency its merchant does not use — the form + * derives it, and this verifies it anyway. + */ +export function validateIssue( + input: IssueInput, + merchantCurrency: Currency | undefined, +): IssueValidation { + const nickname = + typeof input.nickname === "string" ? input.nickname.trim() : "" + if (!nickname) return { ok: false, message: "Give the card a nickname." } + + if (typeof input.merchantId !== "string" || !input.merchantId) { + return { ok: false, message: "Choose a merchant." } + } + if (!merchantCurrency) { + return { ok: false, message: "That merchant does not exist." } + } + + const spendLimit = input.spendLimit + if (typeof spendLimit !== "number" || !Number.isInteger(spendLimit)) { + return { + ok: false, + message: "Spend limit must be a whole number of minor units.", + } + } + if (spendLimit <= 0) { + return { ok: false, message: "Spend limit must be greater than zero." } + } + if (spendLimit > MAX_SPEND_LIMIT) { + return { + ok: false, + message: "Spend limit cannot exceed 5,000,000 minor units.", + } + } + + if (!CURRENCIES.includes(input.currency as Currency)) { + return { ok: false, message: "Currency must be USD, EUR, or GBP." } + } + const currency = input.currency as Currency + if (currency !== merchantCurrency) { + return { + ok: false, + message: `That merchant trades in ${merchantCurrency}, so the card cannot be issued in ${currency}.`, + } + } + + let categoryLock: CategoryLock | null = null + if (input.categoryLock !== undefined && input.categoryLock !== null) { + if (!CATEGORY_LOCKS.includes(input.categoryLock as CategoryLock)) { + return { ok: false, message: "Unknown merchant category." } + } + categoryLock = input.categoryLock as CategoryLock + } + + return { + ok: true, + value: { nickname, merchantId: input.merchantId, spendLimit, currency, categoryLock }, + } +} diff --git a/docs/specs/README.md b/docs/specs/README.md index ae2e5ae..f9338d7 100644 --- a/docs/specs/README.md +++ b/docs/specs/README.md @@ -29,6 +29,12 @@ Then build with it loaded: @docs/specs/NWP-201-issue-cards.md ``` +## Written specs + +| Spec | Ticket | +| --- | --- | +| [`NWP-201-issue-cards.md`](../../NWP-201-issue-cards.md) | [NWP-201](../tickets/NWP-201.md) — issue virtual cards from the console | + ## Rules - No code in a spec. File paths and function names, yes. Implementations, no.