From 9f5a221d529f09d85cb546a7929c43ae5f82ccb1 Mon Sep 17 00:00:00 2001 From: timusmanov Date: Thu, 10 Sep 2026 11:43:12 -0700 Subject: [PATCH 1/5] NWP-201: spec the virtual card issue flow --- docs/specs/NWP-201-issue-cards.md | 197 ++++++++++++++++++++++++++++++ 1 file changed, 197 insertions(+) create mode 100644 docs/specs/NWP-201-issue-cards.md diff --git a/docs/specs/NWP-201-issue-cards.md b/docs/specs/NWP-201-issue-cards.md new file mode 100644 index 00000000..7e7a5e4a --- /dev/null +++ b/docs/specs/NWP-201-issue-cards.md @@ -0,0 +1,197 @@ +# SPEC · NWP-201 — Issue virtual cards from the console + +> Written before any code. Load it as context when you build: `@docs/specs/NWP-201-issue-cards.md` + +**Ticket:** [NWP-201](../tickets/NWP-201.md) +**Author:** tim_usmanov +**Status:** reviewed + +## Problem + +Ops issues virtual cards by messaging the platform team, who create them by hand. It takes hours, happens twelve to twenty times a week, and last month two cards were created with the wrong spend limit because the request lived in a Slack thread. Marcus Bell wants the request, the limit, and the card in one place, so the limit is typed into a validated field instead of a chat message. + +## Current state + +Every claim below was read out of the tree, not assumed. + +- `src/data/store.ts` — the in-memory store, held on `globalThis.__northwindStore` so the dev server's module reloading does not hand each request a fresh copy. The `Store` interface carries `merchants`, `payments`, `refunds`, `disputes`, `payouts`. **There is no `cards` array.** +- `src/data/types.ts` — `Currency = "USD" | "EUR" | "GBP"`, `Merchant`, `Payment`, `PaymentFilters`. **No card types exist.** `Merchant.currency` is present on every merchant and is the hook for currency validation. +- `src/data/merchants.ts` — eight fictional merchants, each with its own currency: `mch_04` Halcyon Studio is GBP, `mch_05`/`mch_06` are EUR, the rest USD. `merchantById(id)` is exported and is the lookup to use. +- `src/data/queries.ts` — the one payment query builder. `parseFilters` is the allowlist-validation pattern to copy (`STATUSES.includes(...)` before anything reaches a query). `paginate` is generic and reusable. Card lookups must not be bolted into this file. +- `src/lib/money.ts` — `formatMoney(minorUnits, currency)`, `formatMoneyCompact`, `sumMinorUnits`, `parseAmountToMinorUnits(input)`. The last one is the boundary converter for form input and returns `null` on anything that is not `^\d+(\.\d{1,2})?$`. **Nothing in this ticket may add a second formatter or a second parser.** +- `src/lib/dates.ts` — `formatDate(iso)` for tables (UTC), `formatInZone(iso, tz)` for merchant-local timestamps, `utcDayKey`. +- `src/app/api/payments/route.ts` — six lines: parse params, call the builder, `NextResponse.json`. New routes match this shape. +- `src/app/payments/page.tsx` — async server component. Reads `searchParams`, calls `queryPayments()` **directly** rather than fetching its own API, renders `TableRoot/Table/TableHead/TableBody`, has a written empty state in a `colSpan` row, calls `formatMoney` at the point of render. This is the template for `/cards`. +- `src/app/payments/[id]/page.tsx` — async server component, `notFound()` when the record is missing, `
` of fields via a local `Field` helper, and a `timeline` array sorted by `at`. This is the template for `/cards/[id]` and for the audit trail. +- `src/components/` — Button, Input, Select, Badge, Table, Divider, Drawer, DropdownMenu, Skeleton. **`.claude/rules/components.md` claims a `Dialog` exists. It does not.** `src/components/Drawer.tsx` wraps `@radix-ui/react-dialog` and already provides the overlay, focus trap, Escape-to-close and an accessible title slot. Use `Drawer` for both the issue flow and the cancel confirmation. +- `src/components/ui/payments/StatusBadge.tsx` — one badge for all statuses, driven by `LABELS`, `DOTS` and `VARIANTS` maps over an `AnyStatus` union. Card statuses get added to those maps; a second badge component would be a duplicate. +- `src/app/siteConfig.ts` and `src/components/ui/navigation/AppSidebar.tsx` — `baseLinks` and the sidebar `navigation` array. **Neither knows about `/cards`,** so the route is unreachable until both are updated. +- `vitest.config.ts` — `environment: "node"`, `include: ["src/**/*.test.ts"]`. **No jsdom**, so tests cover pure logic only. Existing suites: `src/lib/money.test.ts`, `dates.test.ts`, `csv.test.ts`. + +**Pre-existing defect found while reading, in scope because the card list will sort:** `sortPayments` in `src/data/queries.ts` compares amounts as text — `String(a.amount).localeCompare(String(b.amount))` — so a 900 amount sorts after a 1000 one. The comment above it claims the order matches the formatted display, which is the reasoning that introduced it. + +## Domain rules + +| Rule | Source | What breaks if ignored | +| --- | --- | --- | +| "Money is integer minor units. `$250.00` is `25000`." | `merchant-console/CLAUDE.md` | Cents drift; a limit of `25000.0000001` compares wrong | +| "Format once, at the edge, next to its currency code." | `.claude/rules/money.md` | Two halves of the app disagree about the same number | +| "Generated numbers use the `4242` test BIN and a valid Luhn check digit." | `.claude/rules/cards.md` | Something in the repo resembles a real PAN | +| "Generate on the server. A card number produced in the browser is a bug." | `.claude/rules/cards.md` | The PAN is mintable by anyone with devtools | +| "The full number appears in the creation response and nowhere else: not on the card record, not in a list or detail payload, not left in client state after the success screen closes." | `.claude/rules/cards.md` | A PAN is recoverable after issue | +| "Status is a state machine: `active ⇄ frozen`, either to `cancelled`, and `cancelled` is terminal. Guard the transition on the server, not only in the UI." | `merchant-console/CLAUDE.md` | A cancelled card comes back to life | +| "Validate on the server. Anything from the client is checked against an allowlist." | `merchant-console/CLAUDE.md` | The client is the enforcement point, which is no enforcement | +| "Every amount travels with its currency code." | `.claude/rules/money.md` | A GBP limit renders as dollars | +| "Card numbers are masked everywhere except the single creation response." | `docs/ORG-STANDARDS.md` #8 | Reportable data handling failure | + +## Approach + +Cards get their own vertical slice that mirrors the payments slice exactly: types in `src/data/types.ts`, pure logic in `src/lib/cards.ts` with its test beside it, store access in `src/data/cards.ts`, two route handlers under `src/app/api/cards/`, and server-component pages under `src/app/cards/`. The reveal-once guarantee is enforced **by the type**: `Card` has no field that can hold a full number, only `last4` and an opaque `reference`, so the PAN physically cannot be persisted or serialised by a list or detail render. It exists as a local variable in the POST handler and in that one response body. Server-side validation lives in the route handler as a flat sequence of reject-early checks, in the same allowlist spirit as `parseFilters`. + +**Considered and rejected:** a `GET /api/cards` route feeding the list page by `fetch`. Rejected because `src/app/payments/page.tsx` reads `queryPayments()` directly in the server component and `.claude/rules/components.md` says to keep data fetching out of components "where a route handler already returns what you need" — adding a read route nobody consumes would be dead surface and a larger diff. Writes go through route handlers because they need validation and a status code; reads go straight to the store like their payment equivalents. + +**Also rejected:** deriving `reference` from the PAN (a hash or a truncation). It would make the reference a partial leak of the number it is standing in for. It is an independent opaque handle. + +## Interface contract + +Three builders work in parallel against this. These signatures are fixed; a builder that needs to change one stops and says so rather than improvising. + +**`src/data/types.ts`** — added, not modified: + +``` +CardStatus = "active" | "frozen" | "cancelled" +CardCategory = "advertising" | "software" | "travel" | "contractors" | "utilities" + +CardEvent { from: CardStatus | null; to: CardStatus; at: string; actor: string } + from === null means the issue event. actor is "ops" — there is no auth in scope. + +Card { + id: string // `crd_` + 8 lowercase hex, matching the mch_/pay_ prefix convention + nickname: string + merchantId: string + last4: string // 4 digits — always "4242" for generated numbers, stored not derived + reference: string // `cref_` + 12 hex. Opaque handle. NOT derived from the PAN + spendLimit: number // integer minor units + spent: number // integer minor units + currency: Currency + category: CardCategory + status: CardStatus + createdAt: string // ISO 8601 UTC + events: CardEvent[] +} +``` + +There is deliberately **no `number` field on `Card`**. + +**`src/lib/cards.ts`** — pure, no store import, node-testable: + +``` +CARD_BIN = "4242" +CARD_NUMBER_LENGTH = 16 +MAX_SPEND_LIMIT_MINOR_UNITS = 5_000_000 +CARD_CATEGORIES: readonly CardCategory[] +CARD_CURRENCIES: readonly Currency[] // the server-side allowlist +NEAR_LIMIT_PERCENT = 80 + +luhnCheckDigit(digitsWithoutCheck: string): number +isValidLuhn(cardNumber: string): boolean +generateCardNumber(): string // CARD_BIN + 11 random digits + check digit +maskCardNumber(last4: string): string // "•••• 4242" +CARD_TRANSITIONS: Record + active: ["frozen", "cancelled"], frozen: ["active", "cancelled"], cancelled: [] +canTransition(from: CardStatus, to: CardStatus): boolean +isNearLimit(spent: number, spendLimit: number): boolean + integer math only: spent * 100 >= spendLimit * NEAR_LIMIT_PERCENT +``` + +**`src/data/cards.ts`** — store access, mirroring `src/data/queries.ts`: + +``` +listCards(): Card[] // newest first, by createdAt +cardById(id: string): Card | null +createCard(input: { nickname; merchantId; spendLimit; currency; category }): { card: Card; number: string } + generates the number, stores last4 + reference, appends the issue event, returns the PAN once +transitionCard(id: string, to: CardStatus): Card | null // returns null when the card is missing +cardForIdempotencyKey(key: string): Card | null +recordIdempotencyKey(key: string, cardId: string): void +``` + +**`POST /api/cards`** — body `{ nickname, merchantId, spendLimit, currency, category }`, `spendLimit` already in minor units; optional `Idempotency-Key` header. + +Reject early, each with `{ message }` and a status that means it: +- unknown or missing `merchantId` → 400 +- `nickname` empty after trim → 400 +- `spendLimit` not an integer, `<= 0`, or `> 5_000_000` → 400 +- `currency` outside `CARD_CURRENCIES` → 400 +- `currency` !== the merchant's own currency → 400 +- `category` outside `CARD_CATEGORIES` → 400 + +On success `201 { card, number }`. On a repeated `Idempotency-Key` → `200 { card }` for the original card, **no `number`**, no second card minted. + +**`PATCH /api/cards/[id]`** — body `{ status }`. Unknown card → 404. `status` outside `CardStatus` → 400. Illegal transition (anything out of `cancelled`, or a no-op) → **409** `{ message }`. On success `200 { card }`. + +## File map + +| File | Add or change | Why | Owner | +| --- | --- | --- | --- | +| `src/data/types.ts` | change | `Card`, `CardStatus`, `CardCategory`, `CardEvent`. No `number` field | A | +| `src/data/store.ts` | change | `cards: Card[]` and `cardIdempotencyKeys: Map` on `Store`; `??=` guard so a hot-reloaded store gains the new shape | A | +| `src/lib/cards.ts` | add | Luhn, BIN, masking, transition table, limit constants, 80% threshold | A | +| `src/lib/cards.test.ts` | add | Luhn and transitions, beside the code they cover | A | +| `src/data/cards.ts` | add | Store access and the idempotency ledger, mirroring `queries.ts` | A | +| `src/app/api/cards/route.ts` | add | `POST` with all server-side validation and the one-time reveal | A | +| `src/app/api/cards/[id]/route.ts` | add | `PATCH`, guarded by `canTransition` | A | +| `src/data/queries.ts` | change | Fix `sortPayments` comparing amounts as text | B | +| `src/data/queries.test.ts` | add | Proves the numeric sort; fails against the current code | B | +| `src/app/siteConfig.ts` | change | `baseLinks.cards` | B | +| `src/components/ui/navigation/AppSidebar.tsx` | change | Cards nav entry, `Wallet` icon | B | +| `src/components/ui/payments/StatusBadge.tsx` | change | Card statuses added to the existing maps | B | +| `src/app/cards/page.tsx` | add | The `/cards` list, written empty state, issue trigger | C | +| `src/app/cards/issue-card-drawer.tsx` | add | Client form, currency derived from merchant, one-time reveal, idempotency key | C | +| `src/app/cards/card-actions.tsx` | add | Freeze/unfreeze without a reload; cancel behind a confirm | C | +| `src/app/cards/[id]/page.tsx` | add | Detail, spend progress, audit trail | C | + +## Plan + +1. **Types, store, pure logic and its test** — done when: `npm test` passes with new cases proving `generateCardNumber()` starts `4242`, is 16 digits, satisfies `isValidLuhn` across 200 draws, and that `cancelled` transitions nowhere. +2. **Both route handlers** — done when: `curl -X POST /api/cards` with a valid body returns 201 with a `number`; a GBP currency against `mch_01` (USD) returns 400; `spendLimit: 5000001` returns 400; the same `Idempotency-Key` twice returns one card and no second `number`; `PATCH` from `cancelled` returns 409. +3. **Nav, badge, and the sort fix** — done when: `/cards` appears in the sidebar and `queries.test.ts` fails on `main` and passes here. +4. **List and issue flow** — done when: submitting the drawer shows the full number once on the success screen, closing it drops the number from state, and the row appears masked as `•••• 4242`. +5. **Detail, progress, audit trail, actions** — done when: the bar turns amber past 80%, freeze then unfreeze updates the row without a page reload, and cancel requires a confirm and then renders a terminal state with no way back. +6. **`/ship-ready`, then push** — done when: checks are clean and the PR body carries the real `npm test` summary line. + +## Verification + +| Acceptance criterion | How it is proven | +| --- | --- | +| Issue a card | Drawer submits → 201 → row present in `/cards` | +| Card list | `/cards` renders nickname, merchant, `•••• 4242`, `formatMoney(spendLimit, currency)`, `StatusBadge`, `formatDate(createdAt)` | +| Card detail | `/cards/[id]` renders the full record plus `spent` against `spendLimit` | +| Generated numbers | `src/lib/cards.test.ts` — 200 generated numbers, all `4242`-prefixed, 16 digits, Luhn-valid; a tampered digit fails | +| Reveal once | `Card` has no number field (type-level); success screen shows it, close clears it; no read route or page payload contains it | +| Server-side validation | `curl` each rejection case against `POST /api/cards` and paste the status + message | +| State machine | `src/lib/cards.test.ts` on `CARD_TRANSITIONS`; `PATCH` out of `cancelled` returns 409 | +| Minor units | `spendLimit`/`spent` are `number` integers end to end; `parseAmountToMinorUnits` at the form boundary, `formatMoney` at render, nothing between | +| Spend progress amber past 80% | `isNearLimit` unit cases at 79%, exactly 80%, 81% | +| Idempotent issue | Two POSTs with one `Idempotency-Key`; `listCards()` length unchanged | +| Currency matches merchant | Form derives it from the selected merchant **and** the route rejects a mismatch with 400 | +| Sort bug fixed | `src/data/queries.test.ts` orders 900 before 1000 ascending | + +## Risks + +- **The store is cached on `globalThis`.** A dev server that was running before `cards` was added keeps the old object and `store.cards` is `undefined`. Guard with `??=` in `store.ts` rather than relying on a restart. +- **Three builders in parallel** against a fixed contract. File ownership in the file map is strict; a builder that wants to edit a file it does not own stops and reports instead. +- **`Drawer` is not `Dialog`.** The rules file names a component that does not exist; using `Drawer` is the accessible path that already works. +- **`spent` must not be invented.** It is `0` at issue and stays `0`, and the progress bar renders that truthfully. Fabricating spend to make the amber state visible would be a false record; the 80% threshold is proven by unit test instead. + +## Out of scope + +- Persistence, any database, ORM or migration — NWP-203, and explicitly a quality failure here. +- Authentication, roles and permissions. `CardEvent.actor` is the literal `"ops"`. +- Real card network calls. There is no issuer. +- Editing a spend limit after issue — NWP-202. +- The float accumulation, local-date bucketing and refunds-in-gross defects in `src/data/metrics.ts`. Real, but on the overview path, not the cards path; noted here rather than fixed to keep this diff reviewable. + +## Open questions + +- None blocking. Card categories are not enumerated anywhere in the repo, so the five in the contract are chosen to match the ticket's stated use — "vendor subscriptions, ad spend, and contractor tools". From 5e6cbf84231dfc66e838632f2ceef53c49a2646b Mon Sep 17 00:00:00 2001 From: timusmanov Date: Thu, 10 Sep 2026 11:47:41 -0700 Subject: [PATCH 2/5] NWP-201: card types, in-memory store, and the Luhn generator Card records carry last4 and an opaque reference; there is deliberately no field able to hold a full number, so the PAN cannot be persisted or serialised by a later render. --- .../merchant-console/src/data/store.ts | 21 +++- .../merchant-console/src/data/types.ts | 38 ++++++ .../merchant-console/src/lib/cards.test.ts | 115 ++++++++++++++++++ .../merchant-console/src/lib/cards.ts | 88 ++++++++++++++ 4 files changed, 260 insertions(+), 2 deletions(-) create mode 100644 build-battle/merchant-console/src/lib/cards.test.ts create mode 100644 build-battle/merchant-console/src/lib/cards.ts diff --git a/build-battle/merchant-console/src/data/store.ts b/build-battle/merchant-console/src/data/store.ts index ba71d950..3bfc99a2 100644 --- a/build-battle/merchant-console/src/data/store.ts +++ b/build-battle/merchant-console/src/data/store.ts @@ -1,6 +1,6 @@ import { generate } from "./generate" import { merchants } from "./merchants" -import { Dispute, Payment, Payout, Refund } from "./types" +import { Card, Dispute, Payment, Payout, Refund } from "./types" /** * In-memory store. @@ -19,6 +19,9 @@ interface Store { refunds: Refund[] disputes: Dispute[] payouts: Payout[] + cards: Card[] + /** Idempotency-Key -> card id, so a repeated key never mints a second card. */ + cardIdempotencyKeys: Map } declare global { @@ -28,11 +31,25 @@ declare global { function createStore(): Store { const { payments, refunds, disputes, payouts } = generate() - return { merchants, payments, refunds, disputes, payouts } + return { + merchants, + payments, + refunds, + disputes, + payouts, + cards: [], + cardIdempotencyKeys: new Map(), + } } export const store: Store = globalThis.__northwindStore ?? createStore() +// A dev server that was running before `cards` existed keeps the old object +// shape across hot reloads. Guard so the new fields exist even then, rather +// than relying on a restart. +store.cards ??= [] +store.cardIdempotencyKeys ??= new Map() + if (process.env.NODE_ENV !== "production") { globalThis.__northwindStore = store } diff --git a/build-battle/merchant-console/src/data/types.ts b/build-battle/merchant-console/src/data/types.ts index 6697e576..abeabc97 100644 --- a/build-battle/merchant-console/src/data/types.ts +++ b/build-battle/merchant-console/src/data/types.ts @@ -82,3 +82,41 @@ export interface PaymentFilters { sort?: "createdAt" | "amount" direction?: "asc" | "desc" } + +export type CardStatus = "active" | "frozen" | "cancelled" + +export type CardCategory = + | "advertising" + | "software" + | "travel" + | "contractors" + | "utilities" + +export interface CardEvent { + /** null means the issue event. */ + from: CardStatus | null + to: CardStatus + at: string + /** Always "ops" — there is no auth in scope. */ + actor: string +} + +export interface Card { + id: string + nickname: string + merchantId: string + /** 4 digits, always "4242" for generated numbers. Stored, not derived. */ + last4: string + /** Opaque handle. NOT derived from the PAN. */ + reference: string + /** Integer minor units. Never a float. */ + spendLimit: number + /** Integer minor units. Never a float. */ + spent: number + currency: Currency + category: CardCategory + status: CardStatus + /** ISO 8601, always UTC. */ + createdAt: string + events: CardEvent[] +} diff --git a/build-battle/merchant-console/src/lib/cards.test.ts b/build-battle/merchant-console/src/lib/cards.test.ts new file mode 100644 index 00000000..49efcd28 --- /dev/null +++ b/build-battle/merchant-console/src/lib/cards.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, it } from "vitest" +import { + CARD_BIN, + CARD_NUMBER_LENGTH, + CARD_TRANSITIONS, + canTransition, + generateCardNumber, + isNearLimit, + isValidLuhn, + luhnCheckDigit, + maskCardNumber, +} from "./cards" +import { CardStatus } from "@/data/types" + +/** + * Generated numbers must never resemble a real PAN, so every test here stays + * on the 4242 test BIN. Reveal-once and server-side generation are enforced + * by the type and the route, not by anything unit-testable — this file + * covers the math underneath them: Luhn, the transition table, and the + * spend-limit threshold. + */ + +describe("luhnCheckDigit", () => { + it("matches a hand-checked case: 424242424242424 -> 2", () => { + // 4242424242424242 is the well-known Luhn-valid test PAN. Dropping its + // check digit and recomputing should reproduce it. + expect(luhnCheckDigit("424242424242424")).toBe(2) + }) +}) + +describe("generateCardNumber", () => { + const numbers = Array.from({ length: 200 }, () => generateCardNumber()) + + it("is always 16 digits", () => { + for (const number of numbers) { + expect(number).toHaveLength(CARD_NUMBER_LENGTH) + expect(number).toMatch(/^\d{16}$/) + } + }) + + it("always starts with the 4242 test BIN", () => { + for (const number of numbers) { + expect(number.startsWith(CARD_BIN)).toBe(true) + } + }) + + it("always satisfies Luhn", () => { + for (const number of numbers) { + expect(isValidLuhn(number)).toBe(true) + } + }) +}) + +describe("isValidLuhn", () => { + it("rejects a tampered digit", () => { + const number = generateCardNumber() + const tamperedDigit = (Number(number[5]) + 1) % 10 + const tampered = + number.slice(0, 5) + String(tamperedDigit) + number.slice(6) + + expect(isValidLuhn(tampered)).toBe(false) + }) +}) + +describe("maskCardNumber", () => { + it("shows only the last four digits", () => { + expect(maskCardNumber("4242")).toBe("•••• 4242") + }) +}) + +describe("CARD_TRANSITIONS / canTransition", () => { + const ALL_STATUSES: CardStatus[] = ["active", "frozen", "cancelled"] + + it("matches the fixed table for every from/to combination", () => { + for (const from of ALL_STATUSES) { + for (const to of ALL_STATUSES) { + expect(canTransition(from, to)).toBe( + CARD_TRANSITIONS[from].includes(to), + ) + } + } + }) + + it("allows every legal transition", () => { + expect(canTransition("active", "frozen")).toBe(true) + expect(canTransition("active", "cancelled")).toBe(true) + expect(canTransition("frozen", "active")).toBe(true) + expect(canTransition("frozen", "cancelled")).toBe(true) + }) + + it("rejects a no-op transition", () => { + expect(canTransition("active", "active")).toBe(false) + expect(canTransition("frozen", "frozen")).toBe(false) + expect(canTransition("cancelled", "cancelled")).toBe(false) + }) + + it("treats cancelled as terminal: nothing comes back from it", () => { + expect(canTransition("cancelled", "active")).toBe(false) + expect(canTransition("cancelled", "frozen")).toBe(false) + }) +}) + +describe("isNearLimit", () => { + it("is false just under the 80% threshold", () => { + expect(isNearLimit(7900, 10000)).toBe(false) + }) + + it("is true exactly at the 80% threshold", () => { + expect(isNearLimit(8000, 10000)).toBe(true) + }) + + it("is true just over the 80% threshold", () => { + expect(isNearLimit(8100, 10000)).toBe(true) + }) +}) diff --git a/build-battle/merchant-console/src/lib/cards.ts b/build-battle/merchant-console/src/lib/cards.ts new file mode 100644 index 00000000..ec57771b --- /dev/null +++ b/build-battle/merchant-console/src/lib/cards.ts @@ -0,0 +1,88 @@ +import { CardCategory, CardStatus, Currency } from "@/data/types" + +/** + * Pure card logic: number generation, masking, and the status state machine. + * No store import — this file is node-testable on its own. + * + * Card numbers use the 4242 test BIN with a valid Luhn check digit. Nothing + * here may resemble a real PAN, including in tests and fixtures. + */ + +export const CARD_BIN = "4242" +export const CARD_NUMBER_LENGTH = 16 +export const MAX_SPEND_LIMIT_MINOR_UNITS = 5_000_000 +export const NEAR_LIMIT_PERCENT = 80 + +export const CARD_CATEGORIES: readonly CardCategory[] = [ + "advertising", + "software", + "travel", + "contractors", + "utilities", +] + +/** The server-side allowlist for card currency. */ +export const CARD_CURRENCIES: readonly Currency[] = ["USD", "EUR", "GBP"] + +/** + * Computes the Luhn check digit for a string of digits that does not yet + * include it. Doubles every digit that will land on an even position + * counting from the right once the check digit is appended. + */ +export function luhnCheckDigit(digitsWithoutCheck: string): number { + let sum = 0 + for (let i = 0; i < digitsWithoutCheck.length; i++) { + let digit = Number(digitsWithoutCheck[digitsWithoutCheck.length - 1 - i]) + if (i % 2 === 0) { + digit *= 2 + if (digit > 9) digit -= 9 + } + sum += digit + } + return (10 - (sum % 10)) % 10 +} + +/** True when every digit of `cardNumber` (check digit included) satisfies Luhn. */ +export function isValidLuhn(cardNumber: string): boolean { + let sum = 0 + for (let i = 0; i < cardNumber.length; i++) { + let digit = Number(cardNumber[cardNumber.length - 1 - i]) + if (i % 2 === 1) { + digit *= 2 + if (digit > 9) digit -= 9 + } + sum += digit + } + return sum % 10 === 0 +} + +/** CARD_BIN + 11 random digits + a Luhn check digit. Generate on the server only. */ +export function generateCardNumber(): string { + const randomDigitCount = CARD_NUMBER_LENGTH - CARD_BIN.length - 1 + let randomDigits = "" + for (let i = 0; i < randomDigitCount; i++) { + randomDigits += Math.floor(Math.random() * 10).toString() + } + const digitsWithoutCheck = CARD_BIN + randomDigits + return digitsWithoutCheck + luhnCheckDigit(digitsWithoutCheck).toString() +} + +/** "•••• 4242" — the only form a card number takes outside the creation response. */ +export function maskCardNumber(last4: string): string { + return `•••• ${last4}` +} + +export const CARD_TRANSITIONS: Record = { + active: ["frozen", "cancelled"], + frozen: ["active", "cancelled"], + cancelled: [], +} + +export function canTransition(from: CardStatus, to: CardStatus): boolean { + return CARD_TRANSITIONS[from].includes(to) +} + +/** Integer math only: spent / spendLimit >= 80%, without ever dividing. */ +export function isNearLimit(spent: number, spendLimit: number): boolean { + return spent * 100 >= spendLimit * NEAR_LIMIT_PERCENT +} From 71dff5e1d81c55285a292088e49e06d42efa5e74 Mon Sep 17 00:00:00 2001 From: timusmanov Date: Thu, 10 Sep 2026 11:47:41 -0700 Subject: [PATCH 3/5] NWP-201: sort payment amounts numerically, not as text sortPayments compared amounts with String(..).localeCompare, so lexicographic order beat numeric order and a 900 amount sorted after a 1000 one. Amounts are integer minor units; compare them as integers. Test fails without the fix. --- .../merchant-console/src/data/queries.test.ts | 54 +++++++++++++++++++ .../merchant-console/src/data/queries.ts | 4 +- 2 files changed, 56 insertions(+), 2 deletions(-) create mode 100644 build-battle/merchant-console/src/data/queries.test.ts diff --git a/build-battle/merchant-console/src/data/queries.test.ts b/build-battle/merchant-console/src/data/queries.test.ts new file mode 100644 index 00000000..5da0924c --- /dev/null +++ b/build-battle/merchant-console/src/data/queries.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest" +import { sortPayments } from "./queries" +import { Payment } from "@/data/types" + +/** + * sortPayments has to order amounts numerically because amounts are integer + * minor units, not formatted text. These fixtures are chosen so that text + * order and numeric order disagree ("1000" sorts before "900" as text, but + * 900 is the smaller amount), so a regression back to string comparison + * fails loudly. + */ + +function payment(overrides: Partial): Payment { + return { + id: "pay_00000000", + merchantId: "mch_01", + amount: 1000, + currency: "USD", + status: "captured", + method: "card", + cardBrand: "visa", + last4: "4242", + createdAt: "2024-01-01T00:00:00.000Z", + description: "Test payment", + ...overrides, + } +} + +const payments: Payment[] = [ + payment({ id: "pay_a", amount: 900, createdAt: "2024-01-01T00:00:00.000Z" }), + payment({ id: "pay_b", amount: 1000, createdAt: "2024-01-02T00:00:00.000Z" }), + payment({ id: "pay_c", amount: 25000, createdAt: "2024-01-03T00:00:00.000Z" }), + payment({ id: "pay_d", amount: 9999, createdAt: "2024-01-04T00:00:00.000Z" }), +] + +describe("sortPayments", () => { + it("orders amounts numerically ascending, not as text", () => { + const sorted = sortPayments(payments, "amount", "asc") + expect(sorted.map((p) => p.amount)).toEqual([900, 1000, 9999, 25000]) + }) + + it("orders amounts numerically descending, not as text", () => { + const sorted = sortPayments(payments, "amount", "desc") + expect(sorted.map((p) => p.amount)).toEqual([25000, 9999, 1000, 900]) + }) + + it("still sorts by createdAt ascending and descending", () => { + const asc = sortPayments(payments, "createdAt", "asc") + expect(asc.map((p) => p.id)).toEqual(["pay_a", "pay_b", "pay_c", "pay_d"]) + + const desc = sortPayments(payments, "createdAt", "desc") + expect(desc.map((p) => p.id)).toEqual(["pay_d", "pay_c", "pay_b", "pay_a"]) + }) +}) diff --git a/build-battle/merchant-console/src/data/queries.ts b/build-battle/merchant-console/src/data/queries.ts index cc4ca009..fbca7eca 100644 --- a/build-battle/merchant-console/src/data/queries.ts +++ b/build-battle/merchant-console/src/data/queries.ts @@ -77,8 +77,8 @@ export function sortPayments( const factor = direction === "asc" ? 1 : -1 return [...payments].sort((a, b) => { if (sort === "amount") { - // Sort by the formatted amount so the order matches what the table shows. - return String(a.amount).localeCompare(String(b.amount)) * factor + // Amounts are integer minor units; compare them numerically, not as text. + return (a.amount - b.amount) * factor } return a.createdAt.localeCompare(b.createdAt) * factor }) From 3ace597b4ccc3c75c652dc7f299f988ee8bb8560 Mon Sep 17 00:00:00 2001 From: timusmanov Date: Thu, 10 Sep 2026 11:47:41 -0700 Subject: [PATCH 4/5] NWP-201: surface cards in the sidebar and the status badge --- build-battle/merchant-console/src/app/siteConfig.ts | 1 + .../src/components/ui/navigation/AppSidebar.tsx | 8 +++++++- .../src/components/ui/payments/StatusBadge.tsx | 13 +++++++++++-- 3 files changed, 19 insertions(+), 3 deletions(-) diff --git a/build-battle/merchant-console/src/app/siteConfig.ts b/build-battle/merchant-console/src/app/siteConfig.ts index c59e5da2..08c5d3d7 100644 --- a/build-battle/merchant-console/src/app/siteConfig.ts +++ b/build-battle/merchant-console/src/app/siteConfig.ts @@ -7,6 +7,7 @@ export const siteConfig = { payments: "/payments", disputes: "/disputes", payouts: "/payouts", + cards: "/cards", }, } diff --git a/build-battle/merchant-console/src/components/ui/navigation/AppSidebar.tsx b/build-battle/merchant-console/src/components/ui/navigation/AppSidebar.tsx index f5e1345b..ec714a64 100644 --- a/build-battle/merchant-console/src/components/ui/navigation/AppSidebar.tsx +++ b/build-battle/merchant-console/src/components/ui/navigation/AppSidebar.tsx @@ -16,7 +16,7 @@ import { } from "@/components/Sidebar" import { cx, focusRing } from "@/lib/utils" import { RiArrowDownSFill } from "@remixicon/react" -import { Banknote, CreditCard, House, ShieldAlert } from "lucide-react" +import { Banknote, CreditCard, House, ShieldAlert, Wallet } from "lucide-react" import * as React from "react" import { Logo } from "../../../../public/Logo" import { UserProfile } from "./UserProfile" @@ -36,6 +36,12 @@ const navigation = [ icon: CreditCard, notifications: false as const, }, + { + name: "Cards", + href: siteConfig.baseLinks.cards, + icon: Wallet, + notifications: false as const, + }, { name: "Disputes", href: siteConfig.baseLinks.disputes, diff --git a/build-battle/merchant-console/src/components/ui/payments/StatusBadge.tsx b/build-battle/merchant-console/src/components/ui/payments/StatusBadge.tsx index 20e5ff26..d0736fc4 100644 --- a/build-battle/merchant-console/src/components/ui/payments/StatusBadge.tsx +++ b/build-battle/merchant-console/src/components/ui/payments/StatusBadge.tsx @@ -1,8 +1,8 @@ import { Badge } from "@/components/Badge" -import { DisputeStatus, PaymentStatus, PayoutStatus } from "@/data/types" +import { CardStatus, DisputeStatus, PaymentStatus, PayoutStatus } from "@/data/types" import { cx } from "@/lib/utils" -type AnyStatus = PaymentStatus | DisputeStatus | PayoutStatus +type AnyStatus = PaymentStatus | DisputeStatus | PayoutStatus | CardStatus const LABELS: Record = { authorized: "Authorized", @@ -17,6 +17,9 @@ const LABELS: Record = { paid: "Paid", in_transit: "In transit", pending: "Pending", + active: "Active", + frozen: "Frozen", + cancelled: "Cancelled", } const DOTS: Record = { @@ -32,6 +35,9 @@ const DOTS: Record = { paid: "bg-emerald-600 dark:bg-emerald-400", in_transit: "bg-blue-500 dark:bg-blue-500", pending: "bg-gray-500 dark:bg-gray-500", + active: "bg-emerald-600 dark:bg-emerald-400", + frozen: "bg-orange-500 dark:bg-orange-500", + cancelled: "bg-gray-500 dark:bg-gray-500", } const VARIANTS: Record = { @@ -47,6 +53,9 @@ const VARIANTS: Record Date: Thu, 10 Sep 2026 11:52:16 -0700 Subject: [PATCH 5/5] NWP-201: issue virtual cards from the console Adds the /cards list, the card detail with spend progress and audit trail, and the issue drawer with a one-time reveal. Numbers are minted server-side on the 4242 test BIN with a Luhn check digit; the record stores only the trailing four and an opaque reference. Server-side: all validation and the status state machine are enforced in the route handlers, an Idempotency-Key replay returns the original card rather than minting a second, and a card's currency must match its merchant's. --- .../src/app/api/cards/[id]/route.ts | 51 +++ .../src/app/api/cards/route.ts | 93 +++++ .../src/app/cards/[id]/page.tsx | 195 ++++++++++ .../src/app/cards/card-actions.tsx | 121 ++++++ .../src/app/cards/issue-card-drawer.tsx | 344 ++++++++++++++++++ .../merchant-console/src/app/cards/page.tsx | 104 ++++++ .../merchant-console/src/data/cards.ts | 81 +++++ .../merchant-console/src/data/types.ts | 2 +- 8 files changed, 990 insertions(+), 1 deletion(-) create mode 100644 build-battle/merchant-console/src/app/api/cards/[id]/route.ts create mode 100644 build-battle/merchant-console/src/app/api/cards/route.ts create mode 100644 build-battle/merchant-console/src/app/cards/[id]/page.tsx create mode 100644 build-battle/merchant-console/src/app/cards/card-actions.tsx create mode 100644 build-battle/merchant-console/src/app/cards/issue-card-drawer.tsx create mode 100644 build-battle/merchant-console/src/app/cards/page.tsx create mode 100644 build-battle/merchant-console/src/data/cards.ts diff --git a/build-battle/merchant-console/src/app/api/cards/[id]/route.ts b/build-battle/merchant-console/src/app/api/cards/[id]/route.ts new file mode 100644 index 00000000..0a4c1d68 --- /dev/null +++ b/build-battle/merchant-console/src/app/api/cards/[id]/route.ts @@ -0,0 +1,51 @@ +import { cardById, transitionCard } from "@/data/cards" +import { CardStatus } from "@/data/types" +import { CARD_TRANSITIONS, canTransition } from "@/lib/cards" +import { NextRequest, NextResponse } from "next/server" + +/** Derived from the transition table so there is one list of statuses, not two. */ +const CARD_STATUSES = Object.keys(CARD_TRANSITIONS) as CardStatus[] + +/** + * Updates a card's status. The state machine is guarded here, on the + * server — canTransition is the single source of truth, not the UI. + */ +export async function PATCH( + request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + const { id } = await params + const card = cardById(id) + if (!card) { + return NextResponse.json({ message: "Card not found." }, { status: 404 }) + } + + let body + try { + body = await request.json() + } catch { + return NextResponse.json( + { message: "Request body must be valid JSON." }, + { status: 400 }, + ) + } + + const { status } = body ?? {} + + if (!CARD_STATUSES.includes(status)) { + return NextResponse.json( + { message: "Unsupported status." }, + { status: 400 }, + ) + } + + if (!canTransition(card.status, status)) { + return NextResponse.json( + { message: `Cannot move a card from ${card.status} to ${status}.` }, + { status: 409 }, + ) + } + + const updated = transitionCard(id, status) + return NextResponse.json({ card: updated }, { status: 200 }) +} diff --git a/build-battle/merchant-console/src/app/api/cards/route.ts b/build-battle/merchant-console/src/app/api/cards/route.ts new file mode 100644 index 00000000..0a1bd64f --- /dev/null +++ b/build-battle/merchant-console/src/app/api/cards/route.ts @@ -0,0 +1,93 @@ +import { cardForIdempotencyKey, createCard, recordIdempotencyKey } from "@/data/cards" +import { merchantById } from "@/data/merchants" +import { CARD_CATEGORIES, CARD_CURRENCIES, MAX_SPEND_LIMIT_MINOR_UNITS } from "@/lib/cards" +import { NextRequest, NextResponse } from "next/server" + +/** + * Issues a virtual card. Every check below is a flat reject-early guard, in + * the same allowlist spirit as parseFilters — nothing from the client + * reaches the store unchecked. + */ +export async function POST(request: NextRequest) { + let body + try { + body = await request.json() + } catch { + return NextResponse.json( + { message: "Request body must be valid JSON." }, + { status: 400 }, + ) + } + + const { nickname, merchantId, spendLimit, currency, category } = body ?? {} + + const idempotencyKey = request.headers.get("Idempotency-Key") + if (idempotencyKey) { + const existing = cardForIdempotencyKey(idempotencyKey) + if (existing) { + return NextResponse.json({ card: existing }, { status: 200 }) + } + } + + const merchant = merchantById(merchantId) + if (!merchant) { + return NextResponse.json( + { message: "Unknown merchant." }, + { status: 400 }, + ) + } + + const trimmedNickname = typeof nickname === "string" ? nickname.trim() : "" + if (!trimmedNickname) { + return NextResponse.json( + { message: "Nickname is required." }, + { status: 400 }, + ) + } + + if ( + !Number.isInteger(spendLimit) || + spendLimit <= 0 || + spendLimit > MAX_SPEND_LIMIT_MINOR_UNITS + ) { + return NextResponse.json( + { message: "Spend limit must be a whole number of minor units, greater than zero and at most 5,000,000." }, + { status: 400 }, + ) + } + + if (!CARD_CURRENCIES.includes(currency)) { + return NextResponse.json( + { message: "Unsupported currency." }, + { status: 400 }, + ) + } + + if (currency !== merchant.currency) { + return NextResponse.json( + { message: "Currency must match the merchant's currency." }, + { status: 400 }, + ) + } + + if (!CARD_CATEGORIES.includes(category)) { + return NextResponse.json( + { message: "Unsupported category." }, + { status: 400 }, + ) + } + + const { card, number } = createCard({ + nickname: trimmedNickname, + merchantId, + spendLimit, + currency, + category, + }) + + if (idempotencyKey) { + recordIdempotencyKey(idempotencyKey, card.id) + } + + return NextResponse.json({ card, number }, { status: 201 }) +} diff --git a/build-battle/merchant-console/src/app/cards/[id]/page.tsx b/build-battle/merchant-console/src/app/cards/[id]/page.tsx new file mode 100644 index 00000000..320b4667 --- /dev/null +++ b/build-battle/merchant-console/src/app/cards/[id]/page.tsx @@ -0,0 +1,195 @@ +import { Divider } from "@/components/Divider" +import { StatusBadge } from "@/components/ui/payments/StatusBadge" +import { cardById } from "@/data/cards" +import { merchantById } from "@/data/merchants" +import { CardEvent, CardStatus } from "@/data/types" +import { isNearLimit, maskCardNumber } from "@/lib/cards" +import { formatInZone } from "@/lib/dates" +import { formatMoney } from "@/lib/money" +import { cx } from "@/lib/utils" +import Link from "next/link" +import { notFound } from "next/navigation" +import { CardActions } from "../card-actions" + +// Tailwind needs each width class spelled out literally somewhere in the +// source to generate it; a template string built from a raw percentage +// would not be picked up. Widths snap to the nearest 5% so every value the +// bar can render already exists as a real class. +const WIDTH_CLASSES = [ + "w-0", + "w-[5%]", + "w-[10%]", + "w-[15%]", + "w-[20%]", + "w-[25%]", + "w-[30%]", + "w-[35%]", + "w-[40%]", + "w-[45%]", + "w-[50%]", + "w-[55%]", + "w-[60%]", + "w-[65%]", + "w-[70%]", + "w-[75%]", + "w-[80%]", + "w-[85%]", + "w-[90%]", + "w-[95%]", + "w-full", +] as const + +function widthClassForPercent(percent: number) { + const index = Math.min(20, Math.max(0, Math.round(percent / 5))) + return WIDTH_CLASSES[index] +} + +const STATUS_LABELS: Record = { + active: "Active", + frozen: "Frozen", + cancelled: "Cancelled", +} + +function eventLabel(event: CardEvent) { + if (event.from === null) return "Card issued" + return `${STATUS_LABELS[event.from]} → ${STATUS_LABELS[event.to]}` +} + +export default async function CardDetail({ + params, +}: { + params: Promise<{ id: string }> +}) { + const { id } = await params + const card = cardById(id) + if (!card) notFound() + + const merchant = merchantById(card.merchantId)! + const near = isNearLimit(card.spent, card.spendLimit) + const percent = + card.spendLimit > 0 + ? Math.min(100, Math.round((card.spent / card.spendLimit) * 100)) + : 0 + const events = [...card.events].sort((a, b) => a.at.localeCompare(b.at)) + + return ( +
+ + ← All cards + + +
+

+ {card.nickname} +

+ +
+

{card.id}

+ + + +
+ + {merchant.name} + {merchant.country} + + + {maskCardNumber(card.last4)} + + + {card.reference} + + + {card.category} + + {card.currency} + + {formatInZone(card.createdAt, merchant.timezone)} + +
+ + + +
+
+

+ Spend +

+

+ {formatMoney(card.spent, card.currency)} of{" "} + {formatMoney(card.spendLimit, card.currency)} spent ({percent}%) +

+
+
+
+
+ {near && ( +

+ Nearing the spend limit. +

+ )} +
+ + + +

+ Audit trail +

+
    + {events.map((event, index) => ( +
  1. +
  2. + ))} +
+ + + + +
+ ) +} + +function Field({ + label, + children, +}: { + label: string + children: React.ReactNode +}) { + return ( +
+
{label}
+
+ {children} +
+
+ ) +} diff --git a/build-battle/merchant-console/src/app/cards/card-actions.tsx b/build-battle/merchant-console/src/app/cards/card-actions.tsx new file mode 100644 index 00000000..ce7402ac --- /dev/null +++ b/build-battle/merchant-console/src/app/cards/card-actions.tsx @@ -0,0 +1,121 @@ +"use client" + +import { Button } from "@/components/Button" +import { + Drawer, + DrawerBody, + DrawerContent, + DrawerDescription, + DrawerFooter, + DrawerHeader, + DrawerTitle, +} from "@/components/Drawer" +import { Card, CardStatus } from "@/data/types" +import { useRouter } from "next/navigation" +import { useState } from "react" + +export function CardActions({ card }: { card: Card }) { + const router = useRouter() + const [isPending, setIsPending] = useState(false) + const [error, setError] = useState(null) + const [confirmOpen, setConfirmOpen] = useState(false) + + // Cancelled is terminal: no toggle, no reactivate, nothing to click. + if (card.status === "cancelled") { + return null + } + + const patch = async (nextStatus: CardStatus) => { + setIsPending(true) + setError(null) + try { + const response = await fetch(`/api/cards/${card.id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ status: nextStatus }), + }) + const body = await response.json() + + if (!response.ok) { + setError(body?.message ?? "Something went wrong. Try again.") + return + } + + setConfirmOpen(false) + router.refresh() + } catch { + setError("Something went wrong. Try again.") + } finally { + setIsPending(false) + } + } + + return ( +
+
+ + +
+ + {error && ( +

+ {error} +

+ )} + + + + + Cancel {card.nickname}? + + Cancelling is permanent and cannot be undone. The card will stop + working immediately and cannot be reactivated afterward. + + + + {error && ( +

+ {error} +

+ )} +
+ + + + +
+
+
+ ) +} diff --git a/build-battle/merchant-console/src/app/cards/issue-card-drawer.tsx b/build-battle/merchant-console/src/app/cards/issue-card-drawer.tsx new file mode 100644 index 00000000..f70c518b --- /dev/null +++ b/build-battle/merchant-console/src/app/cards/issue-card-drawer.tsx @@ -0,0 +1,344 @@ +"use client" + +import { Button } from "@/components/Button" +import { + Drawer, + DrawerBody, + DrawerClose, + DrawerContent, + DrawerDescription, + DrawerFooter, + DrawerHeader, + DrawerTitle, + DrawerTrigger, +} from "@/components/Drawer" +import { Input } from "@/components/Input" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/Select" +import { merchantById, merchants } from "@/data/merchants" +import { CardCategory } from "@/data/types" +import { CARD_CATEGORIES, maskCardNumber } from "@/lib/cards" +import { parseAmountToMinorUnits } from "@/lib/money" +import { useRouter } from "next/navigation" +import { useState, type FormEvent } from "react" + +const CATEGORY_LABELS: Record = { + advertising: "Advertising", + software: "Software", + travel: "Travel", + contractors: "Contractors", + utilities: "Utilities", +} + +type FieldErrors = { + nickname?: string + merchantId?: string + spendLimit?: string + category?: string +} + +export function IssueCardDrawer() { + const router = useRouter() + const [open, setOpen] = useState(false) + const [nickname, setNickname] = useState("") + const [merchantId, setMerchantId] = useState("") + const [spendLimitInput, setSpendLimitInput] = useState("") + const [category, setCategory] = useState("") + const [fieldErrors, setFieldErrors] = useState({}) + const [formError, setFormError] = useState(null) + const [submitting, setSubmitting] = useState(false) + const [idempotencyKey, setIdempotencyKey] = useState("") + const [reveal, setReveal] = useState<{ number: string; last4: string } | null>( + null, + ) + + const selectedMerchant = merchantById(merchantId) + + const resetForm = () => { + setNickname("") + setMerchantId("") + setSpendLimitInput("") + setCategory("") + setFieldErrors({}) + setFormError(null) + setSubmitting(false) + } + + // Opening starts a brand new submission: fresh form, fresh idempotency key. + // Closing (Escape, overlay click, the header's close button, or the + // post-success "Done" button all route through here) drops the revealed + // number from state so it can never be recovered after the drawer shuts. + const handleOpenChange = (next: boolean) => { + if (next) { + resetForm() + setReveal(null) + setIdempotencyKey(crypto.randomUUID()) + } else { + setReveal(null) + } + setOpen(next) + } + + const handleSubmit = async (event: FormEvent) => { + event.preventDefault() + + const errors: FieldErrors = {} + if (!nickname.trim()) errors.nickname = "Give the card a nickname." + if (!merchantId) errors.merchantId = "Choose a merchant." + if (!category) errors.category = "Choose a category." + + const minorUnits = parseAmountToMinorUnits(spendLimitInput) + if (minorUnits === null) { + errors.spendLimit = "Enter a valid amount, like 250.00." + } + + if (Object.keys(errors).length > 0 || minorUnits === null || !selectedMerchant) { + setFieldErrors(errors) + return + } + + setFieldErrors({}) + setFormError(null) + setSubmitting(true) + + try { + const response = await fetch("/api/cards", { + method: "POST", + headers: { + "Content-Type": "application/json", + "Idempotency-Key": idempotencyKey, + }, + body: JSON.stringify({ + nickname: nickname.trim(), + merchantId, + spendLimit: minorUnits, + currency: selectedMerchant.currency, + category, + }), + }) + const body = await response.json() + + if (!response.ok) { + setFormError(body?.message ?? "Something went wrong. Try again.") + return + } + + router.refresh() + + if (typeof body.number === "string") { + setReveal({ number: body.number, last4: body.card.last4 }) + } else { + // A repeated Idempotency-Key returns the existing card with no + // number: it was already revealed once on the original request and + // the server will not hand it out a second time. + setFormError( + "This card was already issued. The number was shown once and cannot be shown again.", + ) + } + } catch { + setFormError("Something went wrong. Try again.") + } finally { + setSubmitting(false) + } + } + + return ( + + + + + + + Issue a virtual card + + The limit is typed here and validated, not sent in a chat message. + + + + {reveal ? ( +
+
+

