Conversation
Ops issued virtual cards by messaging the platform team, who created them by hand. Twelve to twenty times a week, hours per card, and two cards last month with the wrong limit because the request lived in a Slack thread. Adds /cards: a list of every issued card, an issue form taking a nickname, merchant, spend limit and currency, and a detail view showing the record and its spend against the limit. Card numbers are generated server-side on the 4242 test BIN with a valid Luhn check digit. The full number is returned exactly once, in the creation response, and only the last four is stored -- it is masked everywhere else. Status is a state machine guarded on the server: active reversibly to frozen, either to cancelled, and cancelled is terminal. Every field of an issue request is validated server-side: the merchant must exist, the limit must be whole minor units above zero and no more than 5,000,000, and the currency must be USD, EUR or GBP. Also fixes two defects /ship-ready surfaced in the metrics path, unrelated to this ticket: dailyVolume bucketed by server-local day against UTC keys, which misfiled payments and silently dropped some off the oldest edge; and amounts were accumulated as floats in major units before being converted back to minor units. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude Code 101 — Repo Rescue🏆 Build Battle Score: 85 / 100One-line verdict: A clean, well-tested core with honest documentation of its gaps — but it stops short of nearly every Tier 2 stretch, and the ticket's own "merchant category lock" hint was skipped. Core criteria — 95 / 100 (35%)
Correctness rules — 100 / 100 (20%)
Context and planning — 70 / 100 (10%)No spec file surfaces in the diff and none is referenced in the PR body. What the description does provide is a considered, file-accurate plan mapped against the acceptance criteria ( Code quality — 90 / 100 (15%)Tests sit beside the code they cover ( PR description — 100 / 100 (5%)Exemplary: states what was built, walks the acceptance criteria file-by-file, reports verified Stretch goals — 40 / 100 (15%)Tier 1: Freeze/unfreeze without reload ✅ · Spend progress bar (amber >80%) ✅ · Merchant category lock ❌ (explicitly not built) · Luhn/status tests ✅ · Empty/error states ✅ — 4 of 5. Breakdown: Core (95 × 0.35) + Rules (100 × 0.20) + Context (70 × 0.10) + Quality (90 × 0.15) + PR (100 × 0.05) + Stretch (40 × 0.15) = 85 / 100 One thing to do differently next time: Spend the last stretch minutes wiring the server-side currency check against
Powered by Anthropic and Tenex |
Ticket
Closes NWP-201
What changed
Ops can issue a virtual card without messaging the platform team.
/cardslists every issued card — nickname, merchant, masked number, spend limit, status, created date — with an Issue card panel that takes a nickname, merchant, spend limit and currency. Submitting creates the card, shows the full number once, and the card appears in the list masked. Opening a card shows its full record and how much of the limit it has spent.Numbers are generated on the server on the
4242test BIN with a valid Luhn check digit. The full number leaves the server exactly once, in the creation response; only the last four is ever stored, so it cannot be re-read from the list, the detail route, or the record itself.How I verified it
npm testpassesnpm test— 56 passing across 5 files, including 22 new cases insrc/lib/cards.test.tscovering the Luhn generator, the BIN, masking and the status machine.npx tsc --noEmitandnpm run lintboth clean.Against the dev server:
4242226956502653— 16 digits,4242BIN, Luhn valid; storedlast4was2653and the card record had nonumberfield.GET /api/cardsandGET /api/cards/:idboth return records with nonumberkey.400with a readable message: missing merchant, unknown merchant (mch_99),0,-500,5000001,250.5,JPY, blank nickname. Exactly5000000returns201.active→frozen200,frozen→active200,active→cancelled200,cancelled→activeandcancelled→frozenboth409. Unknown status400, unknown card404.In the browser:
/cardsrenders the list with masked numbers and per-merchant currency (£ for Halcyon Studio, € for Brandt & Sohn). Opened the Issue card panel, filled it, submitted — the card was created and appeared in the list masked after the refresh./cards/card_0005shows 84% of limit used with the bar in amber and the "Past 80% of the limit" note;/cards/card_9999returns 404.Acceptance criteria
Core
src/app/cards/issue-dialog.tsx→POST /api/cards./cardsshows nickname, merchant, masked number, spend limit, status and created date.4242BIN, valid Luhn —generateCardNumber()insrc/lib/cards.ts.last4only on the record; cleared from client state when the panel closes.parseCardDraft(), independent of the client.Stretch
PATCH /api/cards/:idthenrouter.refresh().npm testpassing.role="alert"on a failed submit, and a plain-language message on a refused transition.Bugs fixed along the way
Two defects in the metrics path, found by
/ship-ready, both pre-existing and unrelated to cards.src/data/metrics.ts— daily volume bucketed in server local time. The bucket keys came fromlastUtcDays()(UTC) but the lookup key came fromnew Date(p.createdAt).toLocaleDateString("en-CA"), which uses the server's zone. Root cause: a UTC key space read with a local-time key. On this machine (UTC−7) a payment at2026-08-13T02:00:00Zwas filed under2026-08-12; anything falling before the oldest key hitif (!bucket) continueand disappeared from the chart with no error. Now uses theutcDayKey()helper that already existed for this.src/data/metrics.ts— amounts accumulated as floats.bucket.captured += payment.amount / 100built a float total in major units across ~1,600 payments, thenMath.round(x * 100)converted it back. Root cause: converting for readability inside the accumulator instead of at the formatter. Now accumulates integer minor units and returns them directly.src/data/analytics.tshad the same division in the data layer;volumeByWeekandcountAndVolumeByWeeknow return minor units likemerchantRollupalready did, and the three chart formatters convert at the edge.New
src/data/metrics.test.tspins both. The two bucketing tests are deliberately mirrored — an early-UTC and a late-UTC payment — so one fails on a machine west of UTC and the other east of it, wherever CI runs.Notes for the reviewer
•••• 4242. The ticket writes the mask as•••• 4242, but4242is also the BIN, so that example is ambiguous. I stored and display the actual last four (•••• 2653), which matches the existing payments table and rule 2's "store the last four". If the literal string was intended, it is one line inmaskCardNumber().Card.spentis generated with the seed data across a spread of utilisations (including one at 96%) to give the detail view something real. Newly issued cards start at0.generate.ts, using the same deterministic PRNG, so everyone gets identical records.StatusBadgewas extended rather than duplicated — card statuses joined the existing union instead of a second badge component.2500.00;parseAmountToMinorUnits()(already insrc/lib/money.ts) converts once before the request, and the server re-validates the integer independently.headlineMetrics()sums USD, EUR and GBP into one figure and the overview renders it with a dollar sign. It is a mixed-currency total, not a USD one. Fixing it needs an FX source or per-currency reporting, neither of which is in scope here, so I replaced the file's incorrect "reported in USD minor units" comment with an explicitKNOWN LIMITATIONblock. Worth its own ticket.400/409/404paths were verified by curl only; automated coverage is on the generator, the status machine and the validator.CLAUDE.mdedit and apackage-lock.jsonnamecorrection are in my working tree but left out of this PR — neither is ticket work.🤖 Generated with Claude Code