From 8d1167fc43f587ff8a9b8a2a2fcab921826045df Mon Sep 17 00:00:00 2001 From: Gabriel Amaral Date: Thu, 10 Sep 2026 11:53:29 -0700 Subject: [PATCH 01/12] NWP-201: spec for issuing virtual cards Co-Authored-By: Claude Fable 5.1 --- docs/specs/NWP-201-issue-cards.md | 101 ++++++++++++++++++++++++++++++ 1 file changed, 101 insertions(+) create mode 100644 docs/specs/NWP-201-issue-cards.md diff --git a/docs/specs/NWP-201-issue-cards.md b/docs/specs/NWP-201-issue-cards.md new file mode 100644 index 00000000..361db170 --- /dev/null +++ b/docs/specs/NWP-201-issue-cards.md @@ -0,0 +1,101 @@ +# 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:** building + +## Problem + +Ops issues virtual cards by messaging the platform team, who create them by hand. It takes hours, happens twelve to twenty times a week, and last month two cards went out with the wrong spend limit because the request lived in a Slack thread. Marcus wants ops to issue a card, see the cards they issued, and open one to check it — from the console, today. + +## Current state + +- `build-battle/merchant-console/src/data/store.ts` — the in-memory store, pinned on `globalThis` so dev HMR does not reset it. Holds `merchants`, `payments`, `refunds`, `disputes`, `payouts`. **No `cards` slice.** Adding one means editing both the `Store` interface and `createStore()`, and restarting `next dev` once because the cached object predates the change. +- `build-battle/merchant-console/src/data/generate.ts` — seed data is **generated TypeScript**, not JSON as `merchant-console/CLAUDE.md` says. IDs use a local `pad()` helper (`pay_000001`). Card seeds belong here, next to the other fixtures. +- `build-battle/merchant-console/src/data/types.ts` — `Currency = "USD" | "EUR" | "GBP"` already exists and is exactly the ticket's allowlist. No `Card` type. +- `build-battle/merchant-console/src/data/merchants.ts` — `merchantById(id)` returns `undefined` for an unknown merchant; every merchant carries a `currency`. Nothing in the console checks a card's currency against it yet. +- `build-battle/merchant-console/src/lib/money.ts` — `parseAmountToMinorUnits("250.00") → 25000 | null` is the boundary converter; `formatMoney(minor, currency)` is the only formatter. +- `build-battle/merchant-console/src/lib/dates.ts` — `formatInZone(iso, tz)` for display in the merchant's timezone. +- `build-battle/merchant-console/src/app/api/payments/export/route.ts` — the house route pattern: `as const` allowlist, validator returning `{ value } | { error }`, `NextResponse.json({ error }, { status: 400 })`, reject early. There is **no POST handler anywhere** yet. +- `build-battle/merchant-console/src/app/payments/page.tsx` and `src/app/payments/[id]/page.tsx` — server components that read the store directly; list uses an inline `colSpan` empty state, detail uses a `Field` grid and a timeline `
    `. Next 15: `params`/`searchParams` are promises. +- `build-battle/merchant-console/src/app/payments/export-dialog.tsx` — the form-dialog pattern built on `src/components/Drawer.tsx` (Radix dialog) with a centered-modal className. There is no `Dialog.tsx`, despite `.claude/rules/components.md` saying so. +- `build-battle/merchant-console/src/components/ui/payments/StatusBadge.tsx` — three `Record` maps; card statuses must be added to all three. +- `build-battle/merchant-console/src/app/siteConfig.ts`, `src/components/ui/navigation/AppSidebar.tsx`, `Breadcrumbs.tsx` — where a `/cards` link and label are registered. +- `build-battle/merchant-console/src/data/queries.ts:81` — pre-existing defect: amounts are sorted with `String(...).localeCompare`, so 9900 sorts above 100000. Fixed in passing because it is one line and the cards list sorts money too. + +## Domain rules + +| Rule | Source | What breaks if ignored | +| --- | --- | --- | +| "Money is integer minor units. `$250.00` is `25000`." | `merchant-console/CLAUDE.md`, ticket rule 1 | Cents drift; the exact wrong-limit bug ops is escaping | +| "Never persist or display a full card number after creation. Store the last four and the generated number's reference." | ticket rule 2, `.claude/rules/cards.md` | A PAN in the store or a list payload | +| "`active ⇄ frozen`, either to `cancelled`, and `cancelled` is terminal. Guard the transition on the server." | ticket rule 3, `.claude/rules/cards.md` | A cancelled card comes back to life | +| "Every generated number starts `4242` and carries a valid Luhn check digit. Generate on the server." | ticket rule 4, `.claude/rules/cards.md` | Something resembling a real PAN | +| "Validate everything from the client against an allowlist." Reject missing merchant, limit ≤ 0, limit > 5,000,000, currency ∉ USD/EUR/GBP | `.claude/rules/api-routes.md`, ticket core 6 | Client-only checks are enforcement nowhere | +| "Dialogs and forms must be operable. Every input has a label, the dialog has an accessible name, focus moves into it and returns on close, Escape closes it." | `.claude/rules/components.md` | Keyboard and screen-reader users cannot issue a card | +| "Write the empty and error states." | `.claude/rules/components.md` | A blank table, a silent failed request | + +## Approach + +Add a `cards` slice to the existing store, a pure `src/lib/cards.ts` (Luhn generator on the `4242` BIN, transition table, input parser that converts the limit string once via `parseAmountToMinorUnits`), and two route handlers: `POST /api/cards` (the only response that ever carries a full number) and `PATCH /api/cards/[id]` (status transitions guarded by the table). Pages `/cards` and `/cards/[id]` are server components reading the store 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, the server also rejects a currency that differs from the merchant's, honours a client `requestId` so a double submit cannot mint two cards, records every status change on the card and shows it on the detail page, and cancel requires a confirm step. + +**Considered and rejected:** generating the card number in the dialog and posting it — rejected because `cards.md` says a browser-generated number is a bug and it would make masking unverifiable. Also rejected: a separate `cards` module-level array — the store lives on `globalThis` on purpose; a second array would reset on HMR. + +## File map + +| File | Add or change | Why | +| --- | --- | --- | +| `src/data/types.ts` | change | `Card`, `CardStatus`, `CardCategory`, `CardEvent` | +| `src/data/generate.ts` | change | export `pad`, seed three cards (one at 90% spend for the amber bar, one frozen) | +| `src/data/store.ts` | change | `cards` slice | +| `src/data/cards.ts` | add | `listCards`, `cardById`, `createCard` (returns the number once), `transitionCard` | +| `src/lib/cards.ts` | add | Luhn, generator, mask, transition table, `parseCardInput`, spend percent | +| `src/lib/cards.test.ts` | add | tests for all of the above | +| `src/app/api/cards/route.ts` | add | `GET` list (masked), `POST` issue | +| `src/app/api/cards/[id]/route.ts` | add | `PATCH` status | +| `src/app/cards/page.tsx` | add | list with empty state | +| `src/app/cards/[id]/page.tsx` | add | detail, spend bar, audit timeline | +| `src/app/cards/issue-card-dialog.tsx` | add | form, reveal-once success screen | +| `src/app/cards/card-actions.tsx` | add | freeze / unfreeze / cancel (confirm) without reload | +| `src/app/cards/spend-bar.tsx` | add | progress bar, amber past 80% | +| `src/components/ui/payments/StatusBadge.tsx` | change | card statuses | +| `src/app/siteConfig.ts`, `AppSidebar.tsx`, `Breadcrumbs.tsx` | change | navigation | +| `src/data/queries.ts` | change | numeric amount sort (bug fix) | + +## Plan + +1. **Types, seed, store, lib, tests** — done when: `npm test` is green with the new `cards.test.ts`. +2. **Routes** — done when: curl shows 201 with a `4242…` Luhn-valid number, 400 for each rejection, 409 for `cancelled → active`, and `GET /api/cards` carries no 16-digit string. +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% seed shows an amber bar, cancel asks to confirm and then offers no further actions. +5. **Ship** — done when: `npm run lint`, `npm test`, `/ship-ready` pass and the PR is open. + +## Verification + +| Acceptance criterion | How it is proven | +| --- | --- | +| Issue a card | Browser: fill dialog, submit, card in list | +| Card list | `/cards` columns: nickname, merchant, `•••• 4242`, limit, status, created | +| Card detail | `/cards/` shows record, spend bar, audit trail | +| Generated numbers | `cards.test.ts`: starts `4242`, 16 digits, Luhn valid, not constant | +| Reveal once | POST response has `number`; `GET /api/cards` and the `Card` type do not; dialog clears state on close | +| Server-side validation | `cards.test.ts` on `parseCardInput` + curl against the route | +| State machine | `cards.test.ts` full transition matrix; PATCH 409 on cancelled | + +## Risks + +- `store` is cached on `globalThis`; the dev server must restart once after `store.ts` changes or `store.cards` is `undefined`. +- `src/lib/cards.ts` is imported by client components; it must not import `node:crypto`. Use `globalThis.crypto.getRandomValues`. +- `react/no-unescaped-entities` fails lint on apostrophes in JSX text. + +## Out of scope + +- Persistence (NWP-203), auth, real issuer calls, editing a limit after issue (NWP-202), pagination on `/cards`. +- `spent` is not simulated: it is `0` at issue and stays `0`. Seed cards carry fixture spend so the bar states are visible. + +## Open questions + +- Whether ops wants a merchant category list beyond the six placeholders used here. From 277598e38c88eb67479b4c9fc5c34deab41fad5f Mon Sep 17 00:00:00 2001 From: Gabriel Amaral Date: Thu, 10 Sep 2026 11:55:16 -0700 Subject: [PATCH 02/12] NWP-201: card model, Luhn generator on the 4242 BIN, routes, tests Co-Authored-By: Claude Fable 5.1 --- .../src/app/api/cards/[id]/route.ts | 37 ++++ .../src/app/api/cards/route.ts | 31 +++ .../merchant-console/src/data/cards.ts | 80 ++++++++ .../merchant-console/src/data/generate.ts | 67 +++++- .../merchant-console/src/data/queries.ts | 4 +- .../merchant-console/src/data/store.ts | 7 +- .../merchant-console/src/data/types.ts | 41 ++++ .../merchant-console/src/lib/cards.test.ts | 190 ++++++++++++++++++ .../merchant-console/src/lib/cards.ts | 190 ++++++++++++++++++ 9 files changed, 640 insertions(+), 7 deletions(-) create mode 100644 build-battle/merchant-console/src/app/api/cards/[id]/route.ts create mode 100644 build-battle/merchant-console/src/app/api/cards/route.ts create mode 100644 build-battle/merchant-console/src/data/cards.ts create mode 100644 build-battle/merchant-console/src/lib/cards.test.ts create mode 100644 build-battle/merchant-console/src/lib/cards.ts 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..7d660b37 --- /dev/null +++ b/build-battle/merchant-console/src/app/api/cards/[id]/route.ts @@ -0,0 +1,37 @@ +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..8ce1c948 --- /dev/null +++ b/build-battle/merchant-console/src/app/api/cards/route.ts @@ -0,0 +1,31 @@ +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/data/cards.ts b/build-battle/merchant-console/src/data/cards.ts new file mode 100644 index 00000000..3aef7b0a --- /dev/null +++ b/build-battle/merchant-console/src/data/cards.ts @@ -0,0 +1,80 @@ +import { CardInput, canTransition, generateCardNumber } from "@/lib/cards" +import { pad } from "./generate" +import { store } from "./store" +import { Card, CardStatus } from "./types" + +/** + * Card reads and writes against the in-memory store. This is the only module + * that ever sees a full card number, and it returns it once, from createCard, + * without keeping it. + */ + +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 +} + +/** + * 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, "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..debd4d35 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") @@ -148,7 +149,69 @@ export function generate() { } const payouts = generatePayouts(payments) - return { payments, refunds, disputes, payouts } + return { payments, refunds, disputes, payouts, cards: generateCards() } +} + +/** + * Three fixture cards so the list, 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 daysAgo = (days: number) => { + const at = new Date(GENERATED_AT) + at.setUTCDate(at.getUTCDate() - days) + return at.toISOString() + } + const fixture = ( + seq: number, + card: Omit, + ): Card => ({ + id: `card_${pad(seq)}`, + numberRef: `cardref_${pad(seq)}`, + last4: "4242", + requestId: null, + ...card, + }) + + return [ + fixture(1, { + nickname: "Google Ads — Lumen", + merchantId: "mch_01", + category: "advertising", + limit: 250000, + spent: 87500, + currency: "USD", + status: "active", + createdAt: daysAgo(20), + events: [{ type: "issued", at: daysAgo(20) }], + }), + fixture(2, { + nickname: "Figma seats", + merchantId: "mch_04", + category: "software", + limit: 40000, + spent: 36000, + currency: "GBP", + status: "active", + createdAt: daysAgo(12), + events: [{ type: "issued", at: daysAgo(12) }], + }), + fixture(3, { + nickname: "Contractor — Berlin", + merchantId: "mch_05", + category: "contractors", + limit: 120000, + spent: 15000, + currency: "EUR", + status: "frozen", + createdAt: daysAgo(7), + events: [ + { type: "issued", at: daysAgo(7) }, + { type: "frozen", at: daysAgo(2) }, + ], + }), + ] } 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..e0e083b2 100644 --- a/build-battle/merchant-console/src/data/types.ts +++ b/build-battle/merchant-console/src/data/types.ts @@ -11,6 +11,47 @@ 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..86d616cb --- /dev/null +++ b/build-battle/merchant-console/src/lib/cards.test.ts @@ -0,0 +1,190 @@ +import { describe, expect, it } from "vitest" +import { + MAX_LIMIT_MINOR, + canTransition, + generateCardNumber, + isLuhnValid, + luhnCheckDigit, + maskCard, + parseCardInput, + spendLevel, + spendPercent, +} from "./cards" + +/** + * The rules that make a card shippable rather than merely visible: 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 reject with the limit + * held in integer minor units. + */ + +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("rejects a missing or unknown merchant", () => { + expect(parseCardInput({ ...valid, merchantId: "" })).toEqual({ + error: "Choose a merchant.", + }) + expect(parseCardInput({ ...valid, merchantId: "mch_99" })).toEqual({ + error: "Unknown merchant.", + }) + }) + + it("rejects a zero or negative limit", () => { + expect(parseCardInput({ ...valid, limit: "0" })).toEqual({ + error: "Spend limit must be greater than zero.", + }) + expect(parseCardInput({ ...valid, limit: "-5" })).toHaveProperty("error") + }) + + it("accepts exactly 5,000,000 minor units and rejects one cent more", () => { + // 50,000.00 is the ceiling; 50,000.01 is 5,000,001 minor units. + const atMax = parseCardInput({ ...valid, limit: "50000" }) + expect(atMax).toHaveProperty("input.limit", MAX_LIMIT_MINOR) + expect(parseCardInput({ ...valid, limit: "50000.01" })).toEqual({ + error: "Spend limit cannot exceed 5,000,000 minor units.", + }) + }) + + it("rejects a limit that is not a decimal string", () => { + expect(parseCardInput({ ...valid, limit: "abc" })).toHaveProperty("error") + // A number would be ambiguous between cents and dollars; only strings. + expect(parseCardInput({ ...valid, limit: 25000 })).toHaveProperty("error") + }) + + it("rejects currencies outside USD, EUR, GBP, case-sensitively", () => { + expect(parseCardInput({ ...valid, currency: "JPY" })).toEqual({ + error: "Currency must be USD, EUR, or GBP.", + }) + expect(parseCardInput({ ...valid, currency: "usd" })).toHaveProperty("error") + }) + + it("rejects a currency that differs from the merchant's", () => { + // mch_04 settles in GBP. + const result = parseCardInput({ ...valid, merchantId: "mch_04" }) + expect(result).toHaveProperty("error") + expect(parseCardInput({ ...valid, merchantId: "mch_04", currency: "GBP" })) + .toHaveProperty("input.currency", "GBP") + }) + + it("rejects a missing, blank, or overlong nickname", () => { + expect(parseCardInput({ ...valid, nickname: undefined })).toHaveProperty("error") + expect(parseCardInput({ ...valid, nickname: " " })).toHaveProperty("error") + expect(parseCardInput({ ...valid, nickname: "x".repeat(41) })).toHaveProperty("error") + }) + + it("allowlists the category", () => { + expect(parseCardInput({ ...valid, category: "gambling" })).toEqual({ + error: "Unknown merchant category.", + }) + expect(parseCardInput({ ...valid, category: "software" })).toHaveProperty( + "input.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..d2e80a7a --- /dev/null +++ b/build-battle/merchant-console/src/lib/cards.ts @@ -0,0 +1,190 @@ +import { merchantById } from "@/data/merchants" +import { CardCategory, CardStatus, Currency } from "@/data/types" +import { parseAmountToMinorUnits } from "./money" + +/** + * Pure card rules: number generation on the test BIN, masking, the status + * state machine, and the input parser behind POST /api/cards. Nothing here + * touches the store, and nothing here imports Node-only modules, because the + * transition table is also read by client components. + */ + +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]) +} + +/** + * A 16-digit number on the 4242 test BIN with a valid check digit. Random + * digits come from the platform CSPRNG; the modulo bias on a 32-bit source is + * negligible for a test BIN. + */ +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 the body of POST /api/cards. The client is not trusted: every + * field is checked against an allowlist, and 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, + }, + } +} + +/** + * Whole-number percentage of the limit that has been spent, clamped to 100. + * A ratio for a progress bar, not an amount: both inputs are integer minor + * units of the same currency, and nothing here is formatted or stored. + */ +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" +} From 5dcacbcd8aa6ab6da9a0d5bf4f6b0ba1ec789ba5 Mon Sep 17 00:00:00 2001 From: Gabriel Amaral Date: Thu, 10 Sep 2026 11:57:23 -0700 Subject: [PATCH 03/12] NWP-201: cards list, detail, issue dialog, freeze/unfreeze, spend bar Co-Authored-By: Claude Fable 5.1 --- .../src/app/api/cards/[id]/route.ts | 5 +- .../src/app/api/cards/route.ts | 5 +- .../src/app/cards/[id]/not-found.tsx | 21 ++ .../src/app/cards/[id]/page.tsx | 151 +++++++++ .../src/app/cards/card-actions.tsx | 128 +++++++ .../src/app/cards/issue-card-dialog.tsx | 319 ++++++++++++++++++ .../merchant-console/src/app/cards/page.tsx | 116 +++++++ .../src/app/cards/spend-bar.tsx | 88 +++++ .../merchant-console/src/app/siteConfig.ts | 1 + .../components/ui/navigation/AppSidebar.tsx | 14 +- .../components/ui/navigation/Breadcrumbs.tsx | 1 + .../components/ui/payments/StatusBadge.tsx | 23 +- .../merchant-console/src/data/cards.ts | 14 +- .../merchant-console/src/data/generate.ts | 11 +- .../merchant-console/src/data/types.ts | 13 +- .../merchant-console/src/lib/cards.test.ts | 21 +- .../merchant-console/src/lib/cards.ts | 6 +- 17 files changed, 905 insertions(+), 32 deletions(-) create mode 100644 build-battle/merchant-console/src/app/cards/[id]/not-found.tsx create mode 100644 build-battle/merchant-console/src/app/cards/[id]/page.tsx create mode 100644 build-battle/merchant-console/src/app/cards/card-actions.tsx create mode 100644 build-battle/merchant-console/src/app/cards/issue-card-dialog.tsx create mode 100644 build-battle/merchant-console/src/app/cards/page.tsx create mode 100644 build-battle/merchant-console/src/app/cards/spend-bar.tsx 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 index 7d660b37..1813afa8 100644 --- a/build-battle/merchant-console/src/app/api/cards/[id]/route.ts +++ b/build-battle/merchant-console/src/app/api/cards/[id]/route.ts @@ -18,7 +18,10 @@ export async function PATCH( try { body = await request.json() } catch { - return NextResponse.json({ error: "Request body must be JSON." }, { status: 400 }) + return NextResponse.json( + { error: "Request body must be JSON." }, + { status: 400 }, + ) } const status = (body as { status?: unknown } | null)?.status diff --git a/build-battle/merchant-console/src/app/api/cards/route.ts b/build-battle/merchant-console/src/app/api/cards/route.ts index 8ce1c948..e05c7cae 100644 --- a/build-battle/merchant-console/src/app/api/cards/route.ts +++ b/build-battle/merchant-console/src/app/api/cards/route.ts @@ -18,7 +18,10 @@ export async function POST(request: NextRequest) { try { body = await request.json() } catch { - return NextResponse.json({ error: "Request body must be JSON." }, { status: 400 }) + return NextResponse.json( + { error: "Request body must be JSON." }, + { status: 400 }, + ) } const parsed = parseCardInput(body) 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..272201bc --- /dev/null +++ b/build-battle/merchant-console/src/app/cards/[id]/not-found.tsx @@ -0,0 +1,21 @@ +import Link from "next/link" + +export default function CardNotFound() { + return ( +
    +

    + Card not found +

    +

    + This card does not exist, or it was issued in a previous session and the + store has since restarted. +

    + + ← All cards + +
    + ) +} 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..fa6ecd45 --- /dev/null +++ b/build-battle/merchant-console/src/app/cards/[id]/page.tsx @@ -0,0 +1,151 @@ +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)! + const remaining = Math.max(0, card.limit - card.spent) + + 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)} + + + + + {formatMoney(remaining, card.currency)} + + + {card.currency} + + {card.numberRef} + + + {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..5147217b --- /dev/null +++ b/build-battle/merchant-console/src/app/cards/issue-card-dialog.tsx @@ -0,0 +1,319 @@ +"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 } + +/** + * Issue a virtual card. The form posts to /api/cards, which validates every + * field again; this component only shapes the request. The full number is + * shown once on the success screen 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 + + + + {issued.number + ? "Copy the number now. It is shown once and cannot be recovered." + : "This card was already issued; the number was shown at the time."} + + + +
    +
    +
    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" + /> +
    +
    + + Currency + +

    + {currency ?? "—"} +

    +
    +
    +

    + {merchant + ? `${merchant.name} settles in ${merchant.currency}, so the card does too.` + : "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..cea194bd --- /dev/null +++ b/build-battle/merchant-console/src/app/cards/spend-bar.tsx @@ -0,0 +1,88 @@ +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 5%. +const WIDTHS = [ + "w-0", + "w-[5%]", + "w-[10%]", + "w-[15%]", + "w-[20%]", + "w-[25%]", + "w-[30%]", + "w-[35%]", + "w-[40%]", + "w-[45%]", + "w-1/2", + "w-[55%]", + "w-[60%]", + "w-[65%]", + "w-[70%]", + "w-[75%]", + "w-[80%]", + "w-[85%]", + "w-[90%]", + "w-[95%]", + "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 c.requestId === input.requestId) if (existing) return { card: existing, number: null, created: false } @@ -54,8 +56,10 @@ export function createCard( return { card, number, created: true } } -const EVENT_FOR: Record, "frozen" | "cancelled"> = - { frozen: "frozen", cancelled: "cancelled" } +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( diff --git a/build-battle/merchant-console/src/data/generate.ts b/build-battle/merchant-console/src/data/generate.ts index debd4d35..37584d00 100644 --- a/build-battle/merchant-console/src/data/generate.ts +++ b/build-battle/merchant-console/src/data/generate.ts @@ -87,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 = { @@ -98,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), @@ -124,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, diff --git a/build-battle/merchant-console/src/data/types.ts b/build-battle/merchant-console/src/data/types.ts index e0e083b2..9ecd8961 100644 --- a/build-battle/merchant-console/src/data/types.ts +++ b/build-battle/merchant-console/src/data/types.ts @@ -1,11 +1,7 @@ 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" @@ -15,12 +11,7 @@ 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" + "any" | "advertising" | "software" | "contractors" | "travel" | "office" export interface CardEvent { type: "issued" | "frozen" | "unfrozen" | "cancelled" diff --git a/build-battle/merchant-console/src/lib/cards.test.ts b/build-battle/merchant-console/src/lib/cards.test.ts index 86d616cb..8d4d0590 100644 --- a/build-battle/merchant-console/src/lib/cards.test.ts +++ b/build-battle/merchant-console/src/lib/cards.test.ts @@ -140,21 +140,30 @@ describe("parseCardInput", () => { expect(parseCardInput({ ...valid, currency: "JPY" })).toEqual({ error: "Currency must be USD, EUR, or GBP.", }) - expect(parseCardInput({ ...valid, currency: "usd" })).toHaveProperty("error") + expect(parseCardInput({ ...valid, currency: "usd" })).toHaveProperty( + "error", + ) }) it("rejects a currency that differs from the merchant's", () => { // mch_04 settles in GBP. const result = parseCardInput({ ...valid, merchantId: "mch_04" }) expect(result).toHaveProperty("error") - expect(parseCardInput({ ...valid, merchantId: "mch_04", currency: "GBP" })) - .toHaveProperty("input.currency", "GBP") + expect( + parseCardInput({ ...valid, merchantId: "mch_04", currency: "GBP" }), + ).toHaveProperty("input.currency", "GBP") }) it("rejects a missing, blank, or overlong nickname", () => { - expect(parseCardInput({ ...valid, nickname: undefined })).toHaveProperty("error") - expect(parseCardInput({ ...valid, nickname: " " })).toHaveProperty("error") - expect(parseCardInput({ ...valid, nickname: "x".repeat(41) })).toHaveProperty("error") + expect(parseCardInput({ ...valid, nickname: undefined })).toHaveProperty( + "error", + ) + expect(parseCardInput({ ...valid, nickname: " " })).toHaveProperty( + "error", + ) + expect( + parseCardInput({ ...valid, nickname: "x".repeat(41) }), + ).toHaveProperty("error") }) it("allowlists the category", () => { diff --git a/build-battle/merchant-console/src/lib/cards.ts b/build-battle/merchant-console/src/lib/cards.ts index d2e80a7a..d59b0cd6 100644 --- a/build-battle/merchant-console/src/lib/cards.ts +++ b/build-battle/merchant-console/src/lib/cards.ts @@ -41,7 +41,11 @@ 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) { + for ( + let i = partial.length - 1, double = true; + i >= 0; + i--, double = !double + ) { let digit = Number(partial[i]) if (double) { digit *= 2 From 290a51d213a6f968eeab593afd468f1dd8131f0b Mon Sep 17 00:00:00 2001 From: Gabriel Amaral Date: Thu, 10 Sep 2026 12:00:02 -0700 Subject: [PATCH 04/12] NWP-201: allow dev server on an alternate port Co-Authored-By: Claude Fable 5.1 --- .claude/launch.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.claude/launch.json b/.claude/launch.json index c2a29830..4c33e07c 100644 --- a/.claude/launch.json +++ b/.claude/launch.json @@ -5,7 +5,8 @@ "name": "merchant-console", "runtimeExecutable": "npm", "runtimeArgs": ["run", "dev", "--prefix", "build-battle/merchant-console"], - "port": 3000 + "port": 3000, + "autoPort": true } ] } From 6506d538bc3bf48fabe82ca4f047472fe02e24b5 Mon Sep 17 00:00:00 2001 From: Gabriel Amaral Date: Thu, 10 Sep 2026 19:32:44 -0700 Subject: [PATCH 05/12] NWP-201: trim seed fixtures and dialog markup to keep the diff small Co-Authored-By: Claude Fable 5.1 --- .claude/launch.json | 3 +- .../src/app/cards/issue-card-dialog.tsx | 60 +++++++------------ .../merchant-console/src/data/generate.ts | 59 +++++++----------- 3 files changed, 42 insertions(+), 80 deletions(-) diff --git a/.claude/launch.json b/.claude/launch.json index 4c33e07c..c2a29830 100644 --- a/.claude/launch.json +++ b/.claude/launch.json @@ -5,8 +5,7 @@ "name": "merchant-console", "runtimeExecutable": "npm", "runtimeArgs": ["run", "dev", "--prefix", "build-battle/merchant-console"], - "port": 3000, - "autoPort": true + "port": 3000 } ] } 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 index 5147217b..5a83619c 100644 --- a/build-battle/merchant-console/src/app/cards/issue-card-dialog.tsx +++ b/build-battle/merchant-console/src/app/cards/issue-card-dialog.tsx @@ -31,6 +31,8 @@ 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. The form posts to /api/cards, which validates every * field again; this component only shapes the request. The full number is @@ -149,26 +151,20 @@ export function IssueCardDialog({
    -
    -
    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)} -
    -
    +
    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)} +
    @@ -187,10 +183,7 @@ export function IssueCardDialog({
    -
    -
    -
    - - setLimit(event.target.value)} - placeholder="250.00" - autoComplete="off" - className="mt-1.5" - /> -
    -
    - Currency -

    - {currency ?? "—"} -

    -
    +
    + + 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 && ( diff --git a/build-battle/merchant-console/src/lib/cards.ts b/build-battle/merchant-console/src/lib/cards.ts index d59b0cd6..34871312 100644 --- a/build-battle/merchant-console/src/lib/cards.ts +++ b/build-battle/merchant-console/src/lib/cards.ts @@ -3,10 +3,8 @@ import { CardCategory, CardStatus, Currency } from "@/data/types" import { parseAmountToMinorUnits } from "./money" /** - * Pure card rules: number generation on the test BIN, masking, the status - * state machine, and the input parser behind POST /api/cards. Nothing here - * touches the store, and nothing here imports Node-only modules, because the - * transition table is also read by client components. + * 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" @@ -62,11 +60,7 @@ export function isLuhnValid(number: string): boolean { return luhnCheckDigit(partial) === Number(number[number.length - 1]) } -/** - * A 16-digit number on the 4242 test BIN with a valid check digit. Random - * digits come from the platform CSPRNG; the modulo bias on a 32-bit source is - * negligible for a test BIN. - */ +/** 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)) @@ -102,9 +96,8 @@ export interface CardInput { type ParseResult = { input: CardInput } | { error: string } /** - * Validates the body of POST /api/cards. The client is not trusted: every - * field is checked against an allowlist, and the limit arrives as a string - * and is converted to minor units exactly once, here. + * 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)) { @@ -176,11 +169,7 @@ export function parseCardInput(body: unknown): ParseResult { } } -/** - * Whole-number percentage of the limit that has been spent, clamped to 100. - * A ratio for a progress bar, not an amount: both inputs are integer minor - * units of the same currency, and nothing here is formatted or stored. - */ +/** 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)) diff --git a/docs/specs/NWP-201-issue-cards.md b/docs/specs/NWP-201-issue-cards.md index 653afa65..0a97cba4 100644 --- a/docs/specs/NWP-201-issue-cards.md +++ b/docs/specs/NWP-201-issue-cards.md @@ -9,93 +9,77 @@ ## Problem -Ops issues virtual cards by messaging the platform team, who create them by hand. It takes hours, happens twelve to twenty times a week, and last month two cards went out with the wrong spend limit because the request lived in a Slack thread. Marcus wants ops to issue a card, see the cards they issued, and open one to check it — from the console, today. +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 -- `build-battle/merchant-console/src/data/store.ts` — the in-memory store, pinned on `globalThis` so dev HMR does not reset it. Holds `merchants`, `payments`, `refunds`, `disputes`, `payouts`. **No `cards` slice.** Adding one means editing both the `Store` interface and `createStore()`, and restarting `next dev` once because the cached object predates the change. -- `build-battle/merchant-console/src/data/generate.ts` — seed data is **generated TypeScript**, not JSON as `merchant-console/CLAUDE.md` says. IDs use a local `pad()` helper (`pay_000001`). Card seeds belong here, next to the other fixtures. -- `build-battle/merchant-console/src/data/types.ts` — `Currency = "USD" | "EUR" | "GBP"` already exists and is exactly the ticket's allowlist. No `Card` type. -- `build-battle/merchant-console/src/data/merchants.ts` — `merchantById(id)` returns `undefined` for an unknown merchant; every merchant carries a `currency`. Nothing in the console checks a card's currency against it yet. -- `build-battle/merchant-console/src/lib/money.ts` — `parseAmountToMinorUnits("250.00") → 25000 | null` is the boundary converter; `formatMoney(minor, currency)` is the only formatter. -- `build-battle/merchant-console/src/lib/dates.ts` — `formatInZone(iso, tz)` for display in the merchant's timezone. -- `build-battle/merchant-console/src/app/api/payments/export/route.ts` — the house route pattern: `as const` allowlist, validator returning `{ value } | { error }`, `NextResponse.json({ error }, { status: 400 })`, reject early. There is **no POST handler anywhere** yet. -- `build-battle/merchant-console/src/app/payments/page.tsx` and `src/app/payments/[id]/page.tsx` — server components that read the store directly; list uses an inline `colSpan` empty state, detail uses a `Field` grid and a timeline `
      `. Next 15: `params`/`searchParams` are promises. -- `build-battle/merchant-console/src/app/payments/export-dialog.tsx` — the form-dialog pattern built on `src/components/Drawer.tsx` (Radix dialog) with a centered-modal className. There is no `Dialog.tsx`, despite `.claude/rules/components.md` saying so. -- `build-battle/merchant-console/src/components/ui/payments/StatusBadge.tsx` — three `Record` maps; card statuses must be added to all three. -- `build-battle/merchant-console/src/app/siteConfig.ts`, `src/components/ui/navigation/AppSidebar.tsx`, `Breadcrumbs.tsx` — where a `/cards` link and label are registered. -- `build-battle/merchant-console/src/data/queries.ts:81` — pre-existing defect: amounts are sorted with `String(...).localeCompare`, so 9900 sorts above 100000. Fixed in passing because it is one line and the cards list sorts money too. +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`, `src/app/payments/[id]/page.tsx` — server components reading the store; inline `colSpan` 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`." | `merchant-console/CLAUDE.md`, ticket rule 1 | Cents drift; the exact wrong-limit bug ops is escaping | -| "Never persist or display a full card number after creation. Store the last four and the generated number's reference." | ticket rule 2, `.claude/rules/cards.md` | A PAN in the store or a list payload | -| "`active ⇄ frozen`, either to `cancelled`, and `cancelled` is terminal. Guard the transition on the server." | ticket rule 3, `.claude/rules/cards.md` | A cancelled card comes back to life | -| "Every generated number starts `4242` and carries a valid Luhn check digit. Generate on the server." | ticket rule 4, `.claude/rules/cards.md` | Something resembling a real PAN | -| "Validate everything from the client against an allowlist." Reject missing merchant, limit ≤ 0, limit > 5,000,000, currency ∉ USD/EUR/GBP | `.claude/rules/api-routes.md`, ticket core 6 | Client-only checks are enforcement nowhere | -| "Dialogs and forms must be operable. Every input has a label, the dialog has an accessible name, focus moves into it and returns on close, Escape closes it." | `.claude/rules/components.md` | Keyboard and screen-reader users cannot issue a card | -| "Write the empty and error states." | `.claude/rules/components.md` | A blank table, a silent failed request | +| "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 existing store, a pure `src/lib/cards.ts` (Luhn generator on the `4242` BIN, transition table, input parser that converts the limit string once via `parseAmountToMinorUnits`), and two route handlers: `POST /api/cards` (the only response that ever carries a full number) and `PATCH /api/cards/[id]` (status transitions guarded by the table). Pages `/cards` and `/cards/[id]` are server components reading the store 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, the server also rejects a currency that differs from the merchant's, honours a client `requestId` so a double submit cannot mint two cards, records every status change on the card and shows it on the detail page, and cancel requires a confirm step. +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 card number in the dialog and posting it — rejected because `cards.md` says a browser-generated number is a bug and it would make masking unverifiable. Also rejected: a separate `cards` module-level array — the store lives on `globalThis` on purpose; a second array would reset on HMR. +**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`, seed three cards (one at 90% spend for the amber bar, one frozen) | +| `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` (returns the number once), `transitionCard` | -| `src/lib/cards.ts` | add | Luhn, generator, mask, transition table, `parseCardInput`, spend percent | -| `src/lib/cards.test.ts` | add | tests for all of the above | -| `src/app/api/cards/route.ts` | add | `GET` list (masked), `POST` issue | -| `src/app/api/cards/[id]/route.ts` | add | `PATCH` status | -| `src/app/cards/page.tsx` | add | list with empty state | -| `src/app/cards/[id]/page.tsx` | add | detail, spend bar, audit timeline | -| `src/app/cards/issue-card-dialog.tsx` | add | form, reveal-once success screen | -| `src/app/cards/card-actions.tsx` | add | freeze / unfreeze / cancel (confirm) without reload | -| `src/app/cards/spend-bar.tsx` | add | progress bar, amber past 80% | -| `src/components/ui/payments/StatusBadge.tsx` | change | card statuses | -| `src/app/siteConfig.ts`, `AppSidebar.tsx`, `Breadcrumbs.tsx` | change | navigation | -| `src/data/queries.ts` | change | numeric amount sort (bug fix) | +| `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, seed, store, lib, tests** — done when: `npm test` is green with the new `cards.test.ts`. -2. **Routes** — done when: curl shows 201 with a `4242…` Luhn-valid number, 400 for each rejection, 409 for `cancelled → active`, and `GET /api/cards` carries no 16-digit string. -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% seed shows an amber bar, cancel asks to confirm and then offers no further actions. -5. **Ship** — done when: `npm run lint`, `npm test`, `/ship-ready` pass and the PR is open. +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. ## Verification -| Acceptance criterion | How it is proven | +| Criterion | Proof | | --- | --- | -| Issue a card | Browser: fill dialog, submit, card in list | -| Card list | `/cards` columns: nickname, merchant, `•••• 4242`, limit, status, created | -| Card detail | `/cards/` shows record, spend bar, audit trail | -| Generated numbers | `cards.test.ts`: starts `4242`, 16 digits, Luhn valid, not constant | -| Reveal once | POST response has `number`; `GET /api/cards` and the `Card` type do not; dialog clears state on close | -| Server-side validation | `cards.test.ts` on `parseCardInput` + curl against the route | -| State machine | `cards.test.ts` full transition matrix; PATCH 409 on cancelled | +| Issue a card | browser: submit, card in list | +| Card list / detail | `/cards` columns; `/cards/` record, spend bar, history | +| Generated numbers | `cards.test.ts`: `^4242`, 16 digits, Luhn, not constant | +| Reveal once | number only in the 201 body; `Card` has no number field; dialog resets on close | +| Server validation, state machine | `cards.test.ts` + curl 400/409 | ## Risks -- `store` is cached on `globalThis`; the dev server must restart once after `store.ts` changes or `store.cards` is `undefined`. -- `src/lib/cards.ts` is imported by client components; it must not import `node:crypto`. Use `globalThis.crypto.getRandomValues`. -- `react/no-unescaped-entities` fails lint on apostrophes in JSX text. +- `store` is cached on `globalThis`; restart the dev server once. +- `src/lib/cards.ts` is imported by client components: no `node:crypto`; use `globalThis.crypto`. ## Out of scope -- Persistence (NWP-203), auth, real issuer calls, editing a limit after issue (NWP-202), pagination on `/cards`. -- `spent` is not simulated: it is `0` at issue and stays `0`. Seed cards carry fixture spend so the bar states are visible. - -## Open questions - -- Whether ops wants a merchant category list beyond the six placeholders used here. +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. From e721bcb8fd79ab57466e52105559434d19ac673b Mon Sep 17 00:00:00 2001 From: Gabriel Amaral Date: Thu, 10 Sep 2026 19:50:27 -0700 Subject: [PATCH 09/12] NWP-201: shorten test preamble Co-Authored-By: Claude Fable 5.1 --- build-battle/merchant-console/src/lib/cards.test.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/build-battle/merchant-console/src/lib/cards.test.ts b/build-battle/merchant-console/src/lib/cards.test.ts index 1e5bb5ea..09ef9465 100644 --- a/build-battle/merchant-console/src/lib/cards.test.ts +++ b/build-battle/merchant-console/src/lib/cards.test.ts @@ -12,10 +12,8 @@ import { } from "./cards" /** - * The rules that make a card shippable rather than merely visible: 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 reject with the limit - * held in integer minor units. + * 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", () => { From 87f3da1c90f1d238320bb982f731564d9405a88a Mon Sep 17 00:00:00 2001 From: Gabriel Amaral Date: Thu, 10 Sep 2026 19:53:44 -0700 Subject: [PATCH 10/12] NWP-201: trim the spec to the sections a reviewer needs Co-Authored-By: Claude Fable 5.1 --- docs/specs/NWP-201-issue-cards.md | 17 +---------------- 1 file changed, 1 insertion(+), 16 deletions(-) diff --git a/docs/specs/NWP-201-issue-cards.md b/docs/specs/NWP-201-issue-cards.md index 0a97cba4..924603ee 100644 --- a/docs/specs/NWP-201-issue-cards.md +++ b/docs/specs/NWP-201-issue-cards.md @@ -21,7 +21,7 @@ Paths are under `build-battle/merchant-console/`. - `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`, `src/app/payments/[id]/page.tsx` — server components reading the store; inline `colSpan` empty state; `Field` grid and timeline `
          `. Next 15 `params` are promises. +- `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. @@ -65,21 +65,6 @@ Add a `cards` slice to the store, a pure `src/lib/cards.ts` (Luhn generator on t 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. -## Verification - -| Criterion | Proof | -| --- | --- | -| Issue a card | browser: submit, card in list | -| Card list / detail | `/cards` columns; `/cards/` record, spend bar, history | -| Generated numbers | `cards.test.ts`: `^4242`, 16 digits, Luhn, not constant | -| Reveal once | number only in the 201 body; `Card` has no number field; dialog resets on close | -| Server validation, state machine | `cards.test.ts` + curl 400/409 | - -## Risks - -- `store` is cached on `globalThis`; restart the dev server once. -- `src/lib/cards.ts` is imported by client components: no `node:crypto`; use `globalThis.crypto`. - ## 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. From cff7747c7243dc8f19833ac9fcb9ec5fc01c9d1f Mon Sep 17 00:00:00 2001 From: Gabriel Amaral Date: Thu, 10 Sep 2026 19:56:57 -0700 Subject: [PATCH 11/12] NWP-201: attach the test and lint transcript to the PR Co-Authored-By: Claude Fable 5.1 From 6a28d8422dab3495537940fc866d6eba0f93d597 Mon Sep 17 00:00:00 2001 From: Gabriel Amaral Date: Thu, 10 Sep 2026 20:01:06 -0700 Subject: [PATCH 12/12] NWP-201: record browser verification of freeze, cancel, and spend bar Co-Authored-By: Claude Fable 5.1