+ This is the only time the full card number will be shown. + Copy it now. +

+

+ {reveal.number} +

+

+ From now on it is stored and displayed only as{" "} + + {maskCardNumber(reveal.last4)} + + . +

+
+
+ ) : ( +
+
+ + setNickname(event.target.value)} + placeholder="e.g. Ad spend – Q4" + hasError={Boolean(fieldErrors.nickname)} + aria-invalid={Boolean(fieldErrors.nickname)} + /> + {fieldErrors.nickname && ( +

+ {fieldErrors.nickname} +

+ )} +
+ +
+ + + {fieldErrors.merchantId && ( +

+ {fieldErrors.merchantId} +

+ )} +
+ +
+ +
+ setSpendLimitInput(event.target.value)} + placeholder="250.00" + hasError={Boolean(fieldErrors.spendLimit)} + aria-invalid={Boolean(fieldErrors.spendLimit)} + /> + + {selectedMerchant ? selectedMerchant.currency : "—"} + +
+

+ Currency follows the merchant and cannot be changed here. +

+ {fieldErrors.spendLimit && ( +

+ {fieldErrors.spendLimit} +

+ )} +
+ +
+ + + {fieldErrors.category && ( +

+ {fieldErrors.category} +

+ )} +
+ + {formError && ( +

+ {formError} +

+ )} +
+ )} +
+ + {reveal ? ( + + ) : ( + <> + + + + + + )} + +
+
+ ) +} diff --git a/build-battle/merchant-console/src/app/cards/page.tsx b/build-battle/merchant-console/src/app/cards/page.tsx new file mode 100644 index 00000000..20b7fc35 --- /dev/null +++ b/build-battle/merchant-console/src/app/cards/page.tsx @@ -0,0 +1,104 @@ +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeaderCell, + TableRoot, + TableRow, +} from "@/components/Table" +import { StatusBadge } from "@/components/ui/payments/StatusBadge" +import { listCards } from "@/data/cards" +import { merchantById } from "@/data/merchants" +import { maskCardNumber } from "@/lib/cards" +import { formatDate } from "@/lib/dates" +import { formatMoney } from "@/lib/money" +import Link from "next/link" +import { CardActions } from "./card-actions" +import { IssueCardDrawer } from "./issue-card-drawer" + +export default async function CardsPage() { + const cards = listCards() + + return ( +
+
+
+

+ Cards +

+

+ Virtual cards issued to merchants for ad spend, subscriptions, and + contractor tools. +

+
+ +
+ + + + + + Nickname + Merchant + Number + + Spend limit + + Status + Created + + Actions + + + + + {cards.length === 0 && ( + + +

+ No cards have been issued yet +

+

+ Use “Issue card” above to create one with a + validated limit, instead of a Slack thread with the + platform team. +

+
+
+ )} + {cards.map((card) => { + const merchant = merchantById(card.merchantId) + return ( + + + + {card.nickname} + + + {merchant?.name} + + {maskCardNumber(card.last4)} + + + {formatMoney(card.spendLimit, card.currency)} + + + + + {formatDate(card.createdAt)} + + + + + ) + })} +
+
+
+
+ ) +} diff --git a/build-battle/merchant-console/src/data/cards.ts b/build-battle/merchant-console/src/data/cards.ts new file mode 100644 index 00000000..2a48c3ac --- /dev/null +++ b/build-battle/merchant-console/src/data/cards.ts @@ -0,0 +1,81 @@ +import { generateCardNumber } from "@/lib/cards" +import { store } from "./store" +import { Card, CardCategory, CardStatus, Currency } from "./types" + +/** + * Store access for cards, mirroring the shape of queries.ts. Validation + * lives in the route handlers, not here — this file only reads and writes + * the store. + */ + +function randomHex(length: number): string { + let hex = "" + for (let i = 0; i < length; i++) { + hex += Math.floor(Math.random() * 16).toString(16) + } + return hex +} + +export function listCards(): Card[] { + return [...store.cards].sort((a, b) => b.createdAt.localeCompare(a.createdAt)) +} + +export function cardById(id: string): Card | null { + return store.cards.find((card) => card.id === id) ?? null +} + +export function createCard(input: { + nickname: string + merchantId: string + spendLimit: number + currency: Currency + category: CardCategory +}): { card: Card; number: string } { + const number = generateCardNumber() + const now = new Date().toISOString() + + const card: Card = { + id: `crd_${randomHex(8)}`, + nickname: input.nickname, + merchantId: input.merchantId, + // The generated number's true trailing digits, so two cards can be told + // apart in a list. The 4242 test BIN is the number's prefix, which the + // full number carries and no stored record does. + last4: number.slice(-4), + reference: `cref_${randomHex(12)}`, + spendLimit: input.spendLimit, + spent: 0, + currency: input.currency, + category: input.category, + status: "active", + createdAt: now, + events: [{ from: null, to: "active", at: now, actor: "ops" }], + } + + store.cards.push(card) + return { card, number } +} + +export function transitionCard(id: string, to: CardStatus): Card | null { + const card = cardById(id) + if (!card) return null + + card.events.push({ + from: card.status, + to, + at: new Date().toISOString(), + actor: "ops", + }) + card.status = to + return card +} + +export function cardForIdempotencyKey(key: string): Card | null { + const cardId = store.cardIdempotencyKeys.get(key) + if (!cardId) return null + return cardById(cardId) +} + +export function recordIdempotencyKey(key: string, cardId: string): void { + store.cardIdempotencyKeys.set(key, cardId) +} diff --git a/build-battle/merchant-console/src/data/types.ts b/build-battle/merchant-console/src/data/types.ts index abeabc97..dea5049c 100644 --- a/build-battle/merchant-console/src/data/types.ts +++ b/build-battle/merchant-console/src/data/types.ts @@ -105,7 +105,7 @@ export interface Card { id: string nickname: string merchantId: string - /** 4 digits, always "4242" for generated numbers. Stored, not derived. */ + /** The generated number's trailing 4 digits. The only fragment ever stored. */ last4: string /** Opaque handle. NOT derived from the PAN. */ reference: string