Conversation
Everything a card can decide lives in src/lib/cards.ts as pure functions that take randomness, the clock, and the merchant lookup as parameters. Nothing here reads the store, so the state machine and the number generator are testable without a mock and without leaking state across vitest files, which share one store on globalThis. Card carries last4 and no field for the rest of the number, so a record that cannot hold a PAN cannot leak one. mulberry32 is exported from generate.ts so the tests reuse the existing PRNG rather than duplicating it. generate() itself is untouched, so the seeded payment stream is unshifted. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Six demo cards across USD, EUR, and GBP, covering active, frozen, and cancelled, so the list, the state machine, and the detail page are all demonstrable on a fresh boot. They draw from their own mulberry32 stream. The one inside generate() is consumed in sequence while payments, refunds, disputes, and payouts are built, so drawing from it here would shift every later value and rewrite data other pages already show. src/data/cards.ts is the only place a card's status is assigned. It reuses paginate and PAGE_SIZE from queries.ts rather than adding a second pagination shape, and spend is derived from real captured payments instead of being stored as a number nobody can account for. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
POST generates the number on the server, returns it once alongside the record, and stores only the last four. GET list and GET detail serialize a Card, which has no field for a full number, so neither can leak one. PATCH takes a status and nothing else: changing a limit after issue is NWP-202. Validation is the enforcement, not the form. Every value from the client goes through an allowlist, including a card's currency against its own merchant's, which nothing in the console checked before. src/lib/http.ts sets the error shape this codebase did not have: a status code that means what it says, a machine code, and a message safe to show an ops user. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A /cards list with the six columns the ticket asks for, a detail page with the record and its spend against the limit, and a drawer that issues a card and shows its number once. The reveal lives only in the drawer's own state. Closing it drops the number, and the list refresh is deferred until that close: refreshing while the success screen is open re-renders the tree the drawer sits in and takes the number down with it before anyone can copy it. Both pages are force-dynamic. Cards change while the server is up, so a prerendered copy would serve the boot-time list forever and refresh would keep handing back the same payload. StatusBadge is widened rather than duplicated. Freeze and unfreeze go through the guarded PATCH and refresh in place; a cancelled card offers nothing, because nothing comes back from cancelled. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
sortPayments compared amounts with String(a).localeCompare(String(b)), so the payments table sorted them as text: 9000 came above 25000 because "9" beats "2", and the largest payment landed in the middle of the list. Support reads that table top-down when a merchant asks about their biggest charge, so the order was the wrong answer. Amounts are integer minor units and compare as numbers. The new test in queries.test.ts fails against the old comparison, returning [100000, 25000, 700, 9000]. Found while reading the query builder for the cards list, which reuses paginate from this module. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Ticket
Closes NWP-201
Spec:
docs/specs/NWP-201-issue-cards.mdBusiness impact: ops issues virtual cards in the console instead of messaging the platform team, removing a multi-hour manual relay that runs 12–20 times a week — and the spend limit is now validated on the server instead of being read out of a Slack thread.
What changed
Cards exist now: a
/cardslist, a card detail page, and a drawer that issues one.src/lib/cards.ts— every card decision as a pure function: Luhn,4242generation, masking, the transition table, validation, the spend threshold. Randomness, the clock, and the merchant lookup all arrive as parameters, so nothing here reads the store and all of it is unit-tested without a mock or a fake timer.src/data/types.ts—Card,CardStatus,CardCategory,CardAuditEntry.Cardhaslast4and deliberately no field that could hold a full number, so reveal-once is enforced by the compiler rather than by remembering to strip a field.Currencyis now derived from aCURRENCIESallowlist so there is one source of truth.src/data/cards.ts/cards-seed.ts— store glue and six demo cards across USD/EUR/GBP covering active, frozen, and cancelled. It reusespaginateandPAGE_SIZEfromqueries.tsrather than adding a second pagination shape.src/app/api/cards/—GETlist,POSTissue,GETdetail,PATCHstatus.src/lib/http.ts— the error shape this codebase did not have. Nothing here returned a non-200 before, soapiError(status, code, message, field?)sets the precedent required by.claude/rules/api-routes.md.src/app/cards/— the list, the detail page with the spend bar and audit history, the issue drawer, and freeze/unfreeze row actions.siteConfig.ts,AppSidebar.tsx, andBreadcrumbs.tsx;StatusBadgewidened to cover card statuses rather than duplicated.How I verified it
npm testpassesnpm test— 68 passing across 5 files (28 pre-existing, 40 new).npm run buildcompiles clean, and both card routes reportƒ (Dynamic).Server-side validation, every reject path by
curl:State machine, guarded server-side:
Reveal once. Created a card, captured the number from the response, then grepped every later read for it:
In the browser. Issued a card from the drawer: the success screen showed
4242 1310 6668 4815once, clicking Done removed it from the DOM entirely (checked the serialized HTML for both the spaced and unspaced forms), and the list refreshed in place from 15 to 16 with the card masked to•••• 4815. Froze and unfroze from the list with no page reload (navigation timing unchanged); a cancelled row rendersNo actions. Invalid input surfaces the server message in arole="alert". The filtered empty state reads "No cards match these filters".Accessibility, read out of the live accessibility tree: dialog accessible name "Issue a virtual card",
aria-describedbypresent, both text inputs have<label for>, both Radix comboboxes have accessible names viaaria-labelledby, and the submit button is wired byform=so the drawer close button cannot submit.Not verified by test: the drawer, the reveal, and keyboard operation.
vitest.config.tsincludessrc/**/*.test.tsonly and there is no DOM environment installed, so those are browser checks, described above rather than asserted.Acceptance criteria
Core — all six met.
/cardsshows nickname, merchant, masked number, spend limit, status, and created date.4242BIN, valid Luhn. A property test over 200 generated numbers asserts the prefix, the length, an independent Luhn check, and that all 200 differ, so a hardcoded constant would fail it.Cardhas no field for the number.Stretch reached.
merchantById, because the client is not trusted.src/data/merchants.tshas carried each merchant's currency all along and nothing looked at it.Not built: idempotent issue, cancel-from-the-UI, and the audit trail as a first-class feature (transitions are recorded and shown, but there is no dedicated UI for cancelling).
Bugs fixed along the way
src/data/queries.ts—sortPaymentssorted amounts as text.Root cause: the comparison was
String(a.amount).localeCompare(String(b.amount)), with a comment claiming it matched the formatted display. Amounts are integer minor units, so this ordered them lexicographically —9000sorted above25000because"9"beats"2", and the largest payment landed in the middle of the list. Support reads that table top-down when a merchant asks about their biggest charge, so the order was the answer and the answer was wrong.Fixed to a numeric comparison.
src/data/queries.test.tsis new and fails against the old code, returning[100000, 25000, 700, 9000]where[700, 9000, 25000, 100000]is expected. Found while reading the query builder, because the cards list reusespaginatefrom the same module.I deliberately left the three defects in
src/data/metrics.tsalone (local-date bucketing, float accumulation, refunds added to gross volume). They are real, but they move every number on the overview and have no test protecting them, so they do not belong in a cards ticket.Notes for the reviewer
One behavioural bug I introduced and then fixed, worth knowing because it is easy to reintroduce: the drawer originally called
router.refresh()as soon as the card was created. The refresh re-renders the tree the drawer lives in and tore the success screen down about two seconds later — ops could lose the number before copying it. The refresh now waits until the drawer closes, so the number survives for as long as it is being read and the list is current the moment you look back at it.Decisions worth a second opinion:
src/data/types.tslinks aPaymentto a card, so rather than invent a number,spendForCardsums captured payments matching the card's merchant, currency, and last four, issued after the card was. In practice that is$0.00today. I chose that over seeding a plausible-looking figure, which would have made the amber bar demonstrable by making it dishonest — the 80% threshold is pinned by unit test at79999and80000against a100000limit instead.PATCHreturns 409, not a 200 no-op. A no-op success would append a bogus audit entry and tell ops something happened when nothing did.idalready is one. A stored hash of a 16-digit number with a known4242prefix and a Luhn constraint is brute-forceable, so it would be a reversible artifact of the PAN with no consumer.src/data/merchants.tshas no category, and inventing one would mean editing seed data to support a feature.force-dynamic. Cards change while the server is up; without it the build bakes in the boot-time list androuter.refresh()keeps returning the same static payload — fine indev, broken in the build.POST/PATCHhandlers areasyncwhere the existing two are plain functions, because they must awaitrequest.json()and the Next 15 route context; andsrc/app/cards/[id]/page.tsxre-declares the small localFieldcomponent thatpayments/[id]/page.tsxhas, rather than refactoring a working page from inside this ticket.mulberry32is now exported fromgenerate.tsso the seed module and the tests reuse the existing PRNG instead of duplicating it.generate()itself is untouched — drawing from its stream would have shifted every later value and silently rewritten all existing payment, refund, dispute, and payout data.