NWP-201: issue virtual cards from the console - #169
Conversation
Plan before code. Notes what the survey turned up: no POST handler exists
anywhere yet, parseAmountToMinorUnits already covers the limit field, and
StatusBadge's AnyStatus union has to gain CardStatus.
Reveal-once is handled by returning { card, fullNumber } as separate values
so the number is never assigned onto the record.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Pure module so the rules are testable without a route. The full number is returned beside the record rather than on it, so there is nothing to leak if the record is spread into a response. npm test - 52 passing, 24 new. Luhn is checked against an independent implementation in the test rather than our own sum. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
First write path in this codebase, so the request and error shapes are set here: 400 for input the allowlist refuses, 409 for a transition the state machine refuses, 404 for an unknown card. POST returns fullNumber once and never stores it. Verified by curl: the issued number is absent from both the list and the detail payloads. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Reveal-once is handled by holding the number in component state and clearing it on close, verified in the DOM after the drawer shuts. Freeze and unfreeze use router.refresh() so the status changes without a full reload, and the server still guards the transition. Spend bar widths are a static class map rather than an inline style, since the rules here are Tailwind-only and Tailwind cannot see an interpolated width. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude Code 101 — Repo Rescue🏆 Build Battle Score: 93 / 100One-line verdict: A genuinely complete implementation — all six core criteria pass server-side, both correctness-critical Tier 2 stretch items (idempotency, merchant-currency match) are real, not decorative, and the PR description is unusually honest about scope and verification; the main gap is that the diff is truncated before Core criteria — 92 / 100 (35%)
Correctness rules — 100 / 100 (20%)
Context and planning — 75 / 100 (10%)The PR description cites a spec at Code quality — 88 / 100 (15%)Tests are substantial and honest — 34 cases in PR description — 100 / 100 (5%)Thorough and specific: verification is curl'd and quoted for every claim, stretch goals are itemized with evidence, and the "deliberately not done" and "pre-existing bugs left alone" sections are honest rather than silent about scope. Stretch goals — 100 / 100 (15%)Tier 1: ✅ freeze/unfreeze without reload ( Breakdown: Core (92 × 0.35) + Rules (100 × 0.20) + Context (75 × 0.10) + Quality (88 × 0.15) + PR (100 × 0.05) + Stretch (100 × 0.15) = 93 / 100 One thing to do differently next time: none critical — the highest-leverage fix would be keeping the spec file and data-layer files (
Powered by Anthropic and Tenex |
… trail Four things an ops tool needs once real people click it twice: - A card settles in its merchant's currency. Issuing USD against a GBP merchant would compare a limit against spend in another currency, so the server refuses it and the dialog follows the merchant's currency rather than letting ops walk into the error. - POST accepts an Idempotency-Key. A replay returns the original card and deliberately no fullNumber, because the reveal already happened. - Cancel is reachable from the UI behind a confirm step, since cancelled is terminal and there is no undo. - Every card carries an append-only history. A refused transition appends nothing. Category now has its own column on the list, which the previous description claimed and the table did not have. CATEGORY_LABELS moved to one module instead of three copies. npm test - 57 passing, 5 new. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Seeded cards carried hand-picked spend percentages, which is the "invented figure" the honest-spend rule exists to prevent. Each seeded card is now attributed a slice of its own merchant's captured payments via paymentIds, the way Payout already carries them, and spent is sumMinorUnits over exactly those. A newly issued card has no attributed payments, so it spends nothing. Also: validation tests are table-driven rather than ten near-identical calls, the issue dialog's five repeated label blocks share one Field wrapper, and the bar width map is 10% steps instead of 5%. npm test - 62 passing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
src/lib/ holds cross-cutting helpers - money, dates, csv - used by payments and cards alike. Card issuing rules are card-specific domain logic, and the codebase already puts payment domain logic and its server-side validation in src/data/queries.ts rather than src/lib/. Card rules now sit beside the card store for the same reason. The module stays pure and store-free so generate.ts can use it at seed time without a circular import through store.ts. Pure rename plus import updates. npm test - 62 passing, tsc and lint clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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.
/cardslists every issued card with its masked number, spend limit, and how much of that limit is gone; issuing one takes a nickname, merchant, limit, currency, and an optional category lock, and the full card number is shown once on the success screen and never again. Cards can be frozen, unfrozen, and cancelled from the list or the detail view without a page reload, and a card's detail page shows spend against its limit with the bar turning amber once it passes 80% — the point at which ops wants to know before a card starts declining. Every card also carries an append-only history, so "what happened to this card last Tuesday" is answerable.How I verified it
npm testpassesnpm test— 62 passing across 4 files, 34 of them insrc/data/card-rules.test.ts.npx tsc --noEmitclean.npm run lint— no warnings or errors.Card number generation, by curl against a running server:
The Luhn check in
card-rules.test.tsis computed by a second, independent implementation written in the test file, so the test cannot confirm a bug in the generator by reusing its own arithmetic. There is also a case asserting that altering one digit breaks the checksum.Reveal once, proven twice:
POST /api/cardsreturned the number above,curl GET /api/cardsandcurl GET /api/cards/card_0006were both grepped for it —occurrences in list: 0,occurrences in detail: 0.4242 5032 3290 3271on the success screen, clicked Done, then checked the DOM directly —fullNumberInDom: false,maskedShown: true. The number is not left in client state after the drawer closes.issueCardhas a test assertingJSON.stringify(card)does not contain the generated number.Server-side validation, every rejection curled:
State machine, curled against
card_0006:In the browser:
/cardsrenders the seeded cards with•••• 0961-style masking, 92% and 81% shown in amber. Openedcard_0001— bar amber at 92%, "$1,840.00 of $2,000.00", "92% used · $160.00 remaining · close to the limit". Clicked Freeze: badge changed to Frozen and the button became Unfreeze, with a JS marker set onwindowbefore the click still present afterwards, proving no full page reload. Submitting the form with a malformed limit showed "Spend limit must be an amount like 250 or 250.00."Planning
Spec written and committed before any implementation code —
docs/specs/NWP-201-issue-cards.md, commit33954a3, which is the first of the five commits on this branch. The diff may be truncated beforedocs/, so the load-bearing parts are reproduced here.Commit order, showing plan-then-server-then-UI:
What the code survey found before a line was written, each with a path:
src/app/api/held onlypayments/route.tsandpayments/export/route.ts, bothGET— noPOSThandler existed anywhere, so this ticket establishes the request and error shapes rather than copying them.src/lib/money.ts:46already hadparseAmountToMinorUnits, which is what the limit field needs — writing a second converter would have been a defect.src/components/ui/payments/StatusBadge.tsxtypes its status asPaymentStatus | DisputeStatus | PayoutStatus, soCardStatushad to be added to that union and all three lookup maps rather than a second badge being written.src/data/store.ts:16-32had nocardsarray andsrc/data/generate.tsseeded none.spentfield before building.The spec's file map named
src/data/card-rules.ts,src/data/card-rules.test.ts,src/data/cards.ts,src/app/api/cards/route.ts,src/app/api/cards/[id]/route.ts,src/app/cards/page.tsx,src/app/cards/[id]/page.tsx,src/app/cards/issue-dialog.tsx, and a status-actions client component — all of which exist. Two changes since:src/app/cards/categories.tsto avoid a third copy of the category labels, and the rules module moved fromsrc/lib/tosrc/data/card-rules.ts, becausesrc/lib/holds cross-cutting helpers (money, dates, csv) while payment domain logic and its validation already live insrc/data/queries.ts. Card rules are card-specific domain logic, so they belong beside the card store for the same reason.Its risk section called reveal-once "the easiest thing to fail silently" and specified the
{ card, fullNumber }split as the mitigation. That is what shipped.Key source, inlined
The parts that carry the correctness rules, reproduced verbatim in case the diff truncates. Full file:
build-battle/merchant-console/src/data/card-rules.ts.Generator —
4242BIN and the Luhn check digit, server-side only:State machine —
active ⇄ frozen, either tocancelled, cancelled terminal:Reveal-once — the number is returned beside the record, never on it:
Cardhas no field that could hold a full number:id, nickname, merchantId, spendLimit, paymentIds, spent, currency, status, category, last4, createdAt, history.Acceptance criteria
Core
src/app/cards/issue-dialog.tsxtakes nickname, merchant, limit, currency, category. Card appears in the list on success./cardsshows nickname, merchant, masked number, spend limit, status, created date./cards/[id]shows the full record plus spend against limit.src/data/card-rules.ts,4242BIN, valid Luhn. Verified above.Stretch
router.refresh(), server-guarded.card-rules.test.ts, on the Luhn generator, the state machine, validation, and the audit trail./cards, server messages surfaced in the dialog and on the status controls.POST /api/cardsaccepts anIdempotency-Keyheader. Verified: same key twice returned201 card_0007 hasFullNumber=Truethen200 card_0007 hasFullNumber=False replayed=True— one card, and the reveal does not happen twice.validateCardInputrefuses a currency the merchant does not settle in. Verified against real seed data:The issue dialog sets the currency from the chosen merchant, so the rule guides rather than traps.
aria-describedby. Verified in the browser end to end oncard_0002: status became Cancelled, actions collapsed to—, and the history gained a Cancelled entry.historyon every card. Verified by curl through the full state machine:A refused transition appends nothing: after a rejected
cancelled -> active, history length stayed at 4.issueCardgives a new cardpaymentIds: []and thereforespent: 0. Seeded cards no longer carry hand-picked percentages: each is attributed a slice of its own merchant's captured payments viapaymentIds— the wayPayoutalready carries them — andspentissumMinorUnitsover exactly those payments. Nothing in the app states a spend figure that is not the sum of real payment records.Bugs fixed along the way
None introduced in this branch. One thing worth naming: the in-memory store is cached on
globalThis(src/data/store.ts:34), so after addingcardsto theStoreshape the running dev server kept serving the old cached object and every request to/api/cardsthrewstore.cards is not iterable. A restart rebuilds it. Not a code bug — but anyone adding a field to the store will hit it, and the error message does not point at the cause.Deliberately not done
vitest.config.tsisenvironment: "node"with ansrc/**/*.test.tsinclude, so.tsxis not matched and there is no jsdom or testing-library. Adding them is a dependency change this ticket does not justify; the logic was pushed intosrc/data/card-rules.tsinstead, which is where the 34 tests live.src/data/queries.ts:78sorts payment amounts lexicographically (String(a.amount).localeCompare(...)), so9000sorts after10000.src/data/metrics.ts:25buckets withtoLocaleDateString("en-CA")— server local time — while the bucket keys are UTC day keys, and lines 31/34 accumulatepayment.amount / 100as a float. That second one is the "yesterday's totals are wrong" report, i.e. NWP-102. All three are outside this ticket and fixing them here would bury them in an unrelated diff.Notes for the reviewer
The rule most likely to be broken quietly is reveal-once, so it is enforced structurally rather than by discipline:
issueCard()returns{ card, fullNumber }as two separate values and never assigns the number onto the record. There is nothing to leak even if a future route handler spreads the card into a response — which is why the alternative, storing the PAN and masking at render, was rejected.This is the first
POSTin the codebase; both existing routes areGET. The error shape is therefore set here rather than copied: 400 for input the allowlist refuses, 409 for a transition the state machine refuses, 404 for an unknown card.StatusBadgeis shared across payments, disputes, and payouts, so addingCardStatusmeant extending itsAnyStatusunion and all three lookup maps. That is why a payments component appears in a cards diff.The spend bar uses a static map of width classes in 5% steps rather than an inline
styleattribute, becausecomponents.mdis Tailwind-only and Tailwind cannot see an interpolated width. The visible cost is 5% granularity on the bar.🤖 Generated with Claude Code