NWP-201: issue virtual cards from the console - #175
Conversation
Card records carry last4 and an opaque reference; there is deliberately no field able to hold a full number, so the PAN cannot be persisted or serialised by a later render.
sortPayments compared amounts with String(..).localeCompare, so lexicographic order beat numeric order and a 900 amount sorted after a 1000 one. Amounts are integer minor units; compare them as integers. Test fails without the fix.
Adds the /cards list, the card detail with spend progress and audit trail, and the issue drawer with a one-time reveal. Numbers are minted server-side on the 4242 test BIN with a Luhn check digit; the record stores only the trailing four and an opaque reference. Server-side: all validation and the status state machine are enforced in the route handlers, an Idempotency-Key replay returns the original card rather than minting a second, and a card's currency must match its merchant's.
Claude Code 101 — Repo Rescue🏆 Build Battle Score: 95 / 100One-line verdict: A genuinely complete submission — every core criterion, every correctness rule, and both stretch tiers land, with an honest PR description that flags its own gap (no browser check) rather than hiding it. Note: the diff was explicitly truncated (per the PR's own flag). The spec file ( Core criteria — 100 / 100 (35%)
Correctness rules — 100 / 100 (20%)
Context and planning — 70 / 100 (10%)The PR claims a spec at Code quality — 90 / 100 (15%)Tests sit beside the code they cover ( PR description — 95 / 100 (5%)Exceptionally thorough: states what was built, maps every criterion to a file, reports test/typecheck/lint results, names an unmet check honestly (no browser walk-through), and documents a real ambiguity it had to resolve. Close to the ceiling for this section. Stretch goals — 100 / 100 (15%)Tier 1: ✅ Freeze/unfreeze without reload, ✅ amber progress bar with real ARIA, ✅ category lock validated server-side and displayed, ✅ Luhn/transition unit tests, ✅ written empty/error states. Breakdown: Core (100 × 0.35) + Rules (100 × 0.20) + Context (70 × 0.10) + Quality (90 × 0.15) + PR (95 × 0.05) + Stretch (100 × 0.15) = 95 / 100 One thing to do differently next time: Actually open the browser before submitting — the honesty about skipping it is commendable, but a Select/Drawer interaction bug would have been invisible to
Powered by Anthropic and Tenex |
Ticket
Closes NWP-201
What changed
Ops can issue single-merchant virtual cards from the console instead of asking the platform team to create them by hand.
/cardslists every issued card,/cards/[id]opens one with its spend against its limit and its full status history, and cards are issued through a drawer that validates the limit as it is typed and shows the generated number exactly once.Numbers are minted server-side on the
4242test BIN with a computed Luhn check digit. TheCardinterface has no field capable of holding a full number — onlylast4and an opaquereferencethat is not derived from the PAN — so the number exists in one 201 response body and nowhere else in the app.Planned before any code was written:
docs/specs/NWP-201-issue-cards.md, grounded in the actual codebase, and the delivered file map matches it.How I verified it
npx tsc --noEmitexits 0.npx eslintclean on the new files.npm testpasses — 44 tests, 5 filessrc/lib/cards.test.ts(Luhn + state machine), 3 insrc/data/queries.test.ts(the sort fix)Acceptance criteria
Core
POST /api/cardscreates it and it appears in the list (src/app/cards/issue-card-drawer.tsx)/cardsshows nickname, merchant, masked number, limit, status, created date (src/app/cards/page.tsx)src/app/cards/[id]/page.tsx)generateCardNumber()insrc/lib/cards.ts, server-side only,4242BIN, computed Luhn check digit; 200 generated draws asserted valid insrc/lib/cards.test.tsPOST /api/cards; cleared from React state on any drawer close; masked as•••• <last4>everywhere elsesrc/app/api/cards/route.ts: unknown merchant, empty nickname, non-integer/zero/negative limit, limit over 5,000,000, currency outside USD/EUR/GBP, unknown category. Plus a 400 on a malformed JSON bodyStretch — Tier 1
PATCH+router.refresh()(src/app/cards/card-actions.tsx)isNearLimit()using integer math,role="progressbar"with aria values and the percentage in text, not colour aloneCARD_CATEGORIESserver-side, displayed on the detail pagesrc/lib/cards.test.ts{ message }renders inline in the drawer and next to the row actionsBeyond the ticket
crypto.randomUUID()per drawer session sent asIdempotency-Key, honoured server-side by aMapinsrc/data/store.ts; a replay returns the original card with nonumberand mints nothing. Submit is also disabled in flight, but the server guard is the enforcementroute.tsindependently rejects a mismatch with 400.src/data/merchants.tscarries each merchant's currencyspentis 0 at issue and stays 0. Nothing in this ticket moves money, so the bar renders 0% truthfully rather than inventing a figure; the 80% threshold is proven by unit test instead of by fake datacancelled, no actions render at all{ from, to, at, actor }tocard.events(issue event included,from: null), rendered oldest-first on the detail pageBugs fixed along the way
sortPaymentsinsrc/data/queries.tssorted amounts as text. The line wasString(a.amount).localeCompare(String(b.amount)), with a comment claiming this matched the table's display order — which is the reasoning that introduced it. Amounts are integer minor units, so lexicographic order beat numeric order and a 900 amount sorted after a 1000 one. Fixed to compare integers.src/data/queries.test.tsuses fixtures (900/1000/9999/25000) chosen so text and numeric order disagree; verified the test fails against the old line and passes against the new one.Left in place deliberately:
src/data/metrics.tshas three more real defects — it buckets by server-local date instead ofutcDayKey(), accumulates money as floats in major units, and adds refunds to gross volume instead of netting them. All are on the overview path, not the cards path. They are named in the spec's Out of scope section rather than fixed, to keep this diff reviewable.Notes for the reviewer
One ambiguity I had to resolve. The ticket says the mask is
•••• 4242, but4242is the test BIN — the number's first four digits — while the same rule says to "store the last four and a reference". Storing the BIN would make every card render identically and would not be the last four of anything. I stored the generated number's true trailing digits and mask as•••• <last4>. Every number still begins4242, asserted in tests. Flagging it because the displayed string differs from the ticket's literal example.A Tailwind constraint worth knowing.
.claude/rules/components.mdforbids inlinestyle, but Tailwind's scanner cannot see a runtime-interpolated width. The progress bar snaps to the nearest 5% from a literal class list whilearia-valuenowand the visible text report the exact percentage..claude/rules/components.mdnames aDialogcomponent that does not exist in this repo. The issue flow and the cancel confirm both usesrc/components/Drawer.tsx, which wraps@radix-ui/react-dialogand already handles the focus trap, accessible name and Escape — rather than adding a new primitive.Deliberately not built: persistence of any kind (NWP-203 — the store stays in memory on
globalThis), auth and roles (CardEvent.actoris the literal"ops"), real card network calls, and editing a limit after issue (NWP-202).