NWP-201: issue virtual cards from the console - #170
Conversation
Ops can issue a single-merchant virtual card from /cards, see every card issued, and open one to check it — replacing the Slack thread that put the wrong limit on two cards last month. Server side owns everything that matters. Numbers are generated on the 4242 test BIN with a valid Luhn check digit and exist as a full number in exactly one place: the creation response. The stored record carries the last four and an opaque reference, so no later read can surface a PAN. Spend limits are integer minor units end to end, validated against an allowlist before they reach the store. Status is a guarded state machine — active to frozen and back, either to cancelled, and cancelled is terminal. Card reads and writes go through one module, src/data/cards.ts, which returns a discriminated result the route handlers map straight onto a status code. That keeps the handlers as thin as the payments one and makes every validation boundary testable without HTTP. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude Code 101 — Repo Rescue🏆 Build Battle Score: 88 / 100One-line verdict: A genuinely thorough, boundary-tested implementation that nails every core criterion and most of Tier 1 polish, but the two Tier 2 items it gestures at (currency-matches-merchant, idempotent issue) are UI conveniences, not server guarantees — and the diff is missing the files ( Core criteria — 95 / 100 (35%)
Correctness rules — 90 / 100 (20%)
Context and planning — 70 / 100 (10%)The PR claims a spec at Code quality — 90 / 100 (15%)Tests sit at the data-layer boundary ( PR description — 95 / 100 (5%)Exceptionally detailed and honest — reports test counts, curl-verified status codes, DOM-level reveal-once verification, and an explicit "deliberately not built" list. Meets the bar for full credit. Stretch goals — 75 / 100 (15%)Tier 1 (all ✅, 0.50): freeze/unfreeze without reload ( Tier 2:
Tier 1 (0.50) + Tier 2 (0.25, audit trail only) = 0.75. Breakdown: Core (95 × 0.35) + Rules (90 × 0.20) + Context (70 × 0.10) + Quality (90 × 0.15) + PR (95 × 0.05) + Stretch (75 × 0.15) = 88 / 100 One thing to do differently next time: enforce merchant currency server-side (one line in
Powered by Anthropic and Tenex |
The close control in DrawerHeader is an icon-only button whose icon is aria-hidden, so it reached the accessibility tree with no name at all. A screen reader announced it as "button". Found while checking the issue-card drawer against the rule in .claude/rules/components.md that a dialog be operable. The Drawer is shared — the mobile sidebar in src/components/Sidebar.tsx uses it too — so this fixes both. Verified in the browser: the drawer now reports zero buttons without an accessible name, Escape closes it, and focus returns to the trigger. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Ticket
Closes NWP-201
What changed
Ops can issue a virtual card from the console instead of asking the platform team for one.
/cardslists every card issued — nickname, merchant, masked number, spend limit, status, created date — and opening one shows its full record, its spend against the limit, and its history. Cards can be frozen, unfrozen, and cancelled from either the list or the detail page without a page reload.Card numbers are generated on the server on the
4242test BIN with a valid Luhn check digit. The full number exists in exactly one response, the creation one; the stored record carries only the last four and an opaque reference, so no later read can surface a PAN. Spend limits are integer minor units end to end, and every field is checked against an allowlist on the server before it reaches the store.How I verified it
npm testpassesnpm test—Test Files 5 passed (5) / Tests 64 passed (64). 36 of those are new: 16 insrc/lib/cards.test.ts, 20 insrc/data/cards.test.ts. The generator case asserts the BIN, the sixteen-digit length, and Luhn validity across 500 generated numbers, and the transition case enumerates the whole state machine and asserts it permits exactly four edges.npx tsc --noEmit— clean.npm run lint—✔ No ESLint warnings or errors.npm run build— succeeds;/cardsand/cards/[id]both render.curlagainstnpm run dev. Every case below was run and the status code is what came back:POST /api/cardswith a valid body201, body carriescardNumber: "4242043823655733"andcard.last4: "5733"GET /api/cards,GET /api/cards/card_000005200,last4only. Grepping both payloads for the issued number returns 0 matches, and for any run of 13+ digits, 0 matches400"Choose a merchant." / "That merchant does not exist."spendLimit: 0/-1400"A spend limit must be greater than zero."spendLimit: 5000001400"A spend limit cannot exceed 5000000 minor units."spendLimit: 5000000201— the ceiling itself is allowedspendLimit: 250.5/"25000"400"A spend limit is a whole number of minor units — $250.00 is 25000."currency: "JPY"400"Currency must be one of USD, EUR, GBP."400, each naming its fieldPATCHactive→frozen, frozen→active, active→cancelled200PATCHcancelled→active, cancelled→frozen409"A cancelled card is cancelled for good."PATCHactive→active409PATCHwithstatus: "melted"400. Unknown card id →404Browser. Issued a card through the drawer: merchant Halcyon Studio, limit typed as
1,250.75, category Software. Currency switched itself to GBP when the merchant was picked. The POST returned201, the success screen showed4242 1889 3176 2762andSpend limit £1,250.75 GBP, and the list behind the drawer went from 4 cards to 5 with the new row reading•••• 2762— no page reload.Reveal-once, checked in the DOM rather than by eye. While the success screen was open, the page contained the full number. After clicking Done: not in the DOM, not in the URL, not in
localStorage(which is empty), not insessionStorage, not in a cookie. Reopening the issue drawer gives an empty form with no trace of it, and the card's own detail page shows•••• 2762.Freeze without a reload. Set a
Symbolonwindow, clicked Freeze on the detail page, and after the update the sameSymbolwas still there — the document was never replaced. The badge went Active → Frozen, the button became Unfreeze, and a "Frozen" entry appeared in the card's history.Spend bar.
/cards/card_000001renders$2,164.00 of $2,500.00,87% used · $336.00 left, with the bar amber. Itsrole="progressbar"carriesaria-valuenow="87".Empty state. Temporarily made the card seed return nothing, restarted the dev server, and loaded
/cards: the table renders "No cards issued yet / Issue one and it appears here. The full number is shown once, on the screen right after you create it." and the count reads "0 cards". The seed was then restored —git diffonsrc/data/generate.tsagainst the commit is empty.Error state. Submitted an 80-character nickname, which the form does not check but the server does. A
role="alert"banner appeared reading "A nickname is at most 60 characters.", the form kept everything that was typed, and the list stayed at 4 cards — nothing was created.Accessibility, read out of the accessibility tree. The issue drawer has
aria-labelledbyresolving to "Issue a virtual card" and focus moves into it on open. Escape closes it and focus returns to the "Issue card" trigger. Every input has a real<label for>; the three Radix comboboxes expose the names Merchant, Currency, and Category lock. After the fix below, the drawer reports zero buttons without an accessible name. Row actions are distinct per card — "Freeze Meta Ads", "Cancel Messe travel" — rather than eleven buttons called "Freeze". No orphaned labels, no skipped heading levels.Acceptance criteria
Core
/cardsshows nickname, merchant, masked number, spend limit, status, and created date.4242BIN, valid Luhn check digit. Asserted over 500 draws.USD/EUR/GBPall rejected, plus non-integer limits and unknown categories.Stretch
npm testpassing.role="alert", and a refused transition renders next to the button that was clicked.Beyond the ticket
Bugs fixed along the way
src/components/Drawer.tsx— the close button had no accessible name. The X inDrawerHeaderis an icon-onlyButtonwhoseRiCloseLineicon isaria-hidden, so it reached the accessibility tree with no name and a screen reader announced it as "button". Root cause is the missingaria-labelon theButton, not the icon. Found while checking the issue drawer against.claude/rules/components.md. The Drawer is shared, so this also fixes the mobile sidebar insrc/components/Sidebar.tsx. One line, in its own commit.Two more things I found and deliberately did not touch, because both are outside this ticket:
src/data/queries.ts:sortPaymentssorts by amount withString(a.amount).localeCompare(...), so9000sorts above25000. It is a real defect in payment sorting and it has nothing to do with cards; fixing it here would have buried it in this diff.merchant-console/CLAUDE.mdsays seed data is JSON loaded at boot. It is not — it is generated insrc/data/generate.tsby a seeded PRNG. I followed the code rather than the document and seeded cards the same way.Notes for the reviewer
Where the full number lives.
issueCardinsrc/data/cards.tsis the only function that ever holds one, in a local, long enough to take its last four. TheCardtype has no field for it, so masking is a property of the type rather than a filter a future route could forget to apply.src/data/cards.test.tsasserts the stored record and the list payload do not contain it.Why validation returns a result instead of throwing.
validateIssueInputandsetCardStatusreturn{ ok: false, status, error, field }, which the route handlers map straight onto a response. It keeps the handlers as thin asapi/payments/route.ts, and it means every boundary case is a Vitest assertion in the node environment rather than an HTTP test.The one division in card money.
src/app/cards/[id]/page.tsxdivides spend by limit — both integers — to get a percentage, never an amount.remainingis integer subtraction, andformatMoneyis the only thing that renders a decimal point.A Tailwind constraint on the progress bar.
.claude/rules/components.mdforbids inlinestyle, and Tailwind cannot build a class name from a runtime number, so the bar snaps to the nearest five percent from a fixed list of classes. The exact percentage is written beside it, so nothing is lost but a few pixels.spentis seeded, never incremented. Authorising spend against a card is not in this ticket and payments carry no card reference, so seeded cards carry a spend figure to give the bar something real to draw and newly issued cards start at zero. The alternative — inventing a join ontostore.payments— was a bigger diff pointed at the persistence work in NWP-203.Deliberately not built: persistence, auth, real network calls, and editing a limit after issue. The spec is at
docs/specs/NWP-201-issue-cards.mdand the build follows it; the only departure is that the card history was not in the original plan.One thing I could not check. There is no pre-push hook in this repository —
build-battle/CLAUDE.mdsays so explicitly — so nothing was bypassed and nothing was skipped; there simply is not one. Nothing above was run with--no-verify.🤖 Generated with Claude Code