Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
d4117b6
NWP-201: spec for issuing virtual cards
MariMax Sep 22, 2026
2df8d88
NWP-201: issue virtual cards
MariMax Sep 22, 2026
4107b36
NWP-201: lock a card to a merchant category at issue
MariMax Sep 22, 2026
f58e8fc
NWP-201: issue at most one card per idempotency key
MariMax Sep 22, 2026
97348f0
NWP-201: write not-found and error pages for cards
MariMax Sep 22, 2026
4ac5548
NWP-201: update spec for category lock, idempotency, error pages
MariMax Sep 22, 2026
f69b187
NWP-201: require a card's currency to match its merchant
MariMax Sep 22, 2026
b2d5275
NWP-201: keep card spend honest at zero
MariMax Sep 22, 2026
a2e727c
NWP-201: update spec for merchant currency rule and honest spend
MariMax Sep 22, 2026
ea44506
NWP-201: bucket daily volume by UTC day in integer minor units
MariMax Sep 22, 2026
04a2fb2
NWP-201: record the daily-volume fix in the spec
MariMax Sep 22, 2026
6c694da
NWP-201: route overview metrics through the query builder
MariMax Sep 22, 2026
127fd2f
NWP-201: record the query-builder fix in the spec
MariMax Sep 22, 2026
31f52ee
NWP-201: tighten the spec to what a reviewer needs
MariMax Sep 22, 2026
0e6f78a
NWP-201: tighten the card data layer and its tests
MariMax Sep 22, 2026
de61515
NWP-201: tighten the card UI without changing behaviour
MariMax Sep 22, 2026
a58b08a
NWP-201: tighten card rules, seeds, routes and their tests
MariMax Sep 22, 2026
e62a8ce
NWP-201: fit the whole PR in a reviewer's diff view
MariMax Sep 22, 2026
2ed5510
NWP-201: keep the spec's fixed-in-passing list accurate
MariMax Sep 22, 2026
92aced0
NWP-201: cite the card rules by full path, with quotes
MariMax Sep 22, 2026
78c46e0
NWP-201: sort payment amounts as numbers, not strings
MariMax Sep 22, 2026
3987d85
NWP-201: trim the diff so the spec stays in the reviewer's view
MariMax Sep 22, 2026
2efbaf6
NWP-201: route dailyVolume through the one query builder
MariMax 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
22 changes: 22 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,22 @@
import { cardById, parseCardStatus, transitionCard } from "@/data/cards"
import { NextRequest, NextResponse } from "next/server"

type Context = { params: Promise<{ id: string }> }

export async function GET(_request: NextRequest, { params }: Context) {
const card = cardById((await params).id)
if (!card) return NextResponse.json({ error: "Card not found." }, { status: 404 })
return NextResponse.json({ card })
}

/** Status changes only; limit edits are NWP-202. */
export async function PATCH(request: NextRequest, { params }: Context) {
const parsed = parseCardStatus(await request.json().catch(() => null))
if (!parsed.ok) return NextResponse.json({ error: parsed.error }, { status: 400 })
const result = transitionCard((await params).id, parsed.value)
if (!result.ok) {
const status = result.reason === "not_found" ? 404 : 409
return NextResponse.json({ error: result.error }, { status })
}
return NextResponse.json({ card: result.card })
}
24 changes: 24 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,24 @@
import { issueCardOnce, listCards, parseIdempotencyKey, parseIssueCard } from "@/data/cards"
import { NextRequest, NextResponse } from "next/server"

/** Stored cards carry the last four only, never a number. */
export function GET() {
return NextResponse.json({ cards: listCards() })
}

/** The one response with a full number. A replayed key gets 409 without it. */
export async function POST(request: NextRequest) {
const key = parseIdempotencyKey(request.headers.get("idempotency-key"))
if (!key.ok) return NextResponse.json({ error: key.error }, { status: 400 })
const parsed = parseIssueCard(await request.json().catch(() => null))
if (!parsed.ok) return NextResponse.json({ error: parsed.error }, { status: 400 })

const result = issueCardOnce(parsed.value, key.value)
if (result.replayed) {
const error = "This card was already issued. Its number is not shown again."
return NextResponse.json({ error, card: result.card }, { status: 409 })
}

const { card, number } = result
return NextResponse.json({ card, number }, { status: 201, headers: { "cache-control": "no-store" } })
}
13 changes: 13 additions & 0 deletions build-battle/merchant-console/src/app/cards/[id]/not-found.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import Link from "next/link"

export default function CardNotFound() {
return (
<div className="p-4 py-16 text-center sm:p-6">
<h1 className="font-medium text-gray-900 dark:text-gray-50">This card does not exist</h1>
<p className="mt-1 text-sm text-gray-500">
Check the link, or <Link href="/cards" className="text-blue-600 hover:underline dark:text-blue-500">find it in the list</Link>.
Cards issued before the console last restarted are not kept.
</p>
</div>
)
}
89 changes: 89 additions & 0 deletions build-battle/merchant-console/src/app/cards/[id]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { Divider } from "@/components/Divider"
import { StatusBadge } from "@/components/ui/payments/StatusBadge"
import { cardById } from "@/data/cards"
import { merchantById } from "@/data/merchants"
import { CARD_CATEGORY_LABELS, maskCardNumber, spendProgress } 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"

export const dynamic = "force-dynamic"

