Skip to content

NWP-201: issue virtual cards from the console - #174

Open
MahmoodMahmood wants to merge 10 commits into
JJFromTenex:mainfrom
MahmoodMahmood:NWP-201-issue-cards
Open

NWP-201: issue virtual cards from the console#174
MahmoodMahmood wants to merge 10 commits into
JJFromTenex:mainfrom
MahmoodMahmood:NWP-201-issue-cards

Conversation

@MahmoodMahmood

Copy link
Copy Markdown

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.ts and src/data/metrics.test.ts (8, each confirmed failing before its fix).

  • npx tsc --noEmit and npx 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 with number: null and no second card; GET list and GET detail carry last4 and numberRef only; 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 test passes

  • New behavior is covered by a test

  • Checked it in the browser

Acceptance criteria

Core

  • Issue a card: src/app/cards/issue-card-drawer.tsx posts to src/app/api/cards/route.ts; the row appears via router.refresh().
  • Card list at /cards: src/app/cards/page.tsx shows nickname, merchant, masked number, limit, status, created date.
  • Card detail: src/app/cards/[id]/page.tsx with spend-progress.tsx.
  • Generated numbers: generateCardNumber in src/lib/cards.ts, server-side only, 4242 prefix plus Luhn check digit from Web Crypto randomness.
  • Reveal once, mask forever: the number exists only in the POST response and the drawer's success state, which onOpenChange clears. The Card record has no number field; tests assert JSON.stringify(card) never contains it.
  • Server-side validation: issueCard in src/data/cards.ts rejects 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

  • Freeze and unfreeze from the list without a full reload (src/app/cards/card-actions.tsx).
  • Spend progress bar, amber at or past 80% (isSpendWarning in src/lib/cards.ts, tested at 79.996%, 80%, and over-limit). On real data it renders at 0%, see notes.
  • Merchant category lock chosen at issue and shown on list and detail; validated against CARD_CATEGORIES on the server.
  • Tests on the Luhn generator and status transitions, beside the code they cover.
  • Written empty and error states: empty list copy, inline server errors in the drawer and next to the action buttons, a network-failure message.

Beyond the ticket

  • Idempotent issue: the drawer generates a key per form instance and the submit button is disabled while in flight; the server stores the key on the card and returns the existing card with number: null on a repeat.
  • Currency must match the merchant: the form derives it from the chosen merchant, and issueCard rejects a mismatch with a message naming the merchant.
  • Cancel requires a confirm step and goes through the guarded PATCH; a cancelled card renders no actions anywhere.
  • Audit trail: every status change is appended to card.history and shown as the timeline on the detail page.

Bugs fixed along the way

  • src/data/queries.ts, sortPayments: amounts were compared with String(...).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 with toLocaleDateString, the server's local calendar day, while the bucket keys from lastUtcDays are 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 with utcDayKey and summed in integer minor units. Test: src/data/metrics.test.ts, which fails on this PDT machine without the fix.
  • Not fixed: headlineMetrics in the same file adds refunded payment amounts into grossVolume. 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

  • Spec: docs/specs/NWP-201-issue-cards.md, written before the code. The build matches it; the one addition is the idempotencyKey field living on the card record rather than in a separate map, which the spec's rejected-alternatives section explains.
  • Spend is honestly zero. Payment has no cardId, so there is nothing real to derive card spend from. spent is 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.
  • The store is cached on globalThis (src/data/store.ts). A dev server started before this branch will 500 on /api/cards until restarted. That is the existing caching design, not a regression, but it will bite anyone who pulls without restarting.
  • No new dependencies, no database, no seed cards. The empty state is what ops sees first on a fresh boot.
  • Left out on purpose: expiry and CVC (not asked for, and more PAN-adjacent data with no criterion), pagination and filters (twelve to twenty cards a week), and who-issued-it (no auth to attribute to; CardEvent records what and when).
  • .claude/rules/components.md refers to a Dialog component that does not exist; Drawer.tsx is the Radix Dialog wrapper the codebase actually has, so the issue form uses it.

🤖 Generated with Claude Code

