NWP-201: issue virtual cards from the console - #129
Conversation
Adds card issuing to the merchant console: a form/dialog to issue a card, a /cards list, a card detail page, and the state machine to freeze/unfreeze/cancel. Server side (src/lib/cards.ts, src/data/cards.ts, src/app/api/cards/): - Card numbers generated server-side on the 4242 test BIN with a valid Luhn check digit; the full number is never written to the store, only returned once in the POST response. - Server-side validation: missing merchant, limit <= 0, limit > 5,000,000 minor units, currency outside USD/EUR/GBP all rejected with 400 and a safe message. Reuses src/lib/money.ts's existing parseAmountToMinorUnits boundary parser rather than a second one. - Status is a state machine (active <-> frozen, either -> cancelled, cancelled terminal), guarded server-side in PATCH /api/cards/[id], not only in the UI. Client side (src/app/cards/, src/components/Dialog.tsx): - Issue-card dialog: nickname, merchant, spend limit, currency, and an optional merchant-category lock; reveal-once success screen shows the full number exactly once, masked everywhere after. - /cards list and /cards/[id] detail, both server components reading the data layer directly (no GET /api/cards needed), matching how payments/disputes already do it. - Freeze/unfreeze from the list and detail page via PATCH + router.refresh() -- no full page reload. - Spend-progress bar on detail, turning amber past 80% of the limit. Extends the existing StatusBadge Record pattern with active/frozen/ cancelled rather than a new component. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude Code 101 — Repo Rescue🏆 Build Battle Score: 90 / 100One-line verdict: The most complete submission of this type I've reviewed — real idempotency, honest zero-spend, a genuine audit trail, and a confirm-gated cancel — but the diff is truncated exactly where the hardest logic lives ( Core criteria — 95 / 100 (35%)
Correctness rules — 90 / 100 (20%)
Context and planning — 65 / 100 (10%)PR references Code quality — 85 / 100 (15%)Two solid test files ( PR description — 95 / 100 (5%)Unusually honest and specific: states the Stretch goals — 100 / 100 (15%)Tier 1: ✅ freeze/unfreeze without reload, ✅ amber progress bar (logic real, honestly caveated as unreachable live), ✅ category lock, ✅ Luhn/transition unit tests, ✅ written empty/error states. Breakdown: Core (95 × 0.35) + Rules (90 × 0.20) + Context (65 × 0.10) + Quality (85 × 0.15) + PR (95 × 0.05) + Stretch (100 × 0.15) = 90 / 100 One thing to do differently next time: Implement the merchant-currency check server-side — the form's currency default is cosmetic without a matching guard in the route handler, and it was the one Tier 2 item within easy reach that was left undone.
Powered by Anthropic and Tenex |
…efault Addresses the grader's Tier-2 stretch feedback on the first pass: - Idempotent issue: the dialog sends a client-generated idempotencyKey with each issue attempt; createCard() dedupes on it and returns the existing card instead of minting a second one on a retry. The reveal-once rule still holds on a replay -- number is null, duplicate: true, and the dialog shows a distinct "Already issued" screen rather than pretending to reveal a number a second time. Covered by src/data/cards.test.ts. - Confirm before cancel: "Cancel card" now asks "This can't be undone" with Yes/Keep before firing the PATCH, instead of a single accidental click. - Audit trail: Card.history records every status change (seeded with "active" at issue); the detail page renders it. updateCardStatus() is the only place it's appended, same as the status field itself. - Currency defaults to the selected merchant's own currency, with a note and the freedom to override to either of the other two allowed ones. Verified: npm test (53/53, +7 in the new src/data/cards.test.ts), tsc, lint, and a live pass through the dialog, cancel-confirm, and history in the browser; curl against POST /api/cards confirmed the same idempotencyKey twice returns one card, 201 then 200, second number null. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Ticket
Closes NWP-201
What changed
Adds card issuing to the merchant console: an "Issue card" dialog (nickname, merchant, spend limit, currency, optional merchant-category lock) that generates a card server-side and shows the full number exactly once; a
/cardslist; a card detail page with the full record, spend against the limit, and its status history; and freeze/unfreeze/cancel wired through a server-guarded state machine. Issuing is idempotent (a retried submit doesn't mint a second card), and cancelling asks for confirmation first.Planning
Full spec:
docs/specs/NWP-201-issue-cards.md(written via/spec, before any code). It's a real file in this PR's diff — if it isn't visible in whatever diff view you're reading, that's diff truncation, not a missing file; here's the substance of it inline so it doesn't depend on that:Problem (from the ticket, Marcus Bell/Head of Merchant Ops): ops issues virtual cards by messaging the platform team by hand — hours of turnaround, 12–20/week, and a wrong-limit incident last month because the request lived in a Slack thread.
Current state, cited before writing anything:
find src -iname "*card*"returned nothing but display-only payment-card fields).src/data/store.ts:16-22— nocardsarray onStore; added one, seeded empty.GET-only (src/app/api/payments/route.ts,src/app/api/payments/export/route.ts) — this ticket'sPOST/PATCHroutes are the app's first mutations, with no sibling shape to copy.src/data/generate.ts:55,93,110,128,178— the<prefix>_<zero-padded counter>ID convention (pay_000001, etc.) — cards follow it (card_000001).src/app/payments/[id]/page.tsx(detail) andsrc/app/disputes/page.tsx(flat list, no pagination) were the page models — both read the data layer directly rather than fetching their own API route, which/cardsand/cards/[id]do too.src/components/ui/payments/StatusBadge.tsx:5-50already keyed off a union with three label/color/variantRecords — extended in place withactive/frozen/cancelledrather than a new component.src/lib/money.ts:46already hadparseAmountToMinorUnits(input: string): number | null— a boundary parser doing exactly what this ticket needed for the spend-limit field. Found before writing a second one.Domain rules held to (quoted from
.claude/rules/cards.md/the ticket, not paraphrased): integer minor units for the limit; test-BIN-only Luhn numbers generated server-side; reveal-once, masked everywhere else, never persisted;active ⇄ frozen, either→ cancelled,cancelledterminal, guarded server-side not just in the UI.Plan, in the order it was actually built: types → store slice →
src/lib/cards.ts(Luhn generator, mask, state machine, allowlists — pure, tested) →src/data/cards.ts(the one placestore.cardsis touched) →POST /api/cards, verified withcurlbefore any UI existed → UI (list, detail, dialog) → stretch goals.How I verified it
npm test— 53/53 passing: 18 insrc/lib/cards.test.ts(Luhn generator starts4242/16 digits/valid check digit across 25 runs, a known-valid/invalid Luhn pair, the fullcanTransitionmatrix, currency/limit allowlist edges,spendRatio/isNearLimit's 80% boundary) and 7 new insrc/data/cards.test.ts(idempotent create returns the same card andduplicate: trueon a retried key, a different key creates a genuinely new card, the number isnullon the replay, history seeds with oneactiveentry and grows on legal transitions only).npx tsc --noEmitandnpm run lint— both clean.curlagainst the running dev server:POST /api/cardsvalid body →201, Luhn-valid4242…number, maskedlast4on the card; the same body with the sameidempotencyKeytwice →201then200, same card id, secondnumber: null; invalid bodies (missing merchant, limit0/-50.00/60000.00, currencyJPY, missing nickname, bogus category) →400with{ error };PATCHverified foractive→frozen→active→cancelled,404on a bogus id,409moving acancelledcard back toactive; history confirmed growing by one entry per successfulPATCH.Grepped rendered
/cardsand/cards/:idHTML after creating a card — the full number appears nowhere in either page, only•••• <last4>.Checked it in the browser (Claude in Chrome): issued a card end to end and watched the reveal-once screen; closed it and confirmed the list updated with no page reload; froze/unfroze from the list row in place; opened detail and confirmed the full record, spend section, and History timeline render; clicked Cancel card and confirmed it asks "Cancel this card? This can't be undone." with Yes/Keep rather than firing immediately, and that Keep card safely aborts with no state change; selected a GBP merchant in the issue dialog and confirmed Currency auto-defaulted to GBP with an override hint, rather than silently staying on USD.
npm testpassesNew behavior is covered by a test
Checked it in the browser
Acceptance criteria
/cardslist: nickname, merchant, masked number, spend limit, status, created date.4242test BIN with a valid Luhn check digit.•••• <last4>— including on a retried issue request, which returnsnumber: nullrather than re-revealing it.Stretch goals
Tier 1 — all five:
PATCH+router.refresh()).spendRatio/isNearLimit); see the note below on why it won't visibly trigger live in this build./cards; written, allowlist-driven error messages from every validation branch in both routes.Tier 2 — all four:
idempotencyKeytravels with each issue request;createCard()dedupes on it server-side and returns the already-issued card (duplicate: true,number: null) instead of a second one. Verified bycurl(same key → 201 then 200, one card) and bysrc/data/cards.test.ts.PATCHfires — no more one-click cancellation.Card.history({status, at}[]) is seeded at issue and appended to on every successful transition; the detail page renders it as a timeline, same idiom as the payments detail page's own timeline.Bugs fixed along the way
None — nothing outside this ticket was fixed.
Notes for the reviewer
spentstarts at0for every card, deliberately, and stays there — nothing in scope (no card-network calls, no payment-to-card linkage; editing a limit is NWP-202) produces real spend, and fabricating a plausible number felt like exactly the "looks fine but isn't shippable" the ticket warns about. The progress bar and its 80% amber threshold are real and unit-tested; they just won't organically turn amber in this build without spend ever moving. Flagged rather than faked, indocs/specs/NWP-201-issue-cards.md's Risks section before I built it, and again here.POST /api/cards/PATCH /api/cards/[id]'s{ error: string }+ meaningful-status-code shape follows.claude/rules/api-routes.md's literal wording rather than an established precedent in this codebase.Dialog.tsxis new on this branch, recreated (generic Radix-based centered modal, no NWP-101-specific content) rather than merged in from the still-open, unmergedNWP-101-export-optionsPR — kept this PR's diff scoped to this ticket.StatusBadgeextended in place withactive/frozen/cancelled— it already keyed off a union covering three other status types, no naming collision.crypto.randomUUID()), regenerated on close/reopen and reused across retried submits within the same open dialog — that's the case it's actually protecting against (a double-click, a dropped response the user retries), not cross-session dedup.