Skip to content

NWP-201: issue virtual cards from the console - #2

Open
kumarjith wants to merge 5 commits into
mainfrom
NWP-201-issue-cards
Open

kumarjith wants to merge 5 commits into
mainfrom
NWP-201-issue-cards

Conversation

@kumarjith

Copy link
Copy Markdown
Owner

Ticket

Closes NWP-201

Spec: docs/specs/NWP-201-issue-cards.md

Business 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 /cards list, a card detail page, and a drawer that issues one.

  • src/lib/cards.ts — every card decision as a pure function: Luhn, 4242 generation, 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.tsCard, CardStatus, CardCategory, CardAuditEntry. Card has last4 and 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. Currency is now derived from a CURRENCIES allowlist 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 reuses paginate and PAGE_SIZE from queries.ts rather than adding a second pagination shape.
  • src/app/api/cards/GET list, POST issue, GET detail, PATCH status.
  • src/lib/http.ts — the error shape this codebase did not have. Nothing here returned a non-200 before, so apiError(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.
  • Nav wiring in siteConfig.ts, AppSidebar.tsx, and Breadcrumbs.tsx; StatusBadge widened to cover card statuses rather than duplicated.

How I verified it

  • npm test passes
  • New behavior is covered by a test
  • Checked it in the browser

npm test — 68 passing across 5 files (28 pre-existing, 40 new). npm run build compiles clean, and both card routes report ƒ (Dynamic).

Server-side validation, every reject path by curl:

missing merchant        422 merchant_required   | Pick a merchant.
unknown merchant        422 merchant_not_found  | That merchant does not exist.
zero limit              422 limit_invalid       | Enter a spend limit above zero, in whole minor units.
negative limit          422 limit_invalid       | Enter a spend limit above zero, in whole minor units.
float limit (250.5)     422 limit_invalid       | Enter a spend limit above zero, in whole minor units.
limit 5000000           201 created card_0007
limit 5000001           422 limit_too_large     | A card limit cannot be more than $50,000.00.
currency JPY            422 currency_invalid    | Currency must be USD, EUR, GBP.
USD card, GBP merchant  422 currency_mismatch   | Halcyon Studio settles in GBP, so their cards must be GBP.
blank nickname          422 nickname_invalid    | Give the card a nickname of 1 to 64 characters.
bad category            422 category_invalid    | Category must be one of advertising, software, ...

State machine, guarded server-side:

active -> frozen         200
frozen -> frozen         409 | This card is already frozen.
frozen -> active         200
active -> cancelled      200
cancelled -> active      409 | This card was cancelled. Cancellation is permanent.
cancelled -> frozen      409 | This card was cancelled. Cancellation is permanent.
unknown card id          404 | No card with that id.
status "ACTIVE"          422 | Status must be one of active, frozen, cancelled.
status "deleted"         422 | Status must be one of active, frozen, cancelled.

Reveal once. Created a card, captured the number from the response, then grepped every later read for it:

POST /api/cards        -> cardNumber 4242950893529228
card record keys       -> audit, bin, category, currency, id, issuedAt,
                          last4, limit, merchantId, nickname, status
GET /api/cards         -> no full number
GET /api/cards/card_09 -> no full number
any 16-digit run in the list payload -> none

In the browser. Issued a card from the drawer: the success screen showed 4242 1310 6668 4815 once, 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 renders No actions. Invalid input surfaces the server message in a role="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-describedby present, both text inputs have <label for>, both Radix comboboxes have accessible names via aria-labelledby, and the submit button is wired by form= so the drawer close button cannot submit.

Not verified by test: the drawer, the reveal, and keyboard operation. vitest.config.ts includes src/**/*.test.ts only and there is no DOM environment installed, so those are browser checks, described above rather than asserted.

Acceptance criteria

Core — all six met.

  • Issue a card — drawer takes nickname, merchant, spend limit, currency (plus a category lock); submitting creates it and it appears in the list without a reload.
  • Card list/cards shows nickname, merchant, masked number, spend limit, status, and created date.
  • Card detail — full record plus spend against the limit, number masked.
  • Generated card numbers — server-side, 4242 BIN, 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.
  • Reveal once, mask forever — evidence above. Enforced structurally: Card has no field for the number.
  • Server-side validation — evidence above. The form is a convenience; the route is the enforcement.

Stretch reached.

  • Freeze and unfreeze from the list without a full reload
  • Spend progress bar on detail, amber past 80%
  • Merchant category lock, chosen at issue and shown on the card
  • Unit tests on the Luhn generator and the status transitions
  • Written empty and error states
  • Currency matches the merchant — the form derives currency from the chosen merchant and the route re-checks it against merchantById, because the client is not trusted. src/data/merchants.ts has 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.tssortPayments sorted 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 — 9000 sorted 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 answer and the answer was wrong.

Fixed to a numeric comparison. src/data/queries.test.ts is 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 reuses paginate from the same module.

I deliberately left the three defects in src/data/metrics.ts alone (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:

  • Spend is derived, not stored. Nothing in src/data/types.ts links a Payment to a card, so rather than invent a number, spendForCard sums captured payments matching the card's merchant, currency, and last four, issued after the card was. In practice that is $0.00 today. 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 at 79999 and 80000 against a 100000 limit instead.
  • A duplicate PATCH returns 409, not a 200 no-op. A no-op success would append a bogus audit entry and tell ops something happened when nothing did.
  • No stored "number reference". The ticket asks for the number's reference; the card id already is one. A stored hash of a 16-digit number with a known 4242 prefix and a Luhn constraint is brute-forceable, so it would be a reversible artifact of the PAN with no consumer.
  • The category lock lives on the card, not the merchant. src/data/merchants.ts has no category, and inventing one would mean editing seed data to support a feature.
  • Both card pages are force-dynamic. Cards change while the server is up; without it the build bakes in the boot-time list and router.refresh() keeps returning the same static payload — fine in dev, broken in the build.
  • Two forced deviations from local convention, both flagged rather than left to be found: POST/PATCH handlers are async where the existing two are plain functions, because they must await request.json() and the Next 15 route context; and src/app/cards/[id]/page.tsx re-declares the small local Field component that payments/[id]/page.tsx has, rather than refactoring a working page from inside this ticket.
  • mulberry32 is now exported from generate.ts so 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.

peris611 and others added 5 commits September 10, 2026 11:54
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants