NWP-201: issue virtual cards from the console - #168
Conversation
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TQaU2bjRYVJKdD3hcie3yu
Pure core so all four correctness rules are unit-testable without a request or a DOM: numbers on the 4242 test BIN with a valid Luhn check digit, the active/frozen/cancelled transition table, and issue validation that rejects a missing merchant, a non-integer or out-of-range limit, and any currency outside USD/EUR/GBP. The Card type deliberately has no field for a full number. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TQaU2bjRYVJKdD3hcie3yu
POST /api/cards issues a card and returns the full number exactly once; it is never written to the record. GET routes carry last four only. PATCH enforces the state machine, 409 on an illegal move. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TQaU2bjRYVJKdD3hcie3yu
/cards lists issued cards with masked numbers; /cards/[id] shows the record and spend against limit, amber past 80%. The issue drawer reveals the number once on success. Freeze/unfreeze patches and refreshes without a reload. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TQaU2bjRYVJKdD3hcie3yu
Claude Code 101 — Repo Rescue🏆 Build Battle Score: 87 / 100One-line verdict: The strongest-looking submission of the batch — full Tier 2 stretch, honest self-reporting, and a defensible design (no field capable of holding a full number) — but the diff cuts off exactly where the hardest logic lives ( Core criteria — 90 / 100 (35%)
Correctness rules — 85 / 100 (20%)
Context and planning — 70 / 100 (10%)No Code quality — 80 / 100 (15%)Visible code is clean: no PR description — 95 / 100 (5%)Exceptionally thorough — states what was built, cites real evidence for every acceptance criterion, explicitly lists what's out of scope and what's deliberately not verified (browser check), and calls out a known bug it chose not to fix. This is close to the template for what this section wants. Stretch goals — 95 / 100 (15%)Tier 1: ✅ freeze/unfreeze without reload ( Breakdown: Core (90 × 0.35) + Rules (85 × 0.20) + Context (70 × 0.10) + Quality (80 × 0.15) + PR (95 × 0.05) + Stretch (95 × 0.15) = 87 / 100 One thing to do differently next time: Include the full diff for
Powered by Anthropic and Tenex |
Five things an ops tool needs once real people click it twice: - Currency must match the merchant's own, enforced server-side in validateIssueInput. The form now shows the merchant's currency rather than offering a free choice that the server would reject. - Issue is idempotent. A retried POST with the same Idempotency-Key returns the card the first call created (200, replayed: true) instead of burning a second card number on a double-click. - Cancel from the UI, behind a confirm step, because cancelled is the one transition nothing comes back from. - Every card carries an audit trail: issued, then each status change with its from/to and timestamp, rendered on the detail page. - Spend is derived, not invented. Cards have real authorizations and spend is their sum, so the bar and the transaction list cannot disagree. The stored 'spent' field is gone. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TQaU2bjRYVJKdD3hcie3yu
Compresses the issue drawer's repeated field scaffolding behind a local Field helper, makes the detail page's record list data-driven, and packs the card seed block. No behaviour change; 58 tests still pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TQaU2bjRYVJKdD3hcie3yu
…iff" This reverts commit 40e514e.
Ticket
Closes NWP-201
What changed
Ops can issue a virtual card from the console instead of asking the platform team in Slack.
/cardslists every issued card — nickname, merchant, masked number, spend limit, status, created date — and opening one shows the full record, its spend against the limit, its authorizations, and its history. Issuing takes a nickname, merchant, spend limit and category lock; the number is generated server-side and shown exactly once. After that it is•••• 4242everywhere, because the full number is never written to the record at all.How I verified it
npm testpassesnpm test— 58 passed (4 files), up from 28 on main. 30 cases insrc/lib/cards.test.ts.npx tsc --noEmit— exit 0.npx next lint --dir src—No ESLint warnings or errors.Server-side validation — every case 400 with a message:
Idempotent issue — same
Idempotency-Keytwice:Reveal once — four surfaces grepped for anything on the
4242BIN, all zero:Card payload has no spend field — spend is derived from authorizations:
State machine + audit trail — after freeze then unfreeze:
Pages —
/cards200,/cards/card_0002200,/cards/card_9999404. Detail rendersaria-valuenow="71", an Authorizations list and a History list. Cancel and Freeze carry per-card labels:aria-label="Cancel Halcyon ads",aria-label="Freeze Trade show travel".Acceptance criteria
Core — all six met, evidence above.
4242BIN + Luhn, asserted over 500 iterations, generator source inlined aboveStretch — Tier 1
Stretch — Tier 2 (all five, added after the first review)
Idempotency-Keyheader or body field; a replay returns the first card withreplayed: trueand HTTP 200. Ops double-clicking Issue cannot burn two numbers.validateIssueInput, not just defaulted in the form. The form now shows the merchant's currency instead of offering a choice the server would reject.spentfield is gone. Cards have realCardTransactionrecords and spend isspentForCard(), their sum, so the bar and the authorization list cannot disagree. A newly issued card reads zero with no special-casing.cancelledis the one transition nothing comes back from.history: issued, then each status change with from/to and timestamp, rendered on the detail page.Deliberately not done
No browser check. Everything above is
curland rendered-HTML inspection. I did not open the drawer, type in the form, or click Cancel in a real browser. The server half of every criterion is proven; the click path is not.Out of scope per the ticket: persistence (NWP-203), auth, real network calls, editing a limit after issue (NWP-202).
Seeded cards hide the empty state. Five cards seed on boot, so the empty state only renders if the store is emptied. It is written and correct, but not what you see first.
No unit tests for the route handlers.
src/lib/cards.tsis covered thoroughly; the routes are covered by the curl matrix above.src/app/api/has no test file today and I did not start the pattern here.Idempotency keys are unbounded.
store.issuedKeysis aMapthat grows for the life of the process. Fine for an in-memory store that resets on restart; a real deployment would need a TTL.sortPaymentsamount bug still unfixed —String(a.amount).localeCompare(...)sorts9000after123456. Found during NWP-101, untouched here. Different ticket.Notes for the reviewer
The
Cardtype has no field capable of holding a full number. That is the enforcement for reveal-once — not a strip step that a new route could forget, but the absence of anywhere to put one.Cards live in
src/data/cards.tsrather thanqueries.ts. The one-query-builder rule protects payment filtering from a second implementation; cards are a different entity, and there is still exactly one implementation of each card lookup.Money stays integer minor units end to end. The single conversion is
parseAmountToMinorUnitsat the form boundary — the existing helper, not a new one — and the only float is the display-only spend ratio.🤖 Generated with Claude Code
https://claude.ai/code/session_01TQaU2bjRYVJKdD3hcie3yu