Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
58 changes: 58 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,58 @@
import { cardById, setCardStatus, spendForCard } from "@/data/cards"
import { CARD_STATUSES } from "@/data/types"
import { CARD_ERROR_STATUS, isCardStatus } from "@/lib/cards"
import { apiError } from "@/lib/http"
import { NextRequest, NextResponse } from "next/server"

interface RouteContext {
params: Promise<{ id: string }>
}

/** One card and what it has spent. Masked, like every read of a card. */
export async function GET(request: NextRequest, context: RouteContext) {
const { id } = await context.params
const card = cardById(id)
if (!card) return apiError(404, "card_not_found", "No card with that id.")

return NextResponse.json({ card, spend: spendForCard(card) })
}

/**
* Moves a card through the state machine.
*
* Status is the only thing a card exposes to a write. Changing a limit after
* issue is NWP-202, and nothing here will do it.
*/
export async function PATCH(request: NextRequest, context: RouteContext) {
const { id } = await context.params

let body: unknown
try {
body = await request.json()
} catch {
return apiError(400, "invalid_body", "Send a JSON body with a status.")
}

if (typeof body !== "object" || body === null || Array.isArray(body)) {
return apiError(400, "invalid_body", "Send a JSON body with a status.")
}

// request.json() is `any`, so a cast here would accept "ACTIVE", null, or 42.
const status = (body as Record<string, unknown>).status
if (!isCardStatus(status)) {
return apiError(
422,
"status_invalid",
`Status must be one of ${CARD_STATUSES.join(", ")}.`,
"status",
)
}

const result = setCardStatus(id, status)
if (!result.ok) {
const { code, message, field } = result.error
return apiError(CARD_ERROR_STATUS[code], code, message, field)
}

return NextResponse.json({ card: result.value })
}
47 changes: 47 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,47 @@
import { issueCard, parseCardFilters, queryCards } from "@/data/cards"
import { merchantById } from "@/data/merchants"
import { CARD_ERROR_STATUS, validateIssueRequest } from "@/lib/cards"
import { apiError } from "@/lib/http"
import { NextRequest, NextResponse } from "next/server"

/**
* Lists issued cards.
*
* Returns records, and a record has no field for a full number, so this
* response cannot carry one however it is filtered.
*/
export function GET(request: NextRequest) {
const filters = parseCardFilters(request.nextUrl.searchParams)
return NextResponse.json(queryCards(filters))
}

/**
* Issues a card.
*
* The only response in this application that contains a full card number. It
* is generated here, on the server, handed back once, and never written down:
* the stored record keeps the last four and nothing else.
*/
export async function POST(request: NextRequest) {
let body: unknown
try {
body = await request.json()
} catch {
return apiError(
400,
"invalid_body",
"Send a JSON body with the card details.",
)
}

// The client's own checks are a convenience. This is the enforcement.
const validated = validateIssueRequest(body, merchantById)
if (!validated.ok) {
const { code, message, field } = validated.error
return apiError(CARD_ERROR_STATUS[code], code, message, field)
}

const { card, cardNumber } = issueCard(validated.value)

return NextResponse.json({ card, cardNumber }, { status: 201 })
}
172 changes: 172 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,172 @@
import { Divider } from "@/components/Divider"
import { StatusBadge } from "@/components/ui/payments/StatusBadge"
import { cardById, spendForCard } from "@/data/cards"
import { merchantById } from "@/data/merchants"
import { isSpendAmber, maskCard, spendPercent } from "@/lib/cards"
import { formatInZone } from "@/lib/dates"
import { formatMoney } from "@/lib/money"
import { cx } from "@/lib/utils"
import Link from "next/link"
import { notFound } from "next/navigation"
import { CardActions } from "../card-actions"

export const dynamic = "force-dynamic"

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

const merchant = merchantById(card.merchantId)!
const spend = spendForCard(card)
// Derived once, from the same two integers the bar is drawn from, so the
// figure and the bar cannot disagree on screen.
const percent = spendPercent(spend, card.limit)
const amber = isSpendAmber(spend, card.limit)
const remaining = Math.max(0, card.limit - spend)

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">
{card.id} · {maskCard(card.last4)}
</p>

<Divider />

<dl className="grid grid-cols-1 gap-x-8 gap-y-4 sm:grid-cols-2 lg:grid-cols-3">
<Field label="Merchant">
{merchant.name}
<span className="ml-2 text-gray-500">{merchant.country}</span>
</Field>
<Field label="Card number">
<span className="font-mono tabular-nums">{maskCard(card.last4)}</span>
</Field>
<Field label="Spend limit">
<span className="tabular-nums">
{formatMoney(card.limit, card.currency)}
</span>
</Field>
<Field label="Currency">{card.currency}</Field>
<Field label="Category lock" className="capitalize">
{card.category}
</Field>
<Field label="Issued (UTC)">
<span className="font-mono text-sm">{card.issuedAt}</span>
</Field>
<Field label={`Issued (${merchant.timezone})`}>
{formatInZone(card.issuedAt, merchant.timezone)}
</Field>
<Field label="Actions">
<CardActions cardId={card.id} status={card.status} />
</Field>
</dl>

