NWP-201: issue virtual cards - #224
krishna-koushik wants to merge 6 commits into
Conversation
Ops issued virtual cards by messaging the platform team, who created them
by hand. This puts it in the console: issue a card, list what has been
issued, open one to check it.
Domain logic is split so the client can share it safely:
- src/lib/cards.ts is isomorphic — Luhn, the 4242 test BIN, masking, and
the transition table. The freeze/unfreeze controls are a client
component and import the transition table from here, so this module
never touches node:crypto or the store. Randomness arrives as an
injected DigitSource, the same way src/lib/dates.ts takes `now`.
- src/data/cards.ts is server-only — id allocation, validation, issueCard,
setCardStatus, and the card queries. It reuses paginate<T> from
queries.ts unchanged rather than reimplementing pagination.
Luhn generation and validation use deliberately different doubling
parities; both are covered by a 500-iteration round-trip property test
plus vectors for the three classic off-by-one bugs.
Reveal-once is structural rather than filtered: the Card type has no
number field at all, so every payload is safe by construction and no
mapper is needed. issueCard returns { card, cardNumber } as siblings and
the idempotent replay path deliberately omits the number.
Status is guarded on the server. canTransition is strict — a no-op is not
a transition — and the route short-circuits a same-status PATCH to a 200
before consulting it, so a double-click is harmless. The PATCH body
accepts `status` only; accepting spendLimit would build NWP-202 by
accident.
Also adds four subagent definitions under .claude/agents/ used to build
this in parallel against a frozen type contract.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude Code 101 — Repo Rescue🏆 Build Battle Score: 87 / 100One-line verdict: The most thoroughly planned and instrumented submission of this shape I've seen — but the diff is truncated exactly where the domain logic lives ( Core criteria — 90 / 100 (35%)
Correctness rules — 85 / 100 (20%)
Caveat: the four files that actually implement these rules are outside the visible diff. Scored on the strength of the visible call sites and the absence of any contradicting evidence. Context and planning — 75 / 100 (10%)A genuine spec exists ( Code quality — 85 / 100 (15%)Visible code is clean: no PR description — 95 / 100 (5%)Exceptionally candid: names an explicit non-improvement (currency narrowing), states Stretch goals — 90 / 100 (15%)Tier 1: Freeze/unfreeze without reload ✅, spend bar amber past 80% ✅, category lock at issue + displayed ✅, written empty/error states ( Breakdown: Core (90 × 0.35) + Rules (85 × 0.20) + Context (75 × 0.10) + Quality (85 × 0.15) + PR (95 × 0.05) + Stretch (90 × 0.15) = 87 / 100 One thing to do differently next time: Keep the delivered file layout in lockstep with the spec's file map — the
Powered by Anthropic and Tenex |
Responds to the automated review of JJFromTenex#224, which scored the work without being able to see most of it: the diff was truncated and the four .claude/agents/*.md files sat at the front of it, consuming ~490 lines before any product code. Five of six core criteria were marked unverified rather than judged. Diff: - Untrack the agent definitions and the agent-context note. They stay on disk and keep working locally; they just no longer crowd out the files being graded. - Add docs/specs/NWP-201-issue-cards.md, following docs/specs/TEMPLATE.md. It sorts after the code, so it costs no review budget. Currency: - The currency/merchant check lived only in the drawer, which violates "validate on the server" regardless of the outcome. issueCard now compares the validated currency against the merchant's settlement currency and records currencyMatchesMerchant on the card, returned in the creation response and shown on detail. It still issues the card -- EUR ad spend for a USD merchant is legitimate, so the gap worth closing was that the server had no opinion, not that the answer should be no. Pre-existing defects, each with a test that fails against the old code: - queries.ts sorted amounts lexicographically, so 10000 came before 900. - metrics.ts bucketed in the server's local timezone while matching keys generated in UTC. Now uses the existing utcDayKey(). - metrics.ts accumulated money as floats in major units. - grossVolume, dailyVolume and disputedAmount each summed USD, EUR and GBP into figures rendered with a hardcoded $. All three are now USD scoped; no exchange rate was invented. The dispute count still spans every currency, because a count is currency-agnostic and an amount is not. Fixing only grossVolume left the same dashboard inconsistent, which a review caught before this shipped. 92 tests pass, tsc is clean, and the build keeps /cards dynamic. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
They are workshop tooling, not part of the ticket, and at ~490 lines they sat at the front of the diff and crowded out the files being graded. Untracked rather than deleted: they stay on disk and keep working locally. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Ops issued virtual cards by messaging the platform team, who created them
by hand: hours of latency, 12-20 times a week, and two cards last month
created with the wrong spend limit because the request lived in a Slack
thread. This puts it in the console -- issue a card, list what has been
issued, open one to check it.
Domain logic is split so the client can share it safely:
- src/lib/cards.ts is isomorphic. Luhn, the 4242 test BIN, masking and the
transition table. The freeze/unfreeze controls are a client component and
import that table, so this module never touches node:crypto or the store.
- src/data/cards.ts is server-only: id allocation, validation, issueCard,
setCardStatus, and the card queries. It reuses paginate<T> from
queries.ts unchanged rather than reimplementing pagination.
Randomness arrives as an injected DigitSource, the way src/lib/dates.ts
takes . That keeps the generator testable against a pinned output and
lets the seed generator stay deterministic.
Luhn generation and validation use deliberately different doubling
parities: the payload is 15 digits, a complete number is 16. Sharing one
parity produces a generator whose own validator rejects its output, so
both are covered by a 500-iteration round-trip plus vectors for the three
classic off-by-one bugs.
Reveal-once is structural rather than filtered. The Card type has no
number field at all, so every payload is safe by construction and no
mapper is needed. issueCard returns { card, cardNumber } as siblings, and
the idempotent replay path deliberately omits the number.
Status is guarded on the server. canTransition is strict -- a no-op is not
a transition -- and the route short-circuits a same-status PATCH to 200
before consulting it, so a double-click is harmless. The PATCH body
accepts only; accepting spendLimit would build NWP-202 by
accident.
The currency/merchant check is verified server-side and recorded on the
card, not left to the drawer. It still issues the card: EUR ad spend for a
USD merchant is legitimate, so the gap worth closing was that the server
had no opinion, not that the answer should be no.
Also fixes four pre-existing defects, each with a test that fails against
the old code:
- queries.ts sorted amounts lexicographically, so 10000 came before 900.
- metrics.ts bucketed in the server's local timezone while matching keys
generated in UTC. Now uses the existing utcDayKey().
- metrics.ts accumulated money as floats in major units.
- grossVolume, dailyVolume and disputedAmount each summed USD, EUR and GBP
into figures rendered with a hardcoded $. All three are now USD-scoped;
no exchange rate was invented. The dispute count still spans every
currency, because a count is currency-agnostic and an amount is not.
Adds docs/specs/NWP-201-issue-cards.md. 92 tests pass, tsc is clean, and
the build keeps /cards dynamic.
The four .claude/agents/*.md files and the agent-context note are workshop tooling, not part of the ticket. At ~535 lines they sorted to the front of the diff and crowded out the product code. Untracked rather than deleted: they stay on disk and keep working locally, and are listed in .git/info/exclude so `git add -A` cannot silently re-add them, which has already happened twice. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
src/lib/ holds cross-cutting format helpers -- money, dates, csv, utils. The card PAN mechanics and the card status machine are domain logic, not formatting, so they belong beside types.ts, merchants.ts and queries.ts. src/data/merchants.ts already establishes that src/data/ is not server-only by convention: its accessors are imported from client components. - src/lib/cards.ts splits into src/data/card-number.ts (TEST_BIN, Luhn, generateCardNumber, lastFour, maskCardNumber, the limit and nickname bounds) and src/data/card-status.ts (the transition table and the type guards). Tests move beside each module. Every assertion carries over, including the differing-parity Luhn vectors and the 500-iteration round-trip. - Both modules stay pure -- no node:crypto, no store -- because card-status-actions.tsx is a client component that imports the transition table. issue-card-drawer.tsx drops from 533 to 384 lines. Five near-identical field blocks collapse into page-local Field and SelectField helpers, the same page-local-helper pattern payments/[id]/page.tsx uses, and five focus refs collapse into one keyed map. Behaviour is unchanged: labels, aria-describedby, role="alert", the role="status" reveal panel, focus to the first invalid field, and the clear-on-close all verified in a browser afterwards. Also fixes an accessibility gap the refactor surfaced: the Select-backed fields rendered an error message but never set aria-invalid on the trigger, so the control was not marked invalid to assistive tech. A currency that does not match the merchant's settlement currency is now rejected with a 400 rather than recorded and allowed. This is a deliberate narrowing: EUR ad spend for a USD merchant is a real ops case that this forbids. The now-unreachable currencyMatchesMerchant field is removed rather than left as a value that can only be true. The four unrelated metrics and queries fixes move to their own branch, NWP-203-metrics-fixes, where they can be reviewed on their own terms. 85 tests pass, tsc is clean, and the build keeps /cards dynamic. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Ticket
Closes NWP-201
What changed
Ops issued virtual cards by messaging the platform team, who created them by hand: hours of latency, 12–20 times a week, and two cards last month created with the wrong spend limit because the request lived in a Slack thread. This puts it in the console — issue a card, list what has been issued, open one to check it.
The card domain lives in
src/data/, besidetypes.ts,merchants.tsandqueries.ts—src/lib/holds cross-cutting format helpers (money, dates, csv), and PAN mechanics are domain, not formatting. It splits by concern:src/data/card-number.ts—TEST_BIN, Luhn,generateCardNumber,lastFour,maskCardNumber, the limit and nickname bounds.src/data/card-status.ts— the transition table and the type guards.src/data/cards.ts— server-only: id allocation, validation,issueCard,setCardStatus, queries.The first two are pure and isomorphic on purpose:
card-status-actions.tsxis a client component that imports the transition table, so neither may reach fornode:cryptoor the store. Randomness arrives as an injectedDigitSource, the waysrc/lib/dates.tstakesnow— which keeps the generator testable against a pinned output and lets the seed generator stay deterministic.How I verified it
npm testpassesnpx tsc --noEmitis clean andnpm run buildsucceeds with/cardsand/cards/[id]reported asƒ (Dynamic), confirming they are not statically prerendered against a mutable store.Beyond the unit tests I drove the running app: issued a card through the drawer, confirmed the number is shown once, closed it and asserted via the DOM that no 16-digit
4242number remained anywhere on the page, then walked a card Active → Frozen → Active → Cancelled from the list without a page reload and watched the action buttons disappear at the terminal state. Every status code was exercised with curl —201,200replay without the number, the validation400s,404,409, and the200same-status no-op.Two things that came out of running it rather than reading it: the issued number was Luhn-valid with a
last4matching its masked cell, and a card created at the 5,000,000-minor-unit ceiling renders as$50,000.00.Acceptance criteria
Core — all six:
/cardswith nickname, merchant, masked number, limit, status, created date4242BIN, valid Luhn check digit•••• 1234everywhere elseStretch — all five:
error.tsxandnot-found.tsx, neither of which existed anywhere in the app beforeBeyond the ticket: a server-side
requestKeyso a double submit returns the original card with no number rather than issuing a second one, and aCardEventtimeline recording every status change, rendered on detail in the merchant's timezone.Bugs found along the way
Four pre-existing defects, all unrelated to this ticket, now fixed with regression tests on their own branch (
NWP-203-metrics-fixes) so they can be reviewed on their own terms rather than buried here:src/data/queries.tssorted amounts lexicographically, so10000came before900. Reached from/api/payments?sort=amountand the CSV export.src/data/metrics.tsbucketed in the server's local timezone while matching keys generated in UTC.src/data/metrics.tsaccumulated money as floats in major units.grossVolume,dailyVolumeanddisputedAmounteach summed USD, EUR and GBP into figures rendered with a hardcoded$.Notes for the reviewer
Why
src/data/cards.tsis not a second query builder. The convention names payments specifically — "Payment filtering goes through the builder behindGET /api/payments."filterPayments/sortPaymentsarePayment-typed on every line, so reusing them would mean widening their types: a bigger and riskier change than card-specific code. The one genuinely generic piece,paginate<T>, is reused unmodified.src/data/cards.tsis an entity module holding data plus its accessors — the same shape assrc/data/merchants.ts.Reveal-once is structural rather than filtered. The
Cardtype has no number field at all, so every payload is safe by construction and notoPublicCard()mapper is needed — a mapper is just a place to make a mistake.numberRefis independently random, never derived from the number: with a fixed BIN and a Luhn check digit the candidate space is small enough that a hash would be reversible.There is no
GET /api/cards. Server components callqueryCards/cardByIdin-process, matching howpayments/page.tsxcallsqueryPayments. Only the writes needed endpoints.spenthas no honest source, and I did not fake one.Paymenthas nocardIdand there is no card-transaction entity. Attributingpayments.filter(p => p.merchantId === card.merchantId)would be semantically inverted — those are payments the merchant received — and would push every card over its limit. Sospentlives on the card, seeded for some and0for new ones, with a "No spend yet" state. Real attribution belongs with NWP-202/203.A currency that doesn't match the merchant's settlement currency is rejected with a 400, naming both currencies. Worth being explicit that this is a deliberate narrowing, not an improvement: EUR ad spend for a USD merchant is a legitimate ops case that this now forbids. The earlier version recorded the mismatch and issued the card anyway; that field is removed rather than left as a value that can only ever be
true.Restart the dev server after pulling.
src/data/store.tspins the store toglobalThis, so a server running from before this change holds a store with nocardskey:createStore()never re-runs, TypeScript saysCard[], and the first write throws on code that looks correct. There is astore.cards ??= []guard, but a restart is the real fix.Two documentation errors found along the way:
.claude/rules/components.mdclaimssrc/components/has aDialog— it does not, onlyDrawer, which wraps the same Radix dialog root, so trusting that sentence gives a module-not-found.CLAUDE.mdalso describes the seed data as JSON; it is TypeScript.🤖 Generated with Claude Code