Skip to content

NWP-201: Issue virtual cards from the console - #171

Open
qFermions wants to merge 6 commits into
JJFromTenex:mainfrom
qFermions:NWP-201-issue-cards
Open

NWP-201: Issue virtual cards from the console#171
qFermions wants to merge 6 commits into
JJFromTenex:mainfrom
qFermions:NWP-201-issue-cards

Conversation

@qFermions

@qFermions qFermions commented Sep 10, 2026

Copy link
Copy Markdown

Ticket

Closes NWP-201

Plan first

The spec was written before any code and is in this diff at docs/specs/NWP-201-issue-cards.md (it sorts after build-battle/ in the diff, so it is near the end). It records the current state of the codebase with file paths, the domain rules quoted from CLAUDE.md and .claude/rules/, the approach and the alternative rejected, a file map, a sequenced plan, a verification table, and a follow-up section for the second push. Commit sequence on this branch:

  1. NWP-201: add spec for issuing virtual cards
  2. NWP-201: card model, Luhn generator, and validated issuance API
  3. NWP-201: cards list, issue drawer with one-time reveal, and card detail
  4. NWP-201: give the Drawer close button discernible text
  5. NWP-201: mark the spec done and record build departures
  6. NWP-201: match currency to merchant, idempotent issue, and status history

What changed

Ops can now issue a virtual card from the console instead of asking the platform team over Slack. A new Cards page lists every card issued in the session with its nickname, merchant, masked number, spend limit, status, and created date. The Issue card drawer takes a nickname, merchant, spend limit, and currency; the currency defaults to the chosen merchant's own currency, the form warns if ops change it, and the server rejects a currency the merchant does not settle in. Submitting generates a 16-digit number server-side on the 4242 test BIN with a valid Luhn check digit and shows it exactly once on a success screen. After that the console only ever holds and shows the last four plus an opaque number reference. Each card opens to a detail page with its full non-sensitive record, spend against limit, and a history of every status change. Cards can be frozen, unfrozen, or cancelled in place from either the list or the detail page, with the state machine enforced by the server.

A retried issue request cannot create a second card: the drawer sends an Idempotency-Key per open form and the server returns the already-issued card (without the number) for a repeated key.

Money is integer minor units end to end: the form converts what ops type with the existing parseAmountToMinorUnits, the API accepts integers only, and formatMoney renders at the edge.

How I verified it

  • npm test — 5 files, 56 tests passing, 28 of them new: src/lib/cards.test.ts covers the Luhn textbook vector, the 4242…4242 check digit, 200 generated numbers all 16 digits on the 4242 BIN with a valid check digit, every validator rejection (missing/unknown merchant, zero, negative, non-integer, over 5,000,000, non-USD/EUR/GBP, currency not matching the merchant), and the transition table including cancelled being terminal; src/data/cards.test.ts covers the idempotency key lookup, the issuance history entry, history appends on transitions, and that cancelled records nothing further.

  • npx tsc --noEmit — clean. npx next lint — no warnings or errors.

  • curl against a fresh dev server: POST /api/cards returned 201 with number starting 4242 and a one-entry history; a USD request for the GBP merchant Halcyon Studio returned 400 "Halcyon Studio settles in GBP. Choose GBP for this card."; a replay with the same Idempotency-Key returned 200 with the same card id, replayed: true, no number, and the card count stayed at 1; a malformed key returned 400; each other invalid body returned 400 with a field-scoped message; GET /api/cards and GET /api/cards/:id bodies contained no number key and no 16-digit run; the status route returned 200 for active→frozen→active, 400 for an unknown status, 404 for an unknown card, and 409 for cancelled→active; the detail body's history listed active, frozen, active with UTC times.

  • Browser, /cards: empty submit showed inline errors under nickname, merchant, and limit. Picking Halcyon Studio flipped the currency to GBP; switching it to USD showed "Halcyon Studio settles in GBP." wired into the field's aria-describedby. Submitting nickname "Ad spend Q4" with limit 1,250.75 showed the success screen with the full number and £1,250.75 GBP, and the new row appeared in the list behind the drawer without a reload. After Done, the number was gone; reopening the drawer showed a blank form; after a page reload a script scan of the HTML found no full number, zero 16-digit runs, and empty local and session storage.

  • Browser, /cards/card_000001: full record, £0.00 spent, £1,250.75 remaining · 0%, progress bar with aria-valuenow="0", a History section reading Issued, Frozen, Unfrozen with merchant-timezone times, and no full number in the HTML.

  • Browser, freeze from the list: a marker set on window before clicking Freeze survived, the row changed to Frozen and the button to Unfreeze; Unfreeze returned it to Active the same way.

  • Not verified in the browser: that the drawer's outgoing request carries the Idempotency-Key header (the browser tool shows response bodies, not request headers). The header is set in issue-card-drawer.tsx and the server side of the replay was proven with curl.

  • npm test passes

  • New behavior is covered by a test

  • Checked it in the browser

