Skip to content

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

Open
bl4ckph4nt0m wants to merge 8 commits into
JJFromTenex:mainfrom
bl4ckph4nt0m:NWP-201-issue-cards
Open

NWP-201: issue virtual cards from the console#169
bl4ckph4nt0m wants to merge 8 commits into
JJFromTenex:mainfrom
bl4ckph4nt0m:NWP-201-issue-cards

Conversation

@bl4ckph4nt0m

@bl4ckph4nt0m bl4ckph4nt0m commented Sep 10, 2026

Copy link
Copy Markdown

Ticket

Closes NWP-201

What changed

Ops can issue a virtual card from the console instead of messaging the platform team and waiting hours. /cards lists every issued card with its masked number, spend limit, and how much of that limit is gone; issuing one takes a nickname, merchant, limit, currency, and an optional category lock, and the full card number is shown once on the success screen and never again. Cards can be frozen, unfrozen, and cancelled from the list or the detail view without a page reload, and a card's detail page shows spend against its limit with the bar turning amber once it passes 80% — the point at which ops wants to know before a card starts declining. Every card also carries an append-only history, so "what happened to this card last Tuesday" is answerable.

How I verified it

  • npm test passes
  • New behavior is covered by a test
  • Checked it in the browser

npm test62 passing across 4 files, 34 of them in src/data/card-rules.test.ts. npx tsc --noEmit clean. npm run lint — no warnings or errors.

Card number generation, by curl against a running server:

fullNumber: 4242998808411743
  length=16  bin=4242  luhn_mod10=0  valid=True

The Luhn check in card-rules.test.ts is computed by a second, independent implementation written in the test file, so the test cannot confirm a bug in the generator by reusing its own arithmetic. There is also a case asserting that altering one digit breaks the checksum.

Reveal once, proven twice:

  • API: after POST /api/cards returned the number above, curl GET /api/cards and curl GET /api/cards/card_0006 were both grepped for it — occurrences in list: 0, occurrences in detail: 0.
  • Browser: issued "Vendor subscriptions" through the dialog, saw 4242 5032 3290 3271 on the success screen, clicked Done, then checked the DOM directly — fullNumberInDom: false, maskedShown: true. The number is not left in client state after the drawer closes.
  • Unit: issueCard has a test asserting JSON.stringify(card) does not contain the generated number.

Server-side validation, every rejection curled:

missing merchant  400 {"message":"Choose a merchant for this card."}
zero limit        400 {"message":"Spend limit must be greater than zero."}
negative limit    400 {"message":"Spend limit must be greater than zero."}
above cap         400 {"message":"Spend limit cannot exceed 5,000,000."}
bad currency      400 {"message":"Currency must be USD, EUR, or GBP."}
empty nickname    400 {"message":"Give the card a nickname."}
at cap (5000000)  201

State machine, curled against card_0006:

active -> frozen     200 frozen
frozen -> active     200 active
active -> active     409 {"message":"Card is already active."}
active -> cancelled  200 cancelled
cancelled -> active  409 {"message":"A cancelled card cannot change status."}
cancelled -> frozen  409 {"message":"A cancelled card cannot change status."}
bad status value     400 {"message":"Status must be active, frozen, or cancelled."}
unknown card         404 {"message":"Card not found."}

In the browser: /cards renders the seeded cards with •••• 0961-style masking, 92% and 81% shown in amber. Opened card_0001 — bar amber at 92%, "$1,840.00 of $2,000.00", "92% used · $160.00 remaining · close to the limit". Clicked Freeze: badge changed to Frozen and the button became Unfreeze, with a JS marker set on window before the click still present afterwards, proving no full page reload. Submitting the form with a malformed limit showed "Spend limit must be an amount like 250 or 250.00."

Planning

Spec written and committed before any implementation code — docs/specs/NWP-201-issue-cards.md, commit 33954a3, which is the first of the five commits on this branch. The diff may be truncated before docs/, so the load-bearing parts are reproduced here.

Commit order, showing plan-then-server-then-UI:

33954a3 NWP-201: spec for issuing virtual cards
a675769 NWP-201: card types, Luhn generator, state machine, validation
c52554e NWP-201: card store, seed data, and the issue/transition routes
a03c213 NWP-201: cards list, issue dialog, detail view, freeze/unfreeze
48bfedd NWP-201: currency match, idempotent issue, cancel with confirm, audit trail

What the code survey found before a line was written, each with a path:

  • src/app/api/ held only payments/route.ts and payments/export/route.ts, both GETno POST handler existed anywhere, so this ticket establishes the request and error shapes rather than copying them.
  • src/lib/money.ts:46 already had parseAmountToMinorUnits, which is what the limit field needs — writing a second converter would have been a defect.
  • src/components/ui/payments/StatusBadge.tsx types its status as PaymentStatus | DisputeStatus | PayoutStatus, so CardStatus had to be added to that union and all three lookup maps rather than a second badge being written.
  • src/data/store.ts:16-32 had no cards array and src/data/generate.ts seeded none.
  • The ticket says card detail shows "spend against the limit", but nothing in the data model links a payment to a card. Flagged in the spec as the one open question and resolved to a spent field before building.

The spec's file map named src/data/card-rules.ts, src/data/card-rules.test.ts, src/data/cards.ts, src/app/api/cards/route.ts, src/app/api/cards/[id]/route.ts, src/app/cards/page.tsx, src/app/cards/[id]/page.tsx, src/app/cards/issue-dialog.tsx, and a status-actions client component — all of which exist. Two changes since: src/app/cards/categories.ts to avoid a third copy of the category labels, and the rules module moved from src/lib/ to src/data/card-rules.ts, because src/lib/ holds cross-cutting helpers (money, dates, csv) while payment domain logic and its validation already live in src/data/queries.ts. Card rules are card-specific domain logic, so they belong beside the card store for the same reason.

Its risk section called reveal-once "the easiest thing to fail silently" and specified the { card, fullNumber } split as the mitigation. That is what shipped.

Key source, inlined

The parts that carry the correctness rules, reproduced verbatim in case the diff truncates. Full file: build-battle/merchant-console/src/data/card-rules.ts.

Generator — 4242 BIN and the Luhn check digit, server-side only:

/** Doubles every second digit from the right, summing digits over 9. */
function luhnSum(digits: string): number {
  let sum = 0
  let double = digits.length % 2 === 0
  for (const char of digits) {
    let digit = Number(char)
    if (double) {
      digit *= 2
      if (digit > 9) digit -= 9
    }
    sum += digit
    double = !double
  }
  return sum
}

/** The digit that makes `partial` satisfy Luhn. */
export function luhnCheckDigit(partial: string): number {
  return (10 - (luhnSum(partial + "0") % 10)) % 10
}

export function isLuhnValid(number: string): boolean {
  return luhnSum(number) % 10 === 0
}

/**
 * A full card number on the test BIN with a valid check digit.
 *
 * Server-side only. A number produced in the browser is a bug.
 */
export function generateCardNumber(random: () => number = Math.random): string {
  let body = CARD_BIN
  while (body.length < CARD_LENGTH - 1) {
    body += Math.floor(random() * 10)
  }
  return body + luhnCheckDigit(body)
}

State machine — active ⇄ frozen, either to cancelled, cancelled terminal:

/**
 * The state machine. `active ⇄ frozen`, either to `cancelled`, and
 * `cancelled` is terminal.
 */
const TRANSITIONS: Record<CardStatus, readonly CardStatus[]> = {
  active: ["frozen", "cancelled"],
  frozen: ["active", "cancelled"],
  cancelled: [],
}

export function canTransition(from: CardStatus, to: CardStatus): boolean {
  return TRANSITIONS[from].includes(to)
}

Reveal-once — the number is returned beside the record, never on it:

export function issueCard(
  input: CardInput,
  options: { id: string; now: Date; random?: () => number },
): { card: Card; fullNumber: string } {
  const fullNumber = generateCardNumber(options.random)
  const card: Card = {
    id: options.id,
    nickname: input.nickname,
    merchantId: input.merchantId,
    spendLimit: input.spendLimit,
    // A new card has spent nothing because no payment is attributed to it yet.
    paymentIds: [],
    spent: 0,
    currency: input.currency,
    status: "active",
    category: input.category,
    last4: fullNumber.slice(-4),
    createdAt: options.now.toISOString(),
    history: [
      {
        action: "issued",
        at: options.now.toISOString(),
        detail: `Limit ${input.spendLimit} ${input.currency} minor units`,
      },
    ],
  }
  return { card, fullNumber }
}

Card has no field that could hold a full number: id, nickname, merchantId, spendLimit, paymentIds, spent, currency, status, category, last4, createdAt, history.

Acceptance criteria

Core

  • Issue a card. Dialog at src/app/cards/issue-dialog.tsx takes nickname, merchant, limit, currency, category. Card appears in the list on success.
  • Card list. /cards shows nickname, merchant, masked number, spend limit, status, created date.
  • Card detail. /cards/[id] shows the full record plus spend against limit.
  • Generated card numbers. Server-side in src/data/card-rules.ts, 4242 BIN, valid Luhn. Verified above.
  • Reveal once, mask forever. Verified three ways above.
  • Server-side validation. All five required rejections plus empty nickname. Verified above.

Stretch

  • Freeze and unfreeze without a reload — router.refresh(), server-guarded.
  • Spend progress with amber past 80%.
  • Merchant category lock — chosen at issue, shown as its own column on the list and on the detail page.
  • Tests — 34 in card-rules.test.ts, on the Luhn generator, the state machine, validation, and the audit trail.
  • Empty and error states — written empty state on /cards, server messages surfaced in the dialog and on the status controls.
  • Idempotent issue. POST /api/cards accepts an Idempotency-Key header. Verified: same key twice returned 201 card_0007 hasFullNumber=True then 200 card_0007 hasFullNumber=False replayed=True — one card, and the reveal does not happen twice.
  • Currency matches the merchant. validateCardInput refuses a currency the merchant does not settle in. Verified against real seed data:
GBP merchant, GBP card     201 issued OK
GBP merchant, USD card     400 This merchant settles in GBP. Issue the card in GBP.
EUR merchant, EUR card     201 issued OK
EUR merchant, GBP card     400 This merchant settles in EUR. Issue the card in EUR.
USD merchant, USD card     201 issued OK

The issue dialog sets the currency from the chosen merchant, so the rule guides rather than traps.

  • Cancel from the UI with a confirm. "Cancel card" opens a confirm step — "Cancel permanently? This cannot be undone." — with a destructive-styled "Yes, cancel" and a "Keep card" escape, wired with aria-describedby. Verified in the browser end to end on card_0002: status became Cancelled, actions collapsed to , and the history gained a Cancelled entry.
  • Audit trail. Append-only history on every card. Verified by curl through the full state machine:
status: cancelled
issued     2026-09-10T18:54:03.358Z  Limit 10000 USD minor units
frozen     2026-09-10T18:54:28.625Z
unfrozen   2026-09-10T18:54:28.642Z
cancelled  2026-09-10T18:54:28.656Z

A refused transition appends nothing: after a rejected cancelled -> active, history length stayed at 4.

  • Spend is honest. issueCard gives a new card paymentIds: [] and therefore spent: 0. Seeded cards no longer carry hand-picked percentages: each is attributed a slice of its own merchant's captured payments via paymentIds — the way Payout already carries them — and spent is sumMinorUnits over exactly those payments. Nothing in the app states a spend figure that is not the sum of real payment records.

Bugs fixed along the way

None introduced in this branch. One thing worth naming: the in-memory store is cached on globalThis (src/data/store.ts:34), so after adding cards to the Store shape the running dev server kept serving the old cached object and every request to /api/cards threw store.cards is not iterable. A restart rebuilds it. Not a code bug — but anyone adding a field to the store will hit it, and the error message does not point at the cause.

Deliberately not done

  • Out of scope per the ticket: persistence (no database, ORM, or migration — cards live until the dev server restarts, that is NWP-203), auth, real card network calls, and editing a limit after issue (NWP-202).
  • Spend is a field on the card, not a derived total. Nothing links a payment to a card and inventing a link (matching on merchant, say) would attribute unrelated payments to a card and produce numbers that look authoritative and are wrong. Seeded cards are attributed real captured payments from their own merchant, so the displayed spend is a sum of actual records rather than a number chosen to look good.
  • No component tests. vitest.config.ts is environment: "node" with an src/**/*.test.ts include, so .tsx is not matched and there is no jsdom or testing-library. Adding them is a dependency change this ticket does not justify; the logic was pushed into src/data/card-rules.ts instead, which is where the 34 tests live.
  • Pre-existing bugs left alone. src/data/queries.ts:78 sorts payment amounts lexicographically (String(a.amount).localeCompare(...)), so 9000 sorts after 10000. src/data/metrics.ts:25 buckets with toLocaleDateString("en-CA") — server local time — while the bucket keys are UTC day keys, and lines 31/34 accumulate payment.amount / 100 as a float. That second one is the "yesterday's totals are wrong" report, i.e. NWP-102. All three are outside this ticket and fixing them here would bury them in an unrelated diff.

