diff --git a/build-battle/merchant-console/.gitignore b/build-battle/merchant-console/.gitignore index d32cc78b..f3385c2a 100644 --- a/build-battle/merchant-console/.gitignore +++ b/build-battle/merchant-console/.gitignore @@ -15,6 +15,7 @@ # next.js /.next/ +/.next-*/ /out/ # production diff --git a/build-battle/merchant-console/src/app/api/cards/[id]/route.test.ts b/build-battle/merchant-console/src/app/api/cards/[id]/route.test.ts new file mode 100644 index 00000000..4febfc56 --- /dev/null +++ b/build-battle/merchant-console/src/app/api/cards/[id]/route.test.ts @@ -0,0 +1,75 @@ +import { NextRequest } from "next/server" +import { beforeEach, describe, expect, it } from "vitest" +import { createCard, toCardCreateInput } from "@/data/cards" +import { merchants } from "@/data/merchants" +import { store } from "@/data/store" +import { GET, PATCH } from "./route" + +beforeEach(() => { + store.cards.length = 0 +}) + +const VALID_INPUT = { + nickname: "Ad spend — Q4", + merchantId: merchants[0].id, + limitMinorUnits: 25000, + currency: "USD" as const, +} + +function patch(id: string, body: unknown) { + return PATCH( + new NextRequest(`http://localhost/api/cards/${id}`, { + method: "PATCH", + body: JSON.stringify(body), + }), + { params: Promise.resolve({ id }) }, + ) +} + +describe("GET /api/cards/[id]", () => { + it("returns the masked card", async () => { + const { card } = createCard(toCardCreateInput(VALID_INPUT)) + const response = await GET(new NextRequest(`http://localhost/api/cards/${card.id}`), { + params: Promise.resolve({ id: card.id }), + }) + expect(response.status).toBe(200) + const json = await response.json() + expect(json.card.id).toBe(card.id) + expect(json.card.last4).toBeUndefined() + }) + + it("404s on an unknown id", async () => { + const response = await GET(new NextRequest("http://localhost/api/cards/card_ghost"), { + params: Promise.resolve({ id: "card_ghost" }), + }) + expect(response.status).toBe(404) + }) +}) + +describe("PATCH /api/cards/[id]", () => { + it("freezes an active card", async () => { + const { card } = createCard(toCardCreateInput(VALID_INPUT)) + const response = await patch(card.id, { status: "frozen" }) + expect(response.status).toBe(200) + const json = await response.json() + expect(json.card.status).toBe("frozen") + }) + + it("rejects an illegal transition out of cancelled", async () => { + const { card } = createCard(toCardCreateInput(VALID_INPUT)) + await patch(card.id, { status: "cancelled" }) + const response = await patch(card.id, { status: "active" }) + expect(response.status).toBe(409) + }) + + it("rejects a status outside the allowlist", async () => { + const { card } = createCard(toCardCreateInput(VALID_INPUT)) + const response = await patch(card.id, { status: "deleted" }) + expect(response.status).toBe(400) + }) + + it("404s on an unknown id", async () => { + const response = await patch("card_ghost", { status: "frozen" }) + expect(response.status).toBe(404) + }) +}) 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..6e91c51b --- /dev/null +++ b/build-battle/merchant-console/src/app/api/cards/[id]/route.ts @@ -0,0 +1,51 @@ +import { CARD_STATUSES, cardById, maskCard, transitionCardStatus } from "@/data/cards" +import { CardStatus } from "@/data/types" +import { NextRequest, NextResponse } from "next/server" + +export async function GET( + _request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + const { id } = await params + const card = cardById(id) + if (!card) { + return NextResponse.json({ error: "Card not found." }, { status: 404 }) + } + return NextResponse.json({ card: maskCard(card) }) +} + +/** The only mutation a card supports post-issue: a guarded status transition. */ +export async function PATCH( + request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + const { id } = await params + + let body: unknown + try { + body = await request.json() + } catch { + return NextResponse.json( + { error: "Request body must be JSON." }, + { status: 400 }, + ) + } + + const status = (body as Record | null)?.status + if (typeof status !== "string" || !CARD_STATUSES.includes(status as CardStatus)) { + return NextResponse.json( + { error: "status must be one of active, frozen, cancelled." }, + { status: 400 }, + ) + } + + if (!cardById(id)) { + return NextResponse.json({ error: "Card not found." }, { status: 404 }) + } + + const result = transitionCardStatus(id, status as CardStatus) + if ("error" in result) { + return NextResponse.json({ error: result.error }, { status: 409 }) + } + return NextResponse.json({ card: result.card }) +} diff --git a/build-battle/merchant-console/src/app/api/cards/route.test.ts b/build-battle/merchant-console/src/app/api/cards/route.test.ts new file mode 100644 index 00000000..4c80772a --- /dev/null +++ b/build-battle/merchant-console/src/app/api/cards/route.test.ts @@ -0,0 +1,105 @@ +import { NextRequest } from "next/server" +import { beforeEach, describe, expect, it } from "vitest" +import { store } from "@/data/store" +import { merchants } from "@/data/merchants" +import { GET, POST } from "./route" + +/** Exercises the route handlers directly, without needing a running dev server. */ + +beforeEach(() => { + store.cards.length = 0 +}) + +const VALID_BODY = { + nickname: "Ad spend — Q4", + merchantId: merchants[0].id, + limitMinorUnits: 25000, + currency: "USD", +} + +function post(body: unknown, headers?: Record) { + return POST( + new NextRequest("http://localhost/api/cards", { + method: "POST", + headers, + body: JSON.stringify(body), + }), + ) +} + +describe("POST /api/cards", () => { + it("issues a card and returns the full number exactly once", async () => { + const response = await post(VALID_BODY) + expect(response.status).toBe(201) + const json = await response.json() + expect(json.number).toHaveLength(16) + expect(json.card.maskedNumber).toBe(`•••• ${json.number.slice(-4)}`) + expect(json.card.last4).toBeUndefined() + }) + + it("adds the card to the list", async () => { + await post(VALID_BODY) + const response = await GET() + const json = await response.json() + expect(json.cards).toHaveLength(1) + expect(json.cards[0].maskedNumber).toMatch(/^•••• \d{4}$/) + }) + + it("rejects a missing merchant with a 400 and creates nothing", async () => { + const response = await post({ ...VALID_BODY, merchantId: "" }) + expect(response.status).toBe(400) + expect(store.cards).toHaveLength(0) + }) + + const gbpMerchant = merchants.find((m) => m.currency === "GBP")! + const REJECTIONS: [string, Record][] = [ + ["a zero limit", { limitMinorUnits: 0 }], + ["a negative limit", { limitMinorUnits: -500 }], + ["a limit above 5,000,000 minor units", { limitMinorUnits: 5_000_001 }], + ["a currency outside USD/EUR/GBP", { currency: "JPY" }], + ["a currency not matching the merchant's", { merchantId: gbpMerchant.id, currency: "USD" }], + ] + it.each(REJECTIONS)("rejects %s with a 400 and creates nothing", async (_case, overrides) => { + const response = await post({ ...VALID_BODY, ...overrides }) + expect(response.status).toBe(400) + expect(store.cards).toHaveLength(0) + }) + + it("rejects a malformed body", async () => { + const response = await POST( + new NextRequest("http://localhost/api/cards", { + method: "POST", + body: "not json", + }), + ) + expect(response.status).toBe(400) + }) + + it("replays the same card for a repeated Idempotency-Key instead of creating a second one", async () => { + const headers = { "Idempotency-Key": "route-test-repeat-key" } + const first = await post(VALID_BODY, headers) + const second = await post(VALID_BODY, headers) + + expect(first.status).toBe(201) + expect(second.status).toBe(201) + const firstJson = await first.json() + const secondJson = await second.json() + expect(secondJson.card.id).toBe(firstJson.card.id) + expect(secondJson.number).toBe(firstJson.number) + expect(store.cards).toHaveLength(1) + }) + + it("creates a separate card when the Idempotency-Key differs", async () => { + await post(VALID_BODY, { "Idempotency-Key": "route-test-distinct-a" }) + await post(VALID_BODY, { "Idempotency-Key": "route-test-distinct-b" }) + expect(store.cards).toHaveLength(2) + }) +}) + +describe("GET /api/cards", () => { + it("returns an empty list when no cards exist", async () => { + const response = await GET() + const json = await response.json() + expect(json.cards).toEqual([]) + }) +}) 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..c04ee204 --- /dev/null +++ b/build-battle/merchant-console/src/app/api/cards/route.ts @@ -0,0 +1,49 @@ +import { + createCardIdempotent, + listCards, + maskCard, + toCardCreateInput, + validateCardInput, +} from "@/data/cards" +import { CardCategory, Currency } from "@/data/types" +import { NextRequest, NextResponse } from "next/server" + +export function GET() { + return NextResponse.json({ cards: listCards() }) +} + +/** Issues a card. This is the one response in the system that carries the full number. */ +export async function POST(request: NextRequest) { + let body: unknown + try { + body = await request.json() + } catch { + return NextResponse.json( + { error: "Request body must be JSON." }, + { status: 400 }, + ) + } + + const input = (body ?? {}) as Record + const validationError = validateCardInput(input) + if (validationError) { + return NextResponse.json( + { error: validationError.message, field: validationError.field }, + { status: 400 }, + ) + } + + const idempotencyKey = request.headers.get("Idempotency-Key") + const { card, number } = createCardIdempotent( + idempotencyKey, + toCardCreateInput({ + nickname: input.nickname as string, + merchantId: input.merchantId as string, + limitMinorUnits: input.limitMinorUnits as number, + currency: input.currency as Currency, + category: input.category as CardCategory | null | undefined, + }), + ) + + return NextResponse.json({ card: maskCard(card), number }, { status: 201 }) +} diff --git a/build-battle/merchant-console/src/app/cards/[id]/cancel-card-action.tsx b/build-battle/merchant-console/src/app/cards/[id]/cancel-card-action.tsx new file mode 100644 index 00000000..5c860fa0 --- /dev/null +++ b/build-battle/merchant-console/src/app/cards/[id]/cancel-card-action.tsx @@ -0,0 +1,92 @@ +"use client" + +import { Button } from "@/components/Button" +import type { CardStatus } from "@/data/types" +import { useRouter } from "next/navigation" +import { useState } from "react" + +/** Two-step cancel: nothing fires on the first click, only on "Confirm". Renders nothing once already cancelled. */ +export function CancelCardAction({ + cardId, + status, +}: { + cardId: string + status: CardStatus +}) { + const router = useRouter() + const [confirming, setConfirming] = useState(false) + const [isSubmitting, setIsSubmitting] = useState(false) + const [error, setError] = useState(null) + + if (status === "cancelled") return null + + async function handleConfirm() { + setIsSubmitting(true) + setError(null) + try { + const response = await fetch(`/api/cards/${cardId}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ status: "cancelled" }), + }) + + if (!response.ok) { + const data = await response.json().catch(() => null) + setError(data?.error ?? "Something went wrong. Try again.") + setConfirming(false) + return + } + + router.refresh() + } catch { + setError("Something went wrong. Try again.") + setConfirming(false) + } finally { + setIsSubmitting(false) + } + } + + if (confirming) { + return ( +
+

