Skip to content

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

Closed
thesideqst wants to merge 2 commits into
JJFromTenex:mainfrom
thesideqst:NWP-201-issue-cards
Closed

NWP-201: issue virtual cards from the console#138
thesideqst wants to merge 2 commits into
JJFromTenex:mainfrom
thesideqst:NWP-201-issue-cards

Conversation

@thesideqst

Copy link
Copy Markdown

Ticket

Closes NWP-201

What changed

Ops can now issue a virtual card from the console instead of messaging the platform team: a drawer on the new /cards page takes a nickname, merchant, spend limit, and currency (defaulting to the merchant's settlement currency, since a wrong-currency request is how this ticket started), and the created card appears in the list immediately. The full card number — generated server-side on the 4242 test BIN with a valid Luhn check digit — is shown exactly once on the success screen; everywhere else the card is •••• 1234. Cards can be opened to a detail page showing spend against the limit, and frozen/unfrozen straight from the list. Status changes go through a server-guarded state machine where cancelled is terminal.

How I verified it

  • npm test passes — Test Files 4 passed (4), Tests 40 passed (40), including 12 new tests in src/lib/cards.test.ts (Luhn check digit against the textbook vector, 200 generated numbers all matching ^4242\d{12}$ and Luhn-valid, every legal and illegal state-machine edge, cancelled terminal for all three actions)
  • New behavior is covered by a test
  • Checked it in the browser
  • npx tsc --noEmit and next lint both clean
  • curl matrix against the running dev server: empty body, unknown merchant, empty nickname, zero/negative/float limit, limit of 5,000,001, currency JPY, unknown category — all 400 with { error }; valid POST → 201 with a Luhn-valid 4242… number; cancel then freeze on the same card → 409; unfreeze on an active card → 409; unknown action → 400; unknown id → 404
  • Browser flow: issued a card end to end — picking the EUR merchant auto-switched the currency to EUR, the success screen showed the full number once, and after closing, the DOM contains no 16-digit number and reopening the drawer shows a fresh form. Froze and unfrozen a card from the list and confirmed via a window flag that no full page load happened. The seeded 91%-spent card renders the amber progress bar with aria-valuenow="91". curl /cards | grep -E '[0-9]{16}' finds nothing.

Acceptance criteria

Core:

  • Issue a card — drawer form on /cards; the new row is behind the drawer (masked) before the success screen closes
  • Card list/cards shows nickname, merchant, masked number, spend limit, status badge, created date
  • Card detail/cards/[id] shows the full record, spend against limit, remaining, and a status history timeline
  • Generated card numbers — server-side only (src/lib/cards.ts), 4242 BIN + Luhn check digit
  • Reveal once, mask forever — the PAN exists only in the 201 response; the store keeps last4 plus an opaque reference, and the drawer clears the number from client state on close
  • Server-side validation — missing merchant, zero/negative limit, limit over 5,000,000 minor units, and non-USD/EUR/GBP currency all rejected with 400s; the client's checks are convenience only

Stretch:

  • Freeze and unfreeze from the list without a page reload (useTransition + router.refresh())
  • Spend progress bar on detail, amber past 80%
  • Merchant category lock chosen at issue time, allowlist-validated, shown on the detail page
  • Tests on the Luhn generator and status transitions, beside the code, npm test passing
  • Empty and error states — written empty state on the list; inline form errors for parse failures and every server rejection; 409s surfaced next to the freeze button

Bugs fixed along the way

None fixed — this branch touches no existing behavior beyond the shared StatusBadge union. Pre-existing issues found while running /ship-ready are listed below rather than fixed here, since each belongs to other work.

Notes for the reviewer

  • The PATCH route is part of core, not stretch. The ticket requires the state machine be guarded on the server, which needs a server mutation path; the no-reload UI on top of it is the stretch part.
  • No GET /api/cards. Both pages are server components reading the store directly, same as the payments pages — a list route would duplicate what the pages already have.
  • Five cards are seeded deterministically (one ~90% spent, one frozen, one cancelled) because "spend against the limit" is a core criterion and a freshly issued card always has spend 0 — an empty start would make the detail page undemonstrable. The written empty state still exists in the code.
  • Cancel has no UI button — deliberate: the ticket asks for freeze/unfreeze in the UI, and cancel is irreversible. The server supports it (verified by curl) and the seeded cancelled card demonstrates the terminal state.
  • If you run this locally, restart the dev server after pulling. The store is pinned on globalThis, so a server that predates the cards slice will 500 until restarted.
  • /ship-ready reports zero issues in this change. It also surfaced pre-existing findings left untouched: local-time bucketing in src/data/metrics.ts:25, major-unit accumulation in metrics.ts/analytics.ts/overview/page.tsx, a lexicographic amount sort in src/data/queries.ts:81, and unlabeled search inputs in AppSidebar.tsx and filter-bar.tsx.

🤖 Generated with Claude Code

Aliya Renee Khan and others added 2 commits August 31, 2026 16:52
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Issue form with one-time number reveal, /cards list and detail, server-side
4242+Luhn generation, allowlist validation, and a server-guarded status
state machine. Stretch: freeze/unfreeze without reload, spend progress bar
(amber past 80%), category lock, history timeline, unit tests on the Luhn
generator and transitions.

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

Copy link
Copy Markdown
Owner

Claude Code 101 — Repo Rescue

🏆 Build Battle Score: 93 / 100

One-line verdict: A tight, well-planned submission that nails every core criterion and correctness rule with real server-side guards, loses a little ground only on the harder Tier 2 stretch items (currency-merchant matching, idempotency, honest seeded spend).

Core criteria — 97 / 100 (35%)

  1. Issue a card: ✅ — Drawer form posts to /api/cards, new row appears via router.refresh().
  2. Card list: ✅ — /cards shows nickname, merchant, masked number, limit, status, created date.
  3. Card detail: ✅ — /cards/[id] shows full record, spend/limit/remaining, plus a history timeline.
  4. Generated numbers: ✅ — generateCardNumber in src/lib/cards.ts, called only server-side in data/cards.ts, 4242 BIN + real Luhn.
  5. Reveal once: ✅ — fullNumber only in the 201 response; drawer clears it on close; card record stores only last4/numberRef.
  6. Server-side validation: ✅ — src/app/api/cards/route.ts checks merchant existence, nickname, currency allowlist, integer positive limit, max limit, category allowlist.

Correctness rules — 100 / 100 (20%)

  • Minor units: ✅ — spendLimit validated as Number.isInteger, no float parsing on the server.
  • Luhn on 4242 BIN: ✅ — luhnCheckDigit/isLuhnValid tested against textbook vectors and 200-card fuzz.
  • Masking: ✅ — full PAN never stored, never in list/detail responses, cleared from client state on drawer close.
  • State machine: ✅ — nextStatus table enforces active⇄frozen, either→cancelled, cancelled terminal, guarded in the PATCH route (409 on illegal transitions).
  • Server-side validation: ✅ — real allowlist checks in the route handler, not just the form.

Context and planning — 95 / 100 (10%)

docs/specs/NWP-201-issue-cards.md is a genuine pre-code spec: it names real files (src/data/store.ts, StatusBadge.tsx, Drawer.tsx), states the domain rules, maps every file it touches, and even flags risks (exhaustive Record breakage, Tailwind JIT limits) that show up correctly handled in the diff. The delivered code matches the plan closely.

Code quality — 90 / 100 (15%)

Tests sit beside the code they cover and exercise real logic (Luhn, generator distribution, all state-machine edges) — these would fail without the change. Conventions followed (Drawer, Table, formatMoney/parseAmountToMinorUnits reused rather than reimplemented), no DB/ORM, no console.log/TODO, labelled/keyboard-accessible form. Minor deduction: seeded spent values are an arbitrary ratio (spentRatio) rather than derived from anything real in the store, which slightly undercuts the "spend is honest" story even though newly issued cards correctly start at 0.

PR description — 100 / 100 (5%)

Thorough and honest: every criterion checked off with a verification method, stretch goals itemized, and — notably — it reports pre-existing bugs it found but chose not to fix (correctly declining to claim credit rather than claiming a fix it didn't make).

Stretch goals — 75 / 100 (15%)

Tier 1: ✅ Freeze/unfreeze without reload · ✅ amber spend bar · ✅ category lock at issue + displayed · ✅ Luhn/state-machine tests · ✅ written empty/error states — all five, full 0.50.
Tier 2: ❌ Idempotent issue — no idempotency key or server-side dedupe guard, only a loading-disabled button (UI-only, explicitly 0 per rubric). ❌ Currency matches merchant — the form defaults currency to the merchant's settlement currency (issue-card.tsx handleMerchantChange), but the currency <Select> still lets the user pick any of USD/EUR/GBP and src/app/api/cards/route.ts never checks merchant.currency against the submitted value, so a mismatched card can still be issued. ❌ Spend is honest — seeded cards use an invented spentRatio (src/data/generate.ts), not derived from real payments. ❌ Cancel with confirm in UI — deliberately not built. ✅ Audit trail — card.history records timestamp + transition label on issue/freeze/unfreeze/cancel and renders as a timeline on the detail page (src/app/cards/[id]/page.tsx). One Tier 2 item earned = 0.25.


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

One thing to do differently next time: Enforce currency-matches-merchant server-side (the form already knows the merchant's currency — the route just needs to check it), which would have been the cheapest Tier 2 point on the board.


Powered by Anthropic and Tenex

@JJFromTenex JJFromTenex closed this Sep 1, 2026
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