NWP-201: issue virtual cards from the console - #173
Conversation
Plans the build against the code that exists: the shared PRNG in generate.ts that card seeding must not disturb, StatusBadge's exhaustive status maps, and the money and date helpers to reuse rather than rewrite. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Ops issued cards by messaging the platform team, hours per card and twelve to twenty a week, with the limit living in a Slack thread. Issuing now happens in the console with the limit set at creation. Numbers are generated server-side on the 4242 test BIN with a valid Luhn digit, returned exactly once in the creation response and never stored: the Card record holds the last four and an opaque reference, so no later payload has a field to leak. Status transitions are guarded in the query layer, not the UI, and cancelled is terminal. Seed cards are generated after generatePayouts() on purpose. generate.ts shares one PRNG across every record, so drawing earlier would have silently rewritten every payment, dispute and payout in the app. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude Code 101 — Repo Rescue🏆 Build Battle Score: 92 / 100One-line verdict: A genuinely shippable card-issuing flow — server-side validation, honest masking, and a real state machine all check out — let down only by missing Tier 2 stretch items and a light-touch planning trail. Core criteria — 100 / 100 (35%)
Correctness rules — 100 / 100 (20%)
Context and planning — 70 / 100 (10%)No spec file in Code quality — 90 / 100 (15%)Tests are substantial (24 new, covering Luhn, transitions, every validation branch) and sit beside the code they exercise — this reads as honest coverage, not padding. No PR description — 100 / 100 (5%)Thorough and honest: states what was built, what was verified (with concrete curl/grep evidence), what stretch was hit, and explicitly flags what's not done (route-level tests) rather than hiding it. Stretch goals — 75 / 100 (15%)Tier 1: ✅ Freeze/unfreeze without reload · ✅ Amber progress bar · ✅ Category lock · ✅ Luhn/transition tests · ✅ Written empty/error states — all five, cap 0.50. Breakdown: Core (100 × 0.35) + Rules (100 × 0.20) + Context (70 × 0.10) + Quality (90 × 0.15) + PR (100 × 0.05) + Stretch (75 × 0.15) = 92 / 100 One thing to do differently next time: Spend the last few minutes on Tier 2, not more Tier 1 polish — a cancel button with confirm and a server-side idempotency guard were both cheap relative to what was already built, and would have pulled this into the high-90s.
Powered by Anthropic and Tenex |
The form defaulted the currency to the merchant on select but left the dropdown editable, and the server never checked the pairing, so a client could issue a GBP card against a USD merchant. Validated server-side now, and the form locks the field once a merchant is chosen. 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 messaging the platform team and waiting hours. The issue form takes a nickname, merchant, spend limit and currency, and the card exists the moment it is submitted — with its limit set, so the Slack-thread-and-hope path that produced two wrong limits last month is gone.
There is a
/cardslist, a detail page showing spend against the limit, and freeze/unfreeze from either place. Card numbers are generated on the server on the4242test BIN with a valid Luhn check digit, shown once on the success screen, and masked as•••• 1234everywhere afterwards.How I verified it
npm test— 52 passing, 24 of them new insrc/lib/cards.test.ts, covering Luhn validity across 200 generated numbers, the4242BIN prefix, masking, all nine status transitions, and every validation branch.npx tsc --noEmitclean.npm run lintclean.npm run buildpasses with/cards,/cards/[id],/api/cardsand/api/cards/[id]in the route table.Against a dev server:
POST /api/cards, got4242 6177 8944 9882. Grepped for that number inGET /api/cards,GET /api/cards/:id, the/cardsHTML and the/cards/:idHTML: absent from all four. In the browser, issued a card, saw the number on the success screen, clicked Done, and confirmed the full number was gone from the DOM while•••• 9882remained.curlcalls, each returning 400 with a readable message: missing merchant, unknown merchant, zero limit, negative limit, limit of 5,000,001, currencyJPY, a non-integer limit of250.5, and a whitespace-only nickname. A limit of exactly 5,000,000 returns 201, so the boundary is inclusive.active → frozen → active → cancelledall 200.cancelled → activeandcancelled → frozenboth 409. An unknown status is 400; an unknown card is 404.125050minor units and rendered$1,250.50. Card appeared at the top of the list without a reload. Froze a card from the detail page: badge flipped to Frozen and the button became Unfreeze in place, no page reload. Opened a card at 86% spend and confirmed the bar renders amber.GET /api/payments?status=disputedstill returnspay_001610first with 33 total, identical to before this branch.Acceptance criteria
Core:
/cardswith nickname, merchant, masked number, spend limit, status and created date.4242BIN, valid Luhn.Stretch:
router.refresh()).Bugs fixed along the way
None fixed, one found and deliberately left alone:
src/data/metrics.ts:31,34andsrc/data/analytics.ts:54,73accumulate money as floats. Each doespayment.amount / 100and adds the result into a running total, so the overview charts and metrics are summing dollars as floating point rather than integer minor units. That is ORG-STANDARDS #1 ("integer minor units") and #2 ("format once, at the edge" — the division is a display concern happening inside aggregation). Root cause is that the chart components want major units and the conversion was pushed up into the data layer instead of down into the formatter.It is untouched by this ticket and fixing it would have put an unrelated change to the overview in a cards PR, so I left it. It wants its own ticket.
Notes for the reviewer
The seed generator has a trap in it.
src/data/generate.tsuses one module-levelmulberry32PRNG shared by every record it produces. Drawing from it for cards anywhere before the existing payment loop shifts every subsequent draw, silently rewriting every payment, dispute and payout in the app. Card generation is therefore appended aftergeneratePayouts(), and there is a comment saying so. The verification above includes a before/after check on payment ids for exactly this reason.Why the full number cannot leak. The
Cardtype has no field to hold it.issueCard()returns{ card, cardNumber }as two values, so the number never touches the record — a futureGETcannot accidentally serialize something that was never stored. The record keepslast4plus an opaquereference.Spend is seeded, not derived. Nothing in the store links a payment to a card, so inventing that relationship was out of scope for the clock. Seeded cards carry a
spendvalue at fixed ratios (one at 86%, one at 94%) so the amber threshold is demonstrable; newly issued cards start at zero.Category vocabulary is invented. The ticket asks for a category lock but names no categories, so
MerchantCategoryis a fixed five-value union intypes.ts, allowlisted server-side. If there is a real vocabulary somewhere, that union is the one place to change.StatusBadgewas extended rather than duplicated. Its threeRecord<AnyStatus, …>maps are exhaustive, soactive/frozen/cancelledhad to be added to all three — a miss is a build failure rather than a runtime surprise.Not done: no test covers the route handlers themselves, only the pure functions beneath them. The validation and transition logic is fully covered in
cards.test.tsand the routes are thin wrappers over it, but a request-level test would be the next thing I wrote.🤖 Generated with Claude Code