NWP-201: issue virtual cards - #172
Conversation
Server: Card type, Luhn generator on the 4242 test BIN (src/lib/cards.ts), active/frozen/cancelled state machine, allowlist validation (nickname, merchant, integer limit 0 < n <= 5,000,000, currency, category), a mutation layer (src/data/cards.ts) mirroring queries.ts, and GET/POST /api/cards + GET/PATCH /api/cards/[id]. UI: /cards list (nickname, merchant, masked number, limit, status, created, freeze/unfreeze/cancel), /cards/[id] detail (full record, spend bar amber past 80%, status history), issue-card dialog reusing Drawer with reveal-once (full number shown only in the POST response, never stored, never in a GET response). Beyond the ticket: currency is derived from the chosen merchant, not freely editable, and rejected server-side if it ever mismatches; a client-supplied idempotency key lets issueCard() dedupe a retried POST instead of creating a second card; every status change is appended to card.history and rendered as a timeline on the detail page; cancel requires an inline confirm step since it is terminal. Spec: docs/specs/NWP-201-issue-cards.md, written before any code. Verified: npm test (50/50, +22 new in cards.test.ts), npx tsc --noEmit clean, npm run lint clean, curl matrix against every validation and state-machine case, org-standards subagent audit on the server-side diff (no violations), browser check of nav/list/empty-per-status actions/dialog/currency-follows-merchant. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude Code 101 — Repo Rescue🏆 Build Battle Score: 97 / 100One-line verdict: A rare complete run — every core criterion, every correctness rule, and both Tier 1 and Tier 2 stretch goals are genuinely implemented and server-enforced, not just claimed. Core criteria — 100 / 100 (35%)
Correctness rules — 100 / 100 (20%)
Context and planning — 95 / 100 (10%)A genuinely repo-aware spec ( Code quality — 90 / 100 (15%)Tests sit beside the code they cover ( PR description — 95 / 100 (5%)Thorough and honest: states what was built, walks through curl verification for every validation and transition case, and explicitly flags what wasn't fully carried through in the browser (the live reveal screen) rather than hiding it. Exactly the disclosure the rubric rewards. Stretch goals — 100 / 100 (15%)Tier 1: Freeze/unfreeze without reload ✅, spend progress bar amber >80% ✅, category lock chosen at issue + shown on detail ✅, Luhn/transition unit tests ✅, written empty/error states ✅ — all five, capped at 0.50. Breakdown: Core (100 × 0.35) + Rules (100 × 0.20) + Context (95 × 0.10) + Quality (90 × 0.15) + PR (95 × 0.05) + Stretch (100 × 0.15) = 97 / 100 One thing to do differently next time: Carry the browser reveal-screen walkthrough all the way to a completed submission rather than substituting curl evidence — it's the one verification claim in the PR that stops short of what was actually exercised.
Powered by Anthropic and Tenex |
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.
/cardslists every issued card (nickname, merchant, masked number, spend limit, status, created date); opening one shows the full record and its spend against the limit. Numbers are generated server-side on the4242test BIN with a valid Luhn check digit, and the full number is returned exactly once, in the creation response — it is never stored, so there is nothing to leak later.Spec written before any code:
docs/specs/NWP-201-issue-cards.md.How I verified it
npm test—50 passed (4 files), up from 28. 22 new cases insrc/lib/cards.test.ts(Luhn correctness, generated numbers, the transition table,validateIssueCardInput's full matrix).npx tsc --noEmit— clean.npm run lint— no warnings or errors.Server-side validation, curl against
POST /api/cards:Reveal once, mask forever —
Cardhas no field for a full number (src/data/types.ts), onlylast4/reference. Confirmed on a live issue:GET /api/cardsandGET /api/cards/:idresponses never contain anumberkey (checked programmatically, not by eye).Luhn + BIN — every case in
cards.test.tsassertsisLuhnValidon freshly generated numbers and that they start with4242; also confirmed on a live issued number (4242553595409499).State machine over the API:
Browser —
/cardsreachable from the sidebar with a working breadcrumb; list renders all required columns plus per-status actions (Freeze/Cancel on active, Unfreeze/Cancel on frozen, a plain "cannot be reactivated" line on cancelled, no buttons); issue dialog opens, and selecting a GBP merchant instantly relabels the limit field "Spend limit (GBP)" — confirming currency is derived from the merchant rather than typed. I did not carry that specific run through to a completed submission and reveal screen in the browser; the full issue → reveal → list-refresh path is verified above via curl instead.org-standardssubagent (this repo's own reviewer,.claude/agents/org-standards.md) audited every new/changed server-side file againstdocs/ORG-STANDARDS.md's 10 numbered items: no violations found.Acceptance criteria
Core
POST /api/cards→ 201, appears in the next list./cards: nickname, merchant, masked number, limit, status, created date./cards/[id]: full record, spend vs. limit.4242BIN, valid Luhn.POSTresponse.Stretch
PATCH+router.refresh().role="progressbar",aria-valuenow/aria-valuetext.src/lib/cards.test.ts./cardsempty state explains the reveal-once rule rather than a generic message; form and action errors render in a labeled,role="alert"region.Beyond the ticket, server-enforced rather than UI-only:
issueCardalso rejects a mismatch server-side (Card currency must match the merchant's currency (…)) — the exact Slack-thread mistake the ticket is about, closed at both layers.POST /api/cardscan't double-issue. An optional client-generatedidempotencyKeyis checked server-side (src/data/cards.ts); a repeat within the same key returns the original card and number instead of creating a second one — not just a disabled submit button.card.historygets an entry on each transition, rendered as a timeline on the detail page — "what happened to this card" is answerable without guessing.Deliberately not done
spentvalues are hand-picked for the demo (there is no real card-to-payment ledger in this codebase to derive them from); newly issued cards correctly start at 0 and only move via a real transition.sortPaymentsstring-comparison bug found while working NWP-101 — unrelated to this diff.Spec (inlined, because the diff is truncated before
docs/specs/is reached)Full file:
docs/specs/NWP-201-issue-cards.md, committed in this PR before any code.Spec contents
Ticket: NWP-201
Author: Build Battle
Status: building
Problem
Ops issues virtual cards by messaging the platform team, who create them by hand: 12–20 times a week, hours of turnaround, and two cards last month went out with the wrong spend limit because the request lived in a Slack thread. Marcus wants issuing, listing, and inspecting cards inside the console itself, so a limit is typed once by the person who owns it.
Current state
Nothing card-shaped exists yet.
src/data/types.tshas noCardtype.Paymentcarriesmethod,cardBrand,last4— payment-method metadata, not an issued-card entity.src/data/generate.ts:99-101generates a randomlast4per card payment (String(between(1000, 9999))) with no Luhn check — not reusable for real card numbers.src/data/store.ts:16-22—Storehasmerchants, payments, refunds, disputes, payouts. Nocardsarray, and no module currently writes to the store — every existing route isGETonly (src/app/api/payments/route.ts,src/app/api/payments/export/route.ts). This ticket introduces the first mutation.src/lib/money.ts:46—parseAmountToMinorUnits(input: string): number | nullalready parses a decimal string into minor units at the boundary; reuse it for the limit field rather than writing a second parser.src/app/payments/[id]/page.tsxis the pattern for a detail page: async server component,params: Promise<{id}>,notFound()on miss, aFieldhelper over a<dl>grid.src/app/payments/page.tsx:90-104is the only existing empty-state pattern (rows.length === 0).src/app/payments/export-dialog.tsxis the only existing client component that talks to an API route (aGET, viauseEffect+fetch) and the only placeDraweris used outside its own definition — the pattern to copy for the issue-card dialog and for the firstPOST.src/components/ui/payments/StatusBadge.tsx:5—AnyStatus = PaymentStatus | DisputeStatus | PayoutStatus, three parallelRecordmaps (LABELS,DOTS,VARIANTS). AddingCardStatushere means widening the union and adding three entries;active/frozen/cancelleddon't collide with existing keys.src/components/ui/navigation/AppSidebar.tsx:26-51— nav is aconst navigation = [...] as constarray of{name, href, icon, notifications};hrefcomes fromsrc/app/siteConfig.ts:5-10(baseLinks).CreditCardicon is already used by Payments, so Cards needs a different lucide icon.src/components/ui/navigation/Breadcrumbs.tsx:7-12— flatLABELS: Record<string,string>keyed by path segment,?? segmentfallback.src/data/merchants.ts—Merchanthas no category/MCC field, so "merchant category lock" (stretch) is a card-level choice at issue time, not a merchant property.Domain rules
CLAUDE.md, ticket rule 1$250.00limit stored as250.00drifts the moment it's compared or summed.claude/rules/money.md4242BIN, valid Luhn.claude/rules/cards.mdlast4+ a reference.claude/rules/cards.md•••• 4242everywhere else.claude/rules/cards.mdactive ⇄ frozen, either →cancelled,cancelledterminal, enforced server-side.claude/rules/cards.md.claude/rules/api-routes.md.claude/rules/api-routes.mdApproach
Put all card logic in one pure, unit-testable module,
src/lib/cards.ts(Luhn generation/validation, the status-transition table, and the currency/limit/category allowlists), mirroring howsrc/lib/money.tsandsrc/lib/dates.tshold the codebase's other domain rules. Store mutations live in a newsrc/data/cards.ts, a sibling ofsrc/data/queries.ts, so route handlers stay thin (GET/POST/PATCHcalling one function each) the same wayapi/payments/route.tscallsqueryPayments. UI reuses existing primitives only:Drawerfor the issue dialog (already proven byexport-dialog.tsx),Table/StatusBadge/Divider/Button/Input/Select, andformatMoney/formatInZone/formatDate.Considered and rejected: generating the card number inside the route handler instead of a separate
cards.tslib. Rejected because the Luhn generator and the state machine are exactly the kind of logic the ticket's stretch goal wants unit-tested beside the code it covers (src/lib/cards.test.ts), and a route handler isn't importable by a test the way a lib function is.File map
src/data/types.tsCardStatus,CardCategory,Cardsrc/lib/cards.tssrc/lib/cards.test.ts4242, every transition edgesrc/data/store.tscards: Card[]toStore/createStoresrc/data/generate.tssrc/data/cards.tslistCards,cardById,issueCard,transitionCardqueries.ts's shape for paymentssrc/app/api/cards/route.tsGET(masked list),POST(issue)src/app/api/cards/[id]/route.tsGET(detail),PATCH(status)src/app/siteConfig.ts,AppSidebar.tsx,Breadcrumbs.tsxsrc/components/ui/payments/StatusBadge.tsxAnyStatuswithCardStatussrc/app/cards/page.tsxsrc/app/cards/issue-card-dialog.tsxsrc/app/cards/card-actions.tsxsrc/app/cards/[id]/page.tsxPlan
src/lib/cards.ts+ tests — done when:npm testpasses with new Luhn/transition cases.store.cardshas a few deterministic cards,npx tsc --noEmitclean.src/data/cards.tsmutation layer — done when: callingissueCardfrom a scratch script/test returns{card, number}with validation errors on bad input.JPY, unknown id PATCH, illegal transition) returns the right status codes and the GET responses never contain a full number./cardsis reachable from the sidebar with a working breadcrumb./ship-ready+org-standards— done when: both pass clean or every finding is fixed.Verification
/cards/cards4242BIN, valid Luhnsrc/lib/cards.test.tsasserts both on every generated numbernumberfield ever appearsRisks
globalThis— verified by issuing a card twice in the running dev server and confirming the secondGET /api/cardsincludes both.Out of scope
sortPayments(src/data/queries.ts) found during NWP-101 — unrelated to this ticket's diff.Open questions
currency(src/data/types.tsMerchant.currency) unless ops overrides it, since every other money field in this codebase follows the merchant's currency by default.🤖 Generated with Claude Code