MahmoodMahmood and others added 5 commits September 10, 2026 11:56
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>
@JJFromTenex

JJFromTenex commented Sep 10, 2026

Copy link
Copy Markdown
Owner

Claude Code 101 — Repo Rescue

🏆 Build Battle Score: 93 / 100

One-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. src/lib/cards.ts (Luhn generator, mask, transition table), src/data/types.ts, src/data/merchants.ts, src/lib/dates.ts, src/lib/money.ts, the queries.ts/metrics.ts fix diffs, docs/specs/NWP-201-issue-cards.md, and CLAUDE.md are referenced but not visible. Scoring below is inferred from the visible route handlers, cards.test.ts, metrics.test.ts, and the PR description, and is conservative where the underlying source is unseen.

Core criteria — 95 / 100 (35%)

  1. Issue a card: ✅ — drawer posts to POST /api/cards; success calls router.refresh(), row appears in the list.
  2. Card list: ✅ — src/app/cards/page.tsx shows nickname, merchant, masked number, limit, category, status, created date, plus a written empty state.
  3. Card detail: ✅ — full record, SpendProgress, and a status timeline in [id]/page.tsx.
  4. Generated numbers: ✅ (inferred) — tests assert /^4242\d{12}$/ and isValidLuhn(number) on the server response; the generator itself (src/lib/cards.ts) isn't in the visible diff, so this is credited on strong indirect evidence, not direct inspection.
  5. Reveal once: ✅ — Card has no number field (asserted by Object.keys(card) test), number only appears in the POST response, drawer clears state on close.
  6. Server-side validation: ✅ — issueCard in src/data/cards.ts rejects missing/unknown merchant, non-integer/zero/negative/over-limit spend, off-allowlist currency, and unknown category, all exercised by cards.test.ts.

Correctness rules — 95 / 100 (20%)

  • Minor units: ✅ — Number.isInteger(spendLimit) gate, formatMoney used only for display.
  • Luhn on 4242 BIN: ✅ (inferred from tests) — 500-number claim and per-number Luhn assertions in the PR description aren't in the visible diff, but cards.test.ts confirms the same property on every issue.
  • Masking: ✅ — full number never stored on Card, JSON.stringify(card) asserted clean, GET routes return last4/numberRef only.
  • State machine: ✅ — canTransition guarded server-side, cancelled proven terminal against both active and frozen in tests.
  • Server-side validation: ✅ — all checks live in src/data/cards.ts, called from the route handler, not just the client form.

Context and planning — 70 / 100 (10%)

The PR names a spec at docs/specs/NWP-201-issue-cards.md and describes a specific rejected-alternatives decision (idempotency key on the card vs. a separate map), which reads like a real spec rather than a generic one — but the file itself is not in the visible diff, so this can't be verified directly. Scored as a considered, file-citing plan without direct confirmation of the artifact.

Code quality — 92 / 100 (15%)

Tests are dense and honest — every validation branch, the replay path, and both transition edge cases are covered in cards.test.ts, and metrics.test.ts states it fails without the fix. Components reuse existing Drawer/Select/Table primitives rather than reinventing them, no console.log/TODO visible, no database added. Two real, cited bugs were fixed with tests (queries.ts string sort, metrics.ts local-day bucketing + float accumulation), each matching a genuine defect rather than a symptom-only patch — worth the quality bonus. The third known bug (refund in gross volume) was correctly identified and honestly left unfixed rather than "fixed" incorrectly.

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.
Tier 2: ✅ idempotent issue (idempotencyKey on Card, server-checked replay in src/data/cards.ts) · ✅ currency matches merchant (issuedCurrency !== merchant.currency check plus form auto-derivation in issue-card-drawer.tsx) · ✅ spend is honest (0 at issue, stated as such on the detail page) · ✅ cancel with confirm, terminal, no reactivate (card-actions.tsx) · ✅ audit trail (card.history, rendered as the timeline). All five present; capped at 0.50 per rubric.


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.

The diff was too large to review in full, so only the first part was graded.


Powered by Anthropic and Tenex

MahmoodMahmood and others added 5 commits September 10, 2026 11:59
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants