Skip to content

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

Open
timusmanov wants to merge 5 commits into
JJFromTenex:mainfrom
timusmanov:NWP-201-issue-cards
Open

NWP-201: issue virtual cards from the console#175
timusmanov wants to merge 5 commits into
JJFromTenex:mainfrom
timusmanov:NWP-201-issue-cards

Conversation

@timusmanov

Copy link
Copy Markdown

Ticket

Closes NWP-201

What changed

Ops can issue single-merchant virtual cards from the console instead of asking the platform team to create them by hand. /cards lists every issued card, /cards/[id] opens one with its spend against its limit and its full status history, and cards are issued through a drawer that validates the limit as it is typed and shows the generated number exactly once.

Numbers are minted server-side on the 4242 test BIN with a computed Luhn check digit. The Card interface has no field capable of holding a full number — only last4 and an opaque reference that is not derived from the PAN — so the number exists in one 201 response body and nowhere else in the app.

Planned before any code was written: docs/specs/NWP-201-issue-cards.md, grounded in the actual codebase, and the delivered file map matches it.

How I verified it

Test Files  5 passed (5)
     Tests  44 passed (44)

npx tsc --noEmit exits 0. npx eslint clean on the new files.

  • npm test passes — 44 tests, 5 files
  • New behavior is covered by a test — 13 new tests in src/lib/cards.test.ts (Luhn + state machine), 3 in src/data/queries.test.ts (the sort fix)
  • Checked it in the browser — not done, and I am not claiming it. The build typechecks and the logic is unit-tested, but I ran out of clock before walking the UI in a browser. The list, drawer and detail pages are unverified by hand.

Acceptance criteria

Core

  • Issue a card — drawer takes nickname, merchant, spend limit and category; POST /api/cards creates it and it appears in the list (src/app/cards/issue-card-drawer.tsx)
  • Card list/cards shows nickname, merchant, masked number, limit, status, created date (src/app/cards/page.tsx)
  • Card detail — full record plus spend against limit (src/app/cards/[id]/page.tsx)
  • Generated card numbersgenerateCardNumber() in src/lib/cards.ts, server-side only, 4242 BIN, computed Luhn check digit; 200 generated draws asserted valid in src/lib/cards.test.ts
  • Reveal once, mask forever — full number returned only by POST /api/cards; cleared from React state on any drawer close; masked as •••• <last4> everywhere else
  • Server-side validation — six flat reject-early guards in src/app/api/cards/route.ts: unknown merchant, empty nickname, non-integer/zero/negative limit, limit over 5,000,000, currency outside USD/EUR/GBP, unknown category. Plus a 400 on a malformed JSON body

Stretch — Tier 1

  • Freeze and unfreeze without a reload — PATCH + router.refresh() (src/app/cards/card-actions.tsx)
  • Spend progress, amber past 80% — decided by isNearLimit() using integer math, role="progressbar" with aria values and the percentage in text, not colour alone
  • Merchant category lock — chosen at issue, validated against CARD_CATEGORIES server-side, displayed on the detail page
  • Tests on the Luhn generator and the status transitions — src/lib/cards.test.ts
  • Written empty and error states — the empty list row points ops at the issue button; server { message } renders inline in the drawer and next to the row actions

Beyond the ticket

  • Idempotent issuecrypto.randomUUID() per drawer session sent as Idempotency-Key, honoured server-side by a Map in src/data/store.ts; a replay returns the original card with no number and mints nothing. Submit is also disabled in flight, but the server guard is the enforcement
  • Currency matches the merchant — the form derives currency from the selected merchant and offers no picker, and route.ts independently rejects a mismatch with 400. src/data/merchants.ts carries each merchant's currency
  • Spend is honestspent is 0 at issue and stays 0. Nothing in this ticket moves money, so the bar renders 0% truthfully rather than inventing a figure; the 80% threshold is proven by unit test instead of by fake data
  • Cancel from the UI, behind a confirm — a Drawer confirm with "permanent, cannot be undone" and distinct Keep/Cancel actions, through the guarded PATCH; once cancelled, no actions render at all
  • Audit trail — every transition appends { from, to, at, actor } to card.events (issue event included, from: null), rendered oldest-first on the detail page

Bugs fixed along the way

sortPayments in src/data/queries.ts sorted amounts as text. The line was String(a.amount).localeCompare(String(b.amount)), with a comment claiming this matched the table's display order — which is the reasoning that introduced it. Amounts are integer minor units, so lexicographic order beat numeric order and a 900 amount sorted after a 1000 one. Fixed to compare integers. src/data/queries.test.ts uses fixtures (900/1000/9999/25000) chosen so text and numeric order disagree; verified the test fails against the old line and passes against the new one.

Left in place deliberately: src/data/metrics.ts has three more real defects — it buckets by server-local date instead of utcDayKey(), accumulates money as floats in major units, and adds refunds to gross volume instead of netting them. All are on the overview path, not the cards path. They are named in the spec's Out of scope section rather than fixed, to keep this diff reviewable.

Notes for the reviewer

One ambiguity I had to resolve. The ticket says the mask is •••• 4242, but 4242 is the test BIN — the number's first four digits — while the same rule says to "store the last four and a reference". Storing the BIN would make every card render identically and would not be the last four of anything. I stored the generated number's true trailing digits and mask as •••• <last4>. Every number still begins 4242, asserted in tests. Flagging it because the displayed string differs from the ticket's literal example.

A Tailwind constraint worth knowing. .claude/rules/components.md forbids inline style, but Tailwind's scanner cannot see a runtime-interpolated width. The progress bar snaps to the nearest 5% from a literal class list while aria-valuenow and the visible text report the exact percentage.

.claude/rules/components.md names a Dialog component that does not exist in this repo. The issue flow and the cancel confirm both use src/components/Drawer.tsx, which wraps @radix-ui/react-dialog and already handles the focus trap, accessible name and Escape — rather than adding a new primitive.

Deliberately not built: persistence of any kind (NWP-203 — the store stays in memory on globalThis), auth and roles (CardEvent.actor is the literal "ops"), real card network calls, and editing a limit after issue (NWP-202).

timusmanov added 5 commits September 10, 2026 11:43
Card records carry last4 and an opaque reference; there is deliberately no
field able to hold a full number, so the PAN cannot be persisted or
serialised by a later render.
sortPayments compared amounts with String(..).localeCompare, so lexicographic
order beat numeric order and a 900 amount sorted after a 1000 one. Amounts are
integer minor units; compare them as integers. Test fails without the fix.
Adds the /cards list, the card detail with spend progress and audit trail,
and the issue drawer with a one-time reveal. Numbers are minted server-side
on the 4242 test BIN with a Luhn check digit; the record stores only the
trailing four and an opaque reference.

Server-side: all validation and the status state machine are enforced in the
route handlers, an Idempotency-Key replay returns the original card rather
than minting a second, and a card's currency must match its merchant's.
@JJFromTenex

Copy link
Copy Markdown
Owner

Claude Code 101 — Repo Rescue

🏆 Build Battle Score: 95 / 100

One-line verdict: A genuinely complete submission — every core criterion, every correctness rule, and both stretch tiers land, with an honest PR description that flags its own gap (no browser check) rather than hiding it.

Note: the diff was explicitly truncated (per the PR's own flag). The spec file (docs/specs/NWP-201-issue-cards.md) and some referenced modules (merchants.ts, money.ts, Drawer.tsx, CLAUDE.md) are not visible in what was shown, so those pieces are scored on the PR's description of them, not direct inspection.

Core criteria — 100 / 100 (35%)

  1. Issue a card: ✅ — Drawer takes nickname, merchant, spend limit, category; POST /api/cards creates and appears via router.refresh().
  2. Card list: ✅ — /cards/page.tsx shows nickname, merchant, masked number, limit, status, created date.
  3. Card detail: ✅ — full record plus a real spend-vs-limit progress bar and audit trail.
  4. Generated numbers: ✅ — generateCardNumber() server-side only, 4242 BIN, computed Luhn digit, 200-draw property test.
  5. Reveal once: ✅ — number only in the 201 body, no field on Card can hold it, cleared from React state on any drawer close.
  6. Server-side validation: ✅ — six flat guards plus malformed-JSON handling in route.ts, independent of the client.

Correctness rules — 100 / 100 (20%)

  • Minor units: ✅ — isNearLimit uses integer cross-multiplication, no float division.
  • Luhn on 4242 BIN: ✅ — computed digit, not a constant; tested against a hand-checked case.
  • Masking: ✅ — no full-number field exists on Card; nothing leaks through list/detail/reveal-cleanup.
  • State machine: ✅ — CARD_TRANSITIONS enforced server-side via canTransition, cancelled has an empty transition list, tested explicitly as terminal.
  • Server-side validation: ✅ — all checks live in the route handler, not the client.

Context and planning — 70 / 100 (10%)

The PR claims a spec at docs/specs/NWP-201-issue-cards.md "grounded in the actual codebase" with a matching file map, and the description's reasoning (e.g. the BIN-vs-last4 ambiguity, the Dialog-doesn't-exist note) reads like someone who actually read the repo. The spec file itself isn't in the visible diff (truncated), so I can't confirm it cites real paths and matches delivered code directly — scoring the strong circumstantial evidence, not a verified document.

Code quality — 90 / 100 (15%)

Tests sit beside the code they cover (cards.test.ts, queries.test.ts), are specific enough to fail without the fix, and the reported npm test / tsc / eslint results are plausible given what's shown. No console.log, no TODOs, no second implementation of an existing helper, no database. Accessibility is handled properly (labelled inputs, aria-invalid, role="progressbar" with real values, Radix-backed focus trap). The named bug fix (sortPayments text-sorting amounts in queries.ts) is a real, correctly diagnosed defect, fixed with a test proven to fail on the old line — full quality bonus earned. Docked slightly because CLAUDE.md conventions can't be independently verified from the truncated diff, and the author admits no browser verification occurred.

PR description — 95 / 100 (5%)

Exceptionally thorough: states what was built, maps every criterion to a file, reports test/typecheck/lint results, names an unmet check honestly (no browser walk-through), and documents a real ambiguity it had to resolve. Close to the ceiling for this section.

Stretch goals — 100 / 100 (15%)

Tier 1: ✅ Freeze/unfreeze without reload, ✅ amber progress bar with real ARIA, ✅ category lock validated server-side and displayed, ✅ Luhn/transition unit tests, ✅ written empty/error states.
Tier 2: ✅ Idempotent issue (Idempotency-Key header honoured via a server-side Map in data/cards.ts, replay returns the existing card with no number) — ✅ Currency matches merchant (route.ts: currency !== merchant.currency → 400, form derives currency and offers no picker) — ✅ Spend is honest (spent: 0 at issue, stated rationale, no invented figure) — ✅ Cancel with confirm (Drawer confirm, guarded PATCH, terminal state removes all actions) — ✅ Audit trail (card.events array rendered oldest-first on detail page).


Breakdown: Core (100 × 0.35) + Rules (100 × 0.20) + Context (70 × 0.10) + Quality (90 × 0.15) + PR (95 × 0.05) + Stretch (100 × 0.15) = 95 / 100

One thing to do differently next time: Actually open the browser before submitting — the honesty about skipping it is commendable, but a Select/Drawer interaction bug would have been invisible to tsc and unit tests alike, and this is the one gap between "shippable" and "verified."

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


Powered by Anthropic and Tenex

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