Acceptance criteria

Core

  • Issue a card. Drawer on /cards; the card appears in the list on success.
  • Card list. /cards shows nickname, merchant, masked number, spend limit, status, created date.
  • Card detail. /cards/[id] shows the full non-sensitive record, spend against the limit, and the status history.
  • Generated card numbers. Server-side, 4242 BIN, Luhn check digit, tested.
  • Reveal once, mask forever. Full number only in the 201 response and the success screen; store holds last4 and numberRef; a replayed issue response omits it; everywhere else •••• 0837.
  • Server-side validation. Missing merchant, zero or negative limit, limit above 5,000,000, non-integer limit, currency outside USD/EUR/GBP, and currency not matching the merchant all rejected with 400.

Stretch

  • Freeze and unfreeze from the list without a full page reload (also cancel, with a confirm).
  • Spend progress. Partial. The bar, the percentage, and the amber-past-80% styling are built, but nothing in the app records spend against a card yet, so spent is always 0 and the amber state was not exercised in the browser.
  • Merchant category lock. Not built.
  • Tests. Luhn generator and status transitions in src/lib/cards.test.ts, store behaviour in src/data/cards.test.ts, npm test passing.
  • Empty and error states. Written empty state on the list, field-level and form-level errors in the drawer, inline errors on status actions, 404 on an unknown card.

Beyond the ticket

  • Currency matches the merchant. Enforced in validateIssueRequest, hinted in the form.
  • Idempotent issue. Idempotency-Key header, key → card id in the store, replay returns the existing card without the number.
  • Audit trail. Card.history of every status with its UTC time, shown on the detail page.
  • Cancel from the UI with a confirm, and the cancelled state is terminal on the server.

Bugs fixed along the way

  • src/components/Drawer.tsx — the icon-only close button in DrawerHeader had no accessible name (the icon is aria-hidden and there was no text), so every drawer announced an unlabeled button. Added visually hidden "Close" text.

Notes for the reviewer

  • Card rules live in one place, src/lib/cards.ts, and the route handlers are thin so the rules are tested without Next. Store access is in src/data/cards.ts; the Card type has no number field at all, so a leak would have to be deliberate.
  • The generator uses the Web Crypto global rather than node:crypto because the drawer imports two constants from the same module and webpack refuses Node-scheme imports in a client bundle. The card number is still only ever generated inside the route handler.
  • The idempotency map lives on the in-memory store next to the cards, so it has the same lifetime as the cards it protects. A replay returns 200 rather than 201 and never re-sends the number, keeping the one-time reveal rule.
  • .claude/rules/components.md mentions a Dialog primitive; the codebase has Drawer.tsx (a Radix Dialog wrapper), which is what the issue form uses. It gives the dialog an accessible name, traps focus, and closes on Escape.
  • The progress bar width is a static Tailwind class in 5% steps rather than an inline style, per the components rule; the exact percentage is printed beside it.
  • Left out on purpose: persistence (NWP-203), limit editing (NWP-202), merchant category lock, and any seeded cards. The store starts empty, so a fresh server shows the written empty state.
  • There is no pre-push hook in this repository, so tests, types, and lint were run by hand before each push.

🤖 Generated with Claude Code

qFermions and others added 5 commits September 10, 2026 11:33
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Adds the Card record (last four and a number reference, never the
number), the 4242 test-BIN generator with a Luhn check digit, the
server-side issue validator and status state machine in src/lib/cards.ts,
and three route handlers: POST/GET /api/cards, GET /api/cards/[id], and
POST /api/cards/[id]/status. The full number appears only in the 201
issuance response.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Adds /cards (nickname, merchant, masked number, spend limit, status,
created, with a written empty state), an accessible issue drawer that
converts the typed amount with parseAmountToMinorUnits, prevents double
submission, and shows the generated number exactly once before clearing
it on close, a card detail page with spend against limit (amber past
80%), in-place freeze/unfreeze/cancel via router.refresh(), and the Cards
navigation entry. The Luhn generator now uses the Web Crypto global so
the shared card rules module has no Node-only import.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The icon-only close button had no accessible name, so screen readers
announced an unlabeled button on every drawer, including the new issue
card drawer.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@JJFromTenex

JJFromTenex commented Sep 10, 2026

Copy link
Copy Markdown
Owner

Claude Code 101 — Repo Rescue

🏆 Build Battle Score: 89 / 100

One-line verdict: A near-complete build — every core criterion, every correctness rule, and all five Tier 2 stretches (idempotency, currency match, honest spend, confirmed cancel, audit trail) are addressed, with the caveat that the diff shown is missing key implementation files (src/lib/cards.ts, src/lib/money.ts, the spec itself), so some of this is graded on strong inference from tests and call sites rather than direct sight.

