NWP-201: issue virtual cards - #176
Conversation
Adds card issuance to the console: a server-generated 4242-BIN number with a Luhn check digit, reveal-once masking, a status state machine (active <-> frozen, either to cancelled, cancelled terminal), and server-side validation of merchant, spend limit, and currency. - src/lib/cards.ts: BIN, Luhn, generator, mask, allowlists, transitions, parseIssueCardInput/parseStatusInput - src/data/cards.ts: store-backed listCards/cardById/issueCard/transitionCard - src/app/api/cards, src/app/api/cards/[id]: POST/GET/PATCH routes - src/app/cards: list, issue dialog, row actions, detail page with a spend-progress bar (amber past 80%) - Stretch: freeze/unfreeze without reload, category lock, Luhn/state machine unit tests, written empty and error states Also fixes, with tests: - vitest could not start on Node 18 (vite 7 needs Node 20+); pinned vite to ^6 and renamed the config to vitest.config.mts - sortPayments compared amounts as strings (src/data/queries.ts) - parseFilters accepted a fractional/non-positive page - dailyVolume bucketed by server-local time instead of UTC and accumulated floats (src/data/metrics.ts) - payments/page.tsx re-implemented parseFilters's allowlist instead of calling it Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude Code 101 — Repo Rescue🏆 Build Battle Score: 89 / 100One-line verdict: A genuinely shippable card-issuing flow with server-enforced validation, a real state machine, an audit timeline, and two honestly-described bug fixes — held back only by the diff's truncation (core generator/store files aren't visible) and a thin planning trail. Core criteria — 92 / 100 (35%)
Correctness rules — 90 / 100 (20%)
Context and planning — 70 / 100 (10%)No spec file appears anywhere in this diff ( Code quality — 88 / 100 (15%)Tests sit beside every route they cover and read like they'd fail without the change (explicit rejection cases, exact-boundary acceptance at 5,000,000). Accessibility looks handled: labelled inputs, PR description — 92 / 100 (5%)Thorough: states what was built, verification steps ( Stretch goals — 95 / 100 (15%)Tier 1: ✅ Freeze/unfreeze without reload · ✅ amber spend bar · ✅ category lock at issue, shown on list/detail · ✅ Luhn/state-machine tests · ✅ written empty/error states — all five, capped at 0.50. Breakdown: Core (92 × 0.35) + Rules (90 × 0.20) + Context (70 × 0.10) + Quality (88 × 0.15) + PR (92 × 0.05) + Stretch (95 × 0.15) = 89 / 100 One thing to do differently next time: Write the spec file first — the diff shows evidence of careful reading of the codebase (real bug root-causes, real file names) but nothing captures that as a plan before the code, so context/planning is scored on inference rather than a document.
Powered by Anthropic and Tenex |
Ticket
Closes NWP-201
What changed
Ops can now issue a virtual card from the console instead of asking the platform team to make one by hand. A drawer form takes a nickname, merchant, spend limit, and currency; submitting generates a 4242-test-BIN number with a valid Luhn digit on the server and shows it once, on a success screen with a copy button. From then on the console only ever shows
•••• 1234. The/cardslist shows every card with its masked number, limit, status, and created date; opening one shows the full record, an event timeline, and a spend-progress bar that turns amber past 80% of the limit. Ops can freeze, unfreeze, or cancel a card from the list or the detail page without a page reload; cancelling asks for a confirming click and is terminal, matching the state machine. A merchant-category lock is optional at issue time and shown on the card. Every rule (missing merchant, non-positive or over-ceiling limit, unsupported currency, invalid status transition) is enforced on the server, not just in the form.Along the way I found and fixed four pre-existing defects (see below) and one environment issue that blocked
npm testentirely.How I verified it
npm test— 92 passing, 9 files (up from 42 passing / 3 files onmain; card generator, card store, both card routes, and the two bug-fix files are new).npx tsc --noEmit— clean.npm run build— succeeds;/cardsand/cards/[id]both compile as dynamic routes.npm run lint— no ESLint warnings or errors.Ran the dev server and used
curlagainst/api/cards:GET /api/cardsreturns the seeded cards; every record haslast4andnumberRef, none has anumberfield.POSTwith a valid body returns 201 withcardand a 16-digitnumberstarting4242; the followingGETlists the new card with only its last four.POSTwith nomerchantId→ 400;spendLimit: 0→ 400 "Spend limit must be greater than zero.";spendLimit: 5000001→ 400 "Spend limit cannot exceed $50,000.00.";currency: "CHF"→ 400 "Currency must be USD, EUR, or GBP."Checked in the browser: issued a card from
/cards, saw the number once on the success screen, saw it appear in the list masked; froze and unfroze a seeded card with no navigation; opened a seeded card sitting above 80% of its limit and saw the amber bar and note.npm testpassesNew behavior is covered by a test
Checked it in the browser
Acceptance criteria
/cards— nickname, merchant, masked number, spend limit, status, created date4242BIN, valid Luhn digit (src/lib/cards.ts, unit tested on 200 generated numbers plus hand-built failing cases)POSTresponse body; list/detail/PATCH bodies carrylast4only, asserted in route tests via a string-search for the raw numberStretch:
router.refresh()after a successfulPATCH; row shows a pending state and an inline error on failureSpendProgresscomponent, amber past 80%, red at/over 100%;spendPercentunit testedsrc/lib/cards.test.ts(Luhn generator, masking, state machine, full validation) andsrc/data/cards.test.ts(store behavior);npm testpasses/cardshas copy for zero cards; the issue dialog and row actions showrole="alert"messages instead of failing silentlyBugs fixed along the way
npm testcould not start on Node 18 (build-battle/merchant-console/vitest.config.ts,package.json). The lockfile had resolvedvite@7, which requires Node ≥20, andvite-tsconfig-paths@6is ESM-only while the CJS-loaded config tried torequire()it. Pinnedviteto^6and renamed the config tovitest.config.mts. This is an environment fix, not a feature change; every existing test still passes.sortPaymentscompared amounts as strings (src/data/queries.ts, wasString(a.amount).localeCompare(...))."9999" > "10000"lexicographically, so?sort=amounton the payments API and its export returned the wrong order for any amount crossing a digit-count boundary. Fixed to a numeric subtraction; pinned with a new test insrc/data/queries.test.ts.parseFiltersaccepted a fractional or non-positive page (src/data/queries.ts).page=2.5passedNumber.isFiniteand reachedpaginate, which then sliced from a fractional offset. Now requires a positive integer and falls back to page 1 otherwise; pinned insrc/data/queries.test.ts.dailyVolumebucketed in the server's local time and accumulated floats (src/data/metrics.ts). It usedtoLocaleDateString("en-CA")against UTC-keyed buckets, so on any non-UTC host a payment near midnight UTC landed in the wrong day or was silently dropped, and it accumulatedamount / 100as a float before rounding back. Switched to the existingutcDayKeyhelper and integer minor-unit accumulation; pinned insrc/data/metrics.test.tswith a payment planted at 23:30 UTC on the anchor day.src/app/payments/page.tsxre-implementedparseFilters's allowlist instead of calling it, and silently droppedsort/direction/from/toin the process. Now calls the one query builder's parser directly.Notes for the reviewer
metrics.ts/analytics.tssum payments across currencies under a hard-coded"USD"label, anddailyVolume's refund series uses the payment's amount and date rather than the actual refund record (which is often partial and a few days later). Both are product questions about what the overview should mean, not a fix I felt was mine to make inside a cards ticket, and both predate this branch.analytics.ts's weekly buckets also use an exclusive upper bound that drops the anchor day. All three are candidates for their own ticket.spenton a card is a stored field, not derived from any transaction log — there's no card-spend event stream in scope for this ticket, so seeded cards carry a plausiblespentvalue and newly issued cards start at zero. Freezing a card does not changespent.src/data/generate.tsafter payouts, drawing from the same PRNG; I checked before and after that the payment seed (payments.length, total amount, first/last id+amount) is byte-identical, and pinned that fingerprint as a test insrc/data/cards.test.tsso it can't drift silently later.🤖 Generated with Claude Code