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
4 changes: 4 additions & 0 deletions .github/pull_request_template.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ Closes NWP-____

<!-- One short paragraph. What does this do that the app could not do before? -->

## Business impact

<!-- One line, in terms the ops or finance team would recognize. For example: "Ops can send merchant exports without hand-deleting card numbers, saving 3–4 hours a month." -->

## How I verified it

<!-- The commands you ran and what you saw. Paste the test summary line. Say what you clicked and what appeared. -->
Expand Down
6 changes: 6 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,9 @@ Work is submitted as a pull request against this repository and scored automatic
- Branch from `main` with the ticket ID: `NWP-201-issue-cards`
- Commit subjects carry the ticket ID: `NWP-201: issue virtual cards`
- Fill in the pull request template. The grader reads it.

## Release Standards

- Every change needs test evidence before it merges.
- No direct commits to `main`. All work lands through a pull request.
- Every pull request includes a one-line business impact summary.
2 changes: 1 addition & 1 deletion build-battle/merchant-console/.claude/rules/components.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ paths:

# Components

- **Use what is here.** `src/components/` already has Button, Input, Select, Dialog, Badge, and the rest, built on Tremor and Radix. Reach for those before adding a dependency or hand-rolling a control.
- **Use what is here.** `src/components/` already has Button, Input, Select, Drawer, Badge, and the rest, built on Tremor and Radix. Reach for those before adding a dependency or hand-rolling a control. There is no `Dialog` yet; if you need one, build it in `src/components/` on `@radix-ui/react-dialog` (already a dependency), following `Drawer.tsx`.
- **Tailwind only.** No inline `style` attributes, no CSS modules.
- **Dialogs and forms must be operable.** Every input has a label, the dialog has an accessible name, focus moves into it and returns on close, Escape closes it.
- **Format money here, not upstream.** Components receive minor units and a currency code and render the string.
Expand Down
20 changes: 17 additions & 3 deletions build-battle/merchant-console/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,27 @@ npm run dev

No database, no seed step, no Docker.

```bash
npm test # vitest run, node env, <1s
npx vitest run src/lib/csv.test.ts # one file
npx vitest run -t "quotes cells" # one test by name
npm run lint # next lint (core-web-vitals)
npm run build # also type-checks
```

Vitest runs in plain Node with no DOM and only picks up `src/**/*.test.ts`, so component tests will not run without changing `vitest.config.ts`. Imports use the `@/*` alias for `src/*`.

## Data lives in memory

Seed data is JSON, loaded into a store module at boot. Route handlers read and write that store.
Seed data is generated deterministically in `src/data/generate.ts` (fixed seed, so every machine gets identical records) and held in a `globalThis` store in `src/data/store.ts`. Route handlers and pages read and write that store.

- Writes last for the life of the dev server and vanish on restart. That is expected.
- Persistence is tracked separately as NWP-203. **Do not add a database, an ORM, or migrations.**
- If you need more seed data, add it to the JSON. Never edit seed data to make a failing case disappear.
- To add data, change the generator. Never special-case records to make a failing case disappear.

## How requests reach the data

Pages under `src/app/` are server components that import from `src/data/` directly; route handlers exist only where the browser needs a response (`GET /api/payments` for the paged JSON list, `GET /api/payments/export` for the unpaged CSV). Filter state lives in the URL. New filtering still goes through `parseFilters` → `filterPayments`/`sortPayments` → `paginate` in `src/data/queries.ts`.

## Where the rest of the context lives

Expand Down Expand Up @@ -53,7 +67,7 @@ These four explain most of the code, and breaking them is how bugs get in here.
| --- | --- |
| `src/app/` | Console routes: overview, payments, disputes, payouts. Cards is NWP-201 and does not exist yet |
| `src/app/api/` | Route handlers |
| `src/data/` | Seed JSON, the in-memory store, and types |
| `src/data/` | Seed generator, the in-memory store, the query builder, and types |
| `src/components/` | Tremor-based primitives and the console's own components |
| `src/lib/` | Money, date, and CSV helpers, each with a `.test.ts` beside it. Read these before touching an amount |

Expand Down
4 changes: 2 additions & 2 deletions build-battle/merchant-console/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ Vitest, node environment, no DOM. The suite covers the money, date, and CSV
helpers — the three places a quiet mistake costs real money. It runs in under
a second, which is the point: it is meant to run before every push.

Data lives in an in-memory store loaded from JSON at boot. Anything you create lasts for the life of the dev server and resets on restart. That is deliberate — see [`CLAUDE.md`](./CLAUDE.md).
Data lives in an in-memory store, generated deterministically at boot by `src/data/generate.ts`. Anything you create lasts for the life of the dev server and resets on restart. That is deliberate — see [`CLAUDE.md`](./CLAUDE.md).

