NWP-201: issue virtual cards from the console - #174
Conversation
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…nges Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…e way Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude Code 101 — Repo Rescue🏆 Build Battle Score: 93 / 100One-line verdict: A genuinely strong, ops-ready cards flow — server-guarded validation, idempotency, currency-merchant matching, audit trail, and two real bug fixes with tests — held back only by a truncated diff that hides the generator, types, and spec files this review can't independently confirm. Note: the diff was truncated. Core criteria — 95 / 100 (35%)
Correctness rules — 95 / 100 (20%)
Context and planning — 70 / 100 (10%)The PR names a spec at Code quality — 92 / 100 (15%)Tests are dense and honest — every validation branch, the replay path, and both transition edge cases are covered in PR description — 96 / 100 (5%)Exceptionally thorough: enumerates verified behavior with curl and browser checks, states what was skipped and why, and is honest that spend is deliberately zero rather than invented. Matches the rubric's bar for honest reporting almost exactly. Stretch goals — 100 / 100 (15%)Tier 1: ✅ freeze/unfreeze without reload · ✅ amber progress bar at 80% · ✅ category lock at issue, shown on list/detail · ✅ Luhn/transition tests beside the code · ✅ written empty/error states. Breakdown: Core (95 × 0.35) + Rules (95 × 0.20) + Context (70 × 0.10) + Quality (92 × 0.15) + PR (96 × 0.05) + Stretch (100 × 0.15) = 93 / 100 One thing to do differently next time: Include the spec and the generator/types files in the diff itself — the work looks excellent, but a grader (or reviewer) reading only the diff has to take the card-number generation and the spec's existence on faith.
Powered by Anthropic and Tenex |
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Ticket
Closes NWP-201
What changed
Ops can now issue a virtual card from the console instead of asking the platform team. A new Cards page lists every issued card with its masked number, limit, category lock, status, and created date. An Issue card drawer takes a nickname, merchant, spend limit, currency, and optional category lock, and shows the full 4242-BIN number exactly once on its success screen. Each card has a detail page with the full record, a spend-against-limit bar, and a status timeline. Freeze, unfreeze, and a confirmed cancel work from the list and the detail page without a page reload, and every transition is guarded on the server.
How I verified it
npm test: 7 files, 77 passing. New:src/lib/cards.test.ts(20: Luhn check digit, 4242 prefix over 500 generated numbers, mask, transition table, 80% threshold),src/data/cards.test.ts(21: every validation rejection, idempotent replay, history, cancelled is terminal),src/data/queries.test.tsandsrc/data/metrics.test.ts(8, each confirmed failing before its fix).npx tsc --noEmitandnpx next lint: clean.curl against the dev server: missing merchant, unknown merchant, 0, -5, 5000001, JPY, and a GBP card for a EUR merchant all return 400 with
{ error }; malformed JSON returns 400; a valid POST returns 201 with a 16-digit number starting 4242; the same idempotency key again returns 200 withnumber: nulland no second card; GET list and GET detail carrylast4andnumberRefonly; PATCH freeze then freeze again returns 409 "Card is already frozen"; cancel then reactivate returns 409 "A cancelled card cannot be changed"; unknown id 404; bad status 400.In the browser: fresh server shows the written empty state on
/cards. Issued a card via the drawer, saw the grouped full number and Copy button, clicked Done, reopened the drawer and confirmed the form was empty and no number was present in the dialog DOM. The list refreshed to show the new row without a full document reload (checked a window flag set before the click survived). Clicked Freeze: badge flipped to Frozen in place, PATCH 200 in the network log. Clicked Cancel: inline confirm appeared with no request sent; Keep card restored the buttons; Cancel card sent the PATCH and the row rendered the terminal state with no buttons. Detail page showed the record,role="progressbar"with valuemin 0, valuemax 25000, valuenow 0, and a three-entry timeline in Europe/Berlin time. Submitting a GBP card for a USD merchant showed the server message inline with the form still filled.Accessibility: dialog is labelled by its title, all five fields have associated labels (checked
input.labels), focus lands inside the drawer, Escape closes it, every action button has a nickname-specific accessible name.npm testpassesNew behavior is covered by a test
Checked it in the browser
Acceptance criteria
Core
src/app/cards/issue-card-drawer.tsxposts tosrc/app/api/cards/route.ts; the row appears viarouter.refresh()./cards:src/app/cards/page.tsxshows nickname, merchant, masked number, limit, status, created date.src/app/cards/[id]/page.tsxwithspend-progress.tsx.generateCardNumberinsrc/lib/cards.ts, server-side only, 4242 prefix plus Luhn check digit from Web Crypto randomness.onOpenChangeclears. TheCardrecord has no number field; tests assertJSON.stringify(card)never contains it.issueCardinsrc/data/cards.tsrejects a missing or unknown merchant, zero or negative or non-integer limit, a limit above 5,000,000, and any currency outside USD/EUR/GBP.Stretch
src/app/cards/card-actions.tsx).isSpendWarninginsrc/lib/cards.ts, tested at 79.996%, 80%, and over-limit). On real data it renders at 0%, see notes.CARD_CATEGORIESon the server.Beyond the ticket
number: nullon a repeat.issueCardrejects a mismatch with a message naming the merchant.card.historyand shown as the timeline on the detail page.Bugs fixed along the way
src/data/queries.ts,sortPayments: amounts were compared withString(...).localeCompare, so 900 sorted after 10000 on the payments table. Now a numeric compare. Test:src/data/queries.test.ts.src/data/metrics.ts,dailyVolume: buckets were keyed withtoLocaleDateString, the server's local calendar day, while the bucket keys fromlastUtcDaysare UTC, so payments near midnight landed on the wrong day on any non-UTC server. Amounts were also accumulated as major-unit floats and rounded back on read. Now keyed withutcDayKeyand summed in integer minor units. Test:src/data/metrics.test.ts, which fails on this PDT machine without the fix.headlineMetricsin the same file adds refunded payment amounts intogrossVolume. Whether gross should include later-refunded captures is a product question, so it is raised as an open question in the spec rather than changed.Notes for the reviewer
docs/specs/NWP-201-issue-cards.md, written before the code. The build matches it; the one addition is theidempotencyKeyfield living on the card record rather than in a separate map, which the spec's rejected-alternatives section explains.Paymenthas nocardId, so there is nothing real to derive card spend from.spentis 0 at issue, the bar renders that truthfully, and the detail page says so in a sentence. Inventing a number would have looked better and been wrong.globalThis(src/data/store.ts). A dev server started before this branch will 500 on/api/cardsuntil restarted. That is the existing caching design, not a regression, but it will bite anyone who pulls without restarting.CardEventrecords what and when)..claude/rules/components.mdrefers to aDialogcomponent that does not exist;Drawer.tsxis the Radix Dialog wrapper the codebase actually has, so the issue form uses it.🤖 Generated with Claude Code