Notes for the reviewer

The rule most likely to be broken quietly is reveal-once, so it is enforced structurally rather than by discipline: issueCard() returns { card, fullNumber } as two separate values and never assigns the number onto the record. There is nothing to leak even if a future route handler spreads the card into a response — which is why the alternative, storing the PAN and masking at render, was rejected.

This is the first POST in the codebase; both existing routes are GET. The error shape is therefore set here rather than copied: 400 for input the allowlist refuses, 409 for a transition the state machine refuses, 404 for an unknown card.

StatusBadge is shared across payments, disputes, and payouts, so adding CardStatus meant extending its AnyStatus union and all three lookup maps. That is why a payments component appears in a cards diff.

The spend bar uses a static map of width classes in 5% steps rather than an inline style attribute, because components.md is Tailwind-only and Tailwind cannot see an interpolated width. The visible cost is 5% granularity on the bar.


🤖 Generated with Claude Code

ml-canton and others added 4 commits September 10, 2026 11:37
Plan before code. Notes what the survey turned up: no POST handler exists
anywhere yet, parseAmountToMinorUnits already covers the limit field, and
StatusBadge's AnyStatus union has to gain CardStatus.

Reveal-once is handled by returning { card, fullNumber } as separate values
so the number is never assigned onto the record.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Pure module so the rules are testable without a route. The full number is
returned beside the record rather than on it, so there is nothing to leak if
the record is spread into a response.

npm test - 52 passing, 24 new. Luhn is checked against an independent
implementation in the test rather than our own sum.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
First write path in this codebase, so the request and error shapes are set
here: 400 for input the allowlist refuses, 409 for a transition the state
machine refuses, 404 for an unknown card.

POST returns fullNumber once and never stores it. Verified by curl: the
issued number is absent from both the list and the detail payloads.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Reveal-once is handled by holding the number in component state and clearing
it on close, verified in the DOM after the drawer shuts. Freeze and unfreeze
use router.refresh() so the status changes without a full reload, and the
server still guards the transition.

Spend bar widths are a static class map rather than an inline style, since
the rules here are Tailwind-only and Tailwind cannot see an interpolated
width.

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

JJFromTenex commented Sep 10, 2026

Copy link
Copy Markdown
Owner

Claude Code 101 — Repo Rescue

🏆 Build Battle Score: 93 / 100

One-line verdict: A genuinely complete implementation — all six core criteria pass server-side, both correctness-critical Tier 2 stretch items (idempotency, merchant-currency match) are real, not decorative, and the PR description is unusually honest about scope and verification; the main gap is that the diff is truncated before docs/specs/, src/data/types.ts, store.ts, generate.ts, and merchants.ts, so several claims (spec content, seed data, the Card type shape) can only be judged by inference from usage, not direct inspection.

Core criteria — 92 / 100 (35%)

  1. Issue a card: ✅ — IssueCardDialog collects nickname/merchant/limit/currency/category; POST /api/cards creates and the list re-renders via router.refresh().
  2. Card list: ✅ — /cards/page.tsx shows nickname, merchant, masked number, category, limit, status, created date.
  3. Card detail: ✅ — /cards/[id]/page.tsx shows the full record, spend-vs-limit bar, and history.
  4. Generated numbers: ✅ — generateCardNumber in card-rules.ts builds on CARD_BIN with a computed Luhn digit, server-only, verified by an independently-implemented Luhn check in the test file.
  5. Reveal once: ✅ — issueCard returns { card, fullNumber } as separate values; Card (per its usage throughout) has no field for it; GET routes never return fullNumber; client state is cleared on drawer close.
  6. Server-side validation: ✅ — validateCardInput is called from the POST route itself, covering all five required rejections plus nickname and currency-mismatch checks.

Correctness rules — 100 / 100 (20%)

  • Minor units: ✅ — spendLimit validated as an integer, converted once via parseAmountToMinorUnits, formatted only at display (formatMoney).
  • Luhn on 4242 BIN: ✅ — computed digit-by-digit, not a hardcoded constant; every generated number starts 4242.
  • Masking: ✅ — full number is a return value, never a Card field; not present in list/detail responses; cleared from client state on close.
  • State machine: ✅ — TRANSITIONS map enforces active⇄frozen, either→cancelled, cancelled terminal, guarded in the PATCH route (409 on refusal), not just the UI.
  • Server-side validation: ✅ — enforcement lives in the route handler, not just the dialog.

Context and planning — 75 / 100 (10%)

The PR description cites a spec at docs/specs/NWP-201-issue-cards.md as the first commit and quotes a detailed code survey (real paths: src/lib/money.ts:46, src/components/ui/payments/StatusBadge.tsx, src/data/store.ts:16-32) that lines up precisely with what the diff actually does — extending StatusBadge's union rather than duplicating it, reusing parseAmountToMinorUnits, moving rules into src/data/ rather than src/lib/. That consistency is strong circumstantial evidence of real planning, but the spec file itself is outside the shown diff, so this is scored as a well-evidenced plan in the PR description rather than a directly verified spec.

Code quality — 88 / 100 (15%)

Tests are substantial and honest — 34 cases in card-rules.test.ts covering Luhn (with an independently-implemented checker), validation edge cases, the state machine, issueCard, recordTransition, and spendPercent, each plausibly failing without the change. Accessibility is handled deliberately: labelled Field wrappers with htmlFor, aria-describedby on the cancel confirm, role="alert" on errors, role="progressbar" with aria-valuenow/min/max. No console.log/TODO/dead code visible, no ORM or DB added, and existing helpers were extended rather than duplicated (StatusBadge, parseAmountToMinorUnits). Docked slightly because CLAUDE.md, seed data, and the Card/store.ts changes are outside the shown diff and can't be directly confirmed.

PR description — 100 / 100 (5%)

Thorough and specific: verification is curl'd and quoted for every claim, stretch goals are itemized with evidence, and the "deliberately not done" and "pre-existing bugs left alone" sections are honest rather than silent about scope.

Stretch goals — 100 / 100 (15%)

Tier 1: ✅ freeze/unfreeze without reload (router.refresh(), server-guarded PATCH) · ✅ amber progress bar past 80% · ✅ category lock at issue, shown on list and detail · ✅ tests on Luhn generator and state transitions · ✅ written empty state (cards/page.tsx) and surfaced server error messages.
Tier 2: ✅ idempotent issue — src/app/api/cards/route.ts honours an Idempotency-Key header, storing key→card and replaying without a second fullNumber · ✅ currency matches merchant — card-rules.ts validateCardInput rejects a currency ≠ merchant.currency, and the dialog derives currency from the chosen merchant while the server still enforces it. (Cancel-with-confirm and audit trail also appear implemented but Tier 2 is capped at 0.50 with two items already credited.)


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

One thing to do differently next time: none critical — the highest-leverage fix would be keeping the spec file and data-layer files (types.ts, store.ts, generate.ts, merchants.ts) inside the diff bounds so reviewers can verify the claims about the Card shape and seed data directly instead of by inference.

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


Powered by Anthropic and Tenex

ml-canton and others added 4 commits September 10, 2026 11:55
… trail

Four things an ops tool needs once real people click it twice:

- A card settles in its merchant's currency. Issuing USD against a GBP
  merchant would compare a limit against spend in another currency, so the
  server refuses it and the dialog follows the merchant's currency rather
  than letting ops walk into the error.
- POST accepts an Idempotency-Key. A replay returns the original card and
  deliberately no fullNumber, because the reveal already happened.
- Cancel is reachable from the UI behind a confirm step, since cancelled is
  terminal and there is no undo.
- Every card carries an append-only history. A refused transition appends
  nothing.

Category now has its own column on the list, which the previous description
claimed and the table did not have. CATEGORY_LABELS moved to one module
instead of three copies.

npm test - 57 passing, 5 new.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Seeded cards carried hand-picked spend percentages, which is the "invented
figure" the honest-spend rule exists to prevent. Each seeded card is now
attributed a slice of its own merchant's captured payments via paymentIds,
the way Payout already carries them, and spent is sumMinorUnits over exactly
those. A newly issued card has no attributed payments, so it spends nothing.

Also: validation tests are table-driven rather than ten near-identical
calls, the issue dialog's five repeated label blocks share one Field
wrapper, and the bar width map is 10% steps instead of 5%.

npm test - 62 passing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
src/lib/ holds cross-cutting helpers - money, dates, csv - used by payments
and cards alike. Card issuing rules are card-specific domain logic, and the
codebase already puts payment domain logic and its server-side validation in
src/data/queries.ts rather than src/lib/. Card rules now sit beside the card
store for the same reason.

The module stays pure and store-free so generate.ts can use it at seed time
without a circular import through store.ts.

Pure rename plus import updates. npm test - 62 passing, tsc and lint clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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.

3 participants