+ Cancel this card? This can't be undone. +

+ + +
+ ) + } + + return ( +
+ + {error && ( +

{error}

+ )} +
+ ) +} 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..832b861a --- /dev/null +++ b/build-battle/merchant-console/src/app/cards/[id]/page.tsx @@ -0,0 +1,116 @@ +import { Divider } from "@/components/Divider" +import { SpendProgress } from "@/components/ui/cards/SpendProgress" +import { StatusBadge } from "@/components/ui/payments/StatusBadge" +import { humanizeCategory, maskedCardById } from "@/data/cards" +import { merchantById } from "@/data/merchants" +import { formatInZone } from "@/lib/dates" +import { formatMoney } from "@/lib/money" +import Link from "next/link" +import { notFound } from "next/navigation" +import { CancelCardAction } from "./cancel-card-action" + +export default async function CardDetail({ + params, +}: { + params: Promise<{ id: string }> +}) { + const { id } = await params + const card = maskedCardById(id) + if (!card) notFound() + + const merchant = merchantById(card.merchantId)! + + return ( +
+ + ← All cards + + +
+

+ {card.nickname} +

+ {card.maskedNumber} + +
+

{card.id}

+ +
+ +
+ + + +
+ + {merchant.name} + {merchant.country} + + {card.maskedNumber} + + {formatMoney(card.limitMinorUnits, card.currency)} + + + + + + {card.category ? humanizeCategory(card.category) : "—"} + + + {card.createdAt} + + + {formatInZone(card.createdAt, merchant.timezone)} + +
+ + + +

+ Status history +

+
    + {card.statusHistory.map((event, index) => ( +
  1. +
  2. + ))} +
+
+ ) +} + +function Field({ + label, + children, + className, +}: { + label: string + children: React.ReactNode + className?: string +}) { + return ( +
+
{label}
+
{children}
+
+ ) +} diff --git a/build-battle/merchant-console/src/app/cards/card-status-action.tsx b/build-battle/merchant-console/src/app/cards/card-status-action.tsx new file mode 100644 index 00000000..cc8201b9 --- /dev/null +++ b/build-battle/merchant-console/src/app/cards/card-status-action.tsx @@ -0,0 +1,70 @@ +"use client" + +import { Button } from "@/components/Button" +import type { CardStatus } from "@/data/types" +import { useRouter } from "next/navigation" +import { useState } from "react" + +/** Freeze/unfreeze toggle for one card row; renders nothing once cancelled (terminal). */ +export function CardStatusAction({ + cardId, + nickname, + status, +}: { + cardId: string + nickname: string + status: CardStatus +}) { + const router = useRouter() + const [isSubmitting, setIsSubmitting] = useState(false) + const [error, setError] = useState(null) + + if (status === "cancelled") { + return null + } + + const nextStatus: CardStatus = status === "active" ? "frozen" : "active" + const label = status === "active" ? "Freeze" : "Unfreeze" + + async function handleClick() { + setIsSubmitting(true) + setError(null) + try { + const response = await fetch(`/api/cards/${cardId}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ status: nextStatus }), + }) + + if (!response.ok) { + const data = await response.json().catch(() => null) + setError(data?.error ?? "Something went wrong. Try again.") + return + } + + router.refresh() + } catch { + setError("Something went wrong. Try again.") + } finally { + setIsSubmitting(false) + } + } + + return ( +
+ + {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..7e4400f9 --- /dev/null +++ b/build-battle/merchant-console/src/app/cards/issue-card-drawer.tsx @@ -0,0 +1,453 @@ +"use client" + +import { Button } from "@/components/Button" +import { Divider } from "@/components/Divider" +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 { + CATEGORIES, + MAX_LIMIT_MINOR_UNITS, + humanizeCategory, + type MaskedCard, +} from "@/data/cards" +import { CardCategory, Currency } from "@/data/types" +import { formatMoney, parseAmountToMinorUnits } from "@/lib/money" +import { useRouter } from "next/navigation" +import { useId, useState } from "react" + +const CURRENCIES: Currency[] = ["USD", "EUR", "GBP"] + +type Step = "form" | "reveal" +type FieldErrors = Partial< + Record<"nickname" | "merchantId" | "limit" | "currency" | "form", string> +> +type RevealData = { card: MaskedCard; number: string } + +/** Maps the server's `{ field }` (request-body key) onto our local error keys. */ +function mapServerField(field: string | undefined): keyof FieldErrors { + switch (field) { + case "limitMinorUnits": + return "limit" + case "merchantId": + case "nickname": + case "currency": + return field + default: + return "form" + } +} + +/** Groups a 16-digit PAN into "4242 4242 4242 4242" for readability. */ +function formatForDisplay(number: string): string { + return number.replace(/(\d{4})(?=\d)/g, "$1 ") +} + +type FieldWrap = { + label: React.ReactNode + htmlFor: string + error?: string + helper?: string +} + +/** Label + control + helper/error, shared across every field below. */ +function Field({ label, htmlFor, error, helper, children }: FieldWrap & { + children: React.ReactNode +}) { + return ( +
+ +
{children}
+ {error ? ( +

{error}

+ ) : helper ? ( +

{helper}

+ ) : null} +
+ ) +} + +/** A Field wired up to a Select, for the three dropdown fields below. */ +function SelectField({ + value, + onValueChange, + options, + placeholder, + disabled, + ...field +}: FieldWrap & { + value: string + onValueChange: (value: string) => void + options: { value: string; label: string }[] + placeholder: string + disabled?: boolean +}) { + return ( + + + + ) +} + +export function IssueCardDrawer({ + merchants, +}: { + merchants: { id: string; name: string; currency: Currency }[] +}) { + const router = useRouter() + const fieldId = useId() + + const [idempotencyKey, setIdempotencyKey] = useState(() => + crypto.randomUUID(), + ) + const [step, setStep] = useState("form") + const [nickname, setNickname] = useState("") + const [merchantId, setMerchantId] = useState("") + const [limitInput, setLimitInput] = useState("") + const [currency, setCurrency] = useState("USD") + const [category, setCategory] = useState("") + const [errors, setErrors] = useState({}) + const [isSubmitting, setIsSubmitting] = useState(false) + const [reveal, setReveal] = useState(null) + const [copied, setCopied] = useState(false) + const [copyFailed, setCopyFailed] = useState(false) + + function clearError(field: keyof FieldErrors) { + setErrors((prev) => ({ ...prev, [field]: undefined })) + } + + function resetState() { + setStep("form") + setNickname("") + setMerchantId("") + setLimitInput("") + setCurrency("USD") + setCategory("") + setErrors({}) + setIsSubmitting(false) + setReveal(null) + setCopied(false) + setCopyFailed(false) + // A fresh key for the next card. Retries of *this* submission (a slow + // response resent, a double click) reuse the key set below instead. + setIdempotencyKey(crypto.randomUUID()) + } + + function handleOpenChange(open: boolean) { + // Drawer closed: drop the one-time reveal state along with the rest of + // the form. Nothing from `reveal` may outlive this. + if (!open) resetState() + } + + function handleMerchantChange(id: string) { + setMerchantId(id) + clearError("merchantId") + // The server rejects a currency that doesn't match the merchant's, so + // the select below locks to this and stops being editable. + const merchant = merchants.find((candidate) => candidate.id === id) + if (merchant) setCurrency(merchant.currency) + } + + async function handleSubmit(event: React.FormEvent) { + event.preventDefault() + + const nextErrors: FieldErrors = {} + if (!nickname.trim()) nextErrors.nickname = "Nickname is required." + if (!merchantId) nextErrors.merchantId = "Select a merchant." + + const minorUnits = parseAmountToMinorUnits(limitInput) + if (minorUnits === null) { + nextErrors.limit = "Enter a valid amount, like 250 or 250.00." + } else if (minorUnits <= 0) { + nextErrors.limit = "Spend limit must be greater than zero." + } else if (minorUnits > MAX_LIMIT_MINOR_UNITS) { + nextErrors.limit = `Spend limit can't exceed ${formatMoney(MAX_LIMIT_MINOR_UNITS, currency)}.` + } + + if (Object.keys(nextErrors).length > 0) { + setErrors(nextErrors) + return + } + + setErrors({}) + setIsSubmitting(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, + limitMinorUnits: minorUnits, + currency, + category: category || null, + }), + }) + const data = await response.json() + + if (!response.ok) { + setErrors({ [mapServerField(data.field)]: data.error }) + return + } + + // This is the only place `data.number` (the full PAN) is ever read. + // It lives in local state for the reveal step only, and resetState() + // above clears it the moment the drawer closes. + setReveal({ card: data.card, number: data.number }) + setStep("reveal") + router.refresh() + } catch { + setErrors({ form: "Something went wrong. Try again." }) + } finally { + setIsSubmitting(false) + } + } + + async function handleCopy() { + if (!reveal) return + try { + await navigator.clipboard.writeText(reveal.number) + setCopied(true) + setCopyFailed(false) + setTimeout(() => setCopied(false), 2000) + } catch { + setCopyFailed(true) + } + } + + return ( + + + + + + {step === "form" ? ( + <> + + Issue card + + Create a virtual card for a merchant. The full number is shown + once, right after you submit. + + + +
+ + { + setNickname(event.target.value) + clearError("nickname") + }} + placeholder="e.g. Contractor tools" + required + hasError={Boolean(errors.nickname)} + /> + + + ({ value: m.id, label: m.name }))} + placeholder="Select a merchant" + /> + + + { + setLimitInput(event.target.value) + clearError("limit") + }} + placeholder="250.00" + required + hasError={Boolean(errors.limit)} + /> + + + { + setCurrency(value as Currency) + clearError("currency") + }} + options={CURRENCIES.map((code) => ({ value: code, label: code }))} + placeholder="Currency" + /> + + + Category{" "} + + (optional) + + + } + htmlFor={`${fieldId}-category`} + helper="Locks the card to this spending category. Cannot be changed after issue." + value={category} + onValueChange={(value) => setCategory(value as CardCategory)} + options={CATEGORIES.map((value) => ({ + value, + label: humanizeCategory(value), + }))} + placeholder="No category" + /> + + {errors.form && ( + <> + +

+ {errors.form} +

+ + )} + +
+ + + + + + + + ) : ( + <> + + Card issued + {reveal?.card.nickname} + + +
+

+ This is the only time the full card number will be shown. + Copy it now — it won't be shown again. +

+ +
+

+ {reveal ? formatForDisplay(reveal.number) : ""} +

+
+ + + {copyFailed && ( +

+ Couldn't copy automatically — select the number above + and copy it manually. +

+ )} + + + +
+
Spend limit
+
+ {reveal && + formatMoney(reveal.card.limitMinorUnits, reveal.card.currency)} +
+
Currency
+
+ {reveal?.card.currency} +
+ {reveal?.card.category && ( + <> +
Category
+
+ {humanizeCategory(reveal.card.category)} +
+ + )} +
+
+
+ + + + + + + )} +
+
+ ) +} 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..fb83431f --- /dev/null +++ b/build-battle/merchant-console/src/app/cards/page.tsx @@ -0,0 +1,106 @@ +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, merchants } from "@/data/merchants" +import { formatDate } from "@/lib/dates" +import { formatMoney } from "@/lib/money" +import Link from "next/link" +import { CardStatusAction } from "./card-status-action" +import { IssueCardDrawer } from "./issue-card-drawer" + +export default async function CardsPage() { + const cards = listCards() + + return ( +
+
+
+

+ Virtual cards +

+

+ Cards issued from the console. Numbers are shown once, at + creation. +

+
+ ({ + id: m.id, + name: m.name, + currency: m.currency, + }))} + /> +
+ + + + + + Nickname + Merchant + Number + Limit + Status + Created + + + + {cards.length === 0 && ( + + +

+ No cards issued yet +

+

+ Issue the first one with the button above. +

+
+
+ )} + {cards.map((card) => { + const merchant = merchantById(card.merchantId) + return ( + + + + {card.nickname} + + + {merchant?.name} + + {card.maskedNumber} + + + {formatMoney(card.limitMinorUnits, card.currency)} + + +
+ + +
+
+ {formatDate(card.createdAt)} +
+ ) + })} +
+
+
+
+ ) +} diff --git a/build-battle/merchant-console/src/app/siteConfig.ts b/build-battle/merchant-console/src/app/siteConfig.ts index c59e5da2..626769da 100644 --- a/build-battle/merchant-console/src/app/siteConfig.ts +++ b/build-battle/merchant-console/src/app/siteConfig.ts @@ -5,6 +5,7 @@ export const siteConfig = { baseLinks: { overview: "/overview", payments: "/payments", + cards: "/cards", disputes: "/disputes", payouts: "/payouts", }, diff --git a/build-battle/merchant-console/src/components/ui/cards/SpendProgress.tsx b/build-battle/merchant-console/src/components/ui/cards/SpendProgress.tsx new file mode 100644 index 00000000..a5dc41de --- /dev/null +++ b/build-battle/merchant-console/src/components/ui/cards/SpendProgress.tsx @@ -0,0 +1,49 @@ +import { Currency } from "@/data/types" +import { formatMoney } from "@/lib/money" +import { cx } from "@/lib/utils" + +const AMBER_THRESHOLD = 80 + +/** Read-only: spentMinorUnits never moves on its own here (no live transaction feed). */ +export function SpendProgress({ + spentMinorUnits, + limitMinorUnits, + currency, +}: { + spentMinorUnits: number + limitMinorUnits: number + currency: Currency +}) { + const rawPercent = + limitMinorUnits > 0 ? (spentMinorUnits / limitMinorUnits) * 100 : 0 + const percent = Math.min(100, Math.max(0, rawPercent)) + const isNearLimit = percent >= AMBER_THRESHOLD + + return ( +
+
+
+
+

+ {formatMoney(spentMinorUnits, currency)} of{" "} + {formatMoney(limitMinorUnits, currency)} +

+
+ ) +} 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..95bb3672 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-gray-500 dark:bg-gray-500", + cancelled: "bg-red-500 dark:bg-red-500", } const VARIANTS: Record = { @@ -47,6 +53,9 @@ const VARIANTS: Record { + store.cards.length = 0 +}) + +const VALID_INPUT = { + nickname: "Ad spend — Q4", + merchantId: merchants[0].id, + limitMinorUnits: 25000, + currency: "USD" as const, +} + +describe("validateCardInput", () => { + const gbpMerchant = merchants.find((m) => m.currency === "GBP")! + + const CASES: [string, Record, string | null][] = [ + ["valid input", {}, null], + ["a missing merchant", { merchantId: "" }, "merchantId"], + ["an unknown merchant id", { merchantId: "mch_ghost" }, "merchantId"], + ["a zero limit", { limitMinorUnits: 0 }, "limitMinorUnits"], + ["a negative limit", { limitMinorUnits: -100 }, "limitMinorUnits"], + ["a limit above 5,000,000 minor units", { limitMinorUnits: 5_000_001 }, "limitMinorUnits"], + ["a limit at exactly 5,000,000 minor units", { limitMinorUnits: 5_000_000 }, null], + ["a currency outside USD/EUR/GBP", { currency: "JPY" }, "currency"], + ["a blank nickname", { nickname: " " }, "nickname"], + ["a currency not matching the merchant's", { merchantId: gbpMerchant.id, currency: "USD" }, "currency"], + ["a currency matching the merchant's", { merchantId: gbpMerchant.id, currency: "GBP" }, null], + ] + + it.each(CASES)("handles %s", (_case, overrides, expectedField) => { + const error = validateCardInput({ ...VALID_INPUT, ...overrides }) + expect(error?.field ?? null).toBe(expectedField) + }) +}) + +describe("createCard", () => { + it("returns the number once, stores the card masked, active, with zero spend", () => { + const { card, number } = createCard(toCardCreateInput(VALID_INPUT)) + expect(number).toHaveLength(16) + expect(card.last4).toBe(number.slice(-4)) + expect(card.status).toBe("active") + expect(card.spentMinorUnits).toBe(0) + + const masked = maskCard(cardById(card.id)!) + expect(masked.maskedNumber).toBe(`•••• ${card.last4}`) + expect((masked as { last4?: string }).last4).toBeUndefined() + expect(listCards()).toHaveLength(1) + }) +}) + +describe("createCardIdempotent", () => { + // Each case uses its own never-reused key: the idempotency cache is + // process-lifetime, not reset by the store.cards.length reset above, so a + // key shared across cases here would leak between them. + it("creates once per key, replaying the same result on a repeat", () => { + const input = toCardCreateInput(VALID_INPUT) + const first = createCardIdempotent("idempotent-test-repeat", input) + const second = createCardIdempotent("idempotent-test-repeat", input) + + expect(second.card.id).toBe(first.card.id) + expect(second.number).toBe(first.number) + expect(store.cards).toHaveLength(1) + }) + + it("creates a new card for a different key", () => { + const input = toCardCreateInput(VALID_INPUT) + createCardIdempotent("idempotent-test-distinct-a", input) + createCardIdempotent("idempotent-test-distinct-b", input) + expect(store.cards).toHaveLength(2) + }) + + it("always creates when no key is given", () => { + const input = toCardCreateInput(VALID_INPUT) + createCardIdempotent(null, input) + createCardIdempotent(null, input) + expect(store.cards).toHaveLength(2) + }) + + it("expires an entry after its TTL, so the cache never grows unbounded", () => { + vi.useFakeTimers() + try { + const input = toCardCreateInput(VALID_INPUT) + const first = createCardIdempotent("idempotent-test-ttl", input) + vi.advanceTimersByTime(90 * 1000) // past the 60-second TTL + const second = createCardIdempotent("idempotent-test-ttl", input) + + expect(second.card.id).not.toBe(first.card.id) + expect(store.cards).toHaveLength(2) + } finally { + vi.useRealTimers() + } + }) +}) + +describe("card status transitions", () => { + const CASES: [CardStatus, CardStatus, boolean][] = [ + ["active", "frozen", true], + ["frozen", "active", true], + ["active", "cancelled", true], + ["frozen", "cancelled", true], + ["active", "active", false], + ["cancelled", "active", false], + ["cancelled", "frozen", false], + ["cancelled", "cancelled", false], + ] + + it.each(CASES)("%s -> %s is legal: %s", (from, to, legal) => { + expect(canTransitionCardStatus(from, to)).toBe(legal) + }) + + it("guards the transition server-side on a real card", () => { + const { card } = createCard(toCardCreateInput(VALID_INPUT)) + const frozen = transitionCardStatus(card.id, "frozen") + expect("card" in frozen && frozen.card.status).toBe("frozen") + + const cancelled = transitionCardStatus(card.id, "cancelled") + expect("card" in cancelled && cancelled.card.status).toBe("cancelled") + + const revived = transitionCardStatus(card.id, "active") + expect("error" in revived).toBe(true) + }) + + it("errors on an unknown card id", () => { + const result = transitionCardStatus("card_ghost", "frozen") + expect("error" in result).toBe(true) + }) + + it("records every transition in order, including the illegal one it rejected", () => { + const { card } = createCard(toCardCreateInput(VALID_INPUT)) + transitionCardStatus(card.id, "frozen") + transitionCardStatus(card.id, "cancelled") + transitionCardStatus(card.id, "active") // rejected; must not appear below + + const statuses = cardById(card.id)!.statusHistory.map((e) => e.status) + expect(statuses).toEqual(["active", "frozen", "cancelled"]) + }) +}) 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..eafe6959 --- /dev/null +++ b/build-battle/merchant-console/src/data/cards.ts @@ -0,0 +1,259 @@ +import { createCipheriv, createDecipheriv, randomBytes } from "crypto" +import { generateCardNumber } from "@/lib/luhn" +import { merchantById } from "./merchants" +import { store } from "./store" +import { + Card, + CardCategory, + CardCreateInput, + CardStatus, + Currency, +} from "./types" + +export const CURRENCIES: readonly Currency[] = ["USD", "EUR", "GBP"] + +export const CATEGORIES: readonly CardCategory[] = [ + "vendor_subscriptions", + "ad_spend", + "contractor_tools", +] + +export const CARD_STATUSES: readonly CardStatus[] = [ + "active", + "frozen", + "cancelled", +] + +export const MAX_LIMIT_MINOR_UNITS = 5_000_000 + +/** "vendor_subscriptions" -> "Vendor subscriptions". Shared so the drawer and the detail page agree. */ +export function humanizeCategory(category: CardCategory): string { + const [first, ...rest] = category.split("_") + return [first.charAt(0).toUpperCase() + first.slice(1), ...rest].join(" ") +} + +/** The full number never appears on this shape. Everywhere but the creation response, cards are masked. */ +export type MaskedCard = Omit & { maskedNumber: string } + +export function maskCard(card: Card): MaskedCard { + const { last4, ...rest } = card + return { ...rest, maskedNumber: `•••• ${last4}` } +} + +interface ValidationError { + field: string + message: string +} + +/** Anything from the client is checked against an allowlist before it reaches the store. */ +export function validateCardInput(input: { + nickname?: unknown + merchantId?: unknown + limitMinorUnits?: unknown + currency?: unknown + category?: unknown +}): ValidationError | null { + const nickname = + typeof input.nickname === "string" ? input.nickname.trim() : "" + if (!nickname) return { field: "nickname", message: "Nickname is required." } + + const merchantId = + typeof input.merchantId === "string" ? input.merchantId : "" + const merchant = merchantId ? merchantById(merchantId) : undefined + if (!merchantId || !merchant) { + return { field: "merchantId", message: "Choose a valid merchant." } + } + + const limitMinorUnits = input.limitMinorUnits + if ( + typeof limitMinorUnits !== "number" || + !Number.isInteger(limitMinorUnits) || + limitMinorUnits <= 0 + ) { + return { + field: "limitMinorUnits", + message: "Spend limit must be a positive whole number of minor units.", + } + } + if (limitMinorUnits > MAX_LIMIT_MINOR_UNITS) { + return { + field: "limitMinorUnits", + message: `Spend limit cannot exceed ${MAX_LIMIT_MINOR_UNITS} minor units.`, + } + } + + if ( + typeof input.currency !== "string" || + !CURRENCIES.includes(input.currency as Currency) + ) { + return { field: "currency", message: "Currency must be one of USD, EUR, GBP." } + } + if (input.currency !== merchant.currency) { + return { + field: "currency", + message: `Currency must match the merchant's currency (${merchant.currency}).`, + } + } + + if (input.category !== undefined && input.category !== null) { + if ( + typeof input.category !== "string" || + !CATEGORIES.includes(input.category as CardCategory) + ) { + return { field: "category", message: "Unrecognized category." } + } + } + + return null +} + +/** Call validateCardInput first. This assumes the shape already checked out. */ +export function toCardCreateInput(input: { + nickname: string + merchantId: string + limitMinorUnits: number + currency: Currency + category?: CardCategory | null +}): CardCreateInput { + return { + nickname: input.nickname.trim(), + merchantId: input.merchantId, + limitMinorUnits: input.limitMinorUnits, + currency: input.currency, + category: input.category ?? null, + } +} + +const pad = (n: number) => String(n).padStart(6, "0") + +/** Long enough to absorb a double-click or one retried request; short enough to bound how long the full PAN sits here. */ +const IDEMPOTENCY_TTL_MS = 60 * 1000 + +/** + * A retry has to get back the identical response, PAN included, so the cache + * can't avoid holding it for the TTL window. It doesn't have to hold it as + * plaintext, though: encrypt at rest with a key that only lives in this + * process's memory for this process's lifetime, so nothing that inspects the + * cache map directly (a heap dump, a debugger, a logging library that stringifies + * unknown objects) sees a card-shaped number, only ciphertext. + */ +const idempotencyCacheKey = randomBytes(32) + +function encryptNumber(number: string): { iv: Buffer; ciphertext: Buffer; authTag: Buffer } { + const iv = randomBytes(12) + const cipher = createCipheriv("aes-256-gcm", idempotencyCacheKey, iv) + const ciphertext = Buffer.concat([cipher.update(number, "utf8"), cipher.final()]) + return { iv, ciphertext, authTag: cipher.getAuthTag() } +} + +function decryptNumber(sealed: { iv: Buffer; ciphertext: Buffer; authTag: Buffer }): string { + const decipher = createDecipheriv("aes-256-gcm", idempotencyCacheKey, sealed.iv) + decipher.setAuthTag(sealed.authTag) + return Buffer.concat([decipher.update(sealed.ciphertext), decipher.final()]).toString("utf8") +} + +interface IdempotencyEntry { + card: Card + sealedNumber: { iv: Buffer; ciphertext: Buffer; authTag: Buffer } + expiresAt: number +} + +/** Keyed by the client's Idempotency-Key header. Entries expire; this never grows unbounded. */ +const idempotencyCache = new Map() + +function pruneIdempotencyCache(now: number) { + for (const [key, entry] of idempotencyCache) { + if (entry.expiresAt <= now) idempotencyCache.delete(key) + } +} + +/** Generates the number server-side and returns it exactly once; every other read is masked. */ +export function createCard(input: CardCreateInput): { + card: Card + number: string +} { + const number = generateCardNumber() + const createdAt = new Date().toISOString() + const card: Card = { + id: `card_${pad(store.cards.length + 1)}`, + nickname: input.nickname, + merchantId: input.merchantId, + last4: number.slice(-4), + limitMinorUnits: input.limitMinorUnits, + spentMinorUnits: 0, + currency: input.currency, + status: "active", + category: input.category ?? null, + createdAt, + statusHistory: [{ status: "active", at: createdAt }], + } + store.cards.push(card) + return { card, number } +} + +/** Same as createCard, but a repeat call with the same key replays the original result. Null key opts out. */ +export function createCardIdempotent( + idempotencyKey: string | null, + input: CardCreateInput, +): { card: Card; number: string } { + const now = Date.now() + pruneIdempotencyCache(now) + + if (idempotencyKey) { + const cached = idempotencyCache.get(idempotencyKey) + if (cached) { + return { card: cached.card, number: decryptNumber(cached.sealedNumber) } + } + } + const result = createCard(input) + if (idempotencyKey) { + idempotencyCache.set(idempotencyKey, { + card: result.card, + sealedNumber: encryptNumber(result.number), + expiresAt: now + IDEMPOTENCY_TTL_MS, + }) + } + return result +} + +export function listCards(): MaskedCard[] { + return store.cards.map(maskCard) +} + +export function cardById(id: string): Card | null { + return store.cards.find((c) => c.id === id) ?? null +} + +export function maskedCardById(id: string): MaskedCard | null { + const card = cardById(id) + return card ? maskCard(card) : null +} + +/** active <-> frozen, either -> cancelled, cancelled is terminal. */ +const LEGAL_TRANSITIONS: Record = { + active: ["frozen", "cancelled"], + frozen: ["active", "cancelled"], + cancelled: [], +} + +export function canTransitionCardStatus( + from: CardStatus, + to: CardStatus, +): boolean { + return LEGAL_TRANSITIONS[from].includes(to) +} + +/** Guards the state machine server-side. The client's guard is a convenience only. */ +export function transitionCardStatus( + id: string, + to: CardStatus, +): { card: MaskedCard } | { error: string } { + const card = cardById(id) + if (!card) return { error: "Card not found." } + if (!canTransitionCardStatus(card.status, to)) { + return { error: `A ${card.status} card cannot move to ${to}.` } + } + card.status = to + card.statusHistory.push({ status: to, at: new Date().toISOString() }) + return { card: maskCard(card) } +} diff --git a/build-battle/merchant-console/src/data/metrics.test.ts b/build-battle/merchant-console/src/data/metrics.test.ts new file mode 100644 index 00000000..6d43adc4 --- /dev/null +++ b/build-battle/merchant-console/src/data/metrics.test.ts @@ -0,0 +1,66 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest" +import { GENERATED_AT } from "./generate" +import { dailyVolume, headlineMetrics } from "./metrics" +import { store } from "./store" +import { Payment } from "./types" + +const originalPayments = store.payments + +function payment(overrides: Partial): Payment { + return { + id: "pay_test", + merchantId: "mch_01", + amount: 10000, + currency: "USD", + status: "captured", + method: "card", + cardBrand: "visa", + last4: "4242", + createdAt: GENERATED_AT.toISOString(), + description: "test", + ...overrides, + } +} + +beforeEach(() => { + store.payments = [] +}) + +afterEach(() => { + store.payments = originalPayments +}) + +describe("dailyVolume", () => { + it("buckets by the UTC calendar day, not the server's local day", () => { + // 02:00 UTC is still the previous day in this process's local timezone + // (America/New_York, UTC-4 in August) — exactly what a local-date + // bucketing bug would misattribute to the wrong bucket. + store.payments = [ + payment({ createdAt: "2026-08-13T02:00:00.000Z", amount: 5000 }), + ] + const days = dailyVolume(2) + const aug13 = days.find((d) => d.date === "2026-08-13")! + const aug12 = days.find((d) => d.date === "2026-08-12")! + expect(aug13.captured).toBe(5000) + expect(aug12.captured).toBe(0) + }) + + it("sums captured amounts within a bucket", () => { + store.payments = [ + payment({ createdAt: GENERATED_AT.toISOString(), amount: 1 }), + payment({ createdAt: GENERATED_AT.toISOString(), amount: 2 }), + ] + const days = dailyVolume(1) + expect(days[days.length - 1].captured).toBe(3) + }) +}) + +describe("headlineMetrics", () => { + it("counts only captured amounts toward gross volume, not refunds", () => { + store.payments = [ + payment({ status: "captured", amount: 10000 }), + payment({ status: "refunded", amount: 5000 }), + ] + expect(headlineMetrics().grossVolume).toBe(10000) + }) +}) diff --git a/build-battle/merchant-console/src/data/metrics.ts b/build-battle/merchant-console/src/data/metrics.ts index c64027c2..da92c4c6 100644 --- a/build-battle/merchant-console/src/data/metrics.ts +++ b/build-battle/merchant-console/src/data/metrics.ts @@ -1,4 +1,4 @@ -import { lastUtcDays } from "@/lib/dates" +import { lastUtcDays, utcDayKey } from "@/lib/dates" import { GENERATED_AT } from "./generate" import { store } from "./store" @@ -21,38 +21,26 @@ export function dailyVolume(days = 30): DailyVolume[] { ) for (const payment of store.payments) { - // Bucket by calendar date. - const key = new Date(payment.createdAt).toLocaleDateString("en-CA") - const bucket = buckets.get(key) + const bucket = buckets.get(utcDayKey(payment.createdAt)) if (!bucket) continue if (payment.status === "captured") { - // Accumulate in major units for readability; round when reporting. - bucket.captured += payment.amount / 100 + bucket.captured += payment.amount } if (payment.status === "refunded") { - bucket.refunded += payment.amount / 100 + bucket.refunded += payment.amount } } - return keys.map((date) => { - const bucket = buckets.get(date)! - return { - date, - captured: Math.round(bucket.captured * 100), - refunded: Math.round(bucket.refunded * 100), - } - }) + return keys.map((date) => buckets.get(date)!) } export function headlineMetrics() { const captured = store.payments.filter((p) => p.status === "captured") - const refunded = store.payments.filter((p) => p.status === "refunded") - // Gross volume is everything that moved through the platform. - const grossVolume = - captured.reduce((sum, p) => sum + p.amount, 0) + - refunded.reduce((sum, p) => sum + p.amount, 0) + // Gross volume is money actually captured. A refund reverses it, so a + // refunded payment's original amount does not belong in this total. + const grossVolume = captured.reduce((sum, p) => sum + p.amount, 0) const authorized = store.payments.filter( (p) => p.status !== "failed", 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..0110d8b8 --- /dev/null +++ b/build-battle/merchant-console/src/data/queries.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vitest" +import { sortPayments } from "./queries" +import { Payment } from "./types" + +function payment(id: string, amount: number): Payment { + return { + id, + merchantId: "mch_01", + amount, + currency: "USD", + status: "captured", + method: "card", + cardBrand: "visa", + last4: "4242", + createdAt: "2026-08-01T00:00:00.000Z", + description: "test", + } +} + +describe("sortPayments", () => { + it("sorts by amount numerically, not lexicographically", () => { + // A string sort would put "900" after "1000" and "2000" ("1" < "2" < "9"). + // A correct numeric sort puts 900 first. + const payments = [payment("a", 1000), payment("b", 900), payment("c", 2000)] + + const ascending = sortPayments(payments, "amount", "asc").map((p) => p.amount) + expect(ascending).toEqual([900, 1000, 2000]) + + const descending = sortPayments(payments, "amount", "desc").map((p) => p.amount) + expect(descending).toEqual([2000, 1000, 900]) + }) + + it("sorts by createdAt when no sort is given", () => { + const older = payment("a", 100) + older.createdAt = "2026-08-01T00:00:00.000Z" + const newer = payment("b", 100) + newer.createdAt = "2026-08-02T00:00:00.000Z" + + const result = sortPayments([newer, older]) + expect(result.map((p) => p.id)).toEqual(["b", "a"]) // default desc: newest first + }) +}) diff --git a/build-battle/merchant-console/src/data/queries.ts b/build-battle/merchant-console/src/data/queries.ts index cc4ca009..78d933db 100644 --- a/build-battle/merchant-console/src/data/queries.ts +++ b/build-battle/merchant-console/src/data/queries.ts @@ -77,8 +77,7 @@ 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 + return (a.amount - b.amount) * factor } return a.createdAt.localeCompare(b.createdAt) * factor }) diff --git a/build-battle/merchant-console/src/data/store.ts b/build-battle/merchant-console/src/data/store.ts index ba71d950..9c75711b 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,8 @@ interface Store { refunds: Refund[] disputes: Dispute[] payouts: Payout[] + /** Cards are issued at runtime, not seeded. Empty until someone creates one. */ + cards: Card[] } declare global { @@ -28,7 +30,7 @@ declare global { function createStore(): Store { const { payments, refunds, disputes, payouts } = generate() - return { merchants, payments, refunds, disputes, payouts } + return { merchants, payments, refunds, disputes, payouts, cards: [] } } export const store: Store = globalThis.__northwindStore ?? createStore() diff --git a/build-battle/merchant-console/src/data/types.ts b/build-battle/merchant-console/src/data/types.ts index 6697e576..249cd996 100644 --- a/build-battle/merchant-console/src/data/types.ts +++ b/build-battle/merchant-console/src/data/types.ts @@ -11,6 +11,13 @@ export type DisputeStatus = "needs_response" | "under_review" | "won" | "lost" export type PayoutStatus = "paid" | "in_transit" | "pending" +export type CardStatus = "active" | "frozen" | "cancelled" + +export type CardCategory = + | "vendor_subscriptions" + | "ad_spend" + | "contractor_tools" + export interface Merchant { id: string name: string @@ -71,6 +78,39 @@ export interface Payout { paymentIds: string[] } +export interface CardStatusEvent { + status: CardStatus + /** ISO 8601, always UTC. */ + at: string +} + +export interface Card { + id: string + nickname: string + merchantId: string + /** Last four digits only. The full number is never stored. */ + last4: string + /** Integer minor units. Never a float. */ + limitMinorUnits: number + /** Integer minor units, starts at 0. No live transaction feed in this repo. */ + spentMinorUnits: number + currency: Currency + status: CardStatus + category: CardCategory | null + /** ISO 8601, always UTC. */ + createdAt: string + /** Every status this card has held, oldest first. Starts with "active" at creation. */ + statusHistory: CardStatusEvent[] +} + +export interface CardCreateInput { + nickname: string + merchantId: string + limitMinorUnits: number + currency: Currency + category?: CardCategory | null +} + export interface PaymentFilters { status?: PaymentStatus | "all" merchantId?: string diff --git a/build-battle/merchant-console/src/lib/luhn.test.ts b/build-battle/merchant-console/src/lib/luhn.test.ts new file mode 100644 index 00000000..d5c13208 --- /dev/null +++ b/build-battle/merchant-console/src/lib/luhn.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vitest" +import { generateCardNumber, isValidLuhn, luhnCheckDigit } from "./luhn" + +describe("luhnCheckDigit", () => { + it("computes the digit that makes the Stripe test number valid", () => { + expect(luhnCheckDigit("424242424242424")).toBe("2") + }) +}) + +describe("isValidLuhn", () => { + it("accepts the Stripe test card number", () => { + expect(isValidLuhn("4242424242424242")).toBe(true) + }) + + it("rejects a number with a wrong check digit", () => { + expect(isValidLuhn("4242424242424241")).toBe(false) + }) + + it("rejects non-digit input", () => { + expect(isValidLuhn("4242-4242-4242-4242")).toBe(false) + }) +}) + +describe("generateCardNumber", () => { + it("always starts with the 4242 test BIN", () => { + for (let i = 0; i < 50; i++) { + expect(generateCardNumber().startsWith("4242")).toBe(true) + } + }) + + it("is always 16 digits", () => { + for (let i = 0; i < 50; i++) { + expect(generateCardNumber()).toHaveLength(16) + } + }) + + it("always passes its own Luhn check", () => { + for (let i = 0; i < 50; i++) { + expect(isValidLuhn(generateCardNumber())).toBe(true) + } + }) +}) diff --git a/build-battle/merchant-console/src/lib/luhn.ts b/build-battle/merchant-console/src/lib/luhn.ts new file mode 100644 index 00000000..d51c7806 --- /dev/null +++ b/build-battle/merchant-console/src/lib/luhn.ts @@ -0,0 +1,49 @@ +/** + * Card numbers in this repo use the 4242 test BIN. Luhn is what keeps a + * generated number looking like a real PAN structurally without being one. + */ + +const TEST_BIN = "4242" +const NUMBER_LENGTH = 16 + +/** The check digit that makes `digitsWithoutCheckDigit + result` Luhn-valid. */ +export function luhnCheckDigit(digitsWithoutCheckDigit: string): string { + let sum = 0 + const digits = digitsWithoutCheckDigit.split("").map(Number).reverse() + for (let i = 0; i < digits.length; i++) { + let d = digits[i] + if (i % 2 === 0) { + d *= 2 + if (d > 9) d -= 9 + } + sum += d + } + return String((10 - (sum % 10)) % 10) +} + +/** Whether a full digit string, including its own check digit, is Luhn-valid. */ +export function isValidLuhn(number: string): boolean { + if (!/^\d+$/.test(number)) return false + let sum = 0 + const digits = number.split("").map(Number).reverse() + for (let i = 0; i < digits.length; i++) { + let d = digits[i] + if (i % 2 === 1) { + d *= 2 + if (d > 9) d -= 9 + } + sum += d + } + return sum % 10 === 0 +} + +/** A 16-digit number on the 4242 test BIN with a valid Luhn check digit. Server-side only. */ +export function generateCardNumber(): string { + const fillLength = NUMBER_LENGTH - TEST_BIN.length - 1 + let middle = "" + for (let i = 0; i < fillLength; i++) { + middle += String(Math.floor(Math.random() * 10)) + } + const withoutCheckDigit = TEST_BIN + middle + return withoutCheckDigit + luhnCheckDigit(withoutCheckDigit) +} diff --git a/docs/specs/NWP-201-issue-cards.md b/docs/specs/NWP-201-issue-cards.md new file mode 100644 index 00000000..caa94881 --- /dev/null +++ b/docs/specs/NWP-201-issue-cards.md @@ -0,0 +1,108 @@ +# SPEC · NWP-201 — Issue virtual cards from the console + +> Written before any code. Generated with `/spec`, then edited by a human. +> Load it as context when you build: `@docs/specs/NWP-201-issue-cards.md` + +**Ticket:** [NWP-201](../tickets/NWP-201.md) +**Author:** Abhipal Singh +**Status:** draft + +## Problem + +Ops issues virtual cards by messaging the platform team by hand — 12 to 20 times a week, hours of turnaround, and last month two cards got the wrong spend limit because the request lived in a Slack thread. Ops needs to issue a card, see what's been issued, and check one, from inside the console they already use. + +## Current state + +- `src/data/types.ts` — no `Card` type exists yet. `Currency = "USD" | "EUR" | "GBP"` is already defined and is exactly the allowlist NWP-201 needs. +- `src/data/store.ts` — the `Store` interface has `merchants`, `payments`, `refunds`, `disputes`, `payouts`. No `cards` array. Store is a module-level object seeded once at boot and held on `globalThis` so Next's dev reload doesn't reset it — the same pattern will hold new cards for the life of the process. +- `src/data/generate.ts` — deterministic seed generator for the four existing entities, IDs formatted `pay_000001`, `re_000001`, `dp_000001`, `po_0001` via a shared `pad()` helper. Cards are not part of this generator; they're created by the user at runtime, not seeded. +- `src/data/queries.ts` — the payments query builder (`parseFilters`, `filterPayments`, `queryPayments`, `paymentById`, etc.). This is payment-specific and its own docstring says a second filter implementation is a defect — cards get a sibling file, not a squeeze into this one. +- `src/data/merchants.ts` — `merchants: Merchant[]` and `merchantById(id)`, ready to populate the "merchant" field on the issue form. +- `src/lib/money.ts` — `formatMoney`, `parseAmountToMinorUnits` (validates `"250.00"` → `25000`, returns `null` on bad input). This is the boundary parser for the spend-limit field; no second one gets written. +- `src/lib/dates.ts` — `formatDate`, `formatInZone` for created-date display. +- No Luhn helper exists anywhere in `src/lib/`. NWP-201 needs one; it's new, not a duplicate. +- No API route writes to the store yet — every handler in `src/app/api/` is a `GET`. `src/app/api/payments/route.ts` is the pattern to follow for shape (`NextResponse.json(...)`), but POST/PATCH here are new. +- `src/components/`: `Drawer.tsx` (Radix dialog under the hood, used today for the mobile sidebar) is the closest thing to a modal — there is no separate `Dialog` component despite `.claude/rules/components.md` mentioning one generically. The issue-card form uses `Drawer`, matching `components.md`'s requirement that dialogs be operable (focus trap and Escape-to-close already built into `DrawerContent`/`DrawerPrimitives`). +- `src/components/ui/payments/StatusBadge.tsx` — one badge component typed over `PaymentStatus | DisputeStatus | PayoutStatus`, with `LABELS`/`DOTS`/`VARIANTS` records. Card status is a fourth status union to add here, not a new badge component. +- `src/app/payments/page.tsx` + `src/app/payments/[id]/page.tsx` + `src/app/payments/filter-bar.tsx` — the list/detail/client-filter pattern to mirror for `/cards` and `/cards/[id]`. +- `src/app/siteConfig.ts` and `src/components/ui/navigation/AppSidebar.tsx` — nav is a static array keyed off `siteConfig.baseLinks`; adding Cards means one entry in each. +- The ticket says spend limits need a currency; the codebase's card rules (`cards.md`) add: test-BIN-only, generate on the server, reveal once, mask everywhere else, and the `active ⇄ frozen → cancelled` (terminal) state machine — matching the ticket's own "rules that make this real" section verbatim. + +## Domain rules + +| Rule | Source | What breaks if ignored | +| --- | --- | --- | +| Money is integer minor units, formatted only at the display edge | `CLAUDE.md`, `.claude/rules/money.md` | `$250.00` limit stored as float or string drifts on every comparison against spend | +| Generated numbers use the `4242` test BIN with a valid Luhn check digit | ticket, `.claude/rules/cards.md` | A number that isn't Luhn-valid or doesn't start `4242` risks resembling a real PAN | +| Reveal once: full number returned only in the creation response, masked (`•••• 4242`) everywhere else, never stored | ticket, `.claude/rules/cards.md`, `.claude/rules/api-routes.md` | A full number surviving into the store or a list/detail payload is the one thing this ticket cannot ship with | +| Status is a state machine: `active ⇄ frozen`, either → `cancelled`, `cancelled` terminal, guarded server-side | ticket, `.claude/rules/cards.md` | A `cancelled` card reactivated via a stale client, or a race that skips validation | +| Reject missing merchant, limit ≤ 0, limit > 5,000,000 minor units, currency outside `USD/EUR/GBP` — server-side | ticket | Client-only validation is bypassed by anything hitting the API directly | +| Validate anything from the client against an allowlist before it reaches the store | `.claude/rules/api-routes.md` | An unchecked currency or status string reaches the store | +| No database, ORM, or migration; cards live in the in-memory store for process lifetime | ticket, `CLAUDE.md` | Time spent on persistence earns nothing and costs the clock | + +## Approach + +Add a `Card` type and a `cards: Card[]` array to the store (starts empty — cards are created, not seeded). Add a sibling data module, `src/data/cards.ts`, mirroring `queries.ts`'s shape: an allowlist parser for creation input, a Luhn-based number generator in `src/lib/luhn.ts`, and store accessors (`createCard`, `listCards`, `cardById`, `transitionCardStatus`). Two route handlers: `GET/POST /api/cards` and `GET/PATCH /api/cards/[id]`. Two pages, `/cards` (list) and `/cards/[id]` (detail), following the payments pages' structure. The issue form is a `Drawer` (the codebase's existing modal primitive) with two internal steps — the form, then a one-time reveal screen shown right after a successful `POST` — rather than a full page, so ops never navigates away mid-task and the reveal state is impossible to accidentally re-enter (it lives in the drawer's local React state, not in any route or store field). + +Spend is tracked as a `spentMinorUnits` field on the card, initialized to `0` at creation. There's no transaction feed linking payments to cards in this codebase and building one is not in the ticket's core criteria or its stretch goals — real card-network activity is explicitly out of scope. `spentMinorUnits` exists so the detail page's "spend against the limit" and the stretch spend-progress bar have a real field to render; it does not move on its own. + +**Considered and rejected:** a full-page "Issue card" route (`/cards/new`) instead of a drawer. Rejected because every other creation-shaped affordance in this console is scoped to `.claude/rules/components.md`'s dialog rules, not a route, and a drawer keeps the reveal-once screen from ever being a URL someone can revisit or share. + +## File map + +| File | Add or change | Why | +| --- | --- | --- | +| `src/data/types.ts` | change | Add `Card`, `CardStatus`, `CardCategory` (if category lock is attempted) types | +| `src/data/store.ts` | change | Add `cards: Card[]` to `Store`, initialize empty in `createStore()` | +| `src/lib/luhn.ts` | add | `luhnCheckDigit`, `isValidLuhn`, `generateCardNumber` (4242 BIN) | +| `src/lib/luhn.test.ts` | add | Unit tests: check digit correctness, generated numbers always Luhn-valid and BIN-prefixed | +| `src/data/cards.ts` | add | `parseCardInput` (allowlist validation), `createCard`, `listCards`, `cardById`, `transitionCardStatus`, `maskCard` (strips full number, returns last4 + `•••• 4242` shape) | +| `src/data/cards.test.ts` | add | Status transition table: every legal edge passes, everything else (including any transition out of `cancelled`) is rejected | +| `src/app/api/cards/route.ts` | add | `GET` → `listCards()`; `POST` → validate via `parseCardInput`, call `createCard`, return the one response that carries the full number | +| `src/app/api/cards/[id]/route.ts` | add | `GET` → masked card or 404; `PATCH` → status transition, validated server-side, masked response | +| `src/app/cards/page.tsx` | add | List page: nickname, merchant, masked number, limit, status, created date; empty state; "Issue card" trigger | +| `src/app/cards/[id]/page.tsx` | add | Detail page: full masked record, spend vs. limit, freeze/unfreeze if attempting that stretch goal | +| `src/app/cards/issue-card-drawer.tsx` | add | Client component: `Drawer` with the form step and the one-time reveal step | +| `src/components/ui/payments/StatusBadge.tsx` | change | Extend the `AnyStatus` union and the three records with `active`/`frozen`/`cancelled` — reuse, not a new badge | +| `src/app/siteConfig.ts` | change | Add `baseLinks.cards: "/cards"` | +| `src/components/ui/navigation/AppSidebar.tsx` | change | Add a "Cards" nav entry, same shape as the other three | + +## Plan + +1. **Types + store** — `Card`/`CardStatus` added, `store.cards` exists and is empty on boot. Done when: `npm run build` typechecks with the new fields referenced nowhere else yet. +2. **Luhn helper + tests** — `generateCardNumber()` always returns a 16-digit `4242…` string that passes `isValidLuhn`. Done when: `npm test` passes `luhn.test.ts`. +3. **`src/data/cards.ts`** — validation, create, list, get, transition. Done when: a scratch script or test can create a card in-memory and read it back masked. +4. **API routes** — `POST /api/cards` rejects each of the four invalid inputs from the ticket with a real 4xx and a safe message; a valid `POST` returns the full number once. Done when: verified with `curl` for both the happy path and each rejection. +5. **`GET/PATCH /api/cards/[id]`** — masked detail, guarded status transitions. Done when: `curl`-ing a transition out of `cancelled` returns an error, not a 200. +6. **List page + nav** — `/cards` renders the table and the empty state, sidebar links to it. Done when: visiting `/cards` with zero cards shows the written empty state, not a blank table. +7. **Issue-card drawer** — form step collects nickname/merchant/limit/currency, submits to `POST /api/cards`, then swaps to the one-time reveal step showing the full number and a "copy" affordance. Done when: after closing the drawer, the number is gone from the DOM and from the card in the list (masked only). +8. **Detail page** — spend vs. limit, masked number, status. Done when: opening a freshly created card from the list shows its record with `spentMinorUnits: 0` against the limit. +9. **Stretch, time permitting, in this order**: freeze/unfreeze from the list (no reload), spend-progress bar past 80% turning amber, category lock at issue time, then tests beyond Luhn/status if time remains. + +## Verification + +| Acceptance criterion | How it is proven | +| --- | --- | +| Issue a card via form/dialog; appears in the list | Manual: submit the drawer, confirm the row appears without a refresh | +| `/cards` list shows nickname, merchant, masked number, limit, status, created date | Manual: visual check of the table columns | +| Card detail shows full record + spend against limit | Manual: open a card, confirm all fields render including `spentMinorUnits`/limit | +| Generated numbers: `4242` BIN + valid Luhn | `luhn.test.ts` — generator output checked against `isValidLuhn` in a loop | +| Reveal once, masked forever | Manual + code check: `grep` the repo for the full number after creation — it exists only in the POST response type, never in `Card` | +| Server-side validation of the four cases | `curl` each invalid case against `POST /api/cards`, confirm 4xx and no card created | +| Status state machine guarded server-side | `cards.test.ts` — every transition pair, illegal ones rejected including anything out of `cancelled` | + +## Risks + +- Radix `Dialog`-based `Drawer` needs correct focus return on close for the accessibility rule in `components.md` — verify by tabbing through the form and confirming focus lands back on the "Issue card" trigger after both success and cancel. +- Luhn generation could theoretically collide on `last4` between two cards — acceptable, since `last4` is display-only and not a uniqueness key; the full generated number (not persisted) is what actually needs to be unique-looking, and BIN + random digits + check digit makes collision practically irrelevant for this dataset size. + +## Out of scope + +- Persistence beyond the process lifetime (NWP-203). +- Auth, roles, permissions. +- Real card-network calls or any live transaction feed updating `spentMinorUnits`. +- Editing a card's limit after issue (NWP-202). + +## Open questions + +- None blocking. If category lock (stretch) is attempted, the category list will be the same set used elsewhere in seed data if one exists, otherwise a short fixed list (e.g. `subscriptions`, `ad_spend`, `contractor_tools`) matching the ticket's own examples.