From 5a0cae582ff45cfa04b11e45ad2913b4f55c140d Mon Sep 17 00:00:00 2001 From: Krishna Koushik Date: Tue, 22 Sep 2026 11:33:08 -0400 Subject: [PATCH 1/6] NWP-201: issue virtual cards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ops issued virtual cards by messaging the platform team, who created them by hand. This puts it in the console: issue a card, list what has been issued, open one to check it. Domain logic is split so the client can share it safely: - src/lib/cards.ts is isomorphic — Luhn, the 4242 test BIN, masking, and the transition table. The freeze/unfreeze controls are a client component and import the transition table from here, so this module never touches node:crypto or the store. Randomness arrives as an injected DigitSource, the same way src/lib/dates.ts takes `now`. - src/data/cards.ts is server-only — id allocation, validation, issueCard, setCardStatus, and the card queries. It reuses paginate from queries.ts unchanged rather than reimplementing pagination. Luhn generation and validation use deliberately different doubling parities; both are covered by a 500-iteration round-trip property test plus vectors for the three classic off-by-one bugs. Reveal-once is structural rather than filtered: the Card type has no number field at all, so every payload is safe by construction and no mapper is needed. issueCard returns { card, cardNumber } as siblings and the idempotent replay path deliberately omits the number. Status is guarded on the server. canTransition is strict — a no-op is not a transition — and the route short-circuits a same-status PATCH to a 200 before consulting it, so a double-click is harmless. The PATCH body accepts `status` only; accepting spendLimit would build NWP-202 by accident. Also adds four subagent definitions under .claude/agents/ used to build this in parallel against a frozen type contract. Co-Authored-By: Claude Opus 5 --- .claude/agents/card-data-api.md | 149 +++++ .claude/agents/card-domain.md | 107 ++++ .claude/agents/card-reviewer.md | 68 +++ .claude/agents/card-ui.md | 164 ++++++ .../merchant-console/.claude/launch.json | 11 + .../merchant-console/package-lock.json | 4 +- .../src/app/api/cards/[id]/route.ts | 60 ++ .../src/app/api/cards/route.ts | 44 ++ .../src/app/cards/[id]/not-found.tsx | 18 + .../src/app/cards/[id]/page.tsx | 201 +++++++ .../src/app/cards/card-status-actions.tsx | 104 ++++ .../merchant-console/src/app/cards/error.tsx | 30 + .../src/app/cards/filter-bar.tsx | 94 +++ .../src/app/cards/issue-card-drawer.tsx | 533 ++++++++++++++++++ .../merchant-console/src/app/cards/page.tsx | 175 ++++++ .../merchant-console/src/app/siteConfig.ts | 1 + .../components/ui/navigation/AppSidebar.tsx | 8 +- .../components/ui/navigation/Breadcrumbs.tsx | 1 + .../components/ui/payments/StatusBadge.tsx | 13 +- .../merchant-console/src/data/cards.test.ts | 248 ++++++++ .../merchant-console/src/data/cards.ts | 262 +++++++++ .../merchant-console/src/data/generate.ts | 159 +++++- .../merchant-console/src/data/store.ts | 15 +- .../merchant-console/src/data/types.ts | 57 ++ build-battle/merchant-console/src/lib/api.ts | 22 + .../merchant-console/src/lib/cards.test.ts | 148 +++++ .../merchant-console/src/lib/cards.ts | 138 +++++ .../merchant-console/src/lib/money.test.ts | 17 + .../merchant-console/src/lib/money.ts | 15 + docs/specs/NWP-201-agent-context.md | 47 ++ 30 files changed, 2904 insertions(+), 9 deletions(-) create mode 100644 .claude/agents/card-data-api.md create mode 100644 .claude/agents/card-domain.md create mode 100644 .claude/agents/card-reviewer.md create mode 100644 .claude/agents/card-ui.md create mode 100644 build-battle/merchant-console/.claude/launch.json 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/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-status-actions.tsx create mode 100644 build-battle/merchant-console/src/app/cards/error.tsx create mode 100644 build-battle/merchant-console/src/app/cards/filter-bar.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 create mode 100644 build-battle/merchant-console/src/data/cards.test.ts create mode 100644 build-battle/merchant-console/src/data/cards.ts create mode 100644 build-battle/merchant-console/src/lib/api.ts 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-agent-context.md diff --git a/.claude/agents/card-data-api.md b/.claude/agents/card-data-api.md new file mode 100644 index 00000000..257214d8 --- /dev/null +++ b/.claude/agents/card-data-api.md @@ -0,0 +1,149 @@ +--- +name: card-data-api +description: Server-side card data and API routes for NWP-201 — the store collection, seeded cards, the validator, issueCard/setCardStatus, and the POST/PATCH route handlers. +tools: Read, Write, Edit, Grep, Glob, Bash +model: sonnet +effort: high +--- + +You own the server-side card data layer and API for ticket NWP-201. + +## Files you own — and the only ones you may write + +- `src/data/cards.ts` (new) — the entity module +- `src/data/cards.test.ts` (new) +- `src/data/store.ts` (modify — add the collection) +- `src/data/generate.ts` (modify — export `pad`, add `generateCards()`) +- `src/app/api/cards/route.ts` (new — POST) +- `src/app/api/cards/[id]/route.ts` (new — PATCH) + +**Do not edit** `src/data/types.ts` (frozen), `src/data/queries.ts` (import from it, never change it), +`src/lib/**` (another agent owns it), or anything under `src/app/cards/` or `src/components/`. + +## Read first + +`src/data/types.ts` for `Card`, `CardStatus`, `CardCategory`, `CardEvent`, `CardFilters` — that is the frozen +contract. `src/lib/cards.ts` for the domain helpers you must use rather than reimplement. + +## `src/data/cards.ts` — server only + +This module may use `node:crypto`. It is never imported by a client component. Export: + +```ts +export function cardById(id: string): Card | null +export function queryCards(filters: CardFilters) // returns paginate()'s shape +export function validateIssueCard(body: unknown): ValidateResult +export function issueCard(input: IssueCardInput, now: Date): { card: Card; cardNumber: string } | { card: Card; replayed: true } +export function setCardStatus(id: string, next: CardStatus, now: Date): SetStatusResult + +export type SetStatusResult = + | { ok: true; card: Card } + | { ok: false; reason: "not_found" } + | { ok: false; reason: "invalid_transition"; card: Card } +``` + +**Import `paginate` and `PAGE_SIZE` from `./queries` and reuse them unchanged.** Do not reimplement pagination. +Do NOT reuse `filterPayments`/`sortPayments` — they are `Payment`-typed on every line. Sort cards newest-first +with `b.createdAt.localeCompare(a.createdAt)`; ISO-8601 UTC strings sort lexicographically, which is why that is +correct here. If you ever sort a numeric field, subtract — never `String(x).localeCompare(...)`. + +### The id allocator — derive from the store, never a module-level counter + +A module counter resets to 1 on hot reload and collides with a live card. Read the highest existing suffix off +`store.cards` and `pad(max + 1, 4)` (width 4, matching `po_0001`). **Export the existing `pad`** from +`src/data/generate.ts:55` — a one-word diff — rather than declaring a second one. + +### Validation — the house idiom is WRONG here + +`parseFilters` in `queries.ts` uses allowlist + *silent fallback to a default*. That is right for a GET filter and +**wrong for a POST that must reject** — copied, it coerces `"JPY"` to `"USD"` and returns 201. + +Reject, with `400` and `field` set, on every one of: +- missing `merchantId`; a well-formed but nonexistent id (`merchantById` returns undefined) — still `400`, + not `404`, because the collection exists and it is the body field that is wrong +- `spendLimit` that is not an integer, `<= 0`, or `> 5_000_000`. Note **"above 5,000,000" means `>`** — + `5_000_000` exactly is legal. Use `typeof v === "number" && Number.isInteger(v) && v > 0 && v <= MAX`. + `Number.isInteger` rejects `NaN`, `Infinity` and `250.5` in one call; `Number.isFinite` does not reject `250.5`. + Never `Number(body.x)` — `Number(true) === 1` would create a one-cent card. +- a currency outside USD/EUR/GBP, including lowercase `"usd"` (use `isCurrency` from `src/lib/money.ts`) +- an unknown `categoryLock` +- a nickname that is empty or whitespace after trimming, or longer than 48 characters +- a body that is not valid JSON, or is `null`, an array, or a primitive + +Wrap **only** the parse in try/catch and reject early: +```ts +let body: unknown +try { body = await request.json() } catch { return jsonError(400, { code: "invalid_json", message: "…" }) } +``` +**Never spread the body** (`{...body, id}`) — that lets the client set `status`, `id`, `spent` or `numberRef`. +Read named fields only. + +### Reveal-once — structural, not a filter + +`issueCard` returns `{ card, cardNumber }` as **siblings**. `cardNumber` is a local const that is never assigned +onto `card`, never logged, never echoed in an error message. Because the `Card` type has no such field, every +payload is safe by construction — do not write a `toPublicCard()` mapper, it is just a place to make a mistake. + +**A replay must not re-reveal.** If `input.requestKey` matches a card already in the store, return that card with +`replayed: true` and **no `cardNumber`**. + +`numberRef` is **independently random** — `cnr_<16 hex>` from `node:crypto`. Never base64/hex of the number +(that is the number), never a hash of it (a fixed BIN plus a Luhn digit is ~10^11 candidates, brute-forceable). + +Use `randomInt(0, 10)` from `node:crypto` as the `DigitSource`, **not** `randomBytes(1)[0] % 10`, which skews low. + +`issueCard` and `setCardStatus` both take `now: Date` as a parameter — the `src/lib/dates.ts` pattern. The route +passes `new Date()`. That is what makes the timestamps testable. + +Every status change appends a `CardEvent`. Issue appends one with `from: null`. + +## `src/data/store.ts` + +Add `cards: Card[]` to the `Store` interface **and** to `createStore()`. Add `store.cards ??= []` at module scope +as a guard, and note in your report that **the dev server must be restarted** — the `globalThis.__northwindStore` +pin means a running server holds a store with no `cards` key, so `createStore()` never re-runs and `store.cards` +is `undefined` at runtime while TypeScript says `Card[]`. + +## `src/data/generate.ts` + +Export `pad`. Add `generateCards()` producing 6-8 cards across different merchants, currencies and all three +statuses, with one card above 80% spend so the amber progress bar is demonstrable. **`generate()` must stay +deterministic** — pass a mulberry-backed `DigitSource`, never the CSPRNG. Do not modify existing seed data. + +## The routes + +`POST /api/cards` → `201` + `Location` with `{ card, cardNumber }`; `200` `{ card, replayed: true }` on a replay. + +`PATCH /api/cards/[id]` → body `{ status }`, **`status` only**. Accepting `spendLimit` builds NWP-202 by accident. +- `200` `{ card }` on success +- `200` no-op when the card already has that status — short-circuit **before** calling `canTransition` + (`canTransition` is deliberately strict and returns false for `X → X`) +- `400` bad JSON or a status that is not one of the three +- `404` unknown card +- `409` illegal transition, with the current status in the body + +**Next 15 makes route-handler `params` a Promise**, and there is no dynamic API route in this repo to copy: +```ts +export async function PATCH(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { + const { id } = await params +``` +Do all `await`s **before** the find-check-mutate block, so two concurrent requests cannot interleave across an await. + +## Tests — `src/data/cards.test.ts` + +`environment: "node"`, `include: ["src/**/*.test.ts"]`, no globals — start with +`import { describe, expect, it } from "vitest"`. + +Cover: each validator rejection above, one case each; acceptance of `5_000_000` exactly and of `1`; +`issueCard` returns a card where `expect("cardNumber" in result.card).toBe(false)`, `last4` matches the returned +number's tail, `status === "active"`, exactly one event with `from: null`; an injected clock writes that exact +`createdAt`; two consecutive issues get different ids; `setCardStatus` on a cancelled card returns +`invalid_transition` and leaves `status` and `events.length` unchanged. + +**`NODE_ENV=test` triggers the globalThis store pin**, so tests share one store. Assert on returned objects and +on deltas (`const before = store.cards.length`), never absolute counts or `store.cards[0]`. + +## Done when + +`npx vitest run src/data/` passes, and you have curled each status code: a 201, a 400 per validation rule, a 404, +and a 409. Report the exact exported signatures. diff --git a/.claude/agents/card-domain.md b/.claude/agents/card-domain.md new file mode 100644 index 00000000..7ca08a37 --- /dev/null +++ b/.claude/agents/card-domain.md @@ -0,0 +1,107 @@ +--- +name: card-domain +description: Pure card domain logic for NWP-201 — Luhn on the 4242 test BIN, masking, the status transition table, and type guards, with unit tests beside the code. Isomorphic; never touches node:crypto or the store. +tools: Read, Write, Edit, Grep, Glob, Bash +model: sonnet +effort: high +--- + +You own the pure card domain for ticket NWP-201 in the Northwind Payments merchant console. + +## Files you own — and the only ones you may write + +- `src/lib/cards.ts` (a signature stub already exists; fill it in, keep every signature) +- `src/lib/cards.test.ts` (new) +- `src/lib/api.ts` (new) +- `src/lib/money.ts` + `src/lib/money.test.ts` (append only — see below) + +Do not edit any other file. `src/data/types.ts` is frozen. Another agent owns `src/data/` and `src/app/`. + +## This module must stay isomorphic + +`src/app/cards/card-status-actions.tsx` is a **client** component and imports `allowedTransitions` from +`src/lib/cards.ts`. So this module must never import `node:crypto`, `src/data/store`, or anything server-only, +or the client build breaks. Randomness arrives as an injected `DigitSource = () => number`, exactly the way +`src/lib/dates.ts` takes `now`/`from` as parameters. + +## Luhn — generation and validation have DIFFERENT parities + +This is the single most common way to get this wrong. Read carefully. + +**Generating a check digit** from a 15-digit payload (`4242` + 11 digits): +1. Walk the payload right-to-left. +2. Double the **rightmost payload digit** and every second digit thereafter. (Once the check digit is appended + it occupies position 1 from the right, so the payload's last digit sits in a doubled position.) +3. If a doubled value exceeds 9, subtract 9. +4. Sum everything. +5. `checkDigit = (10 - (sum % 10)) % 10` — the **outer `% 10` is mandatory**, or a sum ending in 0 yields 10. +6. Append. + +**Validating a complete 16-digit number**: +1. Walk right-to-left. +2. The rightmost digit (the check digit) is **not** doubled. Double the **second** from the right, and every + second digit thereafter. +3. `>9 → subtract 9`, sum. +4. Valid iff `sum % 10 === 0`. + +Every payload has exactly one valid check digit. Code that loops or retries "until it finds one" is confused — +compute it, do not search for it. + +## Required test cases in `src/lib/cards.test.ts` + +Write these before you consider the generator done. Start the file with +`import { describe, expect, it } from "vitest"` — vitest globals are NOT enabled. + +- `luhnCheckDigit("424242424242424") === 2` (a wrong-parity implementation returns 0) +- `luhnCheckDigit("424200000000000") === 0` (catches a missing outer `% 10`) +- `luhnCheckDigit("424255555555555") === 9` (catches `sum += (2*d) % 10` instead of `-9`) +- `isValidLuhn("4242424242424242") === true` +- `isValidLuhn("4242424242424243") === false` +- Transposing two adjacent unequal digits makes a valid number invalid +- `generateCardNumber(() => 7)` returns a **pinned** literal string — assert the exact value +- Round-trip property, 500 iterations with a counter-based digit source: every result matches + `/^4242\d{12}$/`, has length 16, and passes `isValidLuhn` +- `lastFour("4242424242424242") === "4242"` +- `maskCardNumber("1234") === "•••• 1234"` +- The full 3x3 transition matrix, asserted explicitly. Legal: `active→frozen`, `active→cancelled`, + `frozen→active`, `frozen→cancelled`. Illegal: every `cancelled→*`, **and** `active→active` and + `frozen→frozen` (a no-op is not a transition — the route handles same-status separately) +- `allowedTransitions("cancelled")` has length 0 +- `isCardStatus` rejects `"deleted"`, `""`, `null`, `123`, `"ACTIVE"` (case matters) +- `MAX_SPEND_LIMIT_MINOR_UNITS === 5_000_000` + +## `src/lib/api.ts` — the error envelope + +There is no error path anywhere in this repo today, so you are defining the convention. + +```ts +export interface ApiError { + code: "invalid_json" | "invalid_field" | "not_found" | "invalid_transition" + /** Safe to show an ops user verbatim. Never contains a card number. */ + message: string + /** The request-body field the message belongs to, when there is one. */ + field?: string +} +export function jsonError(status: number, error: ApiError) // → NextResponse.json({ error }, { status }) +``` + +Four codes, not fourteen — `field` carries the specificity. + +## `src/lib/money.ts` — append two exports, derived from what is already there + +The file has a **private** `SYMBOLS: Record`. That is the only runtime artefact in the repo +with the right keys, and `Record` is exhaustiveness-checked by TypeScript. Derive from it: + +```ts +export const CURRENCIES = Object.keys(SYMBOLS) as Currency[] +export function isCurrency(value: unknown): value is Currency +``` + +Do not write a second currency list. Do not change any existing function — `money.test.ts` pins their behaviour. +Add three cases to `money.test.ts`: `isCurrency` accepts `"USD"`/`"EUR"`/`"GBP"` and rejects `"JPY"`, `"usd"`, +`""`, `null`, `1`. + +## Done when + +`npx vitest run src/lib/` passes and `npx tsc --noEmit` reports no errors in files you own. +Report the exact exported signatures you ended up with, so the other agents can rely on them. diff --git a/.claude/agents/card-reviewer.md b/.claude/agents/card-reviewer.md new file mode 100644 index 00000000..bce522bb --- /dev/null +++ b/.claude/agents/card-reviewer.md @@ -0,0 +1,68 @@ +--- +name: card-reviewer +description: Read-only pre-ship audit of NWP-201 card work against the four correctness rules — minor units, Luhn on the test BIN, reveal-once masking, and the status state machine. Returns findings, never a fix. +tools: Read, Grep, Glob +model: sonnet +effort: high +--- + +You audit the NWP-201 card implementation before it ships. You are read-only by design: your output is a report +someone else acts on. Do not propose diffs, only findings with file paths and line numbers. + +## What you check, in priority order + +**1. Reveal-once.** The highest-value check. Verify, each with a grep and a file path: +- The `Card` type in `src/data/types.ts` has no `number`/`pan`/`cardNumber` field in any form. +- The POST handler never spreads the request body (`{...body}`) — that would let a client set `status`, `id`, + `spent` or `numberRef`, and could carry the number onto the record. +- No `GET`/list/detail path returns the number, and the idempotent replay does **not** re-reveal it. +- The number is not in a URL, `sessionStorage`, `localStorage`, a `console.log`, an ``, or an error + message echoing the body. +- Client state holding it is cleared on drawer close, and is owned by a component that unmounts. +- `numberRef` is independently random — not base64, not hex, not a hash of the number. + +**2. Luhn and the BIN.** Generation and validation use different parities; confirm the generator's output passes +the validator and that a round-trip test actually exists and runs. Confirm every generated number starts `4242`. +Confirm the mask renders the card's own `last4`, not a hardcoded `4242` (two cards must show different digits). + +**3. Money.** Integer minor units everywhere; no float arithmetic on amounts; no `toFixed` result stored or +compared; every amount paired with a currency; no sum across currencies; the ceiling compared as minor units +(`5_000_000` = $50,000.00) with `>` not `>=`; no `Number(x)` coercion at the boundary; formatting only in +components, via `formatMoney` and never `formatters.currency` from `src/lib/utils.ts`. + +**4. The state machine.** `cancelled` is terminal; the guard is on the server, not only in the UI; the UI reads +the same table rather than a second copy; a same-status PATCH is a no-op rather than an error; PATCH accepts +`status` only and not `spendLimit` (accepting it builds NWP-202 by accident). + +**5. Conventions.** UTC for storage, bucketing and comparison — display converts, nothing else does. No second +query builder: card queries must reuse `paginate` from `src/data/queries.ts` rather than reimplement it, and +must not have copied `sortPayments`'s lexicographic `String(a).localeCompare(String(b))` for a numeric field. +No inline `style`. Every input has a label. Written empty and error states exist. + +**6. Debris.** Leftover `console.log`, commented-out blocks, `as any`, unused imports, TODOs. + +## Report format + +``` +## Verdict: ship | fix first + +### Blocking +**1. ** — `path/to/file.ts:LINE` +What the code does, why it breaks the rule, which rule. + +### Worth fixing +... + +### Checked and clean +- — how you verified it, with the path you looked at +``` + +## Rules + +- Every claim carries a file path and a line number. No claim without one. +- "I could not verify this read-only" is a valid finding. A confident wrong answer is worse than an honest gap. +- Distinguish defects introduced by this ticket from pre-existing ones. Known pre-existing, not this ticket's + problem: the lexicographic sort in `src/data/queries.ts:81`, the local-time bucketing and float money in + `src/data/metrics.ts`, the cross-currency total in `src/data/metrics.ts` → `src/app/overview/page.tsx`, and + `bg-muted` in `src/components/Skeleton.tsx`. Flag them only if the new code copied them. +- Keep it under one page. diff --git a/.claude/agents/card-ui.md b/.claude/agents/card-ui.md new file mode 100644 index 00000000..c269a367 --- /dev/null +++ b/.claude/agents/card-ui.md @@ -0,0 +1,164 @@ +--- +name: card-ui +description: The /cards console UI for NWP-201 — list, detail with spend progress and timeline, the issue drawer with its one-time number reveal, freeze/unfreeze row actions, and nav registration. +tools: Read, Write, Edit, Grep, Glob +model: sonnet +effort: high +--- + +You own the `/cards` user interface for ticket NWP-201. + +## Files you own — and the only ones you may write + +- `src/app/cards/page.tsx` — the list (async server component) +- `src/app/cards/[id]/page.tsx` — the detail +- `src/app/cards/issue-card-drawer.tsx` — `"use client"`, the form and the reveal +- `src/app/cards/card-status-actions.tsx` — `"use client"`, freeze/unfreeze/cancel +- `src/app/cards/filter-bar.tsx` — `"use client"`, status + merchant filter +- `src/app/cards/error.tsx`, `src/app/cards/[id]/not-found.tsx` +- `src/components/ui/payments/StatusBadge.tsx` (extend) +- `src/app/siteConfig.ts`, `src/components/ui/navigation/AppSidebar.tsx`, + `src/components/ui/navigation/Breadcrumbs.tsx` + +**Do not edit** `src/data/**` or `src/lib/**` — other agents own them. + +## Read these first, and match them exactly + +`src/app/payments/page.tsx` (list shape, table markup, empty state, pagination), +`src/app/payments/[id]/page.tsx` (detail shape, the local `Field` helper, the UTC/timezone date pair, the +timeline `
    `), `src/app/payments/filter-bar.tsx` (the client-component pattern), +`src/components/Drawer.tsx`, `src/components/Button.tsx`, `src/components/Input.tsx`, `src/components/Select.tsx`. + +## The import boundary — violating it fails the build + +Client components may import **only** `@/lib/cards` (runtime) and `@/data/types` (type-only, erased). +**Never** `@/data/cards` or `@/data/store` from a client component — that pulls `node:crypto` and the whole +seeded store into the browser bundle. + +Server pages read the store directly: `import { queryCards, cardById } from "@/data/cards"` and call them +synchronously. That is the house convention — `payments/page.tsx` calls `queryPayments` in-process. There is +no `GET /api/cards`; do not fetch one. + +## There is no Dialog component + +`.claude/rules/components.md` claims `src/components/` has a `Dialog`. **It does not.** Use `Drawer` — it is a +wrapper over `@radix-ui/react-dialog`, so focus trap, Escape and focus-return come free. Do not create a +`Dialog.tsx`; that would be a second wrapper around the same Radix root. + +`DrawerContent` does **not** render a title automatically — you must render `` or Radix errors and +the dialog has no accessible name. `DrawerHeader` already renders its own close button; do not add a second. + +## The list — `src/app/cards/page.tsx` + +Async server component, `searchParams: Promise>`. Calls `queryCards()`. +Wrap in `
    `. Put the issue-drawer trigger where payments puts its Export button. + +Columns: Card (id link) · Nickname · Merchant · Number · Limit · Status · Created · Actions. +**That is 8, so the empty-state `colSpan` is 8** — not the 7 you would copy from payments. + +- Number: `{maskCardNumber(card.last4)}`. + **Use the card's own `last4`.** The ticket writes the mask as `•••• 4242` only because the BIN is 4242; + hardcoding it makes every card render identically. +- Limit: `className="text-right font-medium tabular-nums text-gray-900 dark:text-gray-50"` + + `formatMoney(card.spendLimit, card.currency)` +- Created: `formatDate(card.createdAt)` (UTC — tables are scanned, not reconciled) + +**Two distinct empty states**, chosen on whether filters are active: +no cards at all → "No cards issued yet" / "Issue the first one with the button above"; +filters exclude everything → "No cards match these filters" / "Clear the search or pick a different status". + +## The detail — `src/app/cards/[id]/page.tsx` + +Async, `params: Promise<{ id: string }>`, `if (!card) notFound()`. Copy the page-local `Field` helper from +`payments/[id]/page.tsx`. Show both dates the way payments does: raw ISO in `font-mono` under "Created (UTC)", +and `formatInZone(card.createdAt, merchant.timezone)` under `Created (${merchant.timezone})`. + +**Spend progress bar.** `components.md` forbids inline `style`, and Tailwind's scanner cannot see +`w-[${pct}%]` — a dynamic arbitrary class silently renders zero width. Use ``, which needs +no inline style and is natively accessible, or a static lookup of literal classes. Also: +- guard `spendLimit > 0` or you render `NaN%` +- cap the displayed width at 100% while showing the true percentage in text +- threshold in integers — `spend * 5 >= limit * 4` for the 80% amber. Comparing a rounded float turns the bar + amber at 79.6% +- the percentage is display-only and is never stored +- write a "No spend yet" state for `spent === 0` + +**Timeline** of `card.events`, reusing the `
      ` markup from the payments detail +page, rendered with `formatInZone(entry.at, merchant.timezone)`. + +## The issue drawer — `src/app/cards/issue-card-drawer.tsx` + +Props are plain serializable data computed by the server page (`merchants: {id, name, currency}[]`, etc.). + +State: `open`, `phase: "form" | "success"`, `submitting`, `error`, `issued`, `requestKey`. +Fields: nickname, merchant, spend limit, currency, category lock. + +**POST integer minor units**, not a decimal string — that matches `Payment.amount`. Convert the user's string +once, in the form, with `parseAmountToMinorUnits` from `@/lib/money` (client-side feedback only; the server +validates independently). Note `parseAmountToMinorUnits("0")` returns `0`, not `null` — zero needs its own check. + +Submit button: ` + + ) +} 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..2459ab16 --- /dev/null +++ b/build-battle/merchant-console/src/app/cards/[id]/page.tsx @@ -0,0 +1,201 @@ +import { Divider } from "@/components/Divider" +import { StatusBadge } from "@/components/ui/payments/StatusBadge" +import { cardById } from "@/data/cards" +import { merchantById } from "@/data/merchants" +import { CardCategory, Currency } from "@/data/types" +import { maskCardNumber } 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 { CardStatusActions } from "../card-status-actions" + +const CATEGORY_LABELS: Record = { + advertising: "Advertising", + software: "Software", + travel: "Travel", + contractors: "Contractors", + utilities: "Utilities", +} + +export default async function CardDetail({ + params, +}: { + params: Promise<{ id: string }> +}) { + const { id } = await params + const card = cardById(id) + if (!card) notFound() + + const merchant = merchantById(card.merchantId)! + + return ( +
      + + ← All cards + + +
      +

      + {card.nickname} +

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

      {card.id}

      + +
      + +
      + + + +
      + + {merchant.name} + {merchant.country} + + + {formatMoney(card.spendLimit, card.currency)} + + + {card.categoryLock ? CATEGORY_LABELS[card.categoryLock] : "None"} + + + {card.createdAt} + + + {formatInZone(card.createdAt, merchant.timezone)} + +
      + + + +

      + Spend +

      +
      + +
      + + + +

      + Timeline +

      +
        + {card.events.map((entry, index) => ( +
      1. +
      2. + ))} +
      +
      + ) +} + +function capitalize(value: string): string { + return value.charAt(0).toUpperCase() + value.slice(1) +} + +/** + * `` needs no inline style and is natively accessible, + * unlike a dynamic arbitrary Tailwind class (`w-[${pct}%]`), which the + * scanner cannot see and silently renders zero width. The bar's `value` is + * clamped to `limit` so the rendered width never exceeds 100%, while the + * text next to it always shows the true, uncapped percentage. + */ +function SpendProgress({ + spent, + limit, + currency, +}: { + spent: number + limit: number + currency: Currency +}) { + if (spent === 0) { + return

      No spend yet.

      + } + + if (limit <= 0) { + return ( +

      + {formatMoney(spent, currency)} spent · no spend limit set +

      + ) + } + + // Integer comparison on purpose: comparing a rounded display percentage + // (e.g. 79.6% -> "80%") would flip the bar amber a shade early. + const isAmber = spent * 5 >= limit * 4 + const truePercent = Math.round((spent / limit) * 100) + const barValue = Math.min(spent, limit) + + return ( +
      +
      + + {formatMoney(spent, currency)} of {formatMoney(limit, currency)} + + {truePercent}% +
      + + {spent > limit && ( +

      + Over limit +

      + )} +
      + ) +} + +function Field({ + label, + children, +}: { + label: string + children: React.ReactNode +}) { + return ( +
      +
      {label}
      +
      {children}
      +
      + ) +} diff --git a/build-battle/merchant-console/src/app/cards/card-status-actions.tsx b/build-battle/merchant-console/src/app/cards/card-status-actions.tsx new file mode 100644 index 00000000..bdfea6cd --- /dev/null +++ b/build-battle/merchant-console/src/app/cards/card-status-actions.tsx @@ -0,0 +1,104 @@ +"use client" + +import { Button } from "@/components/Button" +import { allowedTransitions } from "@/lib/cards" +import { CardStatus } from "@/data/types" +import { useRouter } from "next/navigation" +import { useState, useTransition } from "react" + +/** + * The label describes the status the card is MOVING TO, so "frozen" reads as + * the "Freeze" action and "active" reads as "Unfreeze". Cancel gets its own + * two-step inline confirm below rather than a label lookup. + */ +const ACTION_LABELS: Partial> = { + active: "Unfreeze", + frozen: "Freeze", +} + +export function CardStatusActions({ + cardId, + status, +}: { + cardId: string + status: CardStatus +}) { + const router = useRouter() + const [isPending, startTransition] = useTransition() + const [submitting, setSubmitting] = useState(false) + const [confirmingCancel, setConfirmingCancel] = useState(false) + const [error, setError] = useState(null) + + const targets = allowedTransitions(status) + + if (targets.length === 0) return null + + const applyStatus = async (next: CardStatus) => { + setSubmitting(true) + setError(null) + try { + const res = await fetch(`/api/cards/${cardId}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ status: next }), + }) + if (!res.ok) { + const body = await res.json().catch(() => null) + setError( + body?.error?.message ?? "Could not update this card. Try again.", + ) + return + } + startTransition(() => router.refresh()) + } catch { + setError("Could not reach the server. Check your connection and try again.") + } finally { + setSubmitting(false) + setConfirmingCancel(false) + } + } + + const busy = submitting || isPending + + return ( +
      + {targets.map((next) => + next === "cancelled" ? ( + + ) : ( + + ), + )} + {error && ( +

      + {error} +

      + )} +
      + ) +} diff --git a/build-battle/merchant-console/src/app/cards/error.tsx b/build-battle/merchant-console/src/app/cards/error.tsx new file mode 100644 index 00000000..30ce12af --- /dev/null +++ b/build-battle/merchant-console/src/app/cards/error.tsx @@ -0,0 +1,30 @@ +"use client" + +import { Button } from "@/components/Button" +import { useEffect } from "react" + +export default function CardsError({ + error, + reset, +}: { + error: Error & { digest?: string } + reset: () => void +}) { + useEffect(() => { + console.error(error) + }, [error]) + + return ( +
      +

      + Something went wrong loading cards. +

      +

      + Try again, or come back in a moment. +

      + +
      + ) +} diff --git a/build-battle/merchant-console/src/app/cards/filter-bar.tsx b/build-battle/merchant-console/src/app/cards/filter-bar.tsx new file mode 100644 index 00000000..3ef70f95 --- /dev/null +++ b/build-battle/merchant-console/src/app/cards/filter-bar.tsx @@ -0,0 +1,94 @@ +"use client" + +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/Select" +import { useRouter } from "next/navigation" + +const LABELS: Record = { + all: "All statuses", + active: "Active", + frozen: "Frozen", + cancelled: "Cancelled", +} + +export function CardsFilterBar({ + statuses, + merchants, + current, +}: { + statuses: string[] + merchants: { id: string; name: string }[] + current: { status: string; merchantId: string } +}) { + const router = useRouter() + + const apply = (changes: Record) => { + const next = new URLSearchParams() + const merged = { ...current, ...changes } + if (merged.status && merged.status !== "all") next.set("status", merged.status) + if (merged.merchantId) next.set("merchantId", merged.merchantId) + router.push(`/cards?${next.toString()}`) + } + + return ( +
      +
      + + +
      + +
      + + +
      +
      + ) +} 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..1b5b2cf7 --- /dev/null +++ b/build-battle/merchant-console/src/app/cards/issue-card-drawer.tsx @@ -0,0 +1,533 @@ +"use client" + +import { Button } from "@/components/Button" +import { + Drawer, + DrawerBody, + DrawerClose, + DrawerContent, + DrawerFooter, + DrawerHeader, + DrawerTitle, + DrawerTrigger, +} from "@/components/Drawer" +import { Input } from "@/components/Input" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/Select" +import { CardCategory, Card, Currency } from "@/data/types" +import { MAX_SPEND_LIMIT_MINOR_UNITS, NICKNAME_MAX_LENGTH } from "@/lib/cards" +import { CURRENCIES, formatMoney, parseAmountToMinorUnits } from "@/lib/money" +import { Check, Copy, Plus } from "lucide-react" +import { useRouter } from "next/navigation" +import { useRef, useState } from "react" + +/** + * The client's one-shot key for the issue request, so a double submit (a + * slow network, a second click) returns the already-issued card instead of + * minting a second one. `crypto.randomUUID` is the Web Crypto API, available + * in the browser - not `node:crypto`, which this file may never import. + */ +function mintRequestKey(): string { + if (typeof crypto !== "undefined" && "randomUUID" in crypto) { + return crypto.randomUUID() + } + return `req_${Date.now()}_${Math.random().toString(36).slice(2)}` +} + +const CATEGORIES: { value: CardCategory; label: string }[] = [ + { value: "advertising", label: "Advertising" }, + { value: "software", label: "Software" }, + { value: "travel", label: "Travel" }, + { value: "contractors", label: "Contractors" }, + { value: "utilities", label: "Utilities" }, +] + +type Issued = { card: Card; cardNumber: string | null } + +export function IssueCardDrawer({ + merchants, +}: { + merchants: { id: string; name: string; currency: Currency }[] +}) { + const router = useRouter() + + const [open, setOpen] = useState(false) + const [phase, setPhase] = useState<"form" | "success">("form") + const [submitting, setSubmitting] = useState(false) + const [requestKey, setRequestKey] = useState(() => mintRequestKey()) + const [issued, setIssued] = useState(null) + const [copied, setCopied] = useState(false) + + const [nickname, setNickname] = useState("") + const [merchantId, setMerchantId] = useState("") + const [spendLimitInput, setSpendLimitInput] = useState("") + const [currency, setCurrency] = useState("") + const [categoryLock, setCategoryLock] = useState( + "none", + ) + const [formError, setFormError] = useState(null) + const [fieldErrors, setFieldErrors] = useState>({}) + + const nicknameRef = useRef(null) + const merchantTriggerRef = useRef(null) + const spendLimitRef = useRef(null) + const currencyTriggerRef = useRef(null) + const categoryTriggerRef = useRef(null) + + const selectedMerchant = merchants.find((m) => m.id === merchantId) + const currencyForDisplay: Currency = + (currency || selectedMerchant?.currency || "USD") as Currency + const showCurrencyMismatch = + Boolean(selectedMerchant) && + Boolean(currency) && + currency !== selectedMerchant?.currency + + const resetFields = () => { + setNickname("") + setMerchantId("") + setSpendLimitInput("") + setCurrency("") + setCategoryLock("none") + setFormError(null) + setFieldErrors({}) + } + + const handleMerchantChange = (id: string) => { + setMerchantId(id) + const merchant = merchants.find((m) => m.id === id) + if (merchant) setCurrency(merchant.currency) + } + + const focusField = (field: string) => { + if (field === "nickname") nicknameRef.current?.focus() + else if (field === "merchantId") merchantTriggerRef.current?.focus() + else if (field === "spendLimit") spendLimitRef.current?.focus() + else if (field === "currency") currencyTriggerRef.current?.focus() + else if (field === "categoryLock") categoryTriggerRef.current?.focus() + } + + const handleCopy = async () => { + if (!issued?.cardNumber) return + try { + await navigator.clipboard.writeText(issued.cardNumber) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } catch { + // Clipboard access can be denied by the browser; the number is still + // visible on screen to copy by hand. + } + } + + const handleSubmit = async (event: React.FormEvent) => { + event.preventDefault() + setFormError(null) + + const errors: Record = {} + const trimmedNickname = nickname.trim() + if (!trimmedNickname) { + errors.nickname = "Enter a nickname." + } else if (trimmedNickname.length > NICKNAME_MAX_LENGTH) { + errors.nickname = `Nicknames are at most ${NICKNAME_MAX_LENGTH} characters.` + } + + if (!merchantId) { + errors.merchantId = "Choose a merchant." + } + + const minorUnits = parseAmountToMinorUnits(spendLimitInput) + if (minorUnits === null) { + errors.spendLimit = "Enter an amount like 250 or 250.00." + } else if (minorUnits <= 0) { + errors.spendLimit = "Enter an amount greater than 0." + } else if (minorUnits > MAX_SPEND_LIMIT_MINOR_UNITS) { + errors.spendLimit = `Spend limit cannot exceed ${formatMoney(MAX_SPEND_LIMIT_MINOR_UNITS, currencyForDisplay)}.` + } + + if (!currency) { + errors.currency = "Choose a currency." + } + + if (Object.keys(errors).length > 0) { + setFieldErrors(errors) + const firstField = ["nickname", "merchantId", "spendLimit", "currency"].find( + (field) => errors[field], + ) + if (firstField) focusField(firstField) + return + } + if (minorUnits === null || !currency) return // narrows for TS; unreachable given the checks above + + setFieldErrors({}) + setSubmitting(true) + try { + const res = await fetch("/api/cards", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + nickname: trimmedNickname, + merchantId, + spendLimit: minorUnits, + currency, + categoryLock: categoryLock === "none" ? null : categoryLock, + requestKey, + }), + }) + + if (res.status === 201 || res.status === 200) { + const body = await res.json() + setIssued({ card: body.card, cardNumber: body.cardNumber ?? null }) + setPhase("success") + router.refresh() + return + } + + const body = await res.json().catch(() => null) + const apiError = body?.error + if (apiError?.field) { + setFieldErrors({ [apiError.field]: apiError.message }) + focusField(apiError.field) + } else { + setFormError(apiError?.message ?? "Could not issue this card. Try again.") + } + } catch { + setFormError( + "Could not reach the server. Check your connection and try again.", + ) + } finally { + setSubmitting(false) + } + } + + return ( + { + setOpen(next) + if (!next) { + setIssued(null) + setPhase("form") + setCopied(false) + resetFields() + setRequestKey(mintRequestKey()) + } + }} + > + + + + + + + {phase === "form" ? "Issue card" : "Card issued"} + + + + {phase === "form" ? ( +
      + + {formError && ( +

      + {formError} +

      + )} + +
      + + setNickname(event.target.value)} + maxLength={NICKNAME_MAX_LENGTH} + hasError={Boolean(fieldErrors.nickname)} + aria-invalid={Boolean(fieldErrors.nickname)} + aria-describedby={ + fieldErrors.nickname + ? "card-nickname-error" + : "card-nickname-help" + } + className="mt-1" + /> +

      + What ops calls it. Up to {NICKNAME_MAX_LENGTH} characters. +

      + {fieldErrors.nickname && ( + + )} +
      + +
      + + + {fieldErrors.merchantId && ( + + )} +
      + +
      + +
      + setSpendLimitInput(event.target.value)} + placeholder="250.00" + hasError={Boolean(fieldErrors.spendLimit)} + aria-invalid={Boolean(fieldErrors.spendLimit)} + aria-describedby={ + fieldErrors.spendLimit + ? "card-spend-limit-error" + : "card-spend-limit-help" + } + /> + + {currency || selectedMerchant?.currency || ""} + +
      +

      + Up to {formatMoney(MAX_SPEND_LIMIT_MINOR_UNITS, currencyForDisplay)}{" "} + without extra approval. +

      + {fieldErrors.spendLimit && ( + + )} +
      + +
      + + + {fieldErrors.currency && ( + + )} + {!fieldErrors.currency && showCurrencyMismatch && selectedMerchant && ( +

      + {selectedMerchant.name} settles in {selectedMerchant.currency}. + This card will be issued in {currency}. +

      + )} +
      + +
      + + +

      + What the card may be spent on. Not editable after issue. +

      +
      +
      + + + +
      + ) : ( + <> + +
      + {issued?.cardNumber ? ( +
      +

      + This is the only time this number will be shown. Copy it + now. +

      +
      +

      + {issued.cardNumber} +

      + +
      +

      + {issued.card.nickname} ·{" "} + {formatMoney(issued.card.spendLimit, issued.card.currency)}{" "} + limit +

      +
      + ) : ( +

      + This card was already issued. Its number was shown once and + cannot be shown again. +

      + )} +
      +
      + + + + + + + )} +
      +
      + ) +} 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..7ff7e460 --- /dev/null +++ b/build-battle/merchant-console/src/app/cards/page.tsx @@ -0,0 +1,175 @@ +import { Button } from "@/components/Button" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeaderCell, + TableRoot, + TableRow, +} from "@/components/Table" +import { StatusBadge } from "@/components/ui/payments/StatusBadge" +import { queryCards } from "@/data/cards" +import { merchantById, merchants } from "@/data/merchants" +import { CardFilters, CardStatus } from "@/data/types" +import { maskCardNumber } from "@/lib/cards" +import { formatDate } from "@/lib/dates" +import { formatMoney } from "@/lib/money" +import Link from "next/link" +import { CardStatusActions } from "./card-status-actions" +import { CardsFilterBar } from "./filter-bar" +import { IssueCardDrawer } from "./issue-card-drawer" + +const STATUSES: (CardStatus | "all")[] = ["all", "active", "frozen", "cancelled"] + +export default async function CardsPage({ + searchParams, +}: { + searchParams: Promise> +}) { + const params = await searchParams + const filtersActive = Boolean( + (params.status && params.status !== "all") || params.merchantId, + ) + + const filters: CardFilters = { + status: (STATUSES.includes(params.status as CardStatus) + ? params.status + : "all") as CardFilters["status"], + merchantId: params.merchantId || undefined, + page: Number(params.page ?? "1") || 1, + } + + const { rows, total, page, pageCount } = queryCards(filters) + const query = new URLSearchParams( + Object.entries(params).filter(([, v]) => Boolean(v)) as [string, string][], + ) + + const pageHref = (next: number) => { + const q = new URLSearchParams(query) + q.set("page", String(next)) + return `/cards?${q.toString()}` + } + + return ( +
      +
      + ({ id: m.id, name: m.name }))} + current={{ + status: (filters.status as string) ?? "all", + merchantId: filters.merchantId ?? "", + }} + /> + ({ + id: m.id, + name: m.name, + currency: m.currency, + }))} + /> +
      + + + + + + Card + Nickname + Merchant + Number + Limit + Status + Created + Actions + + + + {rows.length === 0 && ( + + + {filtersActive ? ( + <> +

      + No cards match these filters +

      +

      + Clear the search or pick a different status. +

      + + ) : ( + <> +

      + No cards issued yet +

      +

      + Issue the first one with the button above. +

      + + )} +
      +
      + )} + {rows.map((card) => { + const merchant = merchantById(card.merchantId) + return ( + + + + {card.id} + + + {card.nickname} + {merchant?.name} + + + {maskCardNumber(card.last4)} + + + + {formatMoney(card.spendLimit, card.currency)} + + + + + {formatDate(card.createdAt)} + + + + + ) + })} +
      +
      +
      + +
      +

      + {total.toLocaleString()} cards · page {page} of {pageCount} +

      +
      + + +
      +
      +
      + ) +} 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..881b7f69 100644 --- a/build-battle/merchant-console/src/components/ui/navigation/AppSidebar.tsx +++ b/build-battle/merchant-console/src/components/ui/navigation/AppSidebar.tsx @@ -16,7 +16,7 @@ import { } from "@/components/Sidebar" import { cx, focusRing } from "@/lib/utils" import { RiArrowDownSFill } from "@remixicon/react" -import { Banknote, CreditCard, House, ShieldAlert } from "lucide-react" +import { Banknote, CreditCard, House, ShieldAlert, Wallet } from "lucide-react" import * as React from "react" import { Logo } from "../../../../public/Logo" import { UserProfile } from "./UserProfile" @@ -48,6 +48,12 @@ const navigation = [ icon: Banknote, notifications: false as const, }, + { + name: "Cards", + href: siteConfig.baseLinks.cards, + icon: Wallet, + 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..90fb1988 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,8 @@ 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 +17,9 @@ const LABELS: Record = { paid: "Paid", in_transit: "In transit", pending: "Pending", + active: "Active", + frozen: "Frozen", + cancelled: "Cancelled", } const DOTS: Record = { @@ -32,6 +35,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 +53,9 @@ const VARIANTS: Record = {}) { + return { + merchantId: merchant.id, + nickname: "Ops Card", + spendLimit: 10_000, + currency: "USD", + ...overrides, + } +} + +function issueValid(overrides: Record = {}, now = new Date()) { + const result = validateIssueCard(validBody(overrides)) + if (!result.ok) throw new Error("expected a valid body in this helper") + return issueCard(result.value as IssueCardInput, now) +} + +describe("validateIssueCard", () => { + it("rejects a body that is not a JSON object", () => { + expect(validateIssueCard(null).ok).toBe(false) + expect(validateIssueCard([1, 2, 3]).ok).toBe(false) + expect(validateIssueCard("hello").ok).toBe(false) + expect(validateIssueCard(42).ok).toBe(false) + expect(validateIssueCard(true).ok).toBe(false) + }) + + it("rejects a missing merchantId", () => { + const result = validateIssueCard(validBody({ merchantId: undefined })) + expect(result.ok).toBe(false) + expect(!result.ok && result.field).toBe("merchantId") + }) + + it("rejects a well-formed but nonexistent merchantId with 400, not 404", () => { + const result = validateIssueCard(validBody({ merchantId: "mch_does_not_exist" })) + expect(result.ok).toBe(false) + expect(!result.ok && result.field).toBe("merchantId") + }) + + it("rejects a non-integer spendLimit", () => { + const result = validateIssueCard(validBody({ spendLimit: 250.5 })) + expect(result.ok).toBe(false) + expect(!result.ok && result.field).toBe("spendLimit") + }) + + it("rejects a spendLimit of zero or below", () => { + expect(validateIssueCard(validBody({ spendLimit: 0 })).ok).toBe(false) + expect(validateIssueCard(validBody({ spendLimit: -100 })).ok).toBe(false) + }) + + it("rejects a spendLimit above the ceiling", () => { + const result = validateIssueCard( + validBody({ spendLimit: MAX_SPEND_LIMIT_MINOR_UNITS + 1 }), + ) + expect(result.ok).toBe(false) + expect(!result.ok && result.field).toBe("spendLimit") + }) + + it("rejects NaN, Infinity, and a boolean masquerading as a number", () => { + expect(validateIssueCard(validBody({ spendLimit: NaN })).ok).toBe(false) + expect(validateIssueCard(validBody({ spendLimit: Infinity })).ok).toBe(false) + expect(validateIssueCard(validBody({ spendLimit: true })).ok).toBe(false) + }) + + it("accepts a spendLimit of exactly the ceiling", () => { + const result = validateIssueCard( + validBody({ spendLimit: MAX_SPEND_LIMIT_MINOR_UNITS }), + ) + expect(result.ok).toBe(true) + }) + + it("accepts a spendLimit of 1", () => { + const result = validateIssueCard(validBody({ spendLimit: 1 })) + expect(result.ok).toBe(true) + }) + + it("rejects a currency outside USD/EUR/GBP", () => { + const result = validateIssueCard(validBody({ currency: "JPY" })) + expect(result.ok).toBe(false) + expect(!result.ok && result.field).toBe("currency") + }) + + it("rejects a lowercase currency code", () => { + const result = validateIssueCard(validBody({ currency: "usd" })) + expect(result.ok).toBe(false) + expect(!result.ok && result.field).toBe("currency") + }) + + it("rejects an unknown categoryLock", () => { + const result = validateIssueCard(validBody({ categoryLock: "shopping" })) + expect(result.ok).toBe(false) + expect(!result.ok && result.field).toBe("categoryLock") + }) + + it("accepts a null or absent categoryLock", () => { + expect(validateIssueCard(validBody({ categoryLock: null })).ok).toBe(true) + expect(validateIssueCard(validBody()).ok).toBe(true) + }) + + it("rejects a nickname that is empty or whitespace after trimming", () => { + expect(validateIssueCard(validBody({ nickname: "" })).ok).toBe(false) + expect(validateIssueCard(validBody({ nickname: " " })).ok).toBe(false) + }) + + it("rejects a nickname longer than 48 characters", () => { + const result = validateIssueCard(validBody({ nickname: "x".repeat(49) })) + expect(result.ok).toBe(false) + expect(!result.ok && result.field).toBe("nickname") + }) + + it("accepts a nickname of exactly 48 characters", () => { + const result = validateIssueCard(validBody({ nickname: "x".repeat(48) })) + expect(result.ok).toBe(true) + }) +}) + +describe("issueCard", () => { + it("never puts the full number on the card record", () => { + const result = issueValid() + expect("cardNumber" in result.card).toBe(false) + }) + + it("keeps last4 consistent with the revealed number", () => { + const result = issueValid() + if ("replayed" in result) throw new Error("expected a fresh issue") + expect(result.card.last4).toBe(result.cardNumber.slice(-4)) + }) + + it("issues active with exactly one event, from null", () => { + const result = issueValid() + expect(result.card.status).toBe("active") + expect(result.card.events).toHaveLength(1) + expect(result.card.events[0]).toMatchObject({ from: null, to: "active" }) + }) + + it("writes createdAt from the injected clock, not wall-clock time", () => { + const now = new Date("2026-01-01T00:00:00.000Z") + const result = issueValid({}, now) + expect(result.card.createdAt).toBe("2026-01-01T00:00:00.000Z") + expect(result.card.events[0].at).toBe("2026-01-01T00:00:00.000Z") + }) + + it("gives two consecutive issues different ids", () => { + const first = issueValid() + const second = issueValid() + expect(first.card.id).not.toBe(second.card.id) + }) + + it("replays a matching requestKey instead of issuing a second card", () => { + const key = `test-key-${Date.now()}-${Math.random()}` + const before = store.cards.length + + const first = issueValid({ requestKey: key }) + const second = issueValid({ requestKey: key }) + + expect(store.cards.length).toBe(before + 1) + expect("replayed" in second).toBe(true) + expect(second.card.id).toBe(first.card.id) + expect("cardNumber" in second).toBe(false) + }) + + it("appends exactly one card to the store per fresh issue", () => { + const before = store.cards.length + issueValid() + expect(store.cards.length).toBe(before + 1) + }) +}) + +describe("setCardStatus", () => { + it("moves active -> frozen and appends an event", () => { + const issued = issueValid() + const before = issued.card.events.length + + const result = setCardStatus(issued.card.id, "frozen", new Date()) + + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.card.status).toBe("frozen") + expect(result.card.events.length).toBe(before + 1) + expect(result.card.events.at(-1)).toMatchObject({ + from: "active", + to: "frozen", + }) + } + }) + + it("no-ops on a same-status request without appending an event", () => { + const issued = issueValid() + const before = issued.card.events.length + + const result = setCardStatus(issued.card.id, "active", new Date()) + + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.card.events.length).toBe(before) + } + }) + + it("rejects any transition off a cancelled card", () => { + const issued = issueValid() + setCardStatus(issued.card.id, "cancelled", new Date()) + + const before = cardById(issued.card.id) + const beforeStatus = before?.status + const beforeEventCount = before?.events.length ?? 0 + + const result = setCardStatus(issued.card.id, "active", new Date()) + + expect(result.ok).toBe(false) + expect(!result.ok && result.reason).toBe("invalid_transition") + + const after = cardById(issued.card.id) + expect(after?.status).toBe(beforeStatus) + expect(after?.events.length).toBe(beforeEventCount) + }) + + it("returns not_found for an unknown id", () => { + const result = setCardStatus("card_does_not_exist", "frozen", new Date()) + expect(result.ok).toBe(false) + expect(!result.ok && result.reason).toBe("not_found") + }) +}) + +describe("cardById", () => { + it("finds a card just issued and returns null for an unknown id", () => { + const issued = issueValid() + expect(cardById(issued.card.id)?.id).toBe(issued.card.id) + expect(cardById("card_does_not_exist")).toBeNull() + }) +}) 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..a3073b5b --- /dev/null +++ b/build-battle/merchant-console/src/data/cards.ts @@ -0,0 +1,262 @@ +import { randomBytes, randomInt } from "node:crypto" +import { + canTransition, + generateCardNumber, + isCardCategory, + lastFour, + MAX_SPEND_LIMIT_MINOR_UNITS, + NICKNAME_MAX_LENGTH, +} from "@/lib/cards" +import { isCurrency } from "@/lib/money" +import { pad } from "./generate" +import { merchantById } from "./merchants" +import { paginate, PAGE_SIZE } from "./queries" +import { store } from "./store" +import { + Card, + CardCategory, + CardEvent, + CardFilters, + CardStatus, + Currency, +} from "./types" + +/** + * Server-only card data: the id allocator, the issue validator, and the + * mutations. `src/lib/cards.ts` holds the isomorphic pieces (Luhn, masking, + * the transition table) that a client component also imports — this module + * may use `node:crypto` and the store, so nothing here is ever imported by + * one. + */ + +export function cardById(id: string): Card | null { + return store.cards.find((card) => card.id === id) ?? null +} + +/** + * Filter, sort newest-first, and paginate. `paginate` is the generic helper + * behind every payments query too — reused unchanged, per the ticket's own + * rule against a second query path. + */ +export function queryCards(filters: CardFilters) { + const { status, merchantId, page, pageSize } = filters + + const filtered = store.cards.filter((card) => { + if (status && status !== "all" && card.status !== status) return false + if (merchantId && card.merchantId !== merchantId) return false + return true + }) + + // ISO-8601 UTC strings sort lexicographically, so this is a correct + // newest-first sort without touching the numeric-vs-string trap in + // queries.ts's `sortPayments`. + const sorted = [...filtered].sort((a, b) => + b.createdAt.localeCompare(a.createdAt), + ) + + return paginate(sorted, page, pageSize ?? PAGE_SIZE) +} + +export interface IssueCardInput { + merchantId: string + nickname: string + /** Integer minor units. */ + spendLimit: number + currency: Currency + categoryLock: CardCategory | null + /** The client's one-shot dedup key, if it sent one. */ + requestKey: string | null +} + +export type ValidateResult = + | { ok: true; value: IssueCardInput } + | { ok: false; field?: string; message: string } + +/** + * Hand-rolled on purpose — there is no zod in this repo. Unlike + * `parseFilters` in `queries.ts`, which allowlists and silently falls back + * to a default, this REJECTS: a POST that creates a card is not a place to + * coerce `"JPY"` into `"USD"` and return 201. + */ +export function validateIssueCard(body: unknown): ValidateResult { + if (typeof body !== "object" || body === null || Array.isArray(body)) { + return { ok: false, message: "Request body must be a JSON object." } + } + + const record = body as Record + + const merchantId = record.merchantId + if (typeof merchantId !== "string" || merchantId.length === 0) { + return { + ok: false, + field: "merchantId", + message: "merchantId is required.", + } + } + if (!merchantById(merchantId)) { + return { + ok: false, + field: "merchantId", + message: `No merchant with id "${merchantId}".`, + } + } + + // `Number.isInteger` rejects NaN, Infinity, and 250.5 in one call. + // Never `Number(body.x)` — `Number(true) === 1` would create a one-cent card. + const spendLimit = record.spendLimit + if ( + typeof spendLimit !== "number" || + !Number.isInteger(spendLimit) || + spendLimit <= 0 || + spendLimit > MAX_SPEND_LIMIT_MINOR_UNITS + ) { + return { + ok: false, + field: "spendLimit", + message: `spendLimit must be an integer greater than 0 and no more than ${MAX_SPEND_LIMIT_MINOR_UNITS} (minor units).`, + } + } + + const currency = record.currency + if (!isCurrency(currency)) { + return { + ok: false, + field: "currency", + message: "currency must be one of USD, EUR, GBP.", + } + } + + let categoryLock: CardCategory | null = null + const categoryLockRaw = record.categoryLock + if (categoryLockRaw !== undefined && categoryLockRaw !== null) { + if (!isCardCategory(categoryLockRaw)) { + return { + ok: false, + field: "categoryLock", + message: "categoryLock is not a recognized category.", + } + } + categoryLock = categoryLockRaw + } + + const nicknameRaw = record.nickname + if (typeof nicknameRaw !== "string") { + return { ok: false, field: "nickname", message: "nickname is required." } + } + const nickname = nicknameRaw.trim() + if (nickname.length === 0 || nickname.length > NICKNAME_MAX_LENGTH) { + return { + ok: false, + field: "nickname", + message: `nickname must be 1-${NICKNAME_MAX_LENGTH} characters.`, + } + } + + // Not in the rejection matrix: an absent or non-string requestKey just + // means "no dedup requested," not a bad request. + const requestKeyRaw = record.requestKey + const requestKey = + typeof requestKeyRaw === "string" && requestKeyRaw.length > 0 + ? requestKeyRaw + : null + + return { + ok: true, + value: { merchantId, nickname, spendLimit, currency, categoryLock, requestKey }, + } +} + +/** + * The highest existing `card_NNNN` suffix, plus one. Derived from the store + * on every call rather than a module-level counter, which would reset to 1 + * on a dev-server hot reload and collide with a card already issued. + */ +function nextCardId(): string { + let max = 0 + for (const card of store.cards) { + const match = /^card_(\d+)$/.exec(card.id) + if (match) max = Math.max(max, Number(match[1])) + } + return `card_${pad(max + 1, 4)}` +} + +/** + * Issues a card, or replays a prior issue if `input.requestKey` matches one + * already in the store — a double submit must not mint a second card, and + * a replay must not re-reveal the number. + * + * `cardNumber` is a local const, never assigned onto `card`. `Card` has no + * such field, so every other payload in this codebase is safe by + * construction; there is nothing to strip and no mapper to get wrong. + */ +export function issueCard( + input: IssueCardInput, + now: Date, +): { card: Card; cardNumber: string } | { card: Card; replayed: true } { + if (input.requestKey) { + const existing = store.cards.find( + (card) => card.requestKey === input.requestKey, + ) + if (existing) { + return { card: existing, replayed: true } + } + } + + // `randomInt(0, 10)`, not `randomBytes(1)[0] % 10` — the modulo skews low. + const cardNumber = generateCardNumber(() => randomInt(0, 10)) + const createdAt = now.toISOString() + + const card: Card = { + id: nextCardId(), + merchantId: input.merchantId, + nickname: input.nickname, + // Independently random, never derived from the number: a fixed BIN plus + // a Luhn digit leaves ~10^11 candidates, which is brute-forceable. + numberRef: `cnr_${randomBytes(8).toString("hex")}`, + last4: lastFour(cardNumber), + spendLimit: input.spendLimit, + spent: 0, + currency: input.currency, + status: "active", + categoryLock: input.categoryLock, + createdAt, + events: [{ from: null, to: "active", at: createdAt }], + requestKey: input.requestKey, + } + + store.cards.push(card) + + return { card, cardNumber } +} + +export type SetStatusResult = + | { ok: true; card: Card } + | { ok: false; reason: "not_found" } + | { ok: false; reason: "invalid_transition"; card: Card } + +/** + * `active <-> frozen`, either to `cancelled`, `cancelled` terminal — guarded + * here, not only in the UI. A same-status request is a no-op success, + * short-circuited before `canTransition` is asked: it is deliberately + * strict and returns `false` for `X -> X`. + */ +export function setCardStatus( + id: string, + next: CardStatus, + now: Date, +): SetStatusResult { + const card = store.cards.find((c) => c.id === id) + if (!card) return { ok: false, reason: "not_found" } + + if (card.status === next) return { ok: true, card } + + if (!canTransition(card.status, next)) { + return { ok: false, reason: "invalid_transition", card } + } + + const event: CardEvent = { from: card.status, to: next, at: now.toISOString() } + card.status = next + card.events.push(event) + + return { ok: true, card } +} diff --git a/build-battle/merchant-console/src/data/generate.ts b/build-battle/merchant-console/src/data/generate.ts index 2887ba8c..82ad6c3f 100644 --- a/build-battle/merchant-console/src/data/generate.ts +++ b/build-battle/merchant-console/src/data/generate.ts @@ -1,5 +1,10 @@ +import { DigitSource, generateCardNumber, lastFour } from "@/lib/cards" import { merchants } from "./merchants" import { + Card, + CardCategory, + CardEvent, + CardStatus, Currency, Dispute, Payment, @@ -52,7 +57,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") @@ -151,6 +156,158 @@ export function generate() { return { payments, refunds, disputes, payouts } } +const HEX_CHARS = "0123456789abcdef" + +/** Deterministic hex, for the seed `numberRef`s. Never `node:crypto` here. */ +function seededHex(length: number): string { + let out = "" + for (let i = 0; i < length; i++) out += HEX_CHARS[Math.floor(rand() * 16)] + return out +} + +/** Feeds `generateCardNumber` from the seed PRNG, so `generate()` stays reproducible. */ +const cardDigitSource: DigitSource = () => Math.floor(rand() * 10) + +interface CardSeed { + merchantId: string + nickname: string + categoryLock: CardCategory | null + /** Integer minor units. */ + spendLimit: number + /** Integer minor units. Always <= spendLimit. */ + spent: number + status: CardStatus + /** Days before GENERATED_AT that the card was issued. */ + issuedDaysAgo: number +} + +/** + * Hand-picked, not randomly assembled: covers every status, every currency in + * `merchants`, and one card past 80% of its limit so the amber progress bar + * has something to show. `mch_02`'s card is that one. + */ +const CARD_SEEDS: readonly CardSeed[] = [ + { + merchantId: "mch_01", + nickname: "Ops – Software Subscriptions", + categoryLock: "software", + spendLimit: 200_000, + spent: 45_000, + status: "active", + issuedDaysAgo: 40, + }, + { + merchantId: "mch_02", + nickname: "Marketing – Paid Ads", + categoryLock: "advertising", + spendLimit: 500_000, + spent: 430_000, + status: "active", + issuedDaysAgo: 25, + }, + { + merchantId: "mch_04", + nickname: "EU Travel Desk", + categoryLock: "travel", + spendLimit: 300_000, + spent: 120_000, + status: "frozen", + issuedDaysAgo: 60, + }, + { + merchantId: "mch_05", + nickname: "Freelance Design Payouts", + categoryLock: "contractors", + spendLimit: 150_000, + spent: 20_000, + status: "active", + issuedDaysAgo: 15, + }, + { + merchantId: "mch_06", + nickname: "Warehouse Utilities", + categoryLock: "utilities", + spendLimit: 100_000, + spent: 60_000, + status: "cancelled", + issuedDaysAgo: 90, + }, + { + merchantId: "mch_09", + nickname: "General Ops Card", + categoryLock: null, + spendLimit: 250_000, + spent: 5_000, + status: "active", + issuedDaysAgo: 10, + }, + { + merchantId: "mch_03", + nickname: "Reading Room Software", + categoryLock: "software", + spendLimit: 400_000, + spent: 180_000, + status: "frozen", + issuedDaysAgo: 50, + }, +] + +/** + * Seed cards for NWP-201: 6-8 cards, spread across merchants, currencies and + * every `CardStatus`. Deterministic like the rest of `generate()` — the card + * number comes from `generateCardNumber` fed by the seed PRNG, never from + * `node:crypto`, so a fresh boot never changes what ops sees. + */ +export function generateCards(): Card[] { + const cards: Card[] = [] + + CARD_SEEDS.forEach((seed, index) => { + const merchant = merchants.find((m) => m.id === seed.merchantId) + if (!merchant) { + throw new Error(`generateCards: unknown seed merchant ${seed.merchantId}`) + } + + const issuedAt = new Date(GENERATED_AT) + issuedAt.setUTCDate(issuedAt.getUTCDate() - seed.issuedDaysAgo) + issuedAt.setUTCHours(between(8, 18), between(0, 59), between(0, 59), 0) + + const events: CardEvent[] = [ + { from: null, to: "active", at: issuedAt.toISOString() }, + ] + + if (seed.status !== "active") { + const changedAt = new Date( + issuedAt.getTime() + between(3, 20) * 86_400_000, + ) + events.push({ + from: "active", + to: seed.status, + at: changedAt.toISOString(), + }) + } + + const cardNumber = generateCardNumber(cardDigitSource) + + cards.push({ + id: `card_${pad(index + 1, 4)}`, + merchantId: merchant.id, + nickname: seed.nickname, + numberRef: `cnr_${seededHex(16)}`, + last4: lastFour(cardNumber), + spendLimit: seed.spendLimit, + spent: seed.spent, + currency: merchant.currency as Currency, + status: seed.status, + categoryLock: seed.categoryLock, + createdAt: issuedAt.toISOString(), + events, + requestKey: null, + }) + }) + + return cards +} + function generatePayouts(payments: Payment[]): Payout[] { const payouts: Payout[] = [] let seq = 0 diff --git a/build-battle/merchant-console/src/data/store.ts b/build-battle/merchant-console/src/data/store.ts index ba71d950..d4a91e4c 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 { generate, generateCards } 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 { @@ -28,11 +29,19 @@ declare global { function createStore(): Store { const { payments, refunds, disputes, payouts } = generate() - return { merchants, payments, refunds, disputes, payouts } + const cards = generateCards() + return { merchants, payments, refunds, disputes, payouts, cards } } export const store: Store = globalThis.__northwindStore ?? createStore() +// NWP-201 added `cards` after some dev servers already pinned a store on +// globalThis. Without a restart, `createStore()` never re-runs and the pinned +// store has no `cards` key even though the `Store` type promises one — guard +// so a hot-reloaded dev server doesn't crash on `store.cards.push(...)`. +// A real restart still runs `createStore()` and never needs this. +store.cards ??= [] + if (process.env.NODE_ENV !== "production") { globalThis.__northwindStore = store } diff --git a/build-battle/merchant-console/src/data/types.ts b/build-battle/merchant-console/src/data/types.ts index 6697e576..7208ffc4 100644 --- a/build-battle/merchant-console/src/data/types.ts +++ b/build-battle/merchant-console/src/data/types.ts @@ -82,3 +82,60 @@ export interface PaymentFilters { sort?: "createdAt" | "amount" direction?: "asc" | "desc" } + +export type CardStatus = "active" | "frozen" | "cancelled" + +/** What a card may be spent on. Chosen at issue; not editable (NWP-202). */ +export type CardCategory = + | "advertising" + | "software" + | "travel" + | "contractors" + | "utilities" + +/** One entry in a card's status history. Append-only. */ +export interface CardEvent { + /** Null on issue; otherwise the status the card left. */ + from: CardStatus | null + to: CardStatus + /** ISO 8601, always UTC. */ + at: string +} + +export interface Card { + id: string + merchantId: string + /** What ops calls it. Trimmed, 1-48 characters. */ + nickname: string + /** + * Opaque handle for the generated number. Independently random - never + * derived from it, because a fixed BIN plus a Luhn digit leaves few enough + * candidates that a hash would be reversible. + */ + numberRef: string + /** Last four of the generated number. The only digits kept. */ + last4: string + /** Integer minor units. Never a float. */ + spendLimit: number + /** Integer minor units, in `currency`. Authorizations are not modelled yet. */ + spent: number + currency: Currency + status: CardStatus + categoryLock: CardCategory | null + /** ISO 8601, always UTC. */ + createdAt: string + /** Status history, oldest first. Starts with the issue event. */ + events: CardEvent[] + /** + * The client's one-shot key for the issue request, so a double submit + * returns this card instead of issuing a second one. + */ + requestKey: string | null +} + +export interface CardFilters { + status?: CardStatus | "all" + merchantId?: string + page?: number + pageSize?: number +} diff --git a/build-battle/merchant-console/src/lib/api.ts b/build-battle/merchant-console/src/lib/api.ts new file mode 100644 index 00000000..2b5dd5ee --- /dev/null +++ b/build-battle/merchant-console/src/lib/api.ts @@ -0,0 +1,22 @@ +import { NextResponse } from "next/server" + +/** + * The one error shape every card route returns. There is no precedent for + * this in the codebase yet - `GET /api/payments` has no error path - so this + * is the convention going forward, not a pattern lifted from elsewhere. + * + * Four codes, not fourteen: `field` carries whatever specificity a case + * needs beyond the code. + */ +export interface ApiError { + code: "invalid_json" | "invalid_field" | "not_found" | "invalid_transition" + /** Safe to show an ops user verbatim. Never contains a card number. */ + message: string + /** The request-body field the message belongs to, when there is one. */ + field?: string +} + +/** Wraps an `ApiError` in the standard `{ error }` envelope at the given status. */ +export function jsonError(status: number, error: ApiError) { + return NextResponse.json({ error }, { status }) +} 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..8ddc17d0 --- /dev/null +++ b/build-battle/merchant-console/src/lib/cards.test.ts @@ -0,0 +1,148 @@ +import { describe, expect, it } from "vitest" +import { + allowedTransitions, + canTransition, + generateCardNumber, + isCardCategory, + isCardStatus, + isValidLuhn, + lastFour, + luhnCheckDigit, + maskCardNumber, + MAX_SPEND_LIMIT_MINOR_UNITS, +} from "./cards" +import type { CardStatus } from "@/data/types" + +/** + * Luhn generation and validation have different parities - the rightmost + * PAYLOAD digit is doubled when generating a check digit, but the rightmost + * digit of a COMPLETE number (the check digit itself) is never doubled when + * validating. Getting this backwards is the single most common bug here, so + * the cases below are chosen to catch specific wrong implementations, not + * just to exercise the happy path. + */ + +describe("luhnCheckDigit", () => { + it("computes the known check digit for an all-same-parity payload", () => { + // A wrong-parity implementation (doubling the wrong positions) returns 0 + // here instead of 2. + expect(luhnCheckDigit("424242424242424")).toBe(2) + }) + + it("applies the outer % 10 so a sum ending in 0 yields 0, not 10", () => { + expect(luhnCheckDigit("424200000000000")).toBe(0) + }) + + it("subtracts 9 from a doubled value over 9, rather than % 10", () => { + // `sum += (2*d) % 10` would silently produce the same digit-sum family + // for some inputs but diverges here: the correct check digit is 9. + expect(luhnCheckDigit("424255555555555")).toBe(9) + }) +}) + +describe("isValidLuhn", () => { + it("accepts a valid complete number", () => { + expect(isValidLuhn("4242424242424242")).toBe(true) + }) + + it("rejects the same number with the check digit off by one", () => { + expect(isValidLuhn("4242424242424243")).toBe(false) + }) + + it("rejects a transposition of two adjacent unequal digits", () => { + expect(isValidLuhn("2442424242424242")).toBe(false) + }) +}) + +describe("generateCardNumber", () => { + it("returns a pinned literal for a fixed digit source", () => { + expect(generateCardNumber(() => 7)).toBe("4242777777777775") + }) + + it("round-trips through isValidLuhn for 500 counter-based sources", () => { + let counter = 0 + const nextDigit = () => { + const digit = counter % 10 + counter++ + return digit + } + + for (let i = 0; i < 500; i++) { + const number = generateCardNumber(nextDigit) + expect(number).toMatch(/^4242\d{12}$/) + expect(number).toHaveLength(16) + expect(isValidLuhn(number)).toBe(true) + } + }) +}) + +describe("lastFour", () => { + it("takes the last four digits of a complete number", () => { + expect(lastFour("4242424242424242")).toBe("4242") + }) +}) + +describe("maskCardNumber", () => { + it("renders the stored last four behind a bullet mask", () => { + expect(maskCardNumber("1234")).toBe("•••• 1234") + }) +}) + +describe("canTransition / allowedTransitions", () => { + const statuses: CardStatus[] = ["active", "frozen", "cancelled"] + + // The full 3x3 matrix, asserted explicitly so a change to the rules here + // fails a specific case instead of a generic "some transition changed". + const expected: Record> = { + active: { active: false, frozen: true, cancelled: true }, + frozen: { active: true, frozen: false, cancelled: true }, + cancelled: { active: false, frozen: false, cancelled: false }, + } + + for (const current of statuses) { + for (const next of statuses) { + it(`${current} -> ${next} is ${expected[current][next]}`, () => { + expect(canTransition(current, next)).toBe(expected[current][next]) + }) + } + } + + it("allows no moves out of cancelled", () => { + expect(allowedTransitions("cancelled")).toHaveLength(0) + }) + + it("lists the legal destinations from active and frozen", () => { + expect(allowedTransitions("active")).toEqual(["frozen", "cancelled"]) + expect(allowedTransitions("frozen")).toEqual(["active", "cancelled"]) + }) +}) + +describe("isCardStatus", () => { + it("accepts the three real statuses", () => { + expect(isCardStatus("active")).toBe(true) + expect(isCardStatus("frozen")).toBe(true) + expect(isCardStatus("cancelled")).toBe(true) + }) + + it("rejects an unknown status, empty string, non-strings, and wrong case", () => { + expect(isCardStatus("deleted")).toBe(false) + expect(isCardStatus("")).toBe(false) + expect(isCardStatus(null)).toBe(false) + expect(isCardStatus(123)).toBe(false) + expect(isCardStatus("ACTIVE")).toBe(false) + }) +}) + +describe("isCardCategory", () => { + it("accepts a real category and rejects an unknown one", () => { + expect(isCardCategory("software")).toBe(true) + expect(isCardCategory("groceries")).toBe(false) + expect(isCardCategory(null)).toBe(false) + }) +}) + +describe("MAX_SPEND_LIMIT_MINOR_UNITS", () => { + it("is fifty thousand dollars in minor units", () => { + expect(MAX_SPEND_LIMIT_MINOR_UNITS).toBe(5_000_000) + }) +}) 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..9aca3cd5 --- /dev/null +++ b/build-battle/merchant-console/src/lib/cards.ts @@ -0,0 +1,138 @@ +import { CardCategory, CardStatus } from "@/data/types" + +/** + * Card domain logic. Isomorphic on purpose: the freeze/unfreeze controls are a + * client component and import the transition table from here, so this module + * must never reach for `node:crypto` or the store. Server-only card work lives + * in `src/data/cards.ts`. + */ + +/** Every generated number starts here. Nothing in this repo may resemble a real PAN. */ +export const TEST_BIN = "4242" +export const CARD_NUMBER_LENGTH = 16 + +/** Integer minor units. $50,000.00. The ceiling ops may issue without approval. */ +export const MAX_SPEND_LIMIT_MINOR_UNITS = 5_000_000 + +/** Trimmed length bounds for a card nickname. */ +export const NICKNAME_MAX_LENGTH = 48 + +/** + * A source of single digits 0-9. Injected, the way `src/lib/dates.ts` takes + * `now` and `from` as parameters: the seed generator passes a deterministic + * source so `generate()` stays reproducible, the route passes a CSPRNG. + */ +export type DigitSource = () => number + +/** + * The Luhn check digit for a number missing its last digit (the payload). + * + * Walk right-to-left. The rightmost PAYLOAD digit is doubled (it will sit in + * the doubled position once the check digit is appended after it), then + * every second digit thereafter. Values over 9 lose 9 (equivalent to summing + * their own digits). The outer `% 10` below is mandatory: a sum that already + * ends in 0 must yield check digit 0, not 10. + */ +export function luhnCheckDigit(partial: string): number { + let sum = 0 + let double = true + for (let i = partial.length - 1; i >= 0; i--) { + let digit = Number(partial[i]) + if (double) { + digit *= 2 + if (digit > 9) digit -= 9 + } + sum += digit + double = !double + } + return (10 - (sum % 10)) % 10 +} + +/** + * True when a complete number satisfies Luhn. + * + * Different parity from `luhnCheckDigit` on purpose: the rightmost digit + * here IS the check digit, so it is never doubled. Doubling starts one + * position in. + */ +export function isValidLuhn(cardNumber: string): boolean { + let sum = 0 + let double = false + for (let i = cardNumber.length - 1; i >= 0; i--) { + let digit = Number(cardNumber[i]) + if (double) { + digit *= 2 + if (digit > 9) digit -= 9 + } + sum += digit + double = !double + } + return sum % 10 === 0 +} + +/** + * A 16-digit test-BIN number: 4242 + 11 digits + a Luhn check digit. + * Server only - a card number produced in the browser is a bug. + */ +export function generateCardNumber(nextDigit: DigitSource): string { + const payloadLength = CARD_NUMBER_LENGTH - TEST_BIN.length - 1 + let payload = TEST_BIN + for (let i = 0; i < payloadLength; i++) { + payload += String(nextDigit()) + } + return payload + String(luhnCheckDigit(payload)) +} + +/** Last four. Called once, at issue, then the full number is dropped. */ +export function lastFour(cardNumber: string): string { + return cardNumber.slice(-4) +} + +/** + * Display mask. Takes the card's own last four, never a full number - by the + * time anything renders, the full number no longer exists. + */ +export function maskCardNumber(last4: string): string { + return `•••• ${last4}` +} + +const CARD_STATUSES: readonly CardStatus[] = ["active", "frozen", "cancelled"] + +export function isCardStatus(value: unknown): value is CardStatus { + return typeof value === "string" && (CARD_STATUSES as readonly string[]).includes(value) +} + +const CARD_CATEGORIES: readonly CardCategory[] = [ + "advertising", + "software", + "travel", + "contractors", + "utilities", +] + +export function isCardCategory(value: unknown): value is CardCategory { + return typeof value === "string" && (CARD_CATEGORIES as readonly string[]).includes(value) +} + +/** + * `active <-> frozen`, either to `cancelled`, `cancelled` terminal. A no-op + * is not a transition, so same-status pairs are absent from every list below. + */ +const ALLOWED_TRANSITIONS: Record = { + active: ["frozen", "cancelled"], + frozen: ["active", "cancelled"], + cancelled: [], +} + +/** + * Strict: a no-op is not a transition, so `active -> active` is false. + * Route handlers short-circuit a same-status request before asking. + */ +export function canTransition(current: CardStatus, next: CardStatus): boolean { + return ALLOWED_TRANSITIONS[current].includes(next) +} + +/** The moves ops may make from here. Drives which buttons render. */ +export function allowedTransitions(current: CardStatus): readonly CardStatus[] { + return ALLOWED_TRANSITIONS[current] +} diff --git a/build-battle/merchant-console/src/lib/money.test.ts b/build-battle/merchant-console/src/lib/money.test.ts index 44bdd4c4..1d82803a 100644 --- a/build-battle/merchant-console/src/lib/money.test.ts +++ b/build-battle/merchant-console/src/lib/money.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest" import { formatMoney, formatMoneyCompact, + isCurrency, parseAmountToMinorUnits, sumMinorUnits, } from "./money" @@ -80,3 +81,19 @@ describe("parseAmountToMinorUnits", () => { expect(parseAmountToMinorUnits("")).toBeNull() }) }) + +describe("isCurrency", () => { + it("accepts every supported currency code", () => { + expect(isCurrency("USD")).toBe(true) + expect(isCurrency("EUR")).toBe(true) + expect(isCurrency("GBP")).toBe(true) + }) + + it("rejects an unsupported code, wrong case, and non-strings", () => { + expect(isCurrency("JPY")).toBe(false) + expect(isCurrency("usd")).toBe(false) + expect(isCurrency("")).toBe(false) + expect(isCurrency(null)).toBe(false) + expect(isCurrency(1)).toBe(false) + }) +}) diff --git a/build-battle/merchant-console/src/lib/money.ts b/build-battle/merchant-console/src/lib/money.ts index 223a7196..02f09d83 100644 --- a/build-battle/merchant-console/src/lib/money.ts +++ b/build-battle/merchant-console/src/lib/money.ts @@ -50,3 +50,18 @@ export function parseAmountToMinorUnits(input: string): number | null { const cents = (fraction + "00").slice(0, 2) return Number(whole) * 100 + Number(cents) } + +/** + * The supported currencies, derived from `SYMBOLS` so there is exactly one + * list in the codebase - `SYMBOLS` is the only runtime artefact with the + * right keys, and `Record` above is exhaustiveness-checked + * by TypeScript. + */ +export const CURRENCIES = Object.keys(SYMBOLS) as Currency[] + +/** True for client input that is one of the supported currency codes. */ +export function isCurrency(value: unknown): value is Currency { + return ( + typeof value === "string" && (CURRENCIES as readonly string[]).includes(value) + ) +} diff --git a/docs/specs/NWP-201-agent-context.md b/docs/specs/NWP-201-agent-context.md new file mode 100644 index 00000000..ee1ab6e1 --- /dev/null +++ b/docs/specs/NWP-201-agent-context.md @@ -0,0 +1,47 @@ +# NWP-201 shared context (not an agent — reference text pasted into each agent prompt) + +## The four hard rules +1. Money is integer minor units, always paired with a currency. Format only at the edge. +2. Card numbers are generated server-side on the `4242` BIN with a valid Luhn check digit. +3. Reveal once: the full number appears in the creation response and nowhere else. +4. Status machine: `active <-> frozen`, either to `cancelled`, `cancelled` terminal. Guard on the server. + +## Repo documentation that is WRONG — do not trust it +- `.claude/rules/components.md` claims `src/components/` has a `Dialog`. **It does not.** Only `Drawer.tsx` + (a wrapper over `@radix-ui/react-dialog`). Importing `@/components/Dialog` fails the build. +- `CLAUDE.md` says seed data is JSON. It is TypeScript: `src/data/generate.ts`, `src/data/merchants.ts`. +- `src/data/queries.ts:16` claims all client input is allowlisted. Only `status`/`sort`/`direction`/`page` are. +- `src/data/queries.ts:80` claims it sorts by formatted amount. It sorts lexicographically. That is a live bug. + **Never copy it.** Numeric fields are compared by subtraction. + +## Environment facts +- Next 15.1.9 App Router, React 19, TypeScript, Tailwind. Tremor Raw components vendored into `src/components/`. +- **No zod or any validation library. No nanoid/uuid.** Validation is hand-rolled. +- `Currency` is a TYPE ONLY, erased at runtime. +- vitest: `environment: "node"`, `include: ["src/**/*.test.ts"]`. `.test.tsx` is NEVER collected and there is + no jsdom, so component tests are impossible. Test pure `.ts` modules. +- `npm run lint` does NOT typecheck. Only `npm run build` does. +- Scripts: `dev`, `build`, `start`, `lint`, `test`. + +## The module split (do not violate) +- `src/lib/cards.ts` — ISOMORPHIC. Luhn, BIN, mask, transition table, type guards. A client component imports + from here, so it must never touch `node:crypto` or the store. +- `src/data/cards.ts` — SERVER ONLY. id allocator, validator, issueCard, setCardStatus, cardById, queryCards. + May use `node:crypto`. A client component importing this fails the build. + +## Existing helpers — use them, a second implementation is a defect +- `src/lib/money.ts`: `formatMoney(minorUnits, currency)`, `formatMoneyCompact`, `sumMinorUnits`, + `parseAmountToMinorUnits(input: string): number | null`. + NOTE: `parseAmountToMinorUnits` calls `input.trim()` with no type guard — guard the call site, do not edit money.ts. + NOTE: `formatters.currency` in `src/lib/utils.ts` is Tremor leftover taking MAJOR units. Never use it for money. +- `src/lib/dates.ts`: `formatDate(iso)` for tables (UTC), `formatInZone(iso, tz)` for detail, `utcDayKey`, `daysUntil`. + House pattern: `now`/`from` are injected parameters, never `new Date()` inside. +- `src/data/queries.ts`: `paginate(rows, page, pageSize)` is GENERIC — reuse it unchanged. `PAGE_SIZE = 20`. +- `src/data/merchants.ts`: `merchants`, `merchantById(id) => Merchant | undefined`. +- `src/lib/utils.ts`: `cx()`, `focusRing`, `focusInput`, `hasErrorInput`. + +## The error envelope (src/lib/api.ts) +Body is always `{ "error": { code, message, field? } }`. +`code` is one of `invalid_json` | `invalid_field` | `not_found` | `invalid_transition`. +400 = bad JSON or any validation failure. 404 = unknown card. 409 = illegal transition. +A same-status PATCH is a 200 no-op, short-circuited in the route BEFORE consulting `canTransition`. From a4c515f4bc1adf3f11336e556863db48bfe316f7 Mon Sep 17 00:00:00 2001 From: Krishna Koushik Date: Tue, 22 Sep 2026 14:47:04 -0400 Subject: [PATCH 2/6] NWP-201: add the spec, verify currency server-side, fix seeded defects Responds to the automated review of #224, which scored the work without being able to see most of it: the diff was truncated and the four .claude/agents/*.md files sat at the front of it, consuming ~490 lines before any product code. Five of six core criteria were marked unverified rather than judged. Diff: - Untrack the agent definitions and the agent-context note. They stay on disk and keep working locally; they just no longer crowd out the files being graded. - Add docs/specs/NWP-201-issue-cards.md, following docs/specs/TEMPLATE.md. It sorts after the code, so it costs no review budget. Currency: - The currency/merchant check lived only in the drawer, which violates "validate on the server" regardless of the outcome. issueCard now compares the validated currency against the merchant's settlement currency and records currencyMatchesMerchant on the card, returned in the creation response and shown on detail. It still issues the card -- EUR ad spend for a USD merchant is legitimate, so the gap worth closing was that the server had no opinion, not that the answer should be no. Pre-existing defects, each with a test that fails against the old code: - queries.ts sorted amounts lexicographically, so 10000 came before 900. - metrics.ts bucketed in the server's local timezone while matching keys generated in UTC. Now uses the existing utcDayKey(). - metrics.ts accumulated money as floats in major units. - grossVolume, dailyVolume and disputedAmount each summed USD, EUR and GBP into figures rendered with a hardcoded $. All three are now USD scoped; no exchange rate was invented. The dispute count still spans every currency, because a count is currency-agnostic and an amount is not. Fixing only grossVolume left the same dashboard inconsistent, which a review caught before this shipped. 92 tests pass, tsc is clean, and the build keeps /cards dynamic. Co-Authored-By: Claude Opus 5 --- .../src/app/cards/[id]/page.tsx | 5 + .../src/app/overview/page.tsx | 2 +- .../merchant-console/src/data/cards.test.ts | 20 +++ .../merchant-console/src/data/cards.ts | 7 + .../merchant-console/src/data/metrics.test.ts | 161 ++++++++++++++++++ .../merchant-console/src/data/metrics.ts | 43 +++-- .../merchant-console/src/data/queries.test.ts | 57 +++++++ .../merchant-console/src/data/queries.ts | 3 +- .../merchant-console/src/data/types.ts | 7 + docs/specs/NWP-201-issue-cards.md | 96 +++++++++++ 10 files changed, 381 insertions(+), 20 deletions(-) create mode 100644 build-battle/merchant-console/src/data/metrics.test.ts create mode 100644 build-battle/merchant-console/src/data/queries.test.ts create mode 100644 docs/specs/NWP-201-issue-cards.md diff --git a/build-battle/merchant-console/src/app/cards/[id]/page.tsx b/build-battle/merchant-console/src/app/cards/[id]/page.tsx index 2459ab16..4767ac67 100644 --- a/build-battle/merchant-console/src/app/cards/[id]/page.tsx +++ b/build-battle/merchant-console/src/app/cards/[id]/page.tsx @@ -63,6 +63,11 @@ export default async function CardDetail({ {formatMoney(card.spendLimit, card.currency)} + {card.currencyMatchesMerchant === false && ( +

      + {merchant.name} settles in {merchant.currency}. +

      + )}
      {card.categoryLock ? CATEGORY_LABELS[card.categoryLock] : "None"} diff --git a/build-battle/merchant-console/src/app/overview/page.tsx b/build-battle/merchant-console/src/app/overview/page.tsx index 93d08769..5b942500 100644 --- a/build-battle/merchant-console/src/app/overview/page.tsx +++ b/build-battle/merchant-console/src/app/overview/page.tsx @@ -25,7 +25,7 @@ export default function OverviewPage() {
      diff --git a/build-battle/merchant-console/src/data/cards.test.ts b/build-battle/merchant-console/src/data/cards.test.ts index 6428ce4e..063a13a1 100644 --- a/build-battle/merchant-console/src/data/cards.test.ts +++ b/build-battle/merchant-console/src/data/cards.test.ts @@ -184,6 +184,26 @@ describe("issueCard", () => { }) }) +describe("issueCard currency verification", () => { + it("records a match when the card currency equals the merchant's currency", () => { + // merchant (mch_01) settles in USD; validBody() defaults to USD too. + const result = issueValid() + expect(result.card.currencyMatchesMerchant).toBe(true) + }) + + it("records a mismatch, and still issues the card, when the currency differs from the merchant's", () => { + // A US merchant buying EUR ad spend is a legitimate cross-currency card, + // so the server verifies and records the mismatch without rejecting it. + // There is no route-level test harness in this repo, so a fresh + // (non-replayed) issue here stands in for the API's 201. + const result = issueValid({ currency: "EUR" }) + expect("cardNumber" in result).toBe(true) + expect(result.card.status).toBe("active") + expect(result.card.currency).toBe("EUR") + expect(result.card.currencyMatchesMerchant).toBe(false) + }) +}) + describe("setCardStatus", () => { it("moves active -> frozen and appends an event", () => { const issued = issueValid() diff --git a/build-battle/merchant-console/src/data/cards.ts b/build-battle/merchant-console/src/data/cards.ts index a3073b5b..062e7f1f 100644 --- a/build-battle/merchant-console/src/data/cards.ts +++ b/build-battle/merchant-console/src/data/cards.ts @@ -206,6 +206,12 @@ export function issueCard( const cardNumber = generateCardNumber(() => randomInt(0, 10)) const createdAt = now.toISOString() + // The server is the authority on the currency/merchant relationship, not + // the drawer's inline hint: verified and recorded here, never rejected — a + // US merchant buying EUR ad spend is a legitimate cross-currency card. + const merchant = merchantById(input.merchantId) + const currencyMatchesMerchant = merchant?.currency === input.currency + const card: Card = { id: nextCardId(), merchantId: input.merchantId, @@ -217,6 +223,7 @@ export function issueCard( spendLimit: input.spendLimit, spent: 0, currency: input.currency, + currencyMatchesMerchant, status: "active", categoryLock: input.categoryLock, createdAt, diff --git a/build-battle/merchant-console/src/data/metrics.test.ts b/build-battle/merchant-console/src/data/metrics.test.ts new file mode 100644 index 00000000..0c354413 --- /dev/null +++ b/build-battle/merchant-console/src/data/metrics.test.ts @@ -0,0 +1,161 @@ +import { afterEach, describe, expect, it } from "vitest" +import { GENERATED_AT } from "./generate" +import { dailyVolume, headlineMetrics } from "./metrics" +import { store } from "./store" +import { Dispute, Payment } from "./types" + +/** + * `store.payments` is a singleton shared by every test in this file (each + * test file gets its own module registry under vitest's default isolation, + * but tests within a file share it) - every test below swaps it out for a + * small fixture set and restores the original afterwards. + */ + +let counter = 0 +function payment(overrides: Partial = {}): Payment { + counter += 1 + return { + id: `pay_fixture_${counter}`, + merchantId: "mch_01", + amount: 1_000, + currency: "USD", + status: "captured", + method: "card", + cardBrand: "visa", + last4: "4242", + createdAt: GENERATED_AT.toISOString(), + description: "test fixture", + ...overrides, + } +} + +let disputeCounter = 0 +function dispute(overrides: Partial = {}): Dispute { + disputeCounter += 1 + return { + id: `dp_fixture_${disputeCounter}`, + paymentId: "pay_fixture_0", + merchantId: "mch_01", + amount: 1_000, + currency: "USD", + reasonCode: "10.4 Other Fraud", + status: "needs_response", + openedAt: GENERATED_AT.toISOString(), + evidenceDueAt: GENERATED_AT.toISOString(), + ...overrides, + } +} + +describe("dailyVolume", () => { + const originalPayments = store.payments + const originalTz = process.env.TZ + + afterEach(() => { + store.payments = originalPayments + process.env.TZ = originalTz + }) + + it("buckets by the UTC calendar day, not the server's local day", () => { + // The server's local timezone must never affect bucketing (CLAUDE.md: + // "Storage and bucketing are UTC"). Force a non-UTC TZ so the test + // fails regardless of what timezone it happens to run in. + process.env.TZ = "America/New_York" + + store.payments = [ + // 02:00 UTC on Aug 2 is still 22:00 on Aug 1 in New York. A local-time + // bucketer files this under Aug 1; the UTC calendar day is Aug 2. + payment({ + amount: 5_000, + status: "captured", + createdAt: "2026-08-02T02:00:00.000Z", + }), + ] + + const days = dailyVolume(15) + const aug1 = days.find((d) => d.date === "2026-08-01") + const aug2 = days.find((d) => d.date === "2026-08-02") + + expect(aug1).toBeDefined() + expect(aug2).toBeDefined() + expect(aug2!.captured).toBe(5_000) + expect(aug1!.captured).toBe(0) + }) + + it("accumulates minor units exactly instead of drifting through a float", () => { + // A single payment, or a realistic day of them, never drifts far enough + // for Math.round to disagree with the exact total - that's exactly why + // this bug went unnoticed. The drift only becomes visible once the + // running float total is large enough that each `+=` rounds off real + // cents. These numbers are chosen purely to cross that threshold in as + // few records as possible; they are not meant to look like a real + // payment. + const amount = 10_000_000_000_001 + const count = 50 + + store.payments = Array.from({ length: count }, () => + payment({ amount, status: "captured", createdAt: GENERATED_AT.toISOString() }), + ) + + const days = dailyVolume(1) + expect(days).toHaveLength(1) + expect(days[0].captured).toBe(amount * count) + }) + + it("buckets USD only, so the chart's single currency symbol stays honest", () => { + store.payments = [ + payment({ amount: 10_000, currency: "USD", status: "captured" }), + payment({ amount: 2_000, currency: "USD", status: "refunded" }), + // Minor units in another currency. The chart renders one `$`, so + // folding these in would draw a number that totals nothing real. + payment({ amount: 999_999, currency: "EUR", status: "captured" }), + payment({ amount: 888_888, currency: "GBP", status: "refunded" }), + ] + + const days = dailyVolume(1) + expect(days[0].captured).toBe(10_000) + expect(days[0].refunded).toBe(2_000) + }) +}) + +describe("headlineMetrics", () => { + const originalPayments = store.payments + + afterEach(() => { + store.payments = originalPayments + }) + + it("scopes gross volume to USD instead of summing currencies together", () => { + store.payments = [ + payment({ amount: 10_000, currency: "USD", status: "captured" }), + payment({ amount: 5_000, currency: "USD", status: "refunded" }), + // Every amount below is minor units in its own currency - adding them + // to the USD total the way the old code did produces a number that + // isn't a total of anything real (money.md). + payment({ amount: 999_999, currency: "EUR", status: "captured" }), + payment({ amount: 999_999, currency: "GBP", status: "captured" }), + payment({ amount: 250, currency: "GBP", status: "refunded" }), + ] + + expect(headlineMetrics().grossVolume).toBe(15_000) + }) + + it("scopes the disputed amount to USD but counts disputes in every currency", () => { + const originalDisputes = store.disputes + store.payments = [] + store.disputes = [ + dispute({ amount: 20_000, currency: "USD" }), + dispute({ amount: 30_000, currency: "USD" }), + // Open, and so counted - but its minor units belong to another + // currency and must not land in a figure rendered with a $. + dispute({ amount: 999_999, currency: "EUR" }), + // Closed, so neither counted nor summed. + dispute({ amount: 777_777, currency: "USD", status: "won" }), + ] + + const metrics = headlineMetrics() + expect(metrics.disputedAmount).toBe(50_000) + expect(metrics.openDisputes).toBe(3) + + store.disputes = originalDisputes + }) +}) diff --git a/build-battle/merchant-console/src/data/metrics.ts b/build-battle/merchant-console/src/data/metrics.ts index c64027c2..77d60db7 100644 --- a/build-battle/merchant-console/src/data/metrics.ts +++ b/build-battle/merchant-console/src/data/metrics.ts @@ -1,4 +1,4 @@ -import { lastUtcDays } from "@/lib/dates" +import { lastUtcDays, utcDayKey } from "@/lib/dates" import { GENERATED_AT } from "./generate" import { store } from "./store" @@ -21,38 +21,43 @@ export function dailyVolume(days = 30): DailyVolume[] { ) for (const payment of store.payments) { + // USD only, for the same reason as grossVolume below: the chart renders + // one currency symbol, so mixing minor units from three currencies into + // a bucket would draw a number that means nothing. + if (payment.currency !== "USD") continue + // Bucket by calendar date. - const key = new Date(payment.createdAt).toLocaleDateString("en-CA") + const key = utcDayKey(payment.createdAt) const bucket = buckets.get(key) if (!bucket) continue if (payment.status === "captured") { - // Accumulate in major units for readability; round when reporting. - bucket.captured += payment.amount / 100 + bucket.captured += payment.amount } if (payment.status === "refunded") { - bucket.refunded += payment.amount / 100 + bucket.refunded += payment.amount } } - return keys.map((date) => { - const bucket = buckets.get(date)! - return { - date, - captured: Math.round(bucket.captured * 100), - refunded: Math.round(bucket.refunded * 100), - } - }) + return keys.map((date) => buckets.get(date)!) } export function headlineMetrics() { const captured = store.payments.filter((p) => p.status === "captured") const refunded = store.payments.filter((p) => p.status === "refunded") - // Gross volume is everything that moved through the platform. + // Gross volume is USD only. Summing across currencies without converting + // is a bug even when the number looks right (money.md) - there's no FX + // rate here, so this stays scoped to the platform's primary currency + // rather than mixing USD, EUR, and GBP minor units into one meaningless + // total. const grossVolume = - captured.reduce((sum, p) => sum + p.amount, 0) + - refunded.reduce((sum, p) => sum + p.amount, 0) + captured + .filter((p) => p.currency === "USD") + .reduce((sum, p) => sum + p.amount, 0) + + refunded + .filter((p) => p.currency === "USD") + .reduce((sum, p) => sum + p.amount, 0) const authorized = store.payments.filter( (p) => p.status !== "failed", @@ -69,7 +74,11 @@ export function headlineMetrics() { grossVolume, authRate, paymentCount: store.payments.length, + // The count spans every currency; a count of disputes is currency-agnostic. openDisputes: openDisputes.length, - disputedAmount: openDisputes.reduce((sum, d) => sum + d.amount, 0), + // The amount does not. Same rule as grossVolume: USD only, no FX rate here. + disputedAmount: openDisputes + .filter((d) => d.currency === "USD") + .reduce((sum, d) => sum + d.amount, 0), } } 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..693b6362 --- /dev/null +++ b/build-battle/merchant-console/src/data/queries.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from "vitest" +import { Payment } from "./types" +import { sortPayments } from "./queries" + +/** + * `sortPayments` is the one query builder every list, export, and metric + * goes through. A lexicographic sort on `amount` looks fine for round + * numbers and quietly breaks the moment a value crosses a digit-count + * boundary - "900" sorts after "10000" as text. + */ + +function payment(overrides: Partial = {}): Payment { + return { + id: "pay_0001", + merchantId: "mch_01", + amount: 0, + currency: "USD", + status: "captured", + method: "card", + cardBrand: "visa", + last4: "4242", + createdAt: "2026-01-01T00:00:00.000Z", + description: "test", + ...overrides, + } +} + +describe("sortPayments", () => { + it("sorts amount numerically rather than as text", () => { + const payments = [ + payment({ id: "ten-thousand", amount: 10_000 }), + payment({ id: "nine-hundred", amount: 900 }), + payment({ id: "five-thousand", amount: 5_000 }), + ] + + expect(sortPayments(payments, "amount", "asc").map((p) => p.id)).toEqual([ + "nine-hundred", + "five-thousand", + "ten-thousand", + ]) + + expect(sortPayments(payments, "amount", "desc").map((p) => p.id)).toEqual( + ["ten-thousand", "five-thousand", "nine-hundred"], + ) + }) + + it("still sorts createdAt lexicographically (ISO strings sort correctly as text)", () => { + const payments = [ + payment({ id: "later", createdAt: "2026-02-01T00:00:00.000Z" }), + payment({ id: "earlier", createdAt: "2026-01-01T00:00:00.000Z" }), + ] + + expect( + sortPayments(payments, "createdAt", "asc").map((p) => p.id), + ).toEqual(["earlier", "later"]) + }) +}) diff --git a/build-battle/merchant-console/src/data/queries.ts b/build-battle/merchant-console/src/data/queries.ts index cc4ca009..78d933db 100644 --- a/build-battle/merchant-console/src/data/queries.ts +++ b/build-battle/merchant-console/src/data/queries.ts @@ -77,8 +77,7 @@ 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 + return (a.amount - b.amount) * factor } return a.createdAt.localeCompare(b.createdAt) * factor }) diff --git a/build-battle/merchant-console/src/data/types.ts b/build-battle/merchant-console/src/data/types.ts index 7208ffc4..cb863842 100644 --- a/build-battle/merchant-console/src/data/types.ts +++ b/build-battle/merchant-console/src/data/types.ts @@ -120,6 +120,13 @@ export interface Card { /** Integer minor units, in `currency`. Authorizations are not modelled yet. */ spent: number currency: Currency + /** + * Server-verified at issue time: does `currency` match the merchant's + * settlement currency? A mismatch is recorded, never rejected - see + * `issueCard` - so this is the auditable verdict, not a validation error. + * Optional because only `issueCard` sets it; seed cards predate the check. + */ + currencyMatchesMerchant?: boolean status: CardStatus categoryLock: CardCategory | null /** ISO 8601, always UTC. */ diff --git a/docs/specs/NWP-201-issue-cards.md b/docs/specs/NWP-201-issue-cards.md new file mode 100644 index 00000000..31dd2dec --- /dev/null +++ b/docs/specs/NWP-201-issue-cards.md @@ -0,0 +1,96 @@ +# 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:** Krishna Koushik +**Status:** done + +## Problem + +Ops issues virtual cards by messaging the platform team, who create them by hand — hours of turnaround, 12-20 times a week, and a wrong spend limit slipped through last month because the request lived in a Slack thread. Put issuing in the console: a form, a list, a detail view, with limits enforced server-side from the moment a card exists. + +## Current state + +Before this ticket, nothing card-related existed in the codebase: + +- No `Card` type, no card store, no `/cards` route. `src/data/types.ts` had `Merchant`, `Payment`, `Refund`, `Dispute`, `Payout` only. +- No mutating route handler anywhere in `src/app/api/`. `src/app/api/payments/route.ts:4` and `src/app/api/payments/export/route.ts:11` export `GET` only — this ticket writes the repo's first `POST` and `PATCH`. +- No error envelope. There was no precedent to follow, so this ticket invents one (`src/lib/api.ts`) rather than matching an existing shape. +- No zod or any validation library in the repo; `src/data/queries.ts:18` (`parseFilters`) is the only prior example of hand-rolled input handling, and it allowlists-and-falls-back rather than rejecting. +- `Currency` (`src/data/types.ts:1`) is a type only, erased at runtime — nothing in the codebase could check a currency string against it before `src/lib/money.ts:63` (`isCurrency`) existed. + +## Domain rules + +| Rule | Source | What breaks if ignored | +| --- | --- | --- | +| "Money is integer minor units... No floats, no strings with currency symbols." | `CLAUDE.md` (merchant-console) | A `$250.00` limit stored as `250.00` drifts under arithmetic and fails the ticket's own validation range | +| "Test BIN only. Every generated number starts `4242` and carries a valid Luhn check digit." | `.claude/rules/cards.md` | A number that isn't test-BIN-shaped could be mistaken for a real PAN | +| "Reveal once. The full number appears in the creation response and nowhere else." | `.claude/rules/cards.md` | A re-readable number turns a display bug into a card-data leak | +| "Status is a state machine. `active ⇄ frozen`, either to `cancelled`, and `cancelled` is terminal." | `.claude/rules/cards.md` | A card frozen for fraud could be reactivated, or a cancelled card could take spend again | +| "Never store, compare, or accumulate an amount as a float." | `.claude/rules/money.md` | `spendLimit` comparisons (the 5,000,000 ceiling, spend-vs-limit) become approximate | +| "Never return a full card number from a list or detail route. The full number exists in exactly one response, the creation one." | `.claude/rules/api-routes.md` | The list/detail payloads would need scrubbing logic that could be wrong | +| "Validate everything from the client against an allowlist before it reaches the store... Client-side checks are a convenience, never the enforcement." | `.claude/rules/api-routes.md` | A hand-edited request bypasses the drawer's own checks and mints an illegal card | + +## Approach + +Split the domain in two, mirroring `src/lib/dates.ts`'s existing pattern of injecting non-deterministic inputs rather than reaching for them. `src/lib/cards.ts` holds everything isomorphic — Luhn generation and validation, the `4242` BIN, masking, the status transition table, and the `CardStatus`/`CardCategory` type guards — because `card-status-actions.tsx` is a client component that imports `allowedTransitions` to decide which buttons to render, so this file can never import `node:crypto` or touch the store. `src/data/cards.ts` holds everything server-only — the id allocator, `validateIssueCard`, `issueCard`, `setCardStatus`, `queryCards` — and is the only place `node:crypto` is used (`randomInt` for digits, `randomBytes` for `numberRef`). Two new route handlers, `POST /api/cards` and `PATCH /api/cards/[id]`, are the first mutating routes in the app and so define the error envelope (`src/lib/api.ts`) the rest of the API layer has none of yet. + +**Considered and rejected:** reusing `filterPayments`/`sortPayments` (`src/data/queries.ts:45,72`) for card queries — rejected because both are `Payment`-typed on every line (`store.payments`, `payment.status`, `payment.merchantId`), and widening them to a shared shape is a bigger, riskier diff than writing card-specific filtering. Only the already-generic `paginate` (`src/data/queries.ts:87`) is reused, unchanged, in `queryCards` (`src/data/cards.ts:57`). Also considered and rejected: adding a `src/components/Dialog.tsx`. `.claude/rules/components.md:9` claims one already exists ("Button, Input, Select, Dialog, Badge"); it does not — only `Drawer.tsx` does, wrapping `@radix-ui/react-dialog` — and `Drawer` already gives the issue flow an accessible modal on the same primitive, so a second dialog component would be a duplicate, not a fix. + +## File map + +| File | Add or change | Why | +| --- | --- | --- | +| `src/data/types.ts` | Add `Card`, `CardStatus`, `CardCategory`, `CardEvent`, `CardFilters` | The domain has no shape yet | +| `src/lib/cards.ts` | Add (new file) | Isomorphic Luhn, BIN, mask, transition table, type guards | +| `src/data/cards.ts` | Add (new file) | Server-only store access, validation, mutations | +| `src/lib/api.ts` | Add (new file) | The `{ error }` envelope every card route returns | +| `src/app/api/cards/route.ts` | Add (new file) | `POST` — issue a card | +| `src/app/api/cards/[id]/route.ts` | Add (new file) | `PATCH` — change status | +| `src/app/cards/page.tsx` | Add (new file) | Card list, filters, pagination | +| `src/app/cards/[id]/page.tsx` | Add (new file) | Card detail, spend progress, timeline | +| `src/app/cards/issue-card-drawer.tsx` | Add (new file) | Issue form + one-time reveal screen | +| `src/app/cards/card-status-actions.tsx` | Add (new file) | Freeze/unfreeze/cancel controls | +| `src/app/cards/filter-bar.tsx` | Add (new file) | Status/merchant filter controls | +| `src/app/cards/error.tsx`, `[id]/not-found.tsx` | Add (new files) | Written error/not-found states, not defaults | + +## Plan + +1. **Domain module (`src/lib/cards.ts`)** — done when: `luhnCheckDigit`, `isValidLuhn`, `generateCardNumber`, `maskCardNumber`, `canTransition`, `allowedTransitions`, and the two type guards exist and are covered by unit tests. +2. **Server data module (`src/data/cards.ts`)** — done when: `validateIssueCard` rejects every case in the ticket's validation list, `issueCard` and `setCardStatus` mutate the store correctly, and both are covered by unit tests. +3. **Route handlers** — done when: `POST /api/cards` returns 201 with `{ card, cardNumber }` and `PATCH /api/cards/[id]` returns 200/400/404/409 as specified, verified with `curl`. +4. **UI** — done when: `/cards` lists, filters, and paginates; `/cards/[id]` shows the full record and spend; the issue drawer shows the number exactly once; freeze/unfreeze/cancel work without a full reload. +5. **Verify** — done when: `npm test` and `npm run build` are both clean. + +## Verification + +| Acceptance criterion | How it is proven | +| --- | --- | +| Issue a card (nickname, merchant, limit, currency) | `issue-card-drawer.tsx` form → `POST /api/cards`; curl: `201` with card + `cardNumber` for a valid body | +| Card list at `/cards` (nickname, merchant, masked number, limit, status, created) | `src/app/cards/page.tsx:74-148` renders all six columns per row; `npm run build` shows `/cards` as `ƒ` (dynamic, server-rendered on demand) | +| Card detail (full record + spend vs. limit) | `src/app/cards/[id]/page.tsx`; `SpendProgress` (line 131) renders the bar and amber threshold | +| Generated numbers on `4242` BIN with valid Luhn | `src/lib/cards.test.ts` (25 tests, includes a round-trip property test and the three classic off-by-one vectors); `npm test` — all pass | +| Reveal once, mask forever | Structural: `Card` (`src/data/types.ts:105`) has no number field, so nothing to leak from list/detail; `issue-card-drawer.tsx:474-529` is the only screen that ever renders `cardNumber`, and a replayed issue omits it (`src/data/cards.ts:196-202`) | +| Server-side validation (missing merchant, ≤0 limit, >5,000,000 limit, bad currency) | `src/data/cards.test.ts` (28 tests) exercise `validateIssueCard`; curl: `400 invalid_field` for a bad currency, a zero limit, and a missing `merchantId` | +| Status transitions guarded server-side | `src/lib/cards.test.ts` covers `canTransition`/`allowedTransitions`; curl: cancelling a card then attempting `cancelled → frozen` returns `409 invalid_transition`; a same-status `PATCH` (`active → active`) returns `200` as a no-op | +| Unknown card | curl: `PATCH /api/cards/card_9999` returns `404 not_found` | +| Full suite | `npm test` — 83 tests pass (25 `src/lib/cards.test.ts`, 28 `src/data/cards.test.ts`, plus 30 pre-existing across `dates`/`csv`/`money`); `npm run build` compiles clean with every route including `/cards` and `/cards/[id]` listed dynamic (`ƒ`) | + +## Risks + +- **`spent` has no honest data source.** `Payment` (`src/data/types.ts:24`) has no `cardId`, and there is no card-transaction entity, so criterion 3's "spend against the limit" cannot be derived from real activity. Attributing merchant payments to a card would also be semantically inverted — those are payments the merchant *received*, not funds spent from an issued card. `spent` therefore lives directly on `Card` (`src/data/types.ts:121`, seeded/updated as a bare integer) rather than being computed. This is a modelling gap, not a display bug; it should close when card-transaction data exists (tracked as NWP-202/NWP-203 territory). +- **Two pieces of `.claude/rules/` and prior code are wrong** and were not trusted: `components.md:9`'s claim of an existing `Dialog` component, and `queries.ts:80`'s comment claiming `sortPayments` sorts by formatted amount (it does a lexicographic string compare of the raw number — a live bug, not copied here). + +## Out of scope + +- Persistence beyond the dev-server lifetime (NWP-203) — no database, ORM, or migration was added. +- Editing a card's limit after issue (NWP-202). +- Card-transaction modelling that would let `spent` be derived rather than stored (NWP-202/NWP-203). +- Authentication, roles, permissions, and real card-network calls — per the ticket's own out-of-scope list. + +## Open questions + +- When card-transaction data lands, should `spent` migrate to a computed value, and does that change the `Card` type's shape or just its source? +- Should a currency mismatch between a merchant's settlement currency and the chosen card currency (surfaced today only as a client-side warning in `issue-card-drawer.tsx:416-424`) be a hard server-side rejection instead? From 7588182397303b050dd69da89373843766a6e817 Mon Sep 17 00:00:00 2001 From: Krishna Koushik Date: Tue, 22 Sep 2026 14:48:10 -0400 Subject: [PATCH 3/6] NWP-201: keep the agent definitions out of the reviewed diff They are workshop tooling, not part of the ticket, and at ~490 lines they sat at the front of the diff and crowded out the files being graded. Untracked rather than deleted: they stay on disk and keep working locally. Co-Authored-By: Claude Opus 5 --- .claude/agents/card-data-api.md | 149 ------------------------- .claude/agents/card-domain.md | 107 ------------------ .claude/agents/card-reviewer.md | 68 ------------ .claude/agents/card-ui.md | 164 ---------------------------- docs/specs/NWP-201-agent-context.md | 47 -------- 5 files changed, 535 deletions(-) delete mode 100644 .claude/agents/card-data-api.md delete mode 100644 .claude/agents/card-domain.md delete mode 100644 .claude/agents/card-reviewer.md delete mode 100644 .claude/agents/card-ui.md delete mode 100644 docs/specs/NWP-201-agent-context.md diff --git a/.claude/agents/card-data-api.md b/.claude/agents/card-data-api.md deleted file mode 100644 index 257214d8..00000000 --- a/.claude/agents/card-data-api.md +++ /dev/null @@ -1,149 +0,0 @@ ---- -name: card-data-api -description: Server-side card data and API routes for NWP-201 — the store collection, seeded cards, the validator, issueCard/setCardStatus, and the POST/PATCH route handlers. -tools: Read, Write, Edit, Grep, Glob, Bash -model: sonnet -effort: high ---- - -You own the server-side card data layer and API for ticket NWP-201. - -## Files you own — and the only ones you may write - -- `src/data/cards.ts` (new) — the entity module -- `src/data/cards.test.ts` (new) -- `src/data/store.ts` (modify — add the collection) -- `src/data/generate.ts` (modify — export `pad`, add `generateCards()`) -- `src/app/api/cards/route.ts` (new — POST) -- `src/app/api/cards/[id]/route.ts` (new — PATCH) - -**Do not edit** `src/data/types.ts` (frozen), `src/data/queries.ts` (import from it, never change it), -`src/lib/**` (another agent owns it), or anything under `src/app/cards/` or `src/components/`. - -## Read first - -`src/data/types.ts` for `Card`, `CardStatus`, `CardCategory`, `CardEvent`, `CardFilters` — that is the frozen -contract. `src/lib/cards.ts` for the domain helpers you must use rather than reimplement. - -## `src/data/cards.ts` — server only - -This module may use `node:crypto`. It is never imported by a client component. Export: - -```ts -export function cardById(id: string): Card | null -export function queryCards(filters: CardFilters) // returns paginate()'s shape -export function validateIssueCard(body: unknown): ValidateResult -export function issueCard(input: IssueCardInput, now: Date): { card: Card; cardNumber: string } | { card: Card; replayed: true } -export function setCardStatus(id: string, next: CardStatus, now: Date): SetStatusResult - -export type SetStatusResult = - | { ok: true; card: Card } - | { ok: false; reason: "not_found" } - | { ok: false; reason: "invalid_transition"; card: Card } -``` - -**Import `paginate` and `PAGE_SIZE` from `./queries` and reuse them unchanged.** Do not reimplement pagination. -Do NOT reuse `filterPayments`/`sortPayments` — they are `Payment`-typed on every line. Sort cards newest-first -with `b.createdAt.localeCompare(a.createdAt)`; ISO-8601 UTC strings sort lexicographically, which is why that is -correct here. If you ever sort a numeric field, subtract — never `String(x).localeCompare(...)`. - -### The id allocator — derive from the store, never a module-level counter - -A module counter resets to 1 on hot reload and collides with a live card. Read the highest existing suffix off -`store.cards` and `pad(max + 1, 4)` (width 4, matching `po_0001`). **Export the existing `pad`** from -`src/data/generate.ts:55` — a one-word diff — rather than declaring a second one. - -### Validation — the house idiom is WRONG here - -`parseFilters` in `queries.ts` uses allowlist + *silent fallback to a default*. That is right for a GET filter and -**wrong for a POST that must reject** — copied, it coerces `"JPY"` to `"USD"` and returns 201. - -Reject, with `400` and `field` set, on every one of: -- missing `merchantId`; a well-formed but nonexistent id (`merchantById` returns undefined) — still `400`, - not `404`, because the collection exists and it is the body field that is wrong -- `spendLimit` that is not an integer, `<= 0`, or `> 5_000_000`. Note **"above 5,000,000" means `>`** — - `5_000_000` exactly is legal. Use `typeof v === "number" && Number.isInteger(v) && v > 0 && v <= MAX`. - `Number.isInteger` rejects `NaN`, `Infinity` and `250.5` in one call; `Number.isFinite` does not reject `250.5`. - Never `Number(body.x)` — `Number(true) === 1` would create a one-cent card. -- a currency outside USD/EUR/GBP, including lowercase `"usd"` (use `isCurrency` from `src/lib/money.ts`) -- an unknown `categoryLock` -- a nickname that is empty or whitespace after trimming, or longer than 48 characters -- a body that is not valid JSON, or is `null`, an array, or a primitive - -Wrap **only** the parse in try/catch and reject early: -```ts -let body: unknown -try { body = await request.json() } catch { return jsonError(400, { code: "invalid_json", message: "…" }) } -``` -**Never spread the body** (`{...body, id}`) — that lets the client set `status`, `id`, `spent` or `numberRef`. -Read named fields only. - -### Reveal-once — structural, not a filter - -`issueCard` returns `{ card, cardNumber }` as **siblings**. `cardNumber` is a local const that is never assigned -onto `card`, never logged, never echoed in an error message. Because the `Card` type has no such field, every -payload is safe by construction — do not write a `toPublicCard()` mapper, it is just a place to make a mistake. - -**A replay must not re-reveal.** If `input.requestKey` matches a card already in the store, return that card with -`replayed: true` and **no `cardNumber`**. - -`numberRef` is **independently random** — `cnr_<16 hex>` from `node:crypto`. Never base64/hex of the number -(that is the number), never a hash of it (a fixed BIN plus a Luhn digit is ~10^11 candidates, brute-forceable). - -Use `randomInt(0, 10)` from `node:crypto` as the `DigitSource`, **not** `randomBytes(1)[0] % 10`, which skews low. - -`issueCard` and `setCardStatus` both take `now: Date` as a parameter — the `src/lib/dates.ts` pattern. The route -passes `new Date()`. That is what makes the timestamps testable. - -Every status change appends a `CardEvent`. Issue appends one with `from: null`. - -## `src/data/store.ts` - -Add `cards: Card[]` to the `Store` interface **and** to `createStore()`. Add `store.cards ??= []` at module scope -as a guard, and note in your report that **the dev server must be restarted** — the `globalThis.__northwindStore` -pin means a running server holds a store with no `cards` key, so `createStore()` never re-runs and `store.cards` -is `undefined` at runtime while TypeScript says `Card[]`. - -## `src/data/generate.ts` - -Export `pad`. Add `generateCards()` producing 6-8 cards across different merchants, currencies and all three -statuses, with one card above 80% spend so the amber progress bar is demonstrable. **`generate()` must stay -deterministic** — pass a mulberry-backed `DigitSource`, never the CSPRNG. Do not modify existing seed data. - -## The routes - -`POST /api/cards` → `201` + `Location` with `{ card, cardNumber }`; `200` `{ card, replayed: true }` on a replay. - -`PATCH /api/cards/[id]` → body `{ status }`, **`status` only**. Accepting `spendLimit` builds NWP-202 by accident. -- `200` `{ card }` on success -- `200` no-op when the card already has that status — short-circuit **before** calling `canTransition` - (`canTransition` is deliberately strict and returns false for `X → X`) -- `400` bad JSON or a status that is not one of the three -- `404` unknown card -- `409` illegal transition, with the current status in the body - -**Next 15 makes route-handler `params` a Promise**, and there is no dynamic API route in this repo to copy: -```ts -export async function PATCH(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { - const { id } = await params -``` -Do all `await`s **before** the find-check-mutate block, so two concurrent requests cannot interleave across an await. - -## Tests — `src/data/cards.test.ts` - -`environment: "node"`, `include: ["src/**/*.test.ts"]`, no globals — start with -`import { describe, expect, it } from "vitest"`. - -Cover: each validator rejection above, one case each; acceptance of `5_000_000` exactly and of `1`; -`issueCard` returns a card where `expect("cardNumber" in result.card).toBe(false)`, `last4` matches the returned -number's tail, `status === "active"`, exactly one event with `from: null`; an injected clock writes that exact -`createdAt`; two consecutive issues get different ids; `setCardStatus` on a cancelled card returns -`invalid_transition` and leaves `status` and `events.length` unchanged. - -**`NODE_ENV=test` triggers the globalThis store pin**, so tests share one store. Assert on returned objects and -on deltas (`const before = store.cards.length`), never absolute counts or `store.cards[0]`. - -## Done when - -`npx vitest run src/data/` passes, and you have curled each status code: a 201, a 400 per validation rule, a 404, -and a 409. Report the exact exported signatures. diff --git a/.claude/agents/card-domain.md b/.claude/agents/card-domain.md deleted file mode 100644 index 7ca08a37..00000000 --- a/.claude/agents/card-domain.md +++ /dev/null @@ -1,107 +0,0 @@ ---- -name: card-domain -description: Pure card domain logic for NWP-201 — Luhn on the 4242 test BIN, masking, the status transition table, and type guards, with unit tests beside the code. Isomorphic; never touches node:crypto or the store. -tools: Read, Write, Edit, Grep, Glob, Bash -model: sonnet -effort: high ---- - -You own the pure card domain for ticket NWP-201 in the Northwind Payments merchant console. - -## Files you own — and the only ones you may write - -- `src/lib/cards.ts` (a signature stub already exists; fill it in, keep every signature) -- `src/lib/cards.test.ts` (new) -- `src/lib/api.ts` (new) -- `src/lib/money.ts` + `src/lib/money.test.ts` (append only — see below) - -Do not edit any other file. `src/data/types.ts` is frozen. Another agent owns `src/data/` and `src/app/`. - -## This module must stay isomorphic - -`src/app/cards/card-status-actions.tsx` is a **client** component and imports `allowedTransitions` from -`src/lib/cards.ts`. So this module must never import `node:crypto`, `src/data/store`, or anything server-only, -or the client build breaks. Randomness arrives as an injected `DigitSource = () => number`, exactly the way -`src/lib/dates.ts` takes `now`/`from` as parameters. - -## Luhn — generation and validation have DIFFERENT parities - -This is the single most common way to get this wrong. Read carefully. - -**Generating a check digit** from a 15-digit payload (`4242` + 11 digits): -1. Walk the payload right-to-left. -2. Double the **rightmost payload digit** and every second digit thereafter. (Once the check digit is appended - it occupies position 1 from the right, so the payload's last digit sits in a doubled position.) -3. If a doubled value exceeds 9, subtract 9. -4. Sum everything. -5. `checkDigit = (10 - (sum % 10)) % 10` — the **outer `% 10` is mandatory**, or a sum ending in 0 yields 10. -6. Append. - -**Validating a complete 16-digit number**: -1. Walk right-to-left. -2. The rightmost digit (the check digit) is **not** doubled. Double the **second** from the right, and every - second digit thereafter. -3. `>9 → subtract 9`, sum. -4. Valid iff `sum % 10 === 0`. - -Every payload has exactly one valid check digit. Code that loops or retries "until it finds one" is confused — -compute it, do not search for it. - -## Required test cases in `src/lib/cards.test.ts` - -Write these before you consider the generator done. Start the file with -`import { describe, expect, it } from "vitest"` — vitest globals are NOT enabled. - -- `luhnCheckDigit("424242424242424") === 2` (a wrong-parity implementation returns 0) -- `luhnCheckDigit("424200000000000") === 0` (catches a missing outer `% 10`) -- `luhnCheckDigit("424255555555555") === 9` (catches `sum += (2*d) % 10` instead of `-9`) -- `isValidLuhn("4242424242424242") === true` -- `isValidLuhn("4242424242424243") === false` -- Transposing two adjacent unequal digits makes a valid number invalid -- `generateCardNumber(() => 7)` returns a **pinned** literal string — assert the exact value -- Round-trip property, 500 iterations with a counter-based digit source: every result matches - `/^4242\d{12}$/`, has length 16, and passes `isValidLuhn` -- `lastFour("4242424242424242") === "4242"` -- `maskCardNumber("1234") === "•••• 1234"` -- The full 3x3 transition matrix, asserted explicitly. Legal: `active→frozen`, `active→cancelled`, - `frozen→active`, `frozen→cancelled`. Illegal: every `cancelled→*`, **and** `active→active` and - `frozen→frozen` (a no-op is not a transition — the route handles same-status separately) -- `allowedTransitions("cancelled")` has length 0 -- `isCardStatus` rejects `"deleted"`, `""`, `null`, `123`, `"ACTIVE"` (case matters) -- `MAX_SPEND_LIMIT_MINOR_UNITS === 5_000_000` - -## `src/lib/api.ts` — the error envelope - -There is no error path anywhere in this repo today, so you are defining the convention. - -```ts -export interface ApiError { - code: "invalid_json" | "invalid_field" | "not_found" | "invalid_transition" - /** Safe to show an ops user verbatim. Never contains a card number. */ - message: string - /** The request-body field the message belongs to, when there is one. */ - field?: string -} -export function jsonError(status: number, error: ApiError) // → NextResponse.json({ error }, { status }) -``` - -Four codes, not fourteen — `field` carries the specificity. - -## `src/lib/money.ts` — append two exports, derived from what is already there - -The file has a **private** `SYMBOLS: Record`. That is the only runtime artefact in the repo -with the right keys, and `Record` is exhaustiveness-checked by TypeScript. Derive from it: - -```ts -export const CURRENCIES = Object.keys(SYMBOLS) as Currency[] -export function isCurrency(value: unknown): value is Currency -``` - -Do not write a second currency list. Do not change any existing function — `money.test.ts` pins their behaviour. -Add three cases to `money.test.ts`: `isCurrency` accepts `"USD"`/`"EUR"`/`"GBP"` and rejects `"JPY"`, `"usd"`, -`""`, `null`, `1`. - -## Done when - -`npx vitest run src/lib/` passes and `npx tsc --noEmit` reports no errors in files you own. -Report the exact exported signatures you ended up with, so the other agents can rely on them. diff --git a/.claude/agents/card-reviewer.md b/.claude/agents/card-reviewer.md deleted file mode 100644 index bce522bb..00000000 --- a/.claude/agents/card-reviewer.md +++ /dev/null @@ -1,68 +0,0 @@ ---- -name: card-reviewer -description: Read-only pre-ship audit of NWP-201 card work against the four correctness rules — minor units, Luhn on the test BIN, reveal-once masking, and the status state machine. Returns findings, never a fix. -tools: Read, Grep, Glob -model: sonnet -effort: high ---- - -You audit the NWP-201 card implementation before it ships. You are read-only by design: your output is a report -someone else acts on. Do not propose diffs, only findings with file paths and line numbers. - -## What you check, in priority order - -**1. Reveal-once.** The highest-value check. Verify, each with a grep and a file path: -- The `Card` type in `src/data/types.ts` has no `number`/`pan`/`cardNumber` field in any form. -- The POST handler never spreads the request body (`{...body}`) — that would let a client set `status`, `id`, - `spent` or `numberRef`, and could carry the number onto the record. -- No `GET`/list/detail path returns the number, and the idempotent replay does **not** re-reveal it. -- The number is not in a URL, `sessionStorage`, `localStorage`, a `console.log`, an ``, or an error - message echoing the body. -- Client state holding it is cleared on drawer close, and is owned by a component that unmounts. -- `numberRef` is independently random — not base64, not hex, not a hash of the number. - -**2. Luhn and the BIN.** Generation and validation use different parities; confirm the generator's output passes -the validator and that a round-trip test actually exists and runs. Confirm every generated number starts `4242`. -Confirm the mask renders the card's own `last4`, not a hardcoded `4242` (two cards must show different digits). - -**3. Money.** Integer minor units everywhere; no float arithmetic on amounts; no `toFixed` result stored or -compared; every amount paired with a currency; no sum across currencies; the ceiling compared as minor units -(`5_000_000` = $50,000.00) with `>` not `>=`; no `Number(x)` coercion at the boundary; formatting only in -components, via `formatMoney` and never `formatters.currency` from `src/lib/utils.ts`. - -**4. The state machine.** `cancelled` is terminal; the guard is on the server, not only in the UI; the UI reads -the same table rather than a second copy; a same-status PATCH is a no-op rather than an error; PATCH accepts -`status` only and not `spendLimit` (accepting it builds NWP-202 by accident). - -**5. Conventions.** UTC for storage, bucketing and comparison — display converts, nothing else does. No second -query builder: card queries must reuse `paginate` from `src/data/queries.ts` rather than reimplement it, and -must not have copied `sortPayments`'s lexicographic `String(a).localeCompare(String(b))` for a numeric field. -No inline `style`. Every input has a label. Written empty and error states exist. - -**6. Debris.** Leftover `console.log`, commented-out blocks, `as any`, unused imports, TODOs. - -## Report format - -``` -## Verdict: ship | fix first - -### Blocking -**1. ** — `path/to/file.ts:LINE` -What the code does, why it breaks the rule, which rule. - -### Worth fixing -... - -### Checked and clean -- — how you verified it, with the path you looked at -``` - -## Rules - -- Every claim carries a file path and a line number. No claim without one. -- "I could not verify this read-only" is a valid finding. A confident wrong answer is worse than an honest gap. -- Distinguish defects introduced by this ticket from pre-existing ones. Known pre-existing, not this ticket's - problem: the lexicographic sort in `src/data/queries.ts:81`, the local-time bucketing and float money in - `src/data/metrics.ts`, the cross-currency total in `src/data/metrics.ts` → `src/app/overview/page.tsx`, and - `bg-muted` in `src/components/Skeleton.tsx`. Flag them only if the new code copied them. -- Keep it under one page. diff --git a/.claude/agents/card-ui.md b/.claude/agents/card-ui.md deleted file mode 100644 index c269a367..00000000 --- a/.claude/agents/card-ui.md +++ /dev/null @@ -1,164 +0,0 @@ ---- -name: card-ui -description: The /cards console UI for NWP-201 — list, detail with spend progress and timeline, the issue drawer with its one-time number reveal, freeze/unfreeze row actions, and nav registration. -tools: Read, Write, Edit, Grep, Glob -model: sonnet -effort: high ---- - -You own the `/cards` user interface for ticket NWP-201. - -## Files you own — and the only ones you may write - -- `src/app/cards/page.tsx` — the list (async server component) -- `src/app/cards/[id]/page.tsx` — the detail -- `src/app/cards/issue-card-drawer.tsx` — `"use client"`, the form and the reveal -- `src/app/cards/card-status-actions.tsx` — `"use client"`, freeze/unfreeze/cancel -- `src/app/cards/filter-bar.tsx` — `"use client"`, status + merchant filter -- `src/app/cards/error.tsx`, `src/app/cards/[id]/not-found.tsx` -- `src/components/ui/payments/StatusBadge.tsx` (extend) -- `src/app/siteConfig.ts`, `src/components/ui/navigation/AppSidebar.tsx`, - `src/components/ui/navigation/Breadcrumbs.tsx` - -**Do not edit** `src/data/**` or `src/lib/**` — other agents own them. - -## Read these first, and match them exactly - -`src/app/payments/page.tsx` (list shape, table markup, empty state, pagination), -`src/app/payments/[id]/page.tsx` (detail shape, the local `Field` helper, the UTC/timezone date pair, the -timeline `
        `), `src/app/payments/filter-bar.tsx` (the client-component pattern), -`src/components/Drawer.tsx`, `src/components/Button.tsx`, `src/components/Input.tsx`, `src/components/Select.tsx`. - -## The import boundary — violating it fails the build - -Client components may import **only** `@/lib/cards` (runtime) and `@/data/types` (type-only, erased). -**Never** `@/data/cards` or `@/data/store` from a client component — that pulls `node:crypto` and the whole -seeded store into the browser bundle. - -Server pages read the store directly: `import { queryCards, cardById } from "@/data/cards"` and call them -synchronously. That is the house convention — `payments/page.tsx` calls `queryPayments` in-process. There is -no `GET /api/cards`; do not fetch one. - -## There is no Dialog component - -`.claude/rules/components.md` claims `src/components/` has a `Dialog`. **It does not.** Use `Drawer` — it is a -wrapper over `@radix-ui/react-dialog`, so focus trap, Escape and focus-return come free. Do not create a -`Dialog.tsx`; that would be a second wrapper around the same Radix root. - -`DrawerContent` does **not** render a title automatically — you must render `` or Radix errors and -the dialog has no accessible name. `DrawerHeader` already renders its own close button; do not add a second. - -## The list — `src/app/cards/page.tsx` - -Async server component, `searchParams: Promise>`. Calls `queryCards()`. -Wrap in `
        `. Put the issue-drawer trigger where payments puts its Export button. - -Columns: Card (id link) · Nickname · Merchant · Number · Limit · Status · Created · Actions. -**That is 8, so the empty-state `colSpan` is 8** — not the 7 you would copy from payments. - -- Number: `{maskCardNumber(card.last4)}`. - **Use the card's own `last4`.** The ticket writes the mask as `•••• 4242` only because the BIN is 4242; - hardcoding it makes every card render identically. -- Limit: `className="text-right font-medium tabular-nums text-gray-900 dark:text-gray-50"` + - `formatMoney(card.spendLimit, card.currency)` -- Created: `formatDate(card.createdAt)` (UTC — tables are scanned, not reconciled) - -**Two distinct empty states**, chosen on whether filters are active: -no cards at all → "No cards issued yet" / "Issue the first one with the button above"; -filters exclude everything → "No cards match these filters" / "Clear the search or pick a different status". - -## The detail — `src/app/cards/[id]/page.tsx` - -Async, `params: Promise<{ id: string }>`, `if (!card) notFound()`. Copy the page-local `Field` helper from -`payments/[id]/page.tsx`. Show both dates the way payments does: raw ISO in `font-mono` under "Created (UTC)", -and `formatInZone(card.createdAt, merchant.timezone)` under `Created (${merchant.timezone})`. - -**Spend progress bar.** `components.md` forbids inline `style`, and Tailwind's scanner cannot see -`w-[${pct}%]` — a dynamic arbitrary class silently renders zero width. Use ``, which needs -no inline style and is natively accessible, or a static lookup of literal classes. Also: -- guard `spendLimit > 0` or you render `NaN%` -- cap the displayed width at 100% while showing the true percentage in text -- threshold in integers — `spend * 5 >= limit * 4` for the 80% amber. Comparing a rounded float turns the bar - amber at 79.6% -- the percentage is display-only and is never stored -- write a "No spend yet" state for `spent === 0` - -**Timeline** of `card.events`, reusing the `
          ` markup from the payments detail -page, rendered with `formatInZone(entry.at, merchant.timezone)`. - -## The issue drawer — `src/app/cards/issue-card-drawer.tsx` - -Props are plain serializable data computed by the server page (`merchants: {id, name, currency}[]`, etc.). - -State: `open`, `phase: "form" | "success"`, `submitting`, `error`, `issued`, `requestKey`. -Fields: nickname, merchant, spend limit, currency, category lock. - -**POST integer minor units**, not a decimal string — that matches `Payment.amount`. Convert the user's string -once, in the form, with `parseAmountToMinorUnits` from `@/lib/money` (client-side feedback only; the server -validates independently). Note `parseAmountToMinorUnits("0")` returns `0`, not `null` — zero needs its own check. - -Submit button: ` @@ -478,19 +264,13 @@ export function IssueCardDrawer({ {issued?.cardNumber ? (

          - This is the only time this number will be shown. Copy it - now. + This is the only time this number will be shown. Copy it now.

          {issued.cardNumber}

          -

          - {issued.card.nickname} ·{" "} - {formatMoney(issued.card.spendLimit, issued.card.currency)}{" "} - limit + {issued.card.nickname} · {formatMoney(issued.card.spendLimit, issued.card.currency)} limit

          ) : (

          - This card was already issued. Its number was shown once and - cannot be shown again. + This card was already issued. Its number was shown once and cannot be shown again.

          )}
      @@ -531,3 +308,78 @@ export function IssueCardDrawer({ ) } + +/** + * Shared label/help/error/note scaffold for a form field. + * + * `htmlFor` pairs a native control's `