const EVENT_LABELS = { issued: "Card issued", frozen: "Frozen", unfrozen: "Unfrozen", cancelled: "Cancelled" }
const heading = "mt-6 text-sm font-semibold text-gray-900 dark:text-gray-50"

export default async function CardDetail({ params }: { params: Promise<{ id: string }> }) {
const card = cardById((await params).id)
if (!card) notFound()

const merchant = merchantById(card.merchantId)!
const money = (minor: number) => formatMoney(minor, card.currency)
const { percent, nearLimit } = spendProgress(card.spent, card.spendLimit)
const fields: [label: string, value: string, mono?: boolean][] = [
["Merchant", `${merchant.name} · ${merchant.country}`],
["Currency", card.currency],
["Category lock", card.categoryLock ? CARD_CATEGORY_LABELS[card.categoryLock] : "None: any category"],
["Spend limit", money(card.spendLimit)],
["Spent", money(card.spent)],
["Remaining", money(Math.max(0, card.spendLimit - card.spent))],
["Reference", card.reference, true],
[`Created (${merchant.timezone})`, formatInZone(card.createdAt, merchant.timezone)],
]

return (
<div className="p-4 sm:p-6">
<Link href="/cards" className="text-sm text-gray-500 hover:text-gray-900 dark:hover:text-gray-50">
← All cards
</Link>
<div className="mt-4 flex flex-wrap items-center gap-3">
<h1 className="text-2xl font-semibold text-gray-900 dark:text-gray-50">{card.nickname}</h1>
<StatusBadge status={card.status} />
</div>
<p className="mt-1 font-mono text-sm text-gray-500">
{maskCardNumber(card.last4)} · {card.id}
</p>
<Divider />

<dl className="grid grid-cols-1 gap-x-8 gap-y-4 text-sm sm:grid-cols-2 lg:grid-cols-3">
{fields.map(([label, value, mono]) => (
<div key={label}>
<dt className="text-gray-500">{label}</dt>
<dd className={cx("mt-1 tabular-nums text-gray-900 dark:text-gray-50", mono && "font-mono")}>
{value}
</dd>
</div>
))}
</dl>

<h2 className={heading}>Spend</h2>
<p className="mt-2 text-sm tabular-nums text-gray-900 dark:text-gray-50">
{money(card.spent)} of {money(card.spendLimit)} spent
<span className="ml-2 text-gray-500">{percent}% used</span>
</p>
<progress
value={percent}
max={100}
aria-label={`Spend against limit: ${percent}% used`}
className={cx("mt-2 h-2 w-full max-w-md", nearLimit ? "accent-amber-500" : "accent-blue-500")}
/>
{card.spent === 0 && (
<p className="mt-2 text-sm text-gray-500">
No spend recorded. The console is not connected to a card network yet, so spend stays at
zero until authorizations exist.
</p>
)}

<h2 className={heading}>History</h2>
<ol className="mt-2 space-y-3 text-sm">
{card.events.map((event, index) => (
<li key={index}>
<p className="text-gray-900 dark:text-gray-50">{EVENT_LABELS[event.type]}</p>
<p className="text-gray-500">{formatInZone(event.at, merchant.timezone)}</p>
</li>
))}
</ol>
</div>
)
}
78 changes: 78 additions & 0 deletions build-battle/merchant-console/src/app/cards/card-actions.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
"use client"

import { Button } from "@/components/Button"
import type { CardStatus } from "@/data/types"
import { useRouter } from "next/navigation"
import { useState } from "react"

/** Freeze, unfreeze and a two-step cancel. */
export function CardActions(props: { id: string; nickname: string; status: CardStatus }) {
const router = useRouter()
const [pending, setPending] = useState(false)
const [confirming, setConfirming] = useState(false)
const [error, setError] = useState<string | null>(null)

if (props.status === "cancelled") {
return <span className="text-gray-400 dark:text-gray-600">—</span>
}

const update = async (status: CardStatus) => {
setPending(true)
setError(null)
try {
const response = await fetch(`/api/cards/${props.id}`, {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify({ status }),
})
if (!response.ok) {
const body = await response.json().catch(() => null)
return setError(body?.error ?? "The card could not be updated. Try again.")
}
setConfirming(false)
router.refresh()
} catch {
setError("The card could not be updated. Check your connection and try again.")
} finally {
setPending(false)
}
}

const action = (
label: string,
name: string,
onClick: () => void,
variant: "secondary" | "ghost" | "destructive" = "secondary",
) => (
<Button
variant={variant}
className={variant === "ghost" ? "py-1 text-red-600 dark:text-red-500" : "py-1"}
disabled={pending}
aria-label={`${name} ${props.nickname}`}
onClick={onClick}
>
{label}
</Button>
)

return (
<div className="flex flex-col items-end gap-1">
<div className="flex justify-end gap-2">
{confirming ? (
<>
{action("Keep card", "Keep", () => setConfirming(false))}
{action("Confirm cancel", "Confirm cancel", () => update("cancelled"), "destructive")}
</>
) : (
<>
{props.status === "active"
? action("Freeze", "Freeze", () => update("frozen"))
: action("Unfreeze", "Unfreeze", () => update("active"))}
{action("Cancel card", "Cancel card", () => setConfirming(true), "ghost")}
</>
)}
</div>
{error && <p role="alert" className="text-xs text-red-600 dark:text-red-500">{error}</p>}
</div>
)
}
Loading
Loading