<Divider />

<h2 className="text-sm font-semibold text-gray-900 dark:text-gray-50">
Spend against limit
</h2>
<div className="mt-4 max-w-md">
<div className="flex items-baseline justify-between gap-4">
<p className="text-2xl font-semibold tabular-nums text-gray-900 dark:text-gray-50">
{formatMoney(spend, card.currency)}
</p>
<p className="text-sm text-gray-500">
of {formatMoney(card.limit, card.currency)}
</p>
</div>
<progress
value={spend}
max={card.limit}
aria-label="Spend against limit"
className={cx(
"mt-2 h-2 w-full appearance-none overflow-hidden rounded-full",
"[&::-webkit-progress-bar]:bg-gray-200 dark:[&::-webkit-progress-bar]:bg-gray-800",
"[&::-webkit-progress-bar]:rounded-full [&::-webkit-progress-value]:rounded-full",
amber
? "[&::-moz-progress-bar]:bg-amber-500 [&::-webkit-progress-value]:bg-amber-500"
: "[&::-moz-progress-bar]:bg-blue-500 [&::-webkit-progress-value]:bg-blue-500",
)}
>
{percent}%
</progress>
<p className="mt-2 text-sm text-gray-500">
{percent}% used · {formatMoney(remaining, card.currency)} remaining
{amber ? " · close to the limit" : null}
</p>
{spend === 0 && (
<p className="mt-2 text-xs text-gray-500">
Nothing has been spent on this card yet.
</p>
)}
</div>

<Divider />

<h2 className="text-sm font-semibold text-gray-900 dark:text-gray-50">
History
</h2>
<ol className="mt-4 space-y-4">
{card.audit.map((entry, index) => (
<li key={index} className="flex gap-3">
<span
className="mt-1.5 size-2 shrink-0 rounded-full bg-blue-500"
aria-hidden="true"
/>
<div>
<p className="text-sm capitalize text-gray-900 dark:text-gray-50">
{entry.from === null
? "Card issued"
: `${entry.from} to ${entry.to}`}
</p>
<p className="text-sm text-gray-500">
{formatInZone(entry.at, merchant.timezone)} · {entry.actor}
</p>
</div>
</li>
))}
</ol>
</div>
)
}

function Field({
label,
children,
className,
}: {
label: string
children: React.ReactNode
className?: string
}) {
return (
<div>
<dt className="text-sm text-gray-500">{label}</dt>
<dd
className={cx(
"mt-1 text-sm text-gray-900 dark:text-gray-50",
className,
)}
>
{children}
</dd>
</div>
)
}
76 changes: 76 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,76 @@
"use client"

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

/**
* Freeze and unfreeze from the list.
*
* The server owns the state machine; this only ever asks. A refused
* transition is shown rather than swallowed, because the reason a card cannot
* move is exactly what ops needs to read.
*/
export function CardActions({
cardId,
status,
}: {
cardId: string
status: CardStatus
}) {
const router = useRouter()
const [isRefreshing, startTransition] = useTransition()
const [isSending, setIsSending] = useState(false)
const [error, setError] = useState<string | null>(null)
const pending = isSending || isRefreshing

// Nothing comes back from cancelled, so there is nothing to offer.
if (status === "cancelled") {
return <span className="text-sm text-gray-400">No actions</span>
}

const next: CardStatus = status === "active" ? "frozen" : "active"

async function move() {
setIsSending(true)
setError(null)
try {
const response = await fetch(`/api/cards/${cardId}`, {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify({ status: next }),
})
if (!response.ok) {
const body = await response.json()
setError(body.message ?? "That change was refused.")
return
}
// Re-render the server list in place: no document load, no lost scroll.
startTransition(() => router.refresh())
} catch {
setError("Could not reach the server. Try again.")
} finally {
setIsSending(false)
}
}

return (
<div className="flex flex-col items-end gap-1">
<Button
variant="secondary"
className="py-1 text-xs"
onClick={move}
isLoading={pending}
loadingText={status === "active" ? "Freezing" : "Unfreezing"}
>
{status === "active" ? "Freeze" : "Unfreeze"}
</Button>
{error && (
<p role="alert" className="text-xs text-red-600 dark:text-red-500">
{error}
</p>
)}
</div>
)
}
Loading