NWP-201: Issue virtual cards from the console - #171
Conversation
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>
Claude Code 101 — Repo Rescue🏆 Build Battle Score: 89 / 100One-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 ( Core criteria — 92 / 100 (35%)
Correctness rules — 95 / 100 (20%)
Context and planning — 70 / 100 (10%)The PR claims a spec at 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 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). 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
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>
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 afterbuild-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 fromCLAUDE.mdand.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:NWP-201: add spec for issuing virtual cardsNWP-201: card model, Luhn generator, and validated issuance APINWP-201: cards list, issue drawer with one-time reveal, and card detailNWP-201: give the Drawer close button discernible textNWP-201: mark the spec done and record build departuresNWP-201: match currency to merchant, idempotent issue, and status historyWhat 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
4242test 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-Keyper 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, andformatMoneyrenders at the edge.How I verified it
npm test— 5 files, 56 tests passing, 28 of them new:src/lib/cards.test.tscovers the Luhn textbook vector, the4242…4242check digit, 200 generated numbers all 16 digits on the4242BIN 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.tscovers 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/cardsreturned 201 withnumberstarting4242and 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 sameIdempotency-Keyreturned 200 with the same card id,replayed: true, nonumber, 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/cardsandGET /api/cards/:idbodies contained nonumberkey 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'saria-describedby. Submitting nickname "Ad spend Q4" with limit1,250.75showed 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 witharia-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
windowbefore 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-Keyheader (the browser tool shows response bodies, not request headers). The header is set inissue-card-drawer.tsxand the server side of the replay was proven with curl.npm testpassesNew behavior is covered by a test
Checked it in the browser
Acceptance criteria
Core
/cards; the card appears in the list on success./cardsshows nickname, merchant, masked number, spend limit, status, created date./cards/[id]shows the full non-sensitive record, spend against the limit, and the status history.4242BIN, Luhn check digit, tested.last4andnumberRef; a replayed issue response omits it; everywhere else•••• 0837.Stretch
spentis always 0 and the amber state was not exercised in the browser.src/lib/cards.test.ts, store behaviour insrc/data/cards.test.ts,npm testpassing.Beyond the ticket
validateIssueRequest, hinted in the form.Idempotency-Keyheader, key → card id in the store, replay returns the existing card without the number.Card.historyof every status with its UTC time, shown on the detail page.Bugs fixed along the way
src/components/Drawer.tsx— the icon-only close button inDrawerHeaderhad no accessible name (the icon isaria-hiddenand there was no text), so every drawer announced an unlabeled button. Added visually hidden "Close" text.Notes for the reviewer
src/lib/cards.ts, and the route handlers are thin so the rules are tested without Next. Store access is insrc/data/cards.ts; theCardtype has nonumberfield at all, so a leak would have to be deliberate.node:cryptobecause 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..claude/rules/components.mdmentions a Dialog primitive; the codebase hasDrawer.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.🤖 Generated with Claude Code