## The rules of this codebase

Expand All @@ -41,7 +41,7 @@ Read [`CLAUDE.md`](./CLAUDE.md) before writing code. The short version:
| `src/app/` | The console routes: overview, payments, disputes, payouts |
| `src/app/api/` | Route handlers. The query builder behind `GET /api/payments` is the one to reuse |
| `src/components/` | Tremor-based UI primitives and the console's own components |
| `src/data/` | Seed JSON, the in-memory store, and types |
| `src/data/` | Seed generator, the in-memory store, the query builder, and types |
| `src/lib/` | Money, date, and CSV helpers, each with a `.test.ts` beside it |

## Your ticket
Expand Down
42 changes: 35 additions & 7 deletions build-battle/merchant-console/src/app/api/payments/export/route.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,53 @@
import { filterPayments, parseFilters, sortPayments } from "@/data/queries"
import { exportFilename, toCsv } from "@/lib/csv"
import { NextRequest } from "next/server"
import {
exportFilename,
parseExportColumns,
parseExportScope,
toCsv,
} from "@/lib/csv"
import { NextRequest, NextResponse } from "next/server"

/**
* Exports the payments table as CSV.
*
* Honors the active filters and reuses the query builder, but the column set
* and the scope are fixed. Giving ops control over both is NWP-101.
* Ops chooses the columns and the scope; both are validated before anything
* is read. Either scope goes through the one query builder, unpaginated, so
* the file holds every matching row rather than the page on screen.
*/
export function GET(request: NextRequest) {
const filters = parseFilters(request.nextUrl.searchParams)
const params = request.nextUrl.searchParams

const columns = parseExportColumns(params.get("columns"))
if (!columns.ok) {
return NextResponse.json({ error: columns.error }, { status: 400 })
}

const scope = parseExportScope(params.get("scope"))
if (!scope.ok) {
return NextResponse.json({ error: scope.error }, { status: 400 })
}

const filters = parseFilters(
scope.value === "all" ? new URLSearchParams() : params,
)
const rows = sortPayments(
filterPayments(filters),
filters.sort,
filters.direction,
)

return new Response(toCsv(rows), {
// Only validated values reach the filename: the scope or an allowlisted status.
const label =
scope.value === "all"
? "all"
: filters.status && filters.status !== "all"
? filters.status
: "filtered"

return new Response(toCsv(rows, columns.value), {
headers: {
"content-type": "text/csv; charset=utf-8",
"content-disposition": `attachment; filename="${exportFilename()}"`,
"content-disposition": `attachment; filename="${exportFilename(new Date(), label)}"`,
},
})
}
175 changes: 175 additions & 0 deletions build-battle/merchant-console/src/app/payments/export-dialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
"use client"

import { Button } from "@/components/Button"
import {
Dialog,
DialogBody,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/Dialog"
import {
DEFAULT_EXPORT_COLUMNS,
EXPORT_COLUMNS,
ExportColumn,
ExportScope,
} from "@/lib/csv"
import { Download } from "lucide-react"
import { useState } from "react"

const COLUMN_LABELS: Record<ExportColumn, string> = {
id: "Payment ID",
created_at: "Created (UTC)",
merchant: "Merchant",
description: "Description",
status: "Status",
method: "Method",
card_brand: "Card brand",
last4: "Card last four",
amount: "Amount",
currency: "Currency",
}

/**
* Options for the payments export. This only builds the request; the route
* validates the columns and scope again, because this is a convenience and
* not the enforcement.
*/
export function ExportDialog({
query,
counts,
}: {
/** The current filter as a query string, without paging. */
query: string
counts: Record<ExportScope, number>
}) {
const [selected, setSelected] = useState<ExportColumn[]>([
...DEFAULT_EXPORT_COLUMNS,
])
const [scope, setScope] = useState<ExportScope>("filter")

const toggle = (column: ExportColumn, checked: boolean) =>
setSelected((current) =>
checked
? // Keep the table's column order however the boxes were clicked.
EXPORT_COLUMNS.filter((c) => c === column || current.includes(c))
: current.filter((c) => c !== column),
)

const params = new URLSearchParams(scope === "filter" ? query : "")
params.set("scope", scope)
params.set("columns", selected.join(","))
const href = `/api/payments/export?${params.toString()}`

const scopes: { value: ExportScope; label: string }[] = [
{ value: "filter", label: "Current filter" },
{ value: "all", label: "All payments" },
]

return (
<Dialog>
<DialogTrigger asChild>
<Button variant="secondary" className="w-full gap-2 py-1.5 sm:w-fit">
<Download
className="-ml-0.5 size-4 shrink-0 text-gray-400 dark:text-gray-600"
aria-hidden="true"
/>
Export
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Export payments</DialogTitle>
<DialogDescription>
Choose what goes in the CSV. Card last four is off unless you need it.
</DialogDescription>
</DialogHeader>

<DialogBody className="flex flex-col gap-6">
<fieldset className="flex flex-col gap-2">
<legend className="mb-2 text-sm font-medium text-gray-900 dark:text-gray-50">
Scope
</legend>
{scopes.map(({ value, label }) => (
<label
key={value}
htmlFor={`export-scope-${value}`}
className="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300"
>
<input
id={`export-scope-${value}`}
type="radio"
name="export-scope"
value={value}
checked={scope === value}
onChange={() => setScope(value)}
className="size-4 accent-blue-500"
/>
<span>
{label}
<span className="text-gray-500">
{" "}
· {counts[value].toLocaleString()}{" "}
{counts[value] === 1 ? "row" : "rows"}
</span>
</span>
</label>
))}
</fieldset>

<fieldset>
<legend className="mb-2 text-sm font-medium text-gray-900 dark:text-gray-50">
Columns
</legend>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
{EXPORT_COLUMNS.map((column) => (
<label
key={column}
htmlFor={`export-column-${column}`}
className="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300"
>
<input
id={`export-column-${column}`}
type="checkbox"
checked={selected.includes(column)}
onChange={(event) => toggle(column, event.target.checked)}
className="size-4 rounded accent-blue-500"
/>
{COLUMN_LABELS[column]}
</label>
))}
</div>
</fieldset>
</DialogBody>

<DialogFooter>
{selected.length === 0 && (
<p className="text-sm text-gray-500 sm:mr-auto" role="status">
Choose at least one column.
</p>
)}
<DialogClose asChild>
<Button variant="secondary" className="py-1.5">
Cancel
</Button>
</DialogClose>
{selected.length === 0 ? (
<Button className="py-1.5" disabled>
Download
</Button>
) : (
<Button className="py-1.5" asChild>
<a href={href} download>
Download
</a>
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
)
}
36 changes: 14 additions & 22 deletions build-battle/merchant-console/src/app/payments/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,12 @@ import {
} from "@/components/Table"
import { StatusBadge } from "@/components/ui/payments/StatusBadge"
import { merchantById, merchants } from "@/data/merchants"
import { queryPayments } from "@/data/queries"
import { PaymentFilters, PaymentStatus } from "@/data/types"
import { filterPayments, parseFilters, queryPayments } from "@/data/queries"
import { PaymentStatus } from "@/data/types"
import { formatDate } from "@/lib/dates"
import { formatMoney } from "@/lib/money"
import { Download } from "lucide-react"
import Link from "next/link"
import { ExportDialog } from "./export-dialog"
import { PaymentsFilterBar } from "./filter-bar"

const STATUSES: (PaymentStatus | "all")[] = [
Expand All @@ -33,19 +33,16 @@ export default async function PaymentsPage({
searchParams: Promise<Record<string, string | undefined>>
}) {
const params = await searchParams
const filters: PaymentFilters = {
status: (STATUSES.includes(params.status as PaymentStatus)
? params.status
: "all") as PaymentFilters["status"],
merchantId: params.merchantId || undefined,
search: params.search || undefined,
page: Number(params.page ?? "1") || 1,
}

const { rows, total, page, pageCount } = queryPayments(filters)
const query = new URLSearchParams(
Object.entries(params).filter(([, v]) => Boolean(v)) as [string, string][],
)
// The same parser the export route uses, so the count shown in the export
// dialog is the count of rows the file will contain.
const filters = parseFilters(query)

const { rows, total, page, pageCount } = queryPayments(filters)
const exportQuery = new URLSearchParams(query)
exportQuery.delete("page")

const pageHref = (next: number) => {
const q = new URLSearchParams(query)
Expand All @@ -65,15 +62,10 @@ export default async function PaymentsPage({
search: filters.search ?? "",
}}
/>
<Button variant="secondary" className="w-full gap-2 py-1.5 sm:w-fit" asChild>
<a href={`/api/payments/export?${query.toString()}`}>
<Download
className="-ml-0.5 size-4 shrink-0 text-gray-400 dark:text-gray-600"
aria-hidden="true"
/>
Export
</a>
</Button>
<ExportDialog
query={exportQuery.toString()}
counts={{ filter: total, all: filterPayments({}).length }}
/>
</div>

<TableRoot className="border-t border-gray-200 dark:border-gray-800">
Expand Down
Loading
Loading