NWP-201: issue virtual cards from the console - #124
Conversation
Claude Code 101 — Repo Rescue🏆 Build Battle Score: 92 / 100One-line verdict: The most complete submission of the bunch — every core criterion, every correctness rule, the whole Tier 1 polish list, and all five Tier 2 stretch items are genuinely implemented and cross-checked by tests, though the diff is truncated and never shows the Core criteria — 95 / 100 (35%)
Correctness rules — 92 / 100 (20%)
Context and planning — 85 / 100 (10%)
Code quality — 90 / 100 (15%)Tests sit beside the code they cover ( PR description — 93 / 100 (5%)Thorough and honest: reports acceptance criteria against implementation, gives curl-verified rejection cases, a real Stretch goals — 100 / 100 (15%)Tier 1: Freeze/unfreeze without reload ✅ ( Breakdown: Core (95 × 0.35) + Rules (92 × 0.20) + Context (85 × 0.10) + Quality (90 × 0.15) + PR (93 × 0.05) + Stretch (100 × 0.15) = 92 / 100 One thing to do differently next time: Put the epic in
Powered by Anthropic and Tenex |
6752af8 to
9188d82
Compare
Ticket
Closes NWP-201
What changed
Ops can issue single-merchant virtual cards from the console instead of messaging the platform team.
/cardslists every issued card with its masked number, limit, and status; the issue drawer takes a nickname, merchant, spend limit, and optional category lock, and returns the full card number exactly once on the success screen. Card detail shows the full record, spend against the limit, and a history of every status change. Numbers are generated server-side on the4242test BIN with a valid Luhn check digit, and the full number is never written to the store — only the last four and an opaque reference.The plan was written before the code:
NWP-201-issue-cards.md, committed inea8b1a5ahead of every implementation file.How I verified it
npm testpassesnpx tsc --noEmitclean.npx next lintreports no warnings or errors.Server verified by curl before any UI existed. Every rejection returns 422 with a message safe to show a user:
422 "Choose a merchant."spendLimit: 0/-5422 "Spend limit must be greater than zero."spendLimit: 5000001422 "Spend limit cannot exceed 5,000,000 minor units."currency: "JPY"422 "Currency must be USD, EUR, or GBP."422 "That merchant trades in EUR…"requestId200 replayed:true, same card id, no second card, no number in the bodyGET /api/cards/:idnumberfieldState machine, by PATCH:
active→frozen200,frozen→active200,active→cancelled200, thencancelled→activeandcancelled→frozenboth409 "A cancelled card is cancelled for good.", and a bogus status422.In the browser: issued "Contractor tools" against Halcyon Studio. The limit field showed
£1,500.00 — stored as 150000 minor units, currency derived from the merchant. The success screen showed4242 3611 4742 7880under "SHOWN ONCE". After closing, the card appears in the list as•••• 7880and the full number is absent from the DOM entirely. Froze it from the list: the row flipped to Frozen and the button to Unfreeze with no page reload. Card detail for a seeded card shows the spend bar amber at 98%.Acceptance criteria
/cardsshows nickname, merchant, masked number, spend limit, status, created date.generateCardNumberinsrc/lib/cards.ts, server-side,4242BIN, valid Luhn. 500 consecutive draws asserted valid in tests.validateIssueruns in the route handler. All six rejection cases verified above.Stretch
router.refresh(), verified no navigation occurred.role="progressbar"with aria values.role="alert"error in the issue form, refusal message surfaced from the server on a rejected transition.requestIdper open form; the server stores it and returns the existing card on a repeat. Not a UI debounce.merchants.tsalready knows each merchant's currency. The form derives it and the server verifies it independently.{from, to, at}and renders on detail.Bugs fixed along the way
src/data/queries.ts—sortPaymentscompared amounts as text. The line wasString(a.amount).localeCompare(String(b.amount)), with a comment claiming it matched what the table shows. Because amounts are integer minor units, this sorted lexicographically:9000came after10000. Live proof before the fix —GET /api/payments?sort=amount&direction=ascreturned1000, 10004, 10041, 10043…, skipping every four-digit amount above 1000. Every amount-sorted page and every amount-sorted CSV export was subtly wrong in a way that looked plausible.Fixed to a numeric comparison and pinned by
src/data/queries.test.ts, which fails against the old implementation. The existing date-sort behaviour is covered there too so the fix cannot regress it.Notes for the reviewer
NWP-201-issue-cards.md, rather than indocs/specs/. It was written there first and moved so it sits near the front of the diff — a 1,500-line diff meant the reviewer never reached it indocs/specs/.docs/specs/README.mdlinks to it.src/data/cards-seed.tsadds three backdated cards so the list is not empty on a cold start and the spend bar has something real to render. No existing seed data was edited..claude/rules/components.mdrefers to aDialogcomponent; the directory only hasDrawer.tsx, which wraps@radix-ui/react-dialog. I used it rather than adding a second dialog implementation.filterPaymentsfromsrc/data/queries.ts.spentis derived on every read rather than cached. That is fine at this data size and keeps it honest, but it is O(payments) per card and would want memoising before this list grew.