NWP-201: issue virtual cards from the console - #137
Conversation
sortPayments() coerced amount to a string and ran localeCompare on it, which ranks by leading digit rather than magnitude (999 > 25000 as strings). Sorting descending put small payments above large ones. Both /api/payments and the CSV export go through this comparator, so the export inherited the same wrong order. Switched to numeric subtraction, matching every other place in the codebase that already treats amount as the integer it is.
The /payments API already supported sort=amount/createdAt via query params; the table never exposed a way to use it, so ops has been eyeballing the list to find the largest payments. Date and Amount headers are now links that toggle direction (a neutral chevron on inactive sortable columns, a filled up/down arrow on the active one) and reuse the existing GET params — no client JS, no new API. Reset to page 1 on a sort change, matching how changing a filter already behaves. Also switched the page from its own hand-rolled filter parsing to the shared parseFilters() (queries.ts), which is what makes sort/direction available here at all — the page previously never read them. The filter bar now carries the active sort forward when a filter changes, so picking a status or merchant doesn't silently reset the sort back to the default.
A dispute already showed "3 days left" / "overdue" as plain colored text next to its evidence-due date, but only as inline text easy to miss while scanning, and the page sorted ALL disputes (including resolved ones) by evidenceDueAt — so long-settled won/lost cases, whose deadline is in the past, sorted ahead of open ones that still need action. - Open disputes (needs_response, under_review) now sort before resolved ones; within each group, soonest deadline first. - The inline text is now a Badge, matching the severity language the rest of the app already uses (the same "error" red as a failed payment, "warning" amber for a 3-5 day heads-up). Still scoped to needs_response only — once a dispute is under_review the evidence is already submitted, so a deadline badge there would flag something nobody can act on anymore.
Card type, in-memory store slice, and six seeded example cards (a handful of varied statuses so /cards opens onto something real, per the same pattern payments/disputes/payouts already use). src/lib/cards.ts holds the two genuinely stateless, testable pieces: Luhn check-digit generation on the 4242 test BIN, and the active/frozen/cancelled transition rule. src/data/cards.ts is the one place cards are read or mutated — issueCard() validates a missing merchant, a zero/negative limit, a limit over 5,000,000 minor units, a currency outside USD/EUR/GBP, and (going further than the ticket's literal wording) a currency that doesn't match the issuing merchant's own currency, since Merchant already carries one. The full number exists only in issueCard()'s return value — the Card type has no field to leak it into later.
GET/POST /api/cards and GET/PATCH /api/cards/[id]. Validation lives in the data layer (cards.ts); these shape the request in and the response out. POST's 201 body is the only response in the app that ever contains a full card number. PATCH re-checks the status string against an allowlist before it reaches setCardStatus, per api-routes.md's "reject early and return."
/cards: nickname, merchant, masked number, category, limit, status, created date, and the Issue card drawer. /cards/[id]: full record, spend-vs-limit progress bar (amber at 80%+), and status history. The issue drawer derives currency from the selected merchant rather than offering a free choice (see the spec's Approach section for why), disables the submit button while a request is in flight, and clears the revealed number from state the moment the drawer closes by any path — X, Escape, overlay click, or the Done button all funnel through one onOpenChange handler. Freeze/unfreeze/cancel PATCH then router.refresh() rather than navigating, so the page updates without a full reload; cancel sits behind a confirm step since it's terminal. StatusBadge's AnyStatus union now includes CardStatus rather than introducing a second badge component. Cards added to the sidebar, breadcrumbs, and siteConfig — the route existed nowhere reachable before this.
Claude Code 101 — Repo Rescue🏆 Build Battle Score: 90 / 100One-line verdict: Far and away the most complete submission in the field — full state machine, honest masking, a currency-match guard the ticket never asked for, and an audit trail — undercut only by an unfilled epic and a Luhn implementation the diff never actually shows. Core criteria — 92 / 100 (35%)
Correctness rules — 100 / 100 (20%)
Context and planning — 40 / 100 (10%)No epic in Code quality — 95 / 100 (15%)Tests sit beside the code they cover ( PR description — 100 / 100 (5%)Thorough: maps every criterion, states verification commands and real response bodies, discloses what wasn't click-tested in a browser, and is honest that seeded Stretch goals — 100 / 100 (15%)Tier 1: ✅ freeze/unfreeze without reload · ✅ amber progress bar at 80% · ✅ category lock, no edit control · ✅ transition tests in Breakdown: Core (92 × 0.35) + Rules (100 × 0.20) + Context (40 × 0.10) + Quality (95 × 0.15) + PR (100 × 0.05) + Stretch (100 × 0.15) = 90 / 100 One thing to do differently next time: Write the epic before touching code — the planning clearly happened (the PR description proves it), it just never got captured as a
Powered by Anthropic and Tenex |
Ticket
Closes NWP-201
What changed
Ops can now issue a virtual card from the console instead of messaging the platform team: a nickname, a merchant, a spend limit, and a category, submitted from
/cards. The full card number — generated server-side on the4242test BIN with a real Luhn check digit — is shown exactly once, on the success screen, and never again; everywhere else, including the card's own stored record, only carries•••• last4./cardslists every issued card;/cards/[id]shows the full record plus spend against the limit (an amber progress bar past 80%) and a status history. Freeze, unfreeze, and cancel are guarded server-side as a state machine (active ⇄ frozen, either →cancelled,cancelledterminal) and update the page without a full reload.One thing beyond the ticket's literal wording: the issue form derives currency from the selected merchant rather than offering an independent picker, since every
Merchantalready carries its own settlement currency and a mismatch isn't a real product choice — it's a data-entry mistake waiting to happen. The server rejects a mismatch even if one somehow arrives outside the UI.How I verified it
npm test→Test Files 6 passed (6),Tests 57 passed (57)(28 pre-existing + 2 from the sort fix + 14 inlib/cards.test.ts+ 13 indata/cards.test.ts).npm run build→ compiles and type-checks clean, all/cardsroutes present in the route table.npm run lint→ no warnings or errors."Select a merchant to issue this card to.";limit=0→"Enter a spend limit greater than $0.00.";limit=50000.01(i.e. 5,000,001 minor units) →"Spend limit can't exceed 5,000,000 minor units...";currency=JPY→"Choose USD, EUR, or GBP."Plus the currency-mismatch case:currency=EURagainst a USD merchant →"Lumen Coffee Roasters settles in USD — issue the card in that currency."POST /api/cards, valid payload) → 201 withnumber: "4242502259316433"in the body. Thengrep'd that exact number against a subsequentGET /api/cards/<id>andGET /api/cards— zero matches in either. The number exists in one response and nowhere else.active→frozen✅,frozen→active✅,active→cancelled✅, thencancelled→active→ rejected with"Can't move a cancelled card to active.", an unknown status string → rejected, a nonexistent card id → 404. The card'shistoryarray shows all four real transitions with real timestamps./cards/card_0002(seeded at 87% of its limit) → progress bar rendersbg-amber-500at87%. Rendered/cards/card_0001(12%) →bg-blue-500, no amber. Rendered/cards/card_0004(seededcancelled) → shows "Cancelled — no further changes," no Freeze/Unfreeze/Cancel buttons rendered at all./cards/card_nope→ real 404 via Next'snotFound().src/lib/cards.test.tspins the Luhn math itself: reproduces the known check digit for4242424242424242(a widely-known valid test number) by hand, then asserts 50 generated numbers each start with4242, are 16 digits, and passisValidLuhn.Acceptance criteria
Core:
/cards: nickname, merchant, masked number, category, limit, status, created date/cards/[id]: full record, spend vs. limit4242BIN + valid Luhn, proven inlib/cards.test.tsRules:
parseAmountToMinorUnitsat the one boundary (issueCard), never a floatCardtype has no field for it; verified livecancelledcase4242BIN mandatory — hardcoded ingenerateCardNumber, testedStretch:
router.refresh(), no navigationlib/cards.test.ts(14 tests),data/cards.test.ts(13 tests)/cardshas a real empty-state message for zero cards (untriggered by seed data, but present and type-checked)Bugs fixed along the way
src/data/queries.ts:81—sortPaymentscompared amounts as strings (String(a.amount).localeCompare(...)), which ranks by leading digit rather than magnitude, so descending sort put small payments above large ones. Fixed to numeric subtraction; test added.src/app/payments/page.tsx— the page hand-rolled its own filter parsing instead of the sharedparseFilters(), so it silently never readsort/directionat all — the API supported sorting, the UI had no way to use it. Now reusesparseFilters, and the Date/Amount column headers are clickable and toggle direction.src/app/disputes/page.tsx— the queue sorted all disputes by evidence deadline, including resolved ones, so long-settledwon/lostcases (their deadline is necessarily in the past) sorted ahead of open cases that still needed a response. Open disputes now sort first; the inline "days left" text became a proper severity badge.Notes for the reviewer
spenton seeded cards is invented history (there's no transaction feed to derive it from honestly) — small and clearly explained in the spec's Risks section. Every card issued live through the app starts atspent: 0.