From d3c94233bbbf01efdd0ad2c193a619d726616dcd Mon Sep 17 00:00:00 2001 From: peris611 Date: Thu, 10 Sep 2026 11:54:59 -0700 Subject: [PATCH 1/5] NWP-201: add card types and the pure card helpers Everything a card can decide lives in src/lib/cards.ts as pure functions that take randomness, the clock, and the merchant lookup as parameters. Nothing here reads the store, so the state machine and the number generator are testable without a mock and without leaking state across vitest files, which share one store on globalThis. Card carries last4 and no field for the rest of the number, so a record that cannot hold a PAN cannot leak one. mulberry32 is exported from generate.ts so the tests reuse the existing PRNG rather than duplicating it. generate() itself is untouched, so the seeded payment stream is unshifted. Co-Authored-By: Claude Opus 5 --- .../merchant-console/src/data/generate.ts | 2 +- .../merchant-console/src/data/types.ts | 67 ++- .../merchant-console/src/lib/cards.test.ts | 420 ++++++++++++++++++ .../merchant-console/src/lib/cards.ts | 357 +++++++++++++++ docs/specs/NWP-201-issue-cards.md | 157 +++++++ 5 files changed, 1001 insertions(+), 2 deletions(-) create mode 100644 build-battle/merchant-console/src/lib/cards.test.ts create mode 100644 build-battle/merchant-console/src/lib/cards.ts create mode 100644 docs/specs/NWP-201-issue-cards.md diff --git a/build-battle/merchant-console/src/data/generate.ts b/build-battle/merchant-console/src/data/generate.ts index 2887ba8c..696bcc81 100644 --- a/build-battle/merchant-console/src/data/generate.ts +++ b/build-battle/merchant-console/src/data/generate.ts @@ -18,7 +18,7 @@ const DAYS = 120 const PAYMENTS_PER_DAY = 14 /** Small, fast, deterministic PRNG. Not for anything that matters. */ -function mulberry32(a: number) { +export function mulberry32(a: number) { return function () { a |= 0 a = (a + 0x6d2b79f5) | 0 diff --git a/build-battle/merchant-console/src/data/types.ts b/build-battle/merchant-console/src/data/types.ts index 6697e576..fa547621 100644 --- a/build-battle/merchant-console/src/data/types.ts +++ b/build-battle/merchant-console/src/data/types.ts @@ -1,4 +1,6 @@ -export type Currency = "USD" | "EUR" | "GBP" +export const CURRENCIES = ["USD", "EUR", "GBP"] as const + +export type Currency = (typeof CURRENCIES)[number] export type PaymentStatus = | "authorized" @@ -11,6 +13,29 @@ export type DisputeStatus = "needs_response" | "under_review" | "won" | "lost" export type PayoutStatus = "paid" | "in_transit" | "pending" +/** + * The allowlist and the type are one declaration, so a status the server has + * never heard of cannot reach the store. Same shape as EXPORT_COLUMNS. + */ +export const CARD_STATUSES = ["active", "frozen", "cancelled"] as const + +export type CardStatus = (typeof CARD_STATUSES)[number] + +/** + * What a card is allowed to be spent on. Lives on the card rather than the + * merchant: merchants have no category, and inventing one would mean editing + * seed data to support a feature. + */ +export const CARD_CATEGORIES = [ + "advertising", + "software", + "travel", + "fulfillment", + "utilities", +] as const + +export type CardCategory = (typeof CARD_CATEGORIES)[number] + export interface Merchant { id: string name: string @@ -71,6 +96,46 @@ export interface Payout { paymentIds: string[] } +export interface CardAuditEntry { + /** null on issue, when the card came from nowhere. */ + from: CardStatus | null + to: CardStatus + /** ISO 8601, always UTC. */ + at: string + /** No identity in this console yet, so every write is the ops user. */ + actor: string +} + +export interface Card { + id: string + merchantId: string + nickname: string + /** + * Last four of the generated number. There is deliberately no field for the + * rest of it: the full number is returned once, at creation, and a record + * that cannot hold it cannot leak it. + */ + last4: string + /** The test BIN, pinned as a literal so no card can carry another. */ + bin: "4242" + /** Integer minor units. Never a float. */ + limit: number + currency: Currency + category: CardCategory + status: CardStatus + /** ISO 8601, always UTC. */ + issuedAt: string + audit: CardAuditEntry[] +} + +export interface CardFilters { + status?: CardStatus | "all" + merchantId?: string + search?: string + page?: number + pageSize?: number +} + 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 00000000..47f6d8be --- /dev/null +++ b/build-battle/merchant-console/src/lib/cards.test.ts @@ -0,0 +1,420 @@ +import { mulberry32 } from "@/data/generate" +import { Card, Merchant } from "@/data/types" +import { describe, expect, it } from "vitest" +import { + applyTransition, + buildCard, + canTransition, + CARD_ERROR_STATUS, + CARD_LIMIT_MAX, + generateCardNumber, + groupCardNumber, + isCardStatus, + isSpendAmber, + isValidLuhn, + luhnCheckDigit, + maskCard, + nextCardId, + spendPercent, + transitionRejection, + validateIssueRequest, +} from "./cards" + +/** + * A card is the one thing in this console that could be mistaken for a real + * payment instrument, so these tests pin the two rules that keep it from + * becoming one: every number sits on the test BIN with a real check digit, and + * a cancelled card never comes back. They also hold the limit boundary, which + * is what NWP-201 exists to stop ops getting wrong in a Slack thread. + * + * Nothing here imports the store. The store is cached on globalThis for the + * life of a vitest run, so a test that mutated it would leak into other files. + */ + +const merchants: Merchant[] = [ + { + id: "mch_01", + name: "Lumen Coffee Roasters", + country: "US", + timezone: "America/New_York", + currency: "USD", + riskTier: "low", + }, + { + id: "mch_04", + name: "Halcyon Studio", + country: "GB", + timezone: "Europe/London", + currency: "GBP", + riskTier: "standard", + }, +] + +const lookupMerchant = (id: string) => merchants.find((m) => m.id === id) + +const request = { + merchantId: "mch_01", + nickname: "Ad spend", + limit: 25000, + currency: "USD", + category: "advertising", +} + +const now = new Date("2026-09-10T09:30:00.000Z") + +const card: Card = { + id: "card_0001", + merchantId: "mch_01", + nickname: "Ad spend", + last4: "4242", + bin: "4242", + limit: 100000, + currency: "USD", + category: "advertising", + status: "active", + issuedAt: "2026-09-01T00:00:00.000Z", + audit: [ + { + from: null, + to: "active", + at: "2026-09-01T00:00:00.000Z", + actor: "ops", + }, + ], +} + +describe("luhnCheckDigit", () => { + it("produces the digit that completes a known test number", () => { + // 4242424242424242 is the canonical Visa test number, so the check digit + // for its first fifteen digits has to be 2. + expect(luhnCheckDigit("424242424242424")).toBe(2) + }) + + it("returns a single digit for any partial", () => { + expect(luhnCheckDigit("424200000000000")).toBe(0) + expect(luhnCheckDigit("424299999999999")).toBe(1) + }) +}) + +describe("isValidLuhn", () => { + it("accepts a number whose check digit adds up", () => { + expect(isValidLuhn("4242424242424242")).toBe(true) + }) + + it("rejects a number with the wrong check digit", () => { + expect(isValidLuhn("4242424242424241")).toBe(false) + }) + + it("rejects anything that is not sixteen digits", () => { + expect(isValidLuhn("424242424242424")).toBe(false) + expect(isValidLuhn("42424242424242421")).toBe(false) + expect(isValidLuhn("4242 4242 4242 4242")).toBe(false) + expect(isValidLuhn("")).toBe(false) + }) +}) + +describe("generateCardNumber", () => { + it("pins the whole number when the randomness is pinned", () => { + // Not a constant in the implementation: the BIN is fixed, the middle comes + // from the injected rng, and the last digit is computed from both. + expect(generateCardNumber(() => 0)).toBe("4242000000000000") + expect(generateCardNumber(() => 0.9999)).toBe("4242999999999991") + }) + + it("puts every number on the test BIN with a valid check digit", () => { + const rng = mulberry32(20260910) + const seen = new Set() + for (let i = 0; i < 200; i++) { + const cardNumber = generateCardNumber(rng) + expect(cardNumber).toHaveLength(16) + expect(cardNumber.startsWith("4242")).toBe(true) + expect(isValidLuhn(cardNumber)).toBe(true) + seen.add(cardNumber) + } + // A generator that returned the same number every time would still pass + // the checks above, so pin that it does not. + expect(seen.size).toBe(200) + }) +}) + +describe("maskCard", () => { + it("renders the only form a card takes after creation", () => { + expect(maskCard("4242")).toBe("•••• 4242") + }) +}) + +describe("groupCardNumber", () => { + it("spaces the number for the one screen that shows it", () => { + expect(groupCardNumber("4242424242424242")).toBe("4242 4242 4242 4242") + }) +}) + +describe("validateIssueRequest", () => { + it("accepts a well-formed request and trims what it keeps", () => { + const result = validateIssueRequest( + { ...request, nickname: " Ad spend " }, + lookupMerchant, + ) + expect(result.ok).toBe(true) + if (!result.ok) return + expect(result.value.nickname).toBe("Ad spend") + expect(result.value.limit).toBe(25000) + expect(result.value.currency).toBe("USD") + }) + + it("rejects a body that is not an object", () => { + expect(validateIssueRequest(null, lookupMerchant).ok).toBe(false) + expect(validateIssueRequest("nickname=x", lookupMerchant).ok).toBe(false) + expect(validateIssueRequest([request], lookupMerchant).ok).toBe(false) + }) + + it("rejects a missing merchant, which is the case the ticket names", () => { + const missing = validateIssueRequest( + { ...request, merchantId: "" }, + lookupMerchant, + ) + expect(missing.ok).toBe(false) + if (missing.ok) return + expect(missing.error.code).toBe("merchant_required") + expect(missing.error.field).toBe("merchantId") + + const unknown = validateIssueRequest( + { ...request, merchantId: "mch_99" }, + lookupMerchant, + ) + expect(unknown.ok).toBe(false) + if (unknown.ok) return + expect(unknown.error.code).toBe("merchant_not_found") + }) + + it("rejects a nickname that is blank or too long", () => { + const blank = validateIssueRequest( + { ...request, nickname: " " }, + lookupMerchant, + ) + expect(blank.ok).toBe(false) + if (blank.ok) return + expect(blank.error.code).toBe("nickname_invalid") + + const long = validateIssueRequest( + { ...request, nickname: "x".repeat(65) }, + lookupMerchant, + ) + expect(long.ok).toBe(false) + }) + + it("rejects a currency outside the three the console settles in", () => { + const result = validateIssueRequest( + { ...request, currency: "JPY" }, + lookupMerchant, + ) + expect(result.ok).toBe(false) + if (result.ok) return + expect(result.error.code).toBe("currency_invalid") + expect(result.error.field).toBe("currency") + }) + + it("rejects a currency the merchant does not settle in", () => { + // Halcyon Studio is GBP. A USD card for them would be a card nobody can + // reconcile, and nothing in the console checked this before. + const result = validateIssueRequest( + { ...request, merchantId: "mch_04", currency: "USD" }, + lookupMerchant, + ) + expect(result.ok).toBe(false) + if (result.ok) return + expect(result.error.code).toBe("currency_mismatch") + expect(result.error.message).toContain("GBP") + }) + + it("rejects a limit that is zero, negative, or not whole minor units", () => { + for (const limit of [0, -1, 250.5, "25000", null]) { + const result = validateIssueRequest({ ...request, limit }, lookupMerchant) + expect(result.ok).toBe(false) + if (result.ok) return + expect(result.error.code).toBe("limit_invalid") + } + }) + + it("holds the limit ceiling exactly, on both sides", () => { + expect( + validateIssueRequest({ ...request, limit: CARD_LIMIT_MAX }, lookupMerchant) + .ok, + ).toBe(true) + + const over = validateIssueRequest( + { ...request, limit: CARD_LIMIT_MAX + 1 }, + lookupMerchant, + ) + expect(over.ok).toBe(false) + if (over.ok) return + expect(over.error.code).toBe("limit_too_large") + // The message names a real amount, so the currency is checked first. + expect(over.error.message).toContain("$50,000.00") + }) + + it("rejects a category outside the allowlist", () => { + const result = validateIssueRequest( + { ...request, category: "gambling" }, + lookupMerchant, + ) + expect(result.ok).toBe(false) + if (result.ok) return + expect(result.error.code).toBe("category_invalid") + }) + + it("gives every error code a status that means what it says", () => { + expect(CARD_ERROR_STATUS.invalid_body).toBe(400) + expect(CARD_ERROR_STATUS.card_not_found).toBe(404) + expect(CARD_ERROR_STATUS.currency_mismatch).toBe(422) + expect(CARD_ERROR_STATUS.transition_not_allowed).toBe(409) + }) +}) + +describe("isCardStatus", () => { + it("accepts only an exact status, so a crafted body cannot slip through", () => { + expect(isCardStatus("active")).toBe(true) + expect(isCardStatus("frozen")).toBe(true) + expect(isCardStatus("cancelled")).toBe(true) + expect(isCardStatus("ACTIVE")).toBe(false) + expect(isCardStatus("deleted")).toBe(false) + expect(isCardStatus(null)).toBe(false) + expect(isCardStatus(42)).toBe(false) + expect(isCardStatus(["active"])).toBe(false) + }) +}) + +describe("canTransition", () => { + it("lets active and frozen swap", () => { + expect(canTransition("active", "frozen")).toBe(true) + expect(canTransition("frozen", "active")).toBe(true) + }) + + it("lets either one be cancelled", () => { + expect(canTransition("active", "cancelled")).toBe(true) + expect(canTransition("frozen", "cancelled")).toBe(true) + }) + + it("brings nothing back from cancelled", () => { + 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 the card already has", () => { + // A no-op that reported success would append a bogus audit entry and tell + // ops something happened when nothing did. + expect(canTransition("active", "active")).toBe(false) + expect(canTransition("frozen", "frozen")).toBe(false) + }) +}) + +describe("transitionRejection", () => { + it("returns null when the transition is allowed", () => { + expect(transitionRejection("active", "frozen")).toBeNull() + }) + + it("says cancellation is permanent rather than just refusing", () => { + const rejection = transitionRejection("cancelled", "active") + expect(rejection?.code).toBe("transition_not_allowed") + expect(rejection?.message).toContain("permanent") + }) + + it("names the current status when nothing would change", () => { + expect(transitionRejection("frozen", "frozen")?.message).toContain("frozen") + }) +}) + +describe("buildCard", () => { + it("keeps the last four and has nowhere to keep the rest", () => { + const built = buildCard( + { ...request, currency: "USD", category: "advertising" }, + { id: "card_0007", cardNumber: "4242424242421234", now }, + ) + expect(built.last4).toBe("1234") + expect(built.bin).toBe("4242") + + // The serialized record is what a list or detail response would carry. + const serialized = JSON.stringify(built) + expect(serialized).toContain("1234") + expect(serialized.includes("4242424242421234")).toBe(false) + expect(serialized.includes("424242424242")).toBe(false) + }) + + it("issues active, stamped in UTC, with one audit entry", () => { + const built = buildCard( + { ...request, currency: "USD", category: "advertising" }, + { id: "card_0007", cardNumber: "4242424242421234", now }, + ) + expect(built.status).toBe("active") + expect(built.issuedAt).toBe("2026-09-10T09:30:00.000Z") + expect(built.audit).toHaveLength(1) + expect(built.audit[0].from).toBeNull() + expect(built.audit[0].to).toBe("active") + }) +}) + +describe("applyTransition", () => { + it("records the transition without touching the card it was given", () => { + const frozen = applyTransition(card, "frozen", now) + expect(frozen.status).toBe("frozen") + expect(frozen.audit).toHaveLength(2) + expect(frozen.audit[1].from).toBe("active") + expect(frozen.audit[1].to).toBe("frozen") + expect(frozen.audit[1].at).toBe("2026-09-10T09:30:00.000Z") + + // The input is left alone, so a rejected write cannot half-apply. + expect(card.status).toBe("active") + expect(card.audit).toHaveLength(1) + }) +}) + +describe("nextCardId", () => { + it("starts the sequence and continues it from the highest id", () => { + expect(nextCardId([])).toBe("card_0001") + expect(nextCardId([card])).toBe("card_0002") + expect(nextCardId([{ ...card, id: "card_0009" }, card])).toBe("card_0010") + }) +}) + +describe("spendPercent", () => { + it("reports whole percent from integer minor units", () => { + expect(spendPercent(0, 100000)).toBe(0) + expect(spendPercent(25000, 100000)).toBe(25) + expect(spendPercent(80000, 100000)).toBe(80) + }) + + it("floors rather than rounds, so a bar never reads full early", () => { + expect(spendPercent(99999, 100000)).toBe(99) + }) + + it("caps at the limit and survives a limit of zero", () => { + expect(spendPercent(150000, 100000)).toBe(100) + // Validation rejects a zero limit, but a bar must not render NaN if one + // ever reaches it. + expect(spendPercent(1, 0)).toBe(0) + expect(spendPercent(0, 0)).toBe(0) + }) +}) + +describe("isSpendAmber", () => { + it("turns amber exactly at eighty percent, not a cent before", () => { + expect(isSpendAmber(79999, 100000)).toBe(false) + expect(isSpendAmber(80000, 100000)).toBe(true) + }) + + it("stays amber above the limit and off at a limit of zero", () => { + expect(isSpendAmber(100001, 100000)).toBe(true) + expect(isSpendAmber(0, 100000)).toBe(false) + expect(isSpendAmber(1, 0)).toBe(false) + }) + + it("agrees with the percentage it sits next to", () => { + // The bar and the figure are derived from the same integers, so they + // cannot disagree on screen. + expect(spendPercent(80000, 100000)).toBe(80) + expect(isSpendAmber(80000, 100000)).toBe(true) + expect(spendPercent(79999, 100000)).toBe(79) + expect(isSpendAmber(79999, 100000)).toBe(false) + }) +}) 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..96205097 --- /dev/null +++ b/build-battle/merchant-console/src/lib/cards.ts @@ -0,0 +1,357 @@ +import { + Card, + CARD_CATEGORIES, + CARD_STATUSES, + CardCategory, + CardStatus, + CURRENCIES, + Currency, + Merchant, +} from "@/data/types" +import { formatMoney } from "./money" + +/** + * Everything a card can decide, as pure functions. + * + * Nothing here reads the store or the clock. Randomness and time arrive as + * parameters, the way src/lib/dates.ts already takes its `now` — which is what + * lets the whole state machine be tested without a mock or a fake timer. + */ + +/** The test BIN. Every generated number starts here so none can resemble a PAN. */ +export const CARD_BIN = "4242" + +const CARD_LENGTH = 16 + +/** A card limit ceiling, in integer minor units. */ +export const CARD_LIMIT_MAX = 5_000_000 + +const NICKNAME_MAX = 64 + +/** There is no auth in this console yet, so every write is the ops user. */ +export const CARD_ACTOR = "ops" + +/** + * The Luhn check digit for a partial number. + * + * The rightmost digit of the partial sits in a doubled position, because the + * check digit takes the position to its right. + */ +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 +} + +export function isValidLuhn(cardNumber: string): boolean { + if (!new RegExp(`^\\d{${CARD_LENGTH}}$`).test(cardNumber)) return false + const partial = cardNumber.slice(0, CARD_LENGTH - 1) + const check = cardNumber.charCodeAt(CARD_LENGTH - 1) - 48 + return luhnCheckDigit(partial) === check +} + +/** + * Generate a card number on the test BIN. + * + * Server-side only: a number produced in the browser is a bug. The generator + * takes its randomness so a test can pin the output without mocking Math. + */ +export function generateCardNumber(rng: () => number = Math.random): string { + let partial = CARD_BIN + while (partial.length < CARD_LENGTH - 1) { + partial += Math.floor(rng() * 10) + } + return `${partial}${luhnCheckDigit(partial)}` +} + +/** How a card reads everywhere except the creation response. */ +export function maskCard(last4: string): string { + return `•••• ${last4}` +} + +/** Readable grouping for the one screen that shows the whole number. */ +export function groupCardNumber(cardNumber: string): string { + return cardNumber.replace(/(.{4})(?=.)/g, "$1 ") +} + +export type CardErrorCode = + | "invalid_body" + | "nickname_invalid" + | "merchant_required" + | "merchant_not_found" + | "currency_invalid" + | "currency_mismatch" + | "limit_invalid" + | "limit_too_large" + | "category_invalid" + | "card_not_found" + | "status_invalid" + | "transition_not_allowed" + +export interface CardError { + code: CardErrorCode + /** Safe to render to an ops user exactly as it is. */ + message: string + field?: string +} + +/** + * One status per error code, so a route never has to pick one at the call site. + * Record makes the table total: a new code without a status is a type error. + */ +export const CARD_ERROR_STATUS: Record = { + invalid_body: 400, + nickname_invalid: 422, + merchant_required: 422, + merchant_not_found: 422, + currency_invalid: 422, + currency_mismatch: 422, + limit_invalid: 422, + limit_too_large: 422, + category_invalid: 422, + card_not_found: 404, + status_invalid: 422, + transition_not_allowed: 409, +} + +export type Validated = + | { ok: true; value: T } + | { ok: false; error: CardError } + +export interface IssueCardInput { + merchantId: string + nickname: string + /** Integer minor units. */ + limit: number + currency: Currency + category: CardCategory +} + +function isCurrency(value: unknown): value is Currency { + return CURRENCIES.includes(value as Currency) +} + +function isCategory(value: unknown): value is CardCategory { + return CARD_CATEGORIES.includes(value as CardCategory) +} + +/** Anything arriving as a status from the client, checked exactly. */ +export function isCardStatus(value: unknown): value is CardStatus { + return CARD_STATUSES.includes(value as CardStatus) +} + +function fail( + code: CardErrorCode, + message: string, + field?: string, +): { ok: false; error: CardError } { + return { ok: false, error: { code, message, field } } +} + +/** + * Validate an issue request against the allowlists. + * + * The merchant arrives as a lookup rather than a value so this file never + * imports the store. Rejects early and returns; nothing here nests. + */ +export function validateIssueRequest( + raw: unknown, + lookupMerchant: (id: string) => Merchant | undefined, +): Validated { + if (typeof raw !== "object" || raw === null || Array.isArray(raw)) { + return fail("invalid_body", "Send a JSON body with the card details.") + } + const body = raw as Record + + const nickname = + typeof body.nickname === "string" ? body.nickname.trim() : "" + if (nickname.length === 0 || nickname.length > NICKNAME_MAX) { + return fail( + "nickname_invalid", + `Give the card a nickname of 1 to ${NICKNAME_MAX} characters.`, + "nickname", + ) + } + + const merchantId = + typeof body.merchantId === "string" ? body.merchantId.trim() : "" + if (merchantId.length === 0) { + return fail("merchant_required", "Pick a merchant.", "merchantId") + } + + const merchant = lookupMerchant(merchantId) + if (!merchant) { + return fail( + "merchant_not_found", + "That merchant does not exist.", + "merchantId", + ) + } + + if (!isCurrency(body.currency)) { + return fail( + "currency_invalid", + `Currency must be ${CURRENCIES.join(", ")}.`, + "currency", + ) + } + const currency = body.currency + + if (currency !== merchant.currency) { + return fail( + "currency_mismatch", + `${merchant.name} settles in ${merchant.currency}, so their cards must be ${merchant.currency}.`, + "currency", + ) + } + + const limit = body.limit + if (typeof limit !== "number" || !Number.isInteger(limit) || limit <= 0) { + return fail( + "limit_invalid", + "Enter a spend limit above zero, in whole minor units.", + "limit", + ) + } + if (limit > CARD_LIMIT_MAX) { + return fail( + "limit_too_large", + `A card limit cannot be more than ${formatMoney(CARD_LIMIT_MAX, currency)}.`, + "limit", + ) + } + + if (!isCategory(body.category)) { + return fail( + "category_invalid", + `Category must be one of ${CARD_CATEGORIES.join(", ")}.`, + "category", + ) + } + + return { + ok: true, + value: { merchantId, nickname, limit, currency, category: body.category }, + } +} + +/** + * The state machine. active and frozen swap freely, either can be cancelled, + * and cancelled goes nowhere: no key lists it as a destination from itself. + */ +export const CARD_TRANSITIONS: Record = { + active: ["frozen", "cancelled"], + frozen: ["active", "cancelled"], + cancelled: [], +} + +export function canTransition(from: CardStatus, to: CardStatus): boolean { + return CARD_TRANSITIONS[from].includes(to) +} + +/** The reason a transition is refused, or null when it is allowed. */ +export function transitionRejection( + from: CardStatus, + to: CardStatus, +): CardError | null { + if (canTransition(from, to)) return null + if (from === "cancelled") { + return { + code: "transition_not_allowed", + message: "This card was cancelled. Cancellation is permanent.", + field: "status", + } + } + if (from === to) { + return { + code: "transition_not_allowed", + message: `This card is already ${to}.`, + field: "status", + } + } + return { + code: "transition_not_allowed", + message: `A ${from} card cannot become ${to}.`, + field: "status", + } +} + +/** + * Build the stored record. + * + * The number goes in and only its last four come out. There is no field on + * Card that could hold the rest, so the caller cannot persist it by accident. + */ +export function buildCard( + input: IssueCardInput, + context: { id: string; cardNumber: string; now: Date }, +): Card { + const issuedAt = context.now.toISOString() + return { + id: context.id, + merchantId: input.merchantId, + nickname: input.nickname, + last4: context.cardNumber.slice(-4), + bin: CARD_BIN, + limit: input.limit, + currency: input.currency, + category: input.category, + status: "active", + issuedAt, + audit: [{ from: null, to: "active", at: issuedAt, actor: CARD_ACTOR }], + } +} + +/** A new card record at the new status. The input card is left alone. */ +export function applyTransition(card: Card, to: CardStatus, now: Date): Card { + return { + ...card, + status: to, + audit: [ + ...card.audit, + { from: card.status, to, at: now.toISOString(), actor: CARD_ACTOR }, + ], + } +} + +/** Next id in the card_0001 sequence, from whatever is already there. */ +export function nextCardId(cards: Card[]): string { + const highest = cards.reduce((max, card) => { + const suffix = Number(card.id.slice("card_".length)) + return Number.isFinite(suffix) && suffix > max ? suffix : max + }, 0) + return `card_${String(highest + 1).padStart(4, "0")}` +} + +/** + * Spend against the limit, as whole percent. + * + * Integer arithmetic on integer minor units: no division until the very last + * step, and the result is floored rather than rounded so a bar never reads + * 100% while there is still headroom. + */ +export function spendPercent(spent: number, limit: number): number { + if (limit <= 0 || spent <= 0) return 0 + if (spent >= limit) return 100 + return Math.floor((spent * 100) / limit) +} + +/** + * Past 80% of the limit. + * + * Cross-multiplied rather than `spent / limit > 0.8`, so money never touches a + * float and the boundary lands exactly where it should. + */ +export function isSpendAmber(spent: number, limit: number): boolean { + if (limit <= 0) return false + return spent * 5 >= limit * 4 +} diff --git a/docs/specs/NWP-201-issue-cards.md b/docs/specs/NWP-201-issue-cards.md new file mode 100644 index 00000000..40ec0251 --- /dev/null +++ b/docs/specs/NWP-201-issue-cards.md @@ -0,0 +1,157 @@ +# SPEC · NWP-201 — Issue virtual cards from the console + +> Written before any code. +> Load it as context when you build: `@docs/specs/NWP-201-issue-cards.md` + +**Ticket:** [NWP-201](../tickets/NWP-201.md) +**Author:** Sreejith Periyadath +**Status:** building + +## Problem + +Ops issues virtual cards by messaging the platform team, who create them by hand. It takes hours, it happens twelve to twenty times a week, and last month two cards were created with the wrong spend limit because the request lived in a Slack thread. Marcus Bell wants issuing in the console so ops can create a card, see what they have issued, and open one to check it — without a human relay in the middle and without a limit that came from a chat message. + +## Current state + +What the code does today. Every claim carries a file path. + +- `src/app/` — the console has overview, payments, disputes, and payouts. There is no `/cards` route. `build-battle/merchant-console/CLAUDE.md` states plainly that cards is NWP-201 and does not exist yet. +- `src/data/types.ts` — `Currency` is already `"USD" | "EUR" | "GBP"`. There is no `Card` type and no `CardStatus`. Field-level JSDoc appears only where a field could be misused (`/** Integer minor units. Never a float. */`, `/** ISO 8601, always UTC. */`). +- `src/data/store.ts` — the in-memory store, held on `globalThis.__northwindStore` so the dev server's module reloading does not hand every request a fresh copy. The `Store` interface carries `merchants`, `payments`, `refunds`, `disputes`, `payouts`. No `cards`. +- `src/data/generate.ts` — `rand`, `pick`, and `between` are module-private and share one mutable `mulberry32(20260813)` stream consumed inside `generate()`. A call inserted into that stream shifts every subsequent draw and silently rewrites all existing payments, refunds, disputes, and payouts. +- `src/data/queries.ts` — the query builder. `parseFilters(params: URLSearchParams)` is the allowlist idiom: it checks client input against a `readonly` constant and never throws, coercing anything unknown to a safe default. `paginate` returns exactly `{ rows, total, page, pageCount, pageSize }`. Single-record lookups (`paymentById`) return `?? null`. `PAGE_SIZE` is 20. +- `src/data/merchants.ts` — ten merchants, `mch_01` to `mch_10`, each carrying its own `currency` and IANA `timezone`. `merchantById(id)` returns `Merchant | undefined`. **There is no `category` field on a merchant.** +- `src/lib/money.ts` — `formatMoney(minorUnits, currency)`, `formatMoneyCompact`, `sumMinorUnits`, and `parseAmountToMinorUnits(input): number | null`, whose docblock names it as the boundary parser. A second parser or formatter would be a defect. +- `src/lib/dates.ts` — `formatDate(iso)`, `formatInZone(iso, timeZone)`, `utcDayKey`, `daysUntil(iso, now)`. The clock is always an injected parameter, which is why no test in the repo needs fake timers. +- `src/lib/csv.ts` — the strongest allowlist idiom in the codebase: `export const EXPORT_COLUMNS = [...] as const` with `type ExportColumn = (typeof EXPORT_COLUMNS)[number]`, and a `cell()` switch with **no `default`** so the compiler enforces exhaustiveness. +- `src/app/payments/page.tsx`, `src/app/payments/[id]/page.tsx` — pages are server components that import from `@/data/queries` directly and never fetch their own API. `params` and `searchParams` are Promises and are awaited. The detail page uses `notFound()` and a local `Field({ label, children })` over `
`/`
`/`
`. +- `src/app/payments/filter-bar.tsx` — the only client-component precedent. It uses `useRouter().push`. **Nothing in this app POSTs or mutates anything**, so there is no mutation precedent; `router.refresh()` and `useTransition` are unused. +- `src/app/api/` — only `payments/route.ts` (six lines) and `payments/export/route.ts`. Both are plain `export function GET(request: NextRequest)`, not `async`. **No route returns a non-200, and there is no shared error helper**, so the error shape this ticket defines becomes the codebase's first. +- `src/components/Drawer.tsx` — the only modal primitive, built on `@radix-ui/react-dialog`. `.claude/rules/components.md` claims a `Dialog` component exists; that claim does not match the code. There is also no `Label` component, and `grep htmlFor src/` returns zero hits. +- `src/components/ui/payments/StatusBadge.tsx` — three exhaustive `Record` maps (`LABELS`, `DOTS`, `VARIANTS`). Widening `AnyStatus` without filling all three is a compile error. +- `src/app/payments/page.tsx` — the only deliberate empty state in the app: a `TableRow` with a `colSpan` cell, a bold line, and a muted second line. No `error.tsx`, `loading.tsx`, or `not-found.tsx` exists anywhere in `src/app/`. +- `vitest.config.ts` — `environment: "node"`, `include: ["src/**/*.test.ts"]`. **`.tsx` is not collected**, and no `jsdom` or testing-library is installed, so no component test can run. Logic has to live in a `.ts` file to be testable at all. +- Where the ticket does not match the code: it asks to "store the last four and the generated number's reference", but the card `id` already is that reference — a stored derivative of the number would be an extra artifact with no consumer. It also asks for a "merchant category lock" while `src/data/merchants.ts` has no category to lock against. + +## Domain rules + +The constraints that must hold, quoted rather than paraphrased. + +| Rule | Source | What breaks if ignored | +| --- | --- | --- | +| "Money is integer minor units. `$250.00` is `25000`. No floats, no strings with currency symbols." | `merchant-console/CLAUDE.md` | Cents drift on limits and spend; the limit ceiling stops being an exact boundary | +| "Format at the edge, in the component that renders it, and nowhere else." | `.claude/rules/money.md` | A formatter's output re-enters arithmetic and the stored data becomes display text | +| "`toFixed` is a display call. If its result is stored or compared, that is a bug." | `.claude/rules/money.md` | Rounded intermediates in a spend comparison | +| "Storage and bucketing are UTC. Display converts to the merchant's timezone." | `merchant-console/CLAUDE.md` | `issuedAt` drifts by timezone and the created-date column disagrees with the record | +| "Every generated number starts `4242` and carries a valid Luhn check digit. Nothing here may resemble a real PAN, ever, including in tests and fixtures." | `.claude/rules/cards.md` | The repository contains something that looks like a real card | +| "Generate on the server. A card number produced in the browser is a bug." | `.claude/rules/cards.md` | The client controls the PAN | +| "The full number appears in the creation response and nowhere else: not on the card record, not in a list or detail payload, not left in client state after the success screen closes." | `.claude/rules/cards.md` | A PAN is persisted or re-readable; the central rule of the ticket | +| "Mask everywhere else as `•••• 4242`." | `.claude/rules/cards.md` | Inconsistent masking, and eventually a leak | +| "Status is a state machine. `active ⇄ frozen`, either to `cancelled`, and `cancelled` is terminal. Guard the transition on the server, not only in the UI." | `.claude/rules/cards.md` | A cancelled card comes back to life | +| "Validate everything from the client against an allowlist before it reaches the store, a query, or a filename. Client-side checks are a convenience, never the enforcement." | `.claude/rules/api-routes.md` | A crafted request writes a bad currency, status, or limit | +| "Return the same error shape everywhere: a status code that means what it says, and a body with a message safe to show a user. Reject early and return." | `.claude/rules/api-routes.md` | Cases go missing in nested validation and the UI has nothing to display | +| "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` | The issue form is unusable by keyboard or screen reader | +| "Write the empty and error states. A table with no rows and a request that failed both need something deliberate on screen." | `.claude/rules/components.md` | Ops sees a blank table and cannot tell whether it broke | +| "Do not add a database, an ORM, or migrations." | `merchant-console/CLAUDE.md`, ticket | Out of scope for NWP-201 and a quality failure, not a bonus | + +## Approach + +All branching card logic goes into `src/lib/cards.ts` as pure functions that take their data — merchant lookup, clock, randomness — as parameters. `src/data/cards.ts` is thin glue that hands `store.cards` to those functions and performs the `push` or the status assignment. That single split satisfies three constraints at once: everything scored is testable in a `.ts` file that vitest actually collects; no test needs to import or mutate the store, so the shared `globalThis.__northwindStore` cannot leak state across test files; and both new modules match the `src/**/card*.ts` glob so `.claude/rules/cards.md` loads whenever card code is edited. + +The reveal-once guarantee is made structural rather than disciplinary. The `Card` interface has no field capable of holding a full number, so there is nowhere to persist one and the list and detail payloads cannot leak what the type cannot hold. `issueCard()` returns `{ card, cardNumber }` as siblings; because `store.cards` is typed `Card[]`, a record leak is a compile error rather than something a reviewer has to notice. The number exists as a local in the route handler, is serialized once into the creation response, and is dropped from client state when the drawer closes. + +Server-side enforcement is the point, not a formality: every rule is checked in the route handler against a `const` allowlist, including the currency-versus-merchant check, because a card for a EUR merchant issued in GBP is the failure the ticket's Slack-thread story is really about. The form derives currency from the chosen merchant so ops cannot pick a wrong one, and the route re-verifies it because the client is not trusted. + +**Considered and rejected:** + +- *Seeding cards inside `src/data/generate.ts`.* Its `rand`/`pick`/`between` share one mutable PRNG stream, so inserting card draws would shift every subsequent value and silently rewrite all existing payment, refund, dispute, and payout data. Cards get their own module and their own seed; `generate()` is not touched. +- *Storing a hash or token of the full number as "the generated number's reference".* A hash of a 16-digit number with a known `4242` prefix and a Luhn constraint is brute-forceable in the order of 10¹¹ tries, so it is a reversible artifact of the PAN with no consumer. The card `id` is the reference. Only `last4` is stored. +- *Replaying the original creation response on a duplicate submit.* Correct REST idempotency returns the first response byte-for-byte, which would mean persisting the PAN. The card rule outranks REST convention: a replay returns the masked record and a `replayed` flag, never the number. +- *A second `Dialog` component, or a bare ``.* `src/components/Drawer.tsx` is already Radix-backed and supplies focus trapping, focus return, and Escape-to-close. Hand-rolling a modal is how the accessibility requirements get failed. +- *A separate `CardStatusBadge`.* `src/components/ui/payments/StatusBadge.tsx` is the intended reuse path; a second badge is exactly the duplicate implementation the standards penalise. +- *Fetching `/api/cards` from the list page.* Every other page in the app imports from the data layer directly. The API exists for the client mutations and for the server-side validation the ticket demands, not as an internal transport. +- *Sorting cards by spend limit.* Limits are minor units across three currencies, so a cross-currency sort produces a meaningless order. Cards are newest-first only. + +## File map + +| File | Add or change | Why | +| --- | --- | --- | +| `src/data/types.ts` | Change | `CARD_STATUSES`/`CardStatus` and `CARD_CATEGORIES`/`CardCategory` as `as const` allowlists beside the other status unions; `Card` and `CardAuditEntry` interfaces; `CardFilters` | +| `src/lib/cards.ts` | Add | All pure card logic: Luhn, `4242` generation with injected rng, masking, the transition table, validation, the spend-tone threshold | +| `src/lib/cards.test.ts` | Add | Unit tests for the above. `.ts` so vitest collects it; imports no store | +| `src/lib/http.ts` | Add | The error-shape precedent — one `apiError(status, code, message, field?)` used by every non-2xx in both new routes | +| `src/data/cards.ts` | Add | Store glue: `parseCardFilters`, `queryCards`, `cardById`, `spendForCard`, `issueCard`, `setCardStatus`. Imports `paginate` and `PAGE_SIZE` from `./queries` so there is one pagination implementation | +| `src/data/cards-seed.ts` | Add | Deterministic demo cards on their own `mulberry32` seed, covering all three statuses and all three currencies | +| `src/data/store.ts` | Change | `cards: Card[]` on the `Store` interface and in `createStore()` | +| `src/data/generate.ts` | Change | Export `mulberry32` so the seed module reuses it instead of duplicating the PRNG. `generate()` itself is untouched, so the existing stream is unshifted | +| `src/data/queries.ts` | Change | Fix `sortPayments` comparing amounts as text; unrelated to cards and landed as its own commit | +| `src/data/queries.test.ts` | Add | Proves the numeric amount order; fails before the fix | +| `src/app/api/cards/route.ts` | Add | `GET` list (masked, allowlisted filters) and `POST` issue (validated, server-generated, currency-matched) | +| `src/app/api/cards/[id]/route.ts` | Add | `GET` detail with derived spend and `PATCH` status through the guarded transition | +| `src/app/cards/page.tsx` | Add | Server component list with all six required columns and both written empty states. `force-dynamic`, or the list is prerendered at build time | +| `src/app/cards/[id]/page.tsx` | Add | Server component detail: the full record, derived spend against the limit, the spend bar, the audit trail | +| `src/app/cards/issue-card-drawer.tsx` | Add | Client Drawer form, `POST`, the one-time reveal screen, the written error state | +| `src/app/cards/card-actions.tsx` | Add | Client freeze/unfreeze via `PATCH` plus `router.refresh()`, with row-level error text | +| `src/components/ui/payments/StatusBadge.tsx` | Change | Widen `AnyStatus` with `CardStatus` and fill all three `Record` maps | +| `src/app/siteConfig.ts` | Change | `cards: "/cards"` in `baseLinks` | +| `src/components/ui/navigation/AppSidebar.tsx` | Change | The nav entry, with a lucide icon not already used by Payments | +| `src/components/ui/navigation/Breadcrumbs.tsx` | Change | `cards: "Cards"` in `LABELS`, or the breadcrumb renders the raw path segment | + +## Plan + +Sequenced so each step ends somewhere verifiable. Every step leaves `npm test` and `npm run build` green, because the pre-push hook runs both and a type error anywhere blocks the push. + +1. **Card types and the pure card library, with its tests** — done when: `npm test` proves every generated number is sixteen digits, starts `4242`, and passes an independent Luhn check; that all four legal transitions pass and every `cancelled → *` and self-transition is refused; and that validation rejects a missing merchant, `0`, `-1`, a non-integer, `5_000_001`, `"JPY"`, and a currency that disagrees with the merchant, while accepting `1` and `5_000_000`. +2. **Store wiring and seeded cards** — done when: `store.cards` is populated with cards covering `active`, `frozen`, and `cancelled` across USD, EUR, and GBP, and the existing 28 tests still pass with the generated payment data unchanged. +3. **The API** — done when: every reject path returns the documented status code and a `{ code, message }` body safe to display; `PATCH` refuses an illegal transition with 409; and `curl`ing the list and detail endpoints and grepping for a sixteen-digit number finds nothing. +4. **List page and navigation** — done when: `/cards` renders nickname, merchant, masked number, spend limit, status, and created date; is reachable from the sidebar with a correct breadcrumb; and shows written copy when there are no cards and when a filter matches nothing. +5. **The issue drawer** — done when: the form takes a nickname, merchant, spend limit, and currency; currency follows the merchant; submitting creates a card that appears in the list without a page reload; the full number appears exactly once on the success screen; closing the drawer drops it from state; and a rejected submit shows the server's own message. +6. **Card detail** — done when: the full record renders with a masked number, and spend against the limit is derived once and shown as a bar that turns amber past 80%. +7. **Freeze and unfreeze from the list** — done when: a card's status changes from the list without a full page reload, and a cancelled card offers no action at all. +8. **The `sortPayments` fix** — done when: `src/data/queries.test.ts` asserts `9000` sorts before `25000` ascending and fails against the old comparison. +9. **`/ship-ready`, then `/pr`** — done when: ship-ready reports clean, `npm test` and `npm run build` are both green, and every section of the pull request template is filled. + +## Verification + +How each acceptance criterion gets proven. + +| Acceptance criterion | How it is proven | +| --- | --- | +| Issue a card | Submit the drawer in the browser; the new card appears in the list without a manual reload. Screenshot in the PR | +| Card list at `/cards` | All six columns render for seeded and newly issued cards. Screenshot in the PR | +| Card detail | Open a card; the full record and spend-against-limit render, with the number masked | +| Generated numbers, server-side, `4242` BIN, valid Luhn | `npm test` — a property test over 200 generated numbers asserts the prefix, the length, and an independent Luhn check. Generation lives in the route handler, so the browser never produces a number | +| Reveal once, mask forever | `curl` the list and detail endpoints and grep the response for a sixteen-digit number: no match. A unit test asserts `JSON.stringify(card)` contains `last4` and not the number. Reopening the drawer after closing shows the empty form | +| Server-side validation | `curl -X POST` each reject path — missing merchant, `0`, `-1`, `5000001`, `"JPY"`, and a merchant/currency mismatch — with the status codes and messages pasted into the PR | +| State machine, guarded server-side | `npm test` on the transition table, plus `curl -X PATCH` attempting `cancelled → active` and receiving 409 | +| Money is integer minor units | `npm test` on the limit validator at the exact `5_000_000` boundary; the amber threshold is `spent * 5 >= limit * 4`, tested at `79999` and `80000` against a `100000` limit. `grep` the diff for `parseFloat`, `toFixed`, and division outside a formatter | +| Freeze and unfreeze without a reload | Click freeze in the browser; the badge changes with no document load. Confirmed in the PR as a manual check | +| Spend bar amber past 80% | `npm test` on the pure threshold function at the boundary. The bar itself is a browser check | +| Empty and error states | Browser check with an empty store and with a filter that matches nothing; the error state by pointing the drawer at a rejected submit | +| No regressions | Full `npm test` (28 existing plus new), `npm run build` clean, `/ship-ready` clean | + +## Risks + +- **The pre-push hook runs `npm test` and `npm run build`, and `next build` fails on ESLint errors.** `react/no-unescaped-entities` is severity 2, and there is currently not one apostrophe in any user-facing string in this repo. Hand-written empty-state, error, and reveal copy is the most likely thing to break the build, so every apostrophe becomes an entity or gets reworded, and `npm run build` is run before the first push attempt rather than discovered through a denied push. +- **`/cards` gets prerendered at build time.** A server page that reads `store.cards` and awaits nothing has no dynamic input, so the build bakes in the boot-time list and `router.refresh()` re-fetches the same static payload — working in `npm run dev` and broken in the build. Mitigated with `export const dynamic = "force-dynamic"`. +- **Tailwind does not scan `src/data`.** `tailwind.config.ts` globs `src/pages`, `src/components`, `src/app`, and `src/lib` only, so any class-name map in `src/data/` type-checks and renders unstyled. Class names stay out of the data layer, and the spend bar avoids a runtime-interpolated `w-[…]` entirely, which the JIT would never emit. +- **A server component passing the card number to a client component serializes it into the HTML.** The number must never cross that boundary; the drawer receives only merchant id, name, and currency, and the number arrives solely as a `fetch` response. +- **`await request.json()` is `any`,** so `body.status as CardStatus` compiles while accepting `"ACTIVE"`, `null`, or `42`. Every client value goes through a runtime guard against a `const` allowlist with exact matching. +- **The shared store under vitest.** `globalThis.__northwindStore` survives across test files because `NODE_ENV` is `"test"`, so a test that mutates `store.cards` would leak nondeterministically. No test imports the store; everything scored is pure and takes its data as a parameter. +- **The `DrawerHeader` close button submits the form.** `Button` sets no default `type` and `DrawerHeader` renders a Radix `Close asChild Button`, so a `
` wrapping the header submits when the X is clicked. The form stays inside `DrawerBody` and is submitted from the footer via `form="issue-card-form"`. +- **Time.** The ticket allows 45 minutes and this scope is larger. Mitigated by the commit order: the core criteria and the correctness rules land before any polish. + +## Out of scope + +- Persistence of any kind. No database, no ORM, no migrations — cards live until the dev server restarts. That is NWP-203, and adding it is a quality failure rather than a bonus. +- Authentication, roles, and permissions. The audit trail records a fixed ops actor because there is no identity in this console yet. +- Real card network or issuer calls. There is no issuer here. +- Editing a card's spend limit after issue — NWP-202. `PATCH` accepts a status and nothing else. +- Filters, search, pagination, or CSV export on the cards list beyond what the six required columns need. +- Card numbers are not exportable; `src/lib/csv.ts` is untouched. +- The three defects in `src/data/metrics.ts` (local-date bucketing, float accumulation, refunds added to gross volume). Real, but they move every number on the overview with no test to protect them, so they are not being fixed inside a cards ticket. + +## Open questions + +- **The category lock has nothing to lock against.** `src/data/merchants.ts` carries no `category`, so this is implemented as a `CardCategory` allowlist on the card, chosen at issue time, validated server-side, and shown on the record — rather than inventing a merchant field. That satisfies "chosen at issue time and shown on the card" without touching the merchant seed data. +- **Derived spend is honestly zero.** Spend is computed from captured payments matching the card's merchant, currency, and last four with `createdAt >= issuedAt`, because nothing in `src/data/types.ts` links a `Payment` to a card. No seeded card carries a fabricated figure, which means real derived spend is usually `$0.00` and the amber threshold is proven by unit test rather than by a screenshot. Seeding a card's `last4` to match an existing payment would be dressing up the demo, so it is not done. From 44a6dfd2bef075cbe31e7f0b2c3244f25d13989b Mon Sep 17 00:00:00 2001 From: peris611 Date: Thu, 10 Sep 2026 11:56:21 -0700 Subject: [PATCH 2/5] NWP-201: seed cards into the in-memory store Six demo cards across USD, EUR, and GBP, covering active, frozen, and cancelled, so the list, the state machine, and the detail page are all demonstrable on a fresh boot. They draw from their own mulberry32 stream. The one inside generate() is consumed in sequence while payments, refunds, disputes, and payouts are built, so drawing from it here would shift every later value and rewrite data other pages already show. src/data/cards.ts is the only place a card's status is assigned. It reuses paginate and PAGE_SIZE from queries.ts rather than adding a second pagination shape, and spend is derived from real captured payments instead of being stored as a number nobody can account for. Co-Authored-By: Claude Opus 5 --- .../merchant-console/src/data/cards-seed.ts | 107 ++++++++++++ .../merchant-console/src/data/cards.ts | 165 ++++++++++++++++++ .../merchant-console/src/data/store.ts | 6 +- 3 files changed, 276 insertions(+), 2 deletions(-) create mode 100644 build-battle/merchant-console/src/data/cards-seed.ts create mode 100644 build-battle/merchant-console/src/data/cards.ts 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 00000000..596541d7 --- /dev/null +++ b/build-battle/merchant-console/src/data/cards-seed.ts @@ -0,0 +1,107 @@ +import { + applyTransition, + buildCard, + generateCardNumber, + IssueCardInput, +} from "@/lib/cards" +import { GENERATED_AT, mulberry32 } from "./generate" +import { Card } from "./types" + +/** + * Demo cards, so the console is not empty on a fresh boot. + * + * These use their own mulberry32 stream rather than the one inside generate(). + * That stream is consumed in sequence while payments, refunds, disputes, and + * payouts are built, so drawing from it here would shift every subsequent + * value and silently rewrite data that other pages already display. + * + * The generated numbers are discarded the moment buildCard has taken their + * last four, exactly as they are in the route handler. + */ + +const SEED = 20260901 + +const DAY_MS = 86_400_000 + +interface SeedSpec extends IssueCardInput { + /** Days before the generator's anchor date that this card was issued. */ + issuedDaysAgo: number + /** Applied after issue, so the audit trail reads like it really happened. */ + freeze?: boolean + cancel?: boolean +} + +const SPECS: SeedSpec[] = [ + { + merchantId: "mch_01", + nickname: "Search ads", + limit: 250000, + currency: "USD", + category: "advertising", + issuedDaysAgo: 96, + }, + { + merchantId: "mch_01", + nickname: "Design tooling", + limit: 48000, + currency: "USD", + category: "software", + issuedDaysAgo: 74, + }, + { + merchantId: "mch_04", + nickname: "Studio travel", + limit: 180000, + currency: "GBP", + category: "travel", + issuedDaysAgo: 61, + }, + { + merchantId: "mch_05", + nickname: "Versandkosten", + limit: 320000, + currency: "EUR", + category: "fulfillment", + issuedDaysAgo: 45, + freeze: true, + }, + { + merchantId: "mch_09", + nickname: "Contractor tools", + limit: 90000, + currency: "GBP", + category: "software", + issuedDaysAgo: 30, + }, + { + merchantId: "mch_07", + nickname: "Old utilities card", + limit: 60000, + currency: "USD", + category: "utilities", + issuedDaysAgo: 21, + cancel: true, + }, +] + +export function seedCards(): Card[] { + const rng = mulberry32(SEED) + + return SPECS.map((spec, index) => { + const issuedAt = new Date( + GENERATED_AT.getTime() - spec.issuedDaysAgo * DAY_MS, + ) + const card = buildCard(spec, { + id: `card_${String(index + 1).padStart(4, "0")}`, + cardNumber: generateCardNumber(rng), + now: issuedAt, + }) + + // A status change happens later than the issue it followed, so the audit + // trail is ordered rather than stamped all at once. + const changedAt = new Date(issuedAt.getTime() + 7 * DAY_MS) + if (spec.freeze) return applyTransition(card, "frozen", changedAt) + if (spec.cancel) return applyTransition(card, "cancelled", changedAt) + return card + }) +} 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..57d125a6 --- /dev/null +++ b/build-battle/merchant-console/src/data/cards.ts @@ -0,0 +1,165 @@ +import { + applyTransition, + buildCard, + CardError, + generateCardNumber, + IssueCardInput, + nextCardId, + transitionRejection, + Validated, +} from "@/lib/cards" +import { sumMinorUnits } from "@/lib/money" +import { merchantById } from "./merchants" +import { paginate, PAGE_SIZE } from "./queries" +import { store } from "./store" +import { Card, CARD_STATUSES, CardFilters, CardStatus } from "./types" + +/** + * The store side of cards. + * + * Every decision this module needs already exists in src/lib/cards.ts. What is + * left here is reading store.cards, handing it to those functions, and writing + * the result back — so there is one place a card's status is ever assigned. + */ + +const STATUSES: readonly (CardStatus | "all")[] = ["all", ...CARD_STATUSES] + +/** + * Anything from the client is checked against an allowlist before it reaches + * a query. Route handlers call this rather than reading params themselves. + */ +export function parseCardFilters(params: URLSearchParams): CardFilters { + const status = params.get("status") + const merchantId = params.get("merchantId") + const page = Number(params.get("page") ?? "1") + + return { + status: STATUSES.includes(status as CardStatus) + ? (status as CardStatus) + : "all", + // Unlike a free-text search, an unknown merchant is dropped rather than + // passed through: it can only ever match nothing. + merchantId: merchantId && merchantById(merchantId) ? merchantId : undefined, + search: params.get("search") ?? undefined, + page: Number.isFinite(page) && page > 0 ? page : 1, + } +} + +export function filterCards(filters: CardFilters): Card[] { + const search = filters.search?.trim().toLowerCase() + + return store.cards.filter((card) => { + if (filters.status && filters.status !== "all") { + if (card.status !== filters.status) return false + } + if (filters.merchantId && card.merchantId !== filters.merchantId) { + return false + } + if (search) { + const merchant = merchantById(card.merchantId) + const haystack = [card.id, card.nickname, card.last4, merchant?.name ?? ""] + .join(" ") + .toLowerCase() + if (!haystack.includes(search)) return false + } + return true + }) +} + +/** + * Newest first, always. + * + * There is deliberately no sort by limit: limits are minor units across three + * currencies, so ordering them against each other would be meaningless. + */ +export function sortCards(cards: Card[]): Card[] { + return [...cards].sort((a, b) => b.issuedAt.localeCompare(a.issuedAt)) +} + +/** Filter, sort, and paginate in one call, on the shape queries.ts returns. */ +export function queryCards(filters: CardFilters) { + return paginate( + sortCards(filterCards(filters)), + filters.page, + filters.pageSize ?? PAGE_SIZE, + ) +} + +export function cardById(id: string) { + return store.cards.find((c) => c.id === id) ?? null +} + +/** + * What has been spent against a card. + * + * Nothing in the store links a payment to a card, so rather than invent a + * figure this derives one: captured payments for the card's merchant, in the + * card's currency, on the card's last four, taken after it was issued. In + * practice that is usually nothing, and a card that has spent nothing should + * say so rather than show a number somebody made up. + */ +export function spendForCard(card: Card): number { + const amounts = store.payments + .filter( + (payment) => + payment.merchantId === card.merchantId && + payment.currency === card.currency && + payment.last4 === card.last4 && + payment.status === "captured" && + payment.createdAt >= card.issuedAt, + ) + .map((payment) => payment.amount) + + return sumMinorUnits(amounts) +} + +/** + * Mint a card. + * + * The number is generated here, on the server, and returned alongside the + * record rather than on it. It is the caller's only chance to see it: nothing + * writes it anywhere, and Card has no field that could hold it. + */ +export function issueCard( + input: IssueCardInput, + now = new Date(), +): { card: Card; cardNumber: string } { + const cardNumber = generateCardNumber() + const card = buildCard(input, { + id: nextCardId(store.cards), + cardNumber, + now, + }) + store.cards.push(card) + return { card, cardNumber } +} + +/** + * Move a card through the state machine. + * + * The guard is the enforcement, not the UI. Read, check, and write happen with + * no await between them, so two requests cannot interleave on Node's single + * thread and both see the same starting status. + */ +export function setCardStatus( + id: string, + to: CardStatus, + now = new Date(), +): Validated { + const index = store.cards.findIndex((c) => c.id === id) + if (index === -1) { + const error: CardError = { + code: "card_not_found", + message: "No card with that id.", + } + return { ok: false, error } + } + + const current = store.cards[index] + const rejection = transitionRejection(current.status, to) + if (rejection) return { ok: false, error: rejection } + + const updated = applyTransition(current, to, now) + store.cards[index] = updated + return { ok: true, value: updated } +} diff --git a/build-battle/merchant-console/src/data/store.ts b/build-battle/merchant-console/src/data/store.ts index ba71d950..15fec2fa 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() From af00702637960bae14fd9d9400d53d5b409e1003 Mon Sep 17 00:00:00 2001 From: peris611 Date: Thu, 10 Sep 2026 11:58:56 -0700 Subject: [PATCH 3/5] NWP-201: add the cards API POST generates the number on the server, returns it once alongside the record, and stores only the last four. GET list and GET detail serialize a Card, which has no field for a full number, so neither can leak one. PATCH takes a status and nothing else: changing a limit after issue is NWP-202. Validation is the enforcement, not the form. Every value from the client goes through an allowlist, including a card's currency against its own merchant's, which nothing in the console checked before. src/lib/http.ts sets the error shape this codebase did not have: a status code that means what it says, a machine code, and a message safe to show an ops user. Co-Authored-By: Claude Opus 5 --- .../src/app/api/cards/[id]/route.ts | 58 +++++++++++++++++++ .../src/app/api/cards/route.ts | 47 +++++++++++++++ build-battle/merchant-console/src/lib/http.ts | 26 +++++++++ 3 files changed, 131 insertions(+) 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/lib/http.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..79b7d11d --- /dev/null +++ b/build-battle/merchant-console/src/app/api/cards/[id]/route.ts @@ -0,0 +1,58 @@ +import { cardById, setCardStatus, spendForCard } from "@/data/cards" +import { CARD_STATUSES } from "@/data/types" +import { CARD_ERROR_STATUS, isCardStatus } from "@/lib/cards" +import { apiError } from "@/lib/http" +import { NextRequest, NextResponse } from "next/server" + +interface RouteContext { + params: Promise<{ id: string }> +} + +/** One card and what it has spent. Masked, like every read of a card. */ +export async function GET(request: NextRequest, context: RouteContext) { + const { id } = await context.params + const card = cardById(id) + if (!card) return apiError(404, "card_not_found", "No card with that id.") + + return NextResponse.json({ card, spend: spendForCard(card) }) +} + +/** + * Moves a card through the state machine. + * + * Status is the only thing a card exposes to a write. Changing a limit after + * issue is NWP-202, and nothing here will do it. + */ +export async function PATCH(request: NextRequest, context: RouteContext) { + const { id } = await context.params + + let body: unknown + try { + body = await request.json() + } catch { + return apiError(400, "invalid_body", "Send a JSON body with a status.") + } + + if (typeof body !== "object" || body === null || Array.isArray(body)) { + return apiError(400, "invalid_body", "Send a JSON body with a status.") + } + + // request.json() is `any`, so a cast here would accept "ACTIVE", null, or 42. + const status = (body as Record).status + if (!isCardStatus(status)) { + return apiError( + 422, + "status_invalid", + `Status must be one of ${CARD_STATUSES.join(", ")}.`, + "status", + ) + } + + const result = setCardStatus(id, status) + if (!result.ok) { + const { code, message, field } = result.error + return apiError(CARD_ERROR_STATUS[code], code, message, field) + } + + return NextResponse.json({ card: result.value }) +} 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..a389fe2e --- /dev/null +++ b/build-battle/merchant-console/src/app/api/cards/route.ts @@ -0,0 +1,47 @@ +import { issueCard, parseCardFilters, queryCards } from "@/data/cards" +import { merchantById } from "@/data/merchants" +import { CARD_ERROR_STATUS, validateIssueRequest } from "@/lib/cards" +import { apiError } from "@/lib/http" +import { NextRequest, NextResponse } from "next/server" + +/** + * Lists issued cards. + * + * Returns records, and a record has no field for a full number, so this + * response cannot carry one however it is filtered. + */ +export function GET(request: NextRequest) { + const filters = parseCardFilters(request.nextUrl.searchParams) + return NextResponse.json(queryCards(filters)) +} + +/** + * Issues a card. + * + * The only response in this application that contains a full card number. It + * is generated here, on the server, handed back once, and never written down: + * the stored record keeps the last four and nothing else. + */ +export async function POST(request: NextRequest) { + let body: unknown + try { + body = await request.json() + } catch { + return apiError( + 400, + "invalid_body", + "Send a JSON body with the card details.", + ) + } + + // The client's own checks are a convenience. This is the enforcement. + const validated = validateIssueRequest(body, merchantById) + if (!validated.ok) { + const { code, message, field } = validated.error + return apiError(CARD_ERROR_STATUS[code], code, message, field) + } + + const { card, cardNumber } = issueCard(validated.value) + + return NextResponse.json({ card, cardNumber }, { status: 201 }) +} diff --git a/build-battle/merchant-console/src/lib/http.ts b/build-battle/merchant-console/src/lib/http.ts new file mode 100644 index 00000000..a89dc3a9 --- /dev/null +++ b/build-battle/merchant-console/src/lib/http.ts @@ -0,0 +1,26 @@ +import { NextResponse } from "next/server" + +/** + * The one error shape every route handler returns. + * + * Nothing in this console returned a non-200 before cards, so this is the + * precedent: a status code that means what it says, a machine-readable code, + * and a message that is safe to put straight in front of an ops user. + */ +export interface ApiErrorBody { + code: string + message: string + /** The field to attach the message to, when one input is at fault. */ + field?: string +} + +export function apiError( + status: number, + code: string, + message: string, + field?: string, +): NextResponse { + return NextResponse.json(field ? { code, message, field } : { code, message }, { + status, + }) +} From c2de0131e3e2ac9f69fa2b0319e6aa8aac1d4c12 Mon Sep 17 00:00:00 2001 From: peris611 Date: Thu, 10 Sep 2026 12:10:07 -0700 Subject: [PATCH 4/5] NWP-201: add the cards console A /cards list with the six columns the ticket asks for, a detail page with the record and its spend against the limit, and a drawer that issues a card and shows its number once. The reveal lives only in the drawer's own state. Closing it drops the number, and the list refresh is deferred until that close: refreshing while the success screen is open re-renders the tree the drawer sits in and takes the number down with it before anyone can copy it. Both pages are force-dynamic. Cards change while the server is up, so a prerendered copy would serve the boot-time list forever and refresh would keep handing back the same payload. StatusBadge is widened rather than duplicated. Freeze and unfreeze go through the guarded PATCH and refresh in place; a cancelled card offers nothing, because nothing comes back from cancelled. Co-Authored-By: Claude Opus 5 --- .../src/app/cards/[id]/page.tsx | 172 +++++++++ .../src/app/cards/card-actions.tsx | 76 ++++ .../src/app/cards/issue-card-drawer.tsx | 349 ++++++++++++++++++ .../merchant-console/src/app/cards/page.tsx | 128 +++++++ .../merchant-console/src/app/siteConfig.ts | 1 + .../components/ui/navigation/AppSidebar.tsx | 8 +- .../components/ui/navigation/Breadcrumbs.tsx | 1 + .../components/ui/payments/StatusBadge.tsx | 18 +- 8 files changed, 750 insertions(+), 3 deletions(-) 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-drawer.tsx create mode 100644 build-battle/merchant-console/src/app/cards/page.tsx 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..a140845f --- /dev/null +++ b/build-battle/merchant-console/src/app/cards/[id]/page.tsx @@ -0,0 +1,172 @@ +import { Divider } from "@/components/Divider" +import { StatusBadge } from "@/components/ui/payments/StatusBadge" +import { cardById, spendForCard } from "@/data/cards" +import { merchantById } from "@/data/merchants" +import { isSpendAmber, maskCard, spendPercent } from "@/lib/cards" +import { formatInZone } from "@/lib/dates" +import { formatMoney } from "@/lib/money" +import { cx } from "@/lib/utils" +import Link from "next/link" +import { notFound } from "next/navigation" +import { CardActions } from "../card-actions" + +export const dynamic = "force-dynamic" + +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 spend = spendForCard(card) + // Derived once, from the same two integers the bar is drawn from, so the + // figure and the bar cannot disagree on screen. + const percent = spendPercent(spend, card.limit) + const amber = isSpendAmber(spend, card.limit) + const remaining = Math.max(0, card.limit - spend) + + return ( +
+ + ← All cards + + +
+

+ {card.nickname} +

+ +
+

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

+ + + +
+ + {merchant.name} + {merchant.country} + + + {maskCard(card.last4)} + + + + {formatMoney(card.limit, card.currency)} + + + {card.currency} + + {card.category} + + + {card.issuedAt} + + + {formatInZone(card.issuedAt, merchant.timezone)} + + + + +
+ + + +

+ Spend against limit +

+
+
+

+ {formatMoney(spend, card.currency)} +

+

+ of {formatMoney(card.limit, card.currency)} +

+
+ + {percent}% + +

+ {percent}% used · {formatMoney(remaining, card.currency)} remaining + {amber ? " · close to the limit" : null} +

+ {spend === 0 && ( +

+ Nothing has been spent on this card yet. +

+ )} +
+ + + +

+ History +

+
    + {card.audit.map((entry, index) => ( +
  1. +
  2. + ))} +
+
+ ) +} + +function Field({ + label, + children, + className, +}: { + label: string + children: React.ReactNode + className?: string +}) { + 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..ecfe1717 --- /dev/null +++ b/build-battle/merchant-console/src/app/cards/card-actions.tsx @@ -0,0 +1,76 @@ +"use client" + +import { Button } from "@/components/Button" +import { CardStatus } from "@/data/types" +import { useRouter } from "next/navigation" +import { useState, useTransition } from "react" + +/** + * Freeze and unfreeze from the list. + * + * The server owns the state machine; this only ever asks. A refused + * transition is shown rather than swallowed, because the reason a card cannot + * move is exactly what ops needs to read. + */ +export function CardActions({ + cardId, + status, +}: { + cardId: string + status: CardStatus +}) { + const router = useRouter() + const [isRefreshing, startTransition] = useTransition() + const [isSending, setIsSending] = useState(false) + const [error, setError] = useState(null) + const pending = isSending || isRefreshing + + // Nothing comes back from cancelled, so there is nothing to offer. + if (status === "cancelled") { + return No actions + } + + const next: CardStatus = status === "active" ? "frozen" : "active" + + async function move() { + setIsSending(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() + setError(body.message ?? "That change was refused.") + return + } + // Re-render the server list in place: no document load, no lost scroll. + startTransition(() => router.refresh()) + } catch { + setError("Could not reach the server. Try again.") + } finally { + setIsSending(false) + } + } + + return ( +
+ + {error && ( +

+ {error} +

+ )} +
+ ) +} diff --git a/build-battle/merchant-console/src/app/cards/issue-card-drawer.tsx b/build-battle/merchant-console/src/app/cards/issue-card-drawer.tsx new file mode 100644 index 00000000..dff912bf --- /dev/null +++ b/build-battle/merchant-console/src/app/cards/issue-card-drawer.tsx @@ -0,0 +1,349 @@ +"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 { Card, CARD_CATEGORIES, CardCategory, Currency } from "@/data/types" +import { groupCardNumber } from "@/lib/cards" +import { formatMoney, parseAmountToMinorUnits } from "@/lib/money" +import { Plus } from "lucide-react" +import { useRouter } from "next/navigation" +import { useEffect, useRef, useState, useTransition } from "react" + +interface MerchantOption { + id: string + name: string + currency: Currency +} + +type Step = + | { kind: "form" } + | { kind: "issued"; card: Card; cardNumber: string } + +const FORM_ID = "issue-card-form" + +const CATEGORY_LABELS: Record = { + advertising: "Advertising", + software: "Software", + travel: "Travel", + fulfillment: "Fulfillment", + utilities: "Utilities", +} + +/** + * Issue a card, and show its number exactly once. + * + * The number arrives in the creation response and lives in this component for + * as long as the success screen is open. Closing the drawer drops it, and + * nothing else in the application can ask for it again. + */ +export function IssueCardDrawer({ merchants }: { merchants: MerchantOption[] }) { + const router = useRouter() + const [isRefreshing, startTransition] = useTransition() + const [open, setOpen] = useState(false) + const [step, setStep] = useState({ kind: "form" }) + + const [merchantId, setMerchantId] = useState(merchants[0]?.id ?? "") + const [nickname, setNickname] = useState("") + const [amount, setAmount] = useState("") + const [category, setCategory] = useState(CARD_CATEGORIES[0]) + const [isSending, setIsSending] = useState(false) + const [error, setError] = useState(null) + const [errorField, setErrorField] = useState(null) + + const revealRef = useRef(null) + const pending = isSending || isRefreshing + + // Currency follows the merchant. The server checks it again anyway, because + // a card that settles in the wrong currency is nobody's idea of a card. + const merchant = merchants.find((m) => m.id === merchantId) + const currency = merchant?.currency + + useEffect(() => { + if (step.kind === "issued") revealRef.current?.focus() + }, [step]) + + function reset() { + setStep({ kind: "form" }) + setNickname("") + setAmount("") + setCategory(CARD_CATEGORIES[0]) + setError(null) + setErrorField(null) + } + + function handleOpenChange(next: boolean) { + setOpen(next) + if (next) return + + // Refreshing the list re-renders the tree this drawer lives in, which + // takes the success screen down with it. So the refresh waits until the + // drawer is closed: ops gets to read the number, and the new card is in + // the list the moment they look back at it. + const issued = step.kind === "issued" + // Dropping the number is the point: it must not survive the drawer. + reset() + if (issued) startTransition(() => router.refresh()) + } + + async function submit(event: React.FormEvent) { + event.preventDefault() + setError(null) + setErrorField(null) + + const limit = parseAmountToMinorUnits(amount) + if (limit === null) { + setError("Enter a spend limit like 250 or 250.00.") + setErrorField("limit") + return + } + + setIsSending(true) + try { + const response = await fetch("/api/cards", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + merchantId, + nickname, + limit, + currency, + category, + }), + }) + const body = await response.json() + if (!response.ok) { + setError(body.message ?? "That card could not be issued.") + setErrorField(body.field ?? null) + return + } + setStep({ kind: "issued", card: body.card, cardNumber: body.cardNumber }) + } catch { + setError("Could not reach the server. Nothing was issued.") + } finally { + setIsSending(false) + } + } + + return ( + + + + + + + + + {step.kind === "form" ? "Issue a virtual card" : "Card issued"} + + + {step.kind === "form" + ? "Single merchant, virtual, and limited from the moment it exists." + : "Copy the number now. This is the only time it is shown."} + + + + {step.kind === "form" ? ( + <> + + +
+ + setNickname(e.target.value)} + placeholder="Vendor subscriptions" + autoComplete="off" + hasError={errorField === "nickname"} + className="mt-2" + required + /> +
+ +
+ + Merchant + + +
+ +
+ + setAmount(e.target.value)} + placeholder="250.00" + autoComplete="off" + hasError={errorField === "limit"} + aria-describedby="card-limit-hint" + className="mt-2" + required + /> +

+ Currency follows the merchant. Maximum{" "} + {formatMoney(5_000_000, currency ?? "USD")}. +

+
+ +
+ + Category lock + + +
+ + {error && ( +

+ {error} +

+ )} + +
+ + + + + ) : ( + <> + +
+
+

+ Full card number +

+

+ {groupCardNumber(step.cardNumber)} +

+

+ Shown once. Everywhere else this card reads {"••••"}{" "} + {step.card.last4}. +

+
+ +
+
+
Nickname
+
+ {step.card.nickname} +
+
+
+
Spend limit
+
+ {formatMoney(step.card.limit, step.card.currency)} +
+
+
+
+
+ + + + + + )} +
+
+ ) +} 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..7a95d589 --- /dev/null +++ b/build-battle/merchant-console/src/app/cards/page.tsx @@ -0,0 +1,128 @@ +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeaderCell, + TableRoot, + TableRow, +} from "@/components/Table" +import { StatusBadge } from "@/components/ui/payments/StatusBadge" +import { parseCardFilters, queryCards } from "@/data/cards" +import { merchantById, merchants } from "@/data/merchants" +import { 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 { IssueCardDrawer } from "./issue-card-drawer" + +/** + * Cards live in memory and change while the server is up, so this page cannot + * be prerendered at build time: a static copy would show the boot-time list + * forever and router.refresh would keep handing back the same payload. + */ +export const dynamic = "force-dynamic" + +const COLUMN_COUNT = 7 + +export default async function CardsPage({ + searchParams, +}: { + searchParams: Promise> +}) { + const params = await searchParams + const query = new URLSearchParams( + Object.entries(params).filter(([, v]) => Boolean(v)) as [string, string][], + ) + const filters = parseCardFilters(query) + const { rows, total } = queryCards(filters) + const filtered = query.toString().length > 0 + + return ( +
+
+
+

+ Virtual cards +

+

+ {total.toLocaleString()} issued +

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

+ {filtered + ? "No cards match these filters" + : "No cards issued yet"} +

+

+ {filtered + ? "Clear the search or pick a different status." + : "Issue one and it will appear here straight away."} +

+
+
+ )} + {rows.map((card) => { + const merchant = merchantById(card.merchantId) + return ( + + + + {card.nickname} + + + {merchant?.name} + + {maskCard(card.last4)} + + + {formatMoney(card.limit, card.currency)} + + + + + {formatDate(card.issuedAt)} + + + + + ) + })} +
+
+
+
+ ) +} 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..7bb21b65 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, WalletCards } from "lucide-react" import * as React from "react" import { Logo } from "../../../../public/Logo" import { UserProfile } from "./UserProfile" @@ -48,6 +48,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..6880e490 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,6 +40,9 @@ 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 = { @@ -47,6 +58,9 @@ const VARIANTS: Record Date: Thu, 10 Sep 2026 21:41:31 -0700 Subject: [PATCH 5/5] NWP-201: sort payments by amount numerically sortPayments compared amounts with String(a).localeCompare(String(b)), so the payments table sorted them as text: 9000 came above 25000 because "9" beats "2", and the largest payment landed in the middle of the list. Support reads that table top-down when a merchant asks about their biggest charge, so the order was the wrong answer. Amounts are integer minor units and compare as numbers. The new test in queries.test.ts fails against the old comparison, returning [100000, 25000, 700, 9000]. Found while reading the query builder for the cards list, which reuses paginate from this module. Co-Authored-By: Claude Opus 5 --- .../merchant-console/src/data/queries.test.ts | 76 +++++++++++++++++++ .../merchant-console/src/data/queries.ts | 5 +- 2 files changed, 79 insertions(+), 2 deletions(-) create mode 100644 build-battle/merchant-console/src/data/queries.test.ts 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 00000000..95c60f53 --- /dev/null +++ b/build-battle/merchant-console/src/data/queries.test.ts @@ -0,0 +1,76 @@ +import { Payment } from "@/data/types" +import { describe, expect, it } from "vitest" +import { sortPayments } from "./queries" + +/** + * Sorting the payments table by amount put the biggest payment in the middle + * of the list, because the comparison ran on the text of the number rather + * than the number: "9000" sorts above "25000" the way "b" sorts above "a". + * Support reads that table top-down when a merchant asks about their largest + * charge, so the order is the answer. + * + * These fixtures are built here rather than read from the store, which is + * cached on globalThis for the life of a vitest run. + */ + +const payment: Payment = { + id: "pay_0001", + merchantId: "mch_01", + amount: 25000, + currency: "USD", + status: "captured", + method: "card", + cardBrand: "visa", + last4: "4242", + createdAt: "2026-03-14T10:15:00.000Z", + description: "Subscription", +} + +const amounts = (rows: Payment[]) => rows.map((row) => row.amount) + +describe("sortPayments", () => { + it("orders amounts by value, not by their leading digit", () => { + const rows = [ + { ...payment, id: "pay_0001", amount: 25000 }, + { ...payment, id: "pay_0002", amount: 9000 }, + { ...payment, id: "pay_0003", amount: 100000 }, + { ...payment, id: "pay_0004", amount: 700 }, + ] + + // Sorted as text this reads 100000, 25000, 700, 9000. + expect(amounts(sortPayments(rows, "amount", "asc"))).toEqual([ + 700, 9000, 25000, 100000, + ]) + expect(amounts(sortPayments(rows, "amount", "desc"))).toEqual([ + 100000, 25000, 9000, 700, + ]) + }) + + it("still orders by date newest first by default", () => { + const rows = [ + { ...payment, id: "pay_0001", createdAt: "2026-03-14T10:15:00.000Z" }, + { ...payment, id: "pay_0002", createdAt: "2026-03-16T09:00:00.000Z" }, + { ...payment, id: "pay_0003", createdAt: "2026-03-15T23:59:00.000Z" }, + ] + + expect(sortPayments(rows).map((row) => row.id)).toEqual([ + "pay_0002", + "pay_0003", + "pay_0001", + ]) + expect(sortPayments(rows, "createdAt", "asc").map((row) => row.id)).toEqual([ + "pay_0001", + "pay_0003", + "pay_0002", + ]) + }) + + it("leaves the array it was given alone", () => { + const rows = [ + { ...payment, amount: 25000 }, + { ...payment, amount: 9000 }, + ] + sortPayments(rows, "amount", "asc") + expect(amounts(rows)).toEqual([25000, 9000]) + }) +}) diff --git a/build-battle/merchant-console/src/data/queries.ts b/build-battle/merchant-console/src/data/queries.ts index cc4ca009..7f740256 100644 --- a/build-battle/merchant-console/src/data/queries.ts +++ b/build-battle/merchant-console/src/data/queries.ts @@ -77,8 +77,9 @@ 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 they compare as numbers. Comparing + // them as text sorted 9000 above 25000, because "9" beats "2". + return (a.amount - b.amount) * factor } return a.createdAt.localeCompare(b.createdAt) * factor })