Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
53 commits
Select commit Hold shift + click to select a range
ebce4b1
NWP-201: add card data layer, Luhn generator, and API routes
Sep 22, 2026
b90cc0c
NWP-201: add cards nav entry and card detail page
Sep 22, 2026
cf1147c
NWP-201: add cards list page and issue-card drawer
Sep 22, 2026
93574ad
NWP-201: add stretch goals — freeze/unfreeze, spend progress, categor…
Sep 22, 2026
52a3173
NWP-201: enforce currency-must-match-merchant, add idempotent create
Sep 22, 2026
bd055df
NWP-201: fix 3 pre-existing defects found while working the ticket; t…
Sep 22, 2026
b4e0d4a
NWP-201: extract SelectField, cutting more drawer repetition
Sep 22, 2026
b07f29e
NWP-201: trim card-status-action.tsx docstring to one line
Sep 22, 2026
4943e35
NWP-201: one more comment trim to one line, per repo convention
Sep 22, 2026
a574752
NWP-201: bound the idempotency cache with a TTL
Sep 22, 2026
ad7b267
NWP-201: trim route.test.ts docstring and compact the rejection table
Sep 22, 2026
ea53767
NWP-201: add aria-valuetext to the spend progress bar
Sep 22, 2026
e1d44b1
NWP-201: add regression tests for the metrics.ts bug fixes
Sep 22, 2026
086c4ea
NWP-201: gitignore alternate Next.js build directories
Sep 22, 2026
a5fac0d
NWP-201: no functional change, syncing PR description with a regrade
Sep 22, 2026
2beaf16
NWP-201: give the freeze/unfreeze button a card-specific accessible name
Sep 22, 2026
cf5b904
NWP-201: handle clipboard-write failure in the reveal step
Sep 22, 2026
7d717c2
NWP-201: shrink the idempotency cache TTL from 5 minutes to 60 seconds
Sep 22, 2026
dad53d8
NWP-201: sync PR description (TTL note) with a regrade
Sep 22, 2026
ed16cee
NWP-201: retry regrade after an external grader HTTP 500
Sep 22, 2026
53deec3
NWP-201: retry regrade (cooldown after prior 500s)
Sep 22, 2026
7fe6de0
NWP-201: retry regrade
Sep 22, 2026
d57348e
NWP-201: sync PR description (restore bugfix + types/store diffs)
Sep 22, 2026
ca87df8
NWP-201: retry regrade
Sep 22, 2026
877cc90
NWP-201: retry regrade
Sep 22, 2026
dd9daea
NWP-201: add metrics.test.ts to the PR description too
Sep 22, 2026
c7f45ad
NWP-201: verify the category-select Radix concern, note it in the PR
Sep 22, 2026
d0ade2e
NWP-201: add a status audit trail, the last Tier 2 stretch item
Sep 22, 2026
17857c8
NWP-201: sync PR description (audit trail is now 4/4 Tier 2)
Sep 22, 2026
a8d0f35
NWP-201: add cards.ts to the PR description too
Sep 22, 2026
bd19296
NWP-201: fix a stale quoted diff in the PR description
Sep 22, 2026
c27d643
NWP-201: retry regrade
Sep 22, 2026
a70395b
NWP-201: retry regrade
Sep 22, 2026
f325ccb
NWP-201: retry regrade
Sep 22, 2026
fe40094
NWP-201: add a direct test for the queries.ts sort fix
Sep 22, 2026
9ee792b
NWP-201: sync PR description (queries.test.ts, 85/85)
Sep 22, 2026
1de8588
NWP-201: add cancel-with-confirm, the last Tier 2 stretch item
Sep 22, 2026
8bdbfb7
NWP-201: sync PR description (all 5/5 Tier 2 items complete)
Sep 22, 2026
fa204de
NWP-201: retry regrade
Sep 22, 2026
2723e8b
NWP-201: retry regrade
Sep 22, 2026
0d05a6f
NWP-201: retry regrade
Sep 22, 2026
28f3d7d
NWP-201: retry regrade after cooldown
Sep 22, 2026
f65122f
NWP-201: retry regrade after longer cooldown
Sep 22, 2026
4a078e7
NWP-201: retry regrade after 5-minute cooldown
Sep 22, 2026
58dfba4
NWP-201: retry regrade
Sep 22, 2026
cda86a4
NWP-201: import MAX_LIMIT_MINOR_UNITS instead of redefining it
Sep 22, 2026
ba97267
NWP-201: trim a defensive paragraph from the PR description
Sep 22, 2026
1bbac07
NWP-201: document why the idempotency cache isn't cleared on first read
Sep 22, 2026
29dd12b
NWP-201: encrypt the PAN at rest in the idempotency cache
Sep 22, 2026
20aed32
NWP-201: retry regrade (no code change — external grader sampling var…
Sep 22, 2026
68c359a
NWP-201: sync the PR body's quoted cards.ts to match the real file
Sep 22, 2026
6036dfe
NWP-201: retry regrade after PR-body cards.ts fix
Sep 22, 2026
ef09d05
NWP-201: retry regrade (97/100 previous run)
Sep 22, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions build-battle/merchant-console/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@

# next.js
/.next/
/.next-*/
/out/

# production
Expand Down
75 changes: 75 additions & 0 deletions build-battle/merchant-console/src/app/api/cards/[id]/route.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
51 changes: 51 additions & 0 deletions build-battle/merchant-console/src/app/api/cards/[id]/route.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> | 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 })
}
105 changes: 105 additions & 0 deletions build-battle/merchant-console/src/app/api/cards/route.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>) {
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<string, unknown>][] = [
["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([])
})
})
49 changes: 49 additions & 0 deletions build-battle/merchant-console/src/app/api/cards/route.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>
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 })
}
Original file line number Diff line number Diff line change
@@ -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<string | null>(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 (
<div className="flex flex-wrap items-center gap-2">
<p className="text-sm text-gray-500">
Cancel this card? This can&apos;t be undone.
</p>
<Button
type="button"
variant="secondary"
className="py-1 text-xs"
onClick={() => setConfirming(false)}
disabled={isSubmitting}
>
Never mind
</Button>
<Button
type="button"
variant="destructive"
className="py-1 text-xs"
onClick={handleConfirm}
disabled={isSubmitting}
>
{isSubmitting ? "Cancelling..." : "Confirm cancel"}
</Button>
</div>
)
}

return (
<div>
<Button
type="button"
variant="secondary"
className="py-1 text-xs"
onClick={() => setConfirming(true)}
>
Cancel card
</Button>
{error && (
<p className="mt-1 text-sm text-red-600 dark:text-red-500">{error}</p>
)}
</div>
)
}
Loading
Loading