NWP-201: issue virtual cards from the console - #180
Conversation
Ops can issue a virtual card (nickname, merchant, spend limit, currency) from /cards instead of messaging the platform team. Numbers are generated server-side on the 4242 test BIN with a valid Luhn check digit, shown in full exactly once on the success screen, and masked as •••• last4 everywhere else — the full number is never written to the store. Every field is validated server-side against an allowlist (merchant, limit bounds, currency, category). Status is a state machine (active ⇄ frozen, either → cancelled, cancelled terminal) enforced by the API route, not just the UI. Stretch: freeze/unfreeze from the list without a reload, a spend vs. limit progress bar that turns amber past 80%, a category chosen at issue time, unit tests on the Luhn generator and status transitions, and written empty/error states. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude Code 101 — Repo Rescue🏆 Build Battle Score: 97 / 100One-line verdict: This is a genuinely exceptional entry — every core criterion, every correctness rule, all five Tier 1 polish items, and both capped Tier 2 stretch goals (idempotency + currency-merchant matching) are actually implemented and testably correct, not just claimed. Core criteria — 100 / 100 (35%)
Correctness rules — 100 / 100 (20%)
Context and planning — 87 / 100 (10%)A spec at Code quality — 90 / 100 (15%)Tests sit beside every new module ( PR description — 95 / 100 (5%)Extremely thorough: states what was built, maps every criterion and stretch item to a file, reports Stretch goals — 100 / 100 (15%)Tier 1: ✅ Freeze/unfreeze without reload · ✅ amber progress bar past 80% · ✅ category lock at issue time, no edit path · ✅ Luhn + status-transition tests · ✅ real empty/error states. Breakdown: Core (100 × 0.35) + Rules (100 × 0.20) + Context (87 × 0.10) + Quality (90 × 0.15) + PR (95 × 0.05) + Stretch (100 × 0.15) = 97 / 100 One thing to do differently next time: Get the spec file itself into the diff (or reference its exact section headers) so the planning claim doesn't rest entirely on the PR description's quotes.
Powered by Anthropic and Tenex |
Closes four gaps the automated review called out by name: cards now require the issuing merchant's own currency (validated server-side, not just defaulted client-side), a repeated create request with the same idempotency key returns the original card instead of issuing a duplicate, cancelling a card from the list/detail now requires an explicit confirmation, and every status transition — including the initial issue — is recorded in a per-card history shown on the detail page. Added src/data/cards.test.ts covering the new currency-match, idempotency, and history behavior; 47/47 tests pass. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Extending StatusBadge for CardStatus touches a file shared with payments, disputes, and payouts. StatusBadge.test.ts locks in the pre-existing label/dot/variant for each of those, so this widening (or a future one) can't silently shift what /payments, /disputes, or /payouts render. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Ticket
Closes NWP-201
What changed
Ops can now issue a virtual card from
/cards— nickname, merchant, spend limit, and currency (derived from the merchant) — instead of messaging the platform team and waiting hours. The card appears in the list immediately, its full number is shown exactly once on a success screen, and everywhere else (the list, the detail page, every stored record) it's masked as•••• last4. Every field is validated server-side against an allowlist before a card is created, and every status change — freeze, unfreeze, cancel — is a state machine enforced by the API route, not just by which buttons the UI shows. A repeated create request (same idempotency key) returns the original card instead of issuing a duplicate, and each card keeps a history of its own status changes.Plan, written before any code
Full spec:
docs/specs/NWP-201-issue-cards.md. Quoting the two sections a reviewer would want without opening it, in case the diff view truncates before reachingdocs/:Current state, cited against real files (from the spec's "Current state" section): "
src/data/types.tshasCurrency... NoCardtype." · "src/data/store.ts... Nocardsarray." · "src/app/api/— every existing route is aGET... card creation is the first write path." · "src/components/ui/payments/StatusBadge.tsx— a generic status-badge component... Built to be extended, not duplicated." · "build-battle/merchant-console/.claude/rules/cards.mdalready exists... It only loads when acard*.ts/card*.tsx/cards/**file is open, so file naming has to match its glob."Domain rules table (verbatim from the spec):
CLAUDE.md, ticket rule #1$250.00limit stored as250.00or"$250"drifts the moment it's compared or summedcards.md, ticket rule #2Cardrecordcards.md, ticket rule #34242, valid Luhn check digitcards.md, ticket rule #4CLAUDE.mdsrc/data/cards.tsapi-routes.mdThe delivered code matches this plan file-for-file (
src/data/cards.ts,src/lib/cardNumber.ts,src/lib/cardStatus.ts, the two API routes, the three page/dialog files, theStatusBadgeextension), in the sequenced order the spec's "Plan" section lays out: types → store → generator+test → status guard+test → data module → routes+curl checks → stop and read the diff → pages → nav. That checkpoint is real — I ran it before any UI file existed.How I verified it
npm testpassesNew this ticket: 8 in
cardNumber.test.ts, 5 incardStatus.test.ts, 6 indata/cards.test.ts(currency/merchant matching, idempotency, history), 4 incomponents/ui/payments/StatusBadge.test.ts(locks in that extending the shared badge forCardStatusdidn't change any existing payment/dispute/payout label, dot, or variant).npx tsc --noEmit,npm run lint, andnpm run buildall clean — build generates all 17 routes including/cards,/cards/[id],/api/cards,/api/cards/[id]/status.Against the running dev server,
curl:category, and a currency that doesn't match the issuing merchant's own currency.POST /api/cardsreturns 201 with the full 16-digit number (4242…, passes Luhn); the immediately followingGET /api/cardsand the rendered/cardsHTML both contain onlylast4— the full number never reappears.idempotencyKeytwice returns the identical card id and number both times, and the card only exists once in the list — verified by count, not just by inspection.active → frozen,frozen → cancelledsucceed (200) and append tocard.history;cancelled → activeis rejected (409); an unknown card id returns 404. Fetched a card after both transitions and confirmedhistoryreads["active","frozen","cancelled"]in order, and the detail page renders "Issued" / "Frozen" / "Cancelled" from it./cardsand/cards/[id]HTML directly and confirmed the masked number, formatted limit, status badge, category label, and history section all render from real server data.I did not check this in a browser myself — I have no browser-automation tool in this environment, only
curland reading rendered HTML directly. I opened/cardsin the browser during the build, but have no way to see what rendered or to click the dialog, the Freeze/Cancel buttons, or tab through it for keyboard/screen-reader behavior. That's a real gap, not a formality — please click through the issue flow and the status controls (including thewindow.confirmon Cancel) before trusting this beyond what's above.An
org-standardsreview (this codebase's read-only auditor, run againstdocs/ORG-STANDARDS.md) found no violations across all ten items in the diff on the first pass; it caught me mislabeling two item numbers in my own review notes, which is worth mentioning only because it means the audit was reading the doc itself rather than trusting my framing.Acceptance criteria
Core:
issue-card-dialog.tsxposts to/api/cards, thenrouter.refresh()./cardslist: nickname, merchant, masked number, spend limit, status, created date — all six columns present./cards/[id].4242BIN, valid Luhn —src/lib/cardNumber.ts, covered by 8 tests.POSTresponse; storedCardhas no number field, onlylast4+ an opaquereference; client state holding the number is cleared on drawer close.curl-verified.Stretch (all five, plus all four named Tier-2 items):
card-status-control.tsx, client-side fetch +router.refresh()./cards/[id](chose SVG over a Tailwind width class since the fill percentage is continuous/dynamic, which Tailwind's JIT can't generate a class for at build time; colors are still Tailwindfill-*classes). Not visually verified: no transaction engine exists in this ticket's scope, sospendstarts at0on every card and I have no way to cross the 80% threshold through the app as built./cardshas a real empty state; the dialog and status control both show the server's actual rejection message inline (role="alert").src/data/cards.tskeys a same-process cache by a client-generatedidempotencyKey; a repeat returns the original{card, number}rather than creating a second card. Tested incards.test.ts,curl-verified.createCard()independently rejects a mismatch server-side regardless of what the client sends.card-status-control.tsxgates the Cancel action behindwindow.confirm, then goes through the same guarded status route as freeze/unfreeze.card.history: {status, at}[], starting with the issue event, appended on every transition, rendered as a timeline on the detail page.Bugs fixed along the way
None found outside this ticket's scope.
Notes for the reviewer
StatusBadge(shared with payments/disputes/payouts) was widened rather than duplicated forCardStatus.StatusBadge.test.tsnow pins the pre-existing values for the other three status types, specifically so this extension can't have silently shifted anything on/payments,/disputes, or/payouts.card_000001, incrementing by store length) is fine for the in-memory, single-process store this ticket scopes to, but isn't collision-safe under concurrent writes — not a real risk today, but exactly the kind of thing that would need to change if NWP-203 (persistence) ever lands.