Core criteria — 92 / 100 (35%)

  1. Issue a card: ✅ — Drawer takes nickname/merchant/limit/currency, posts to /api/cards, list refreshes via router.refresh().
  2. Card list: ✅ — /cards table shows nickname, merchant, masked number, limit+currency, status, created date.
  3. Card detail: ✅ — Full record, spend-vs-limit with percentage/progress bar, and history.
  4. Generated numbers: ✅ — generateCardNumber invoked server-side in the route handler, tested for 4242 BIN + Luhn over 200 samples; the generator source itself is not in this diff, so credited on strong test/usage evidence, not direct sight.
  5. Reveal once: ✅ — Number only appears in the 201 response / success screen; Card type has no number field at all, so it structurally cannot leak.
  6. Server-side validation: ✅ — validateIssueRequest called from the POST route, covers all six required checks plus the extra currency-merchant match.

Correctness rules — 95 / 100 (20%)

  • Minor units: ✅ — spendLimit/spent are integers throughout; formatting isolated to formatMoney.
  • Luhn on 4242 BIN: ✅ — Tested against textbook vector and 200 generated numbers; implementation file not shown, so this rests on the test file being honest.
  • Masking: ✅ — No number field on Card, store/list/detail never return it, drawer resets all state on close per its own comments.
  • State machine: ✅ — canTransition tested for active⇄frozen, either→cancelled, cancelled terminal, and no-op transitions rejected.
  • Server-side validation: ✅ — Route handler is the enforcement point, not the form.

Context and planning — 70 / 100 (10%)

The PR claims a spec at docs/specs/NWP-201-issue-cards.md written before code, cited by file path, with domain rules and a file map — but the spec itself is not in the diff shown (truncation), so it can't be verified directly. The commit sequence (spec → model/API → UI → a11y fix → spec closeout → stretch push) matches the description and is consistent with genuine planning rather than vibing, which is the main evidence available.

Code quality — 85 / 100 (15%)

Tests are substantial, target real behavior (Luhn, validation branches, transitions, idempotency, history), and are placed beside the code they cover. No console.log/TODO/commented-out code visible. Forms and dialogs are labelled, use aria-describedby/aria-invalid, and the drawer's close button gets a real fix for a genuine pre-existing bug (unlabeled icon-only close button in Drawer.tsx) — correctly diagnosed and fixed without breaking anything, worth the quality bonus. No DB/ORM added. Can't verify CLAUDE.md convention adherence directly since that file isn't in the diff.

PR description — 95 / 100 (5%)

Thorough, specific, and honest — reports exact test counts, curl verification steps, and explicitly flags what's not done (merchant category lock, spend derivation) rather than glossing over it. This is close to the template for how these should read.

Stretch goals — 90 / 100 (15%)

Tier 1: Freeze/unfreeze without reload ✅, spend progress bar with amber-past-80% ✅ (though spend is always 0 so amber is unexercised), merchant category lock ❌, Luhn/transition unit tests ✅, written empty/error states ✅ — 4/5 (0.40).
Tier 2: Idempotent issue ✅ (idempotency-key header + cardByIssueKey store lookup in src/app/api/cards/route.ts), currency matches merchant ✅ (validateIssueRequest + merchant-driven default in issue-card-drawer.tsx), spend honest ✅ (stated always-0, no invented numbers), cancel with confirm ✅ (window.confirm + terminal render in card-actions.tsx), audit trail ✅ (Card.history shown on detail page) — all 5 present, capped at 0.50.


Breakdown: Core (92 × 0.35) + Rules (95 × 0.20) + Context (70 × 0.10) + Quality (85 × 0.15) + PR (95 × 0.05) + Stretch (90 × 0.15) = 89 / 100

One thing to do differently next time: Include the spec file and the actual src/lib/cards.ts implementation in the diff — a submission this strong shouldn't leave its two most load-bearing artifacts (the plan and the core module) to inference from tests and callers.

The diff was too large to review in full, so only the first part was graded.


Powered by Anthropic and Tenex

…tory

Addresses the grader's Tier 2 findings on PR JJFromTenex#171:

- validateIssueRequest rejects a currency the merchant does not settle
  in, and the drawer shows the merchant's currency as a hint when they
  differ, so the wrong-currency mistake from the ticket cannot ship.
- POST /api/cards honours an Idempotency-Key header; a retry with the
  same key returns the card already issued, without the number, instead
  of minting a second one. The drawer sends one key per open form.
- Card.history records every status with its UTC time, transitionCard
  appends to it, and the detail page shows it as a timeline in the
  merchant's timezone.

Tests cover the currency rule, the key lookup, and the history.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants