Skip to content

NWP-201: issue virtual cards - #176

Open
moaazelsayed wants to merge 1 commit into
JJFromTenex:mainfrom
moaazelsayed:NWP-201-issue-cards
Open

NWP-201: issue virtual cards#176
moaazelsayed wants to merge 1 commit into
JJFromTenex:mainfrom
moaazelsayed:NWP-201-issue-cards

Conversation

@moaazelsayed

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 to make one by hand. A drawer form takes a nickname, merchant, spend limit, and currency; submitting generates a 4242-test-BIN number with a valid Luhn digit on the server and shows it once, on a success screen with a copy button. From then on the console only ever shows •••• 1234. The /cards list shows every card with its masked number, limit, status, and created date; opening one shows the full record, an event timeline, and a spend-progress bar that turns amber past 80% of the limit. Ops can freeze, unfreeze, or cancel a card from the list or the detail page without a page reload; cancelling asks for a confirming click and is terminal, matching the state machine. A merchant-category lock is optional at issue time and shown on the card. Every rule (missing merchant, non-positive or over-ceiling limit, unsupported currency, invalid status transition) is enforced on the server, not just in the form.

Along the way I found and fixed four pre-existing defects (see below) and one environment issue that blocked npm test entirely.

How I verified it

  • npm test92 passing, 9 files (up from 42 passing / 3 files on main; card generator, card store, both card routes, and the two bug-fix files are new).

  • npx tsc --noEmit — clean.

  • npm run build — succeeds; /cards and /cards/[id] both compile as dynamic routes.

  • npm run lint — no ESLint warnings or errors.

  • Ran the dev server and used curl against /api/cards:

    • GET /api/cards returns the seeded cards; every record has last4 and numberRef, none has a number field.
    • POST with a valid body returns 201 with card and a 16-digit number starting 4242; the following GET lists the new card with only its last four.
    • POST with no merchantId → 400; spendLimit: 0 → 400 "Spend limit must be greater than zero."; spendLimit: 5000001 → 400 "Spend limit cannot exceed $50,000.00."; currency: "CHF" → 400 "Currency must be USD, EUR, or GBP."
  • Checked in the browser: issued a card from /cards, saw the number once on the success screen, saw it appear in the list masked; froze and unfroze a seeded card with no navigation; opened a seeded card sitting above 80% of its limit and saw the amber bar and note.

  • npm test passes

  • New behavior is covered by a test

  • Checked it in the browser

Acceptance criteria

  • Issue a card — form takes nickname, merchant, spend limit, currency; appears in the list on submit
  • Card list at /cards — nickname, merchant, masked number, spend limit, status, created date
  • Card detail — full record plus spend against the limit
  • Generated card numbers — server-side, 4242 BIN, valid Luhn digit (src/lib/cards.ts, unit tested on 200 generated numbers plus hand-built failing cases)
  • Reveal once, mask forever — number only in the POST response body; list/detail/PATCH bodies carry last4 only, asserted in route tests via a string-search for the raw number
  • Server-side validation — missing merchant, unknown merchant, zero/negative limit, limit over 5,000,000, currency outside USD/EUR/GBP; each has a rejecting route test and a passing one at the boundary (exactly 5,000,000 is accepted)

Stretch:

  • Freeze/unfreeze without a reload — router.refresh() after a successful PATCH; row shows a pending state and an inline error on failure
  • Spend progress with an amber threshold — SpendProgress component, amber past 80%, red at/over 100%; spendPercent unit tested
  • Merchant category lock — optional allowlist of five categories, chosen at issue, shown on the list and detail page
  • Tests beside the code — src/lib/cards.test.ts (Luhn generator, masking, state machine, full validation) and src/data/cards.test.ts (store behavior); npm test passes
  • Written empty and error states — /cards has copy for zero cards; the issue dialog and row actions show role="alert" messages instead of failing silently

Bugs fixed along the way

  • npm test could not start on Node 18 (build-battle/merchant-console/vitest.config.ts, package.json). The lockfile had resolved vite@7, which requires Node ≥20, and vite-tsconfig-paths@6 is ESM-only while the CJS-loaded config tried to require() it. Pinned vite to ^6 and renamed the config to vitest.config.mts. This is an environment fix, not a feature change; every existing test still passes.
  • sortPayments compared amounts as strings (src/data/queries.ts, was String(a.amount).localeCompare(...)). "9999" > "10000" lexicographically, so ?sort=amount on the payments API and its export returned the wrong order for any amount crossing a digit-count boundary. Fixed to a numeric subtraction; pinned with a new test in src/data/queries.test.ts.
  • parseFilters accepted a fractional or non-positive page (src/data/queries.ts). page=2.5 passed Number.isFinite and reached paginate, which then sliced from a fractional offset. Now requires a positive integer and falls back to page 1 otherwise; pinned in src/data/queries.test.ts.
  • dailyVolume bucketed in the server's local time and accumulated floats (src/data/metrics.ts). It used toLocaleDateString("en-CA") against UTC-keyed buckets, so on any non-UTC host a payment near midnight UTC landed in the wrong day or was silently dropped, and it accumulated amount / 100 as a float before rounding back. Switched to the existing utcDayKey helper and integer minor-unit accumulation; pinned in src/data/metrics.test.ts with a payment planted at 23:30 UTC on the anchor day.
  • src/app/payments/page.tsx re-implemented parseFilters's allowlist instead of calling it, and silently dropped sort/direction/from/to in the process. Now calls the one query builder's parser directly.

Notes for the reviewer

  • Not fixed, flagged instead: metrics.ts/analytics.ts sum payments across currencies under a hard-coded "USD" label, and dailyVolume's refund series uses the payment's amount and date rather than the actual refund record (which is often partial and a few days later). Both are product questions about what the overview should mean, not a fix I felt was mine to make inside a cards ticket, and both predate this branch. analytics.ts's weekly buckets also use an exclusive upper bound that drops the anchor day. All three are candidates for their own ticket.
  • spent on a card is a stored field, not derived from any transaction log — there's no card-spend event stream in scope for this ticket, so seeded cards carry a plausible spent value and newly issued cards start at zero. Freezing a card does not change spent.
  • Cards are seeded in src/data/generate.ts after payouts, drawing from the same PRNG; I checked before and after that the payment seed (payments.length, total amount, first/last id+amount) is byte-identical, and pinned that fingerprint as a test in src/data/cards.test.ts so it can't drift silently later.
  • Editing a card's limit after issue is out of scope (NWP-202), as is persistence (NWP-203) and auth — nothing here adds a database, an ORM, or a login.

🤖 Generated with Claude Code

Adds card issuance to the console: a server-generated 4242-BIN number
with a Luhn check digit, reveal-once masking, a status state machine
(active <-> frozen, either to cancelled, cancelled terminal), and
server-side validation of merchant, spend limit, and currency.

- src/lib/cards.ts: BIN, Luhn, generator, mask, allowlists, transitions,
  parseIssueCardInput/parseStatusInput
- src/data/cards.ts: store-backed listCards/cardById/issueCard/transitionCard
- src/app/api/cards, src/app/api/cards/[id]: POST/GET/PATCH routes
- src/app/cards: list, issue dialog, row actions, detail page with a
  spend-progress bar (amber past 80%)
- Stretch: freeze/unfreeze without reload, category lock, Luhn/state
  machine unit tests, written empty and error states

Also fixes, with tests:
- vitest could not start on Node 18 (vite 7 needs Node 20+); pinned
  vite to ^6 and renamed the config to vitest.config.mts
- sortPayments compared amounts as strings (src/data/queries.ts)
- parseFilters accepted a fractional/non-positive page
- dailyVolume bucketed by server-local time instead of UTC and
  accumulated floats (src/data/metrics.ts)
- payments/page.tsx re-implemented parseFilters's allowlist instead of
  calling it

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@JJFromTenex

Copy link
Copy Markdown
Owner

Claude Code 101 — Repo Rescue

🏆 Build Battle Score: 89 / 100

One-line verdict: A genuinely shippable card-issuing flow with server-enforced validation, a real state machine, an audit timeline, and two honestly-described bug fixes — held back only by the diff's truncation (core generator/store files aren't visible) and a thin planning trail.

Core criteria — 92 / 100 (35%)

  1. Issue a card: ✅ — Drawer form (issue-card-dialog.tsx) posts to /api/cards, success screen shows the record, router.refresh() puts it in the list.
  2. Card list: ✅ — /cards/page.tsx shows nickname, merchant, masked number, limit, status, created date (plus category, a nice extra).
  3. Card detail: ✅ — full record, spend-vs-limit, and a timeline in cards/[id]/page.tsx.
  4. Generated numbers: ✅ (inferred from tests) — route tests assert TEST_BIN, isLuhnValid, 16-digit format, but src/lib/cards.ts itself is not in this diff, so I can't read the generator directly.
  5. Reveal once: ✅ — number only in the POST body; GET/list/detail tests assert not.toHaveProperty("number") and string-search the body for the raw number.
  6. Server-side validation: ✅ — route.ts calls parseIssueCardInput before touching the store; every rejection case has a route test.

Correctness rules — 90 / 100 (20%)

  • Minor units: ✅ — tests reject floats and dollar strings (spendLimit: 250.5, "$250.00"); money formatted only at display via formatMoney.
  • Luhn on 4242 BIN: ✅ (by test evidence) — isLuhnValid/TEST_BIN referenced and asserted in route tests; the generator file itself isn't in the diff to confirm directly.
  • Masking: ✅ — list/detail/PATCH bodies verified to carry last4/numberRef only, never number.
  • State machine: ✅ — route.test.ts covers active↔frozen, cancelled-is-terminal (409 on any further change), and no-op transitions.
  • Server-side validation: ✅ — confirmed in the route handler itself, not just the form.

Context and planning — 70 / 100 (10%)

No spec file appears anywhere in this diff (docs/specs/ or docs/epics/) — could exist but isn't shown. The PR description is unusually detailed and names real repository files (src/data/queries.ts, src/data/metrics.ts, src/data/merchants.ts) with correct root-cause descriptions, and the commits/tests plausibly follow that plan. That fits the "no spec file, but a considered plan naming real files, commits follow it" tier rather than the top tier.

Code quality — 88 / 100 (15%)

Tests sit beside every route they cover and read like they'd fail without the change (explicit rejection cases, exact-boundary acceptance at 5,000,000). Accessibility looks handled: labelled inputs, role="alert" error text, Drawer-based focus trap, aria-labels on freeze/cancel buttons, a real progressbar with aria-valuenow/aria-valuetext. No console.log/TODO/commented code visible, no DB/ORM added, CLAUDE.md updated with the new test commands rather than diverging from it. Two real pre-existing defects (string-sorted amounts in queries.ts, local-date/float bucketing in metrics.ts) are named with root cause and fixed alongside tests — solid bonus credit here. Can't fully verify npm test: 92 passing since several referenced test/source files (src/lib/cards.ts, src/data/cards.ts, src/data/cards.test.ts, src/lib/cards.test.ts) are outside this diff.

PR description — 92 / 100 (5%)

Thorough: states what was built, verification steps (tsc, build, lint, curl, manual browser check), which stretch goals were hit, and is honest about what's not in scope (currency-across-merchants label bug, refund-series bug, spend not being derived) rather than hiding it.

Stretch goals — 95 / 100 (15%)

Tier 1: ✅ Freeze/unfreeze without reload · ✅ amber spend bar · ✅ category lock at issue, shown on list/detail · ✅ Luhn/state-machine tests · ✅ written empty/error states — all five, capped at 0.50.
Tier 2: ✅ Cancel with confirm, goes through the guarded PATCH, terminal state rendered (card-row-actions.tsx, confirmingCancel) · ✅ Audit trail — event timeline with type + timestamp shown on detail page (cards/[id]/page.tsx) · ⚠️ Spend honesty — PR states new cards start at 0 and stay 0, but seeded cards carry a "plausible" (not derived) value, which is a partial credit, not a clean pass · ❌ Idempotent issue — no idempotency key or server guard visible · ❌ Currency-matches-merchant — form defaults currency from the chosen merchant but lets it be changed freely; no server check shown, not claimed in the PR. Two solid Tier 2 items plus a partial third caps this at 0.50.


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

One thing to do differently next time: Write the spec file first — the diff shows evidence of careful reading of the codebase (real bug root-causes, real file names) but nothing captures that as a plan before the code, so context/planning is scored on inference rather than a document.

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

Generated files were skipped: build-battle/merchant-console/package-lock.json


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