diff --git a/CLAUDE.md b/CLAUDE.md index da0055b1..be84bc21 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 + +- No direct commits to `main`. All changes land through a pull request. +- Every change needs test evidence before merging — a passing test run, a curl output, or a screenshot, attached to the PR. +- Every PR includes a one-line business impact summary: what changes for ops, merchants, or the org standards score. diff --git a/build-battle/merchant-console/src/app/api/payments/export/route.ts b/build-battle/merchant-console/src/app/api/payments/export/route.ts index 1869bbca..f0bf9d8d 100644 --- a/build-battle/merchant-console/src/app/api/payments/export/route.ts +++ b/build-battle/merchant-console/src/app/api/payments/export/route.ts @@ -1,25 +1,62 @@ import { filterPayments, parseFilters, sortPayments } from "@/data/queries" -import { exportFilename, toCsv } from "@/lib/csv" +import { exportFilename, parseExportColumns, toCsv } from "@/lib/csv" import { NextRequest } from "next/server" /** - * Exports the payments table as CSV. + * Exports the payments table as CSV (NWP-101). * - * 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 arrive from the client, so both + * are checked against an allowlist here — the dialog disabling Download is a + * convenience, this is the enforcement. Rows come from the one query builder; + * exporting in the browser would only ever capture the current page. */ export function GET(request: NextRequest) { - const filters = parseFilters(request.nextUrl.searchParams) + const params = request.nextUrl.searchParams + + const columns = parseExportColumns(params.get("columns")) + if (columns.length === 0) { + return Response.json( + { message: "Select at least one column to export." }, + { status: 400 }, + ) + } + + const scope = params.get("scope") === "all" ? "all" : "filtered" + const filters = parseFilters(params) + + // Scope "all" ignores the active filters but keeps the requested ordering. const rows = sortPayments( - filterPayments(filters), + filterPayments(scope === "all" ? {} : filters), filters.sort, filters.direction, ) - return new Response(toCsv(rows), { + return new Response(toCsv(rows, columns), { headers: { "content-type": "text/csv; charset=utf-8", - "content-disposition": `attachment; filename="${exportFilename()}"`, + "content-disposition": `attachment; filename="${exportFilename( + new Date(), + filenameScope(scope, params), + )}"`, }, }) } + +/** + * The scope segment of the filename. A status filter names itself, so ops can + * tell `payments-disputed-...` from `payments-all-...` in a downloads folder. + */ +function filenameScope( + scope: "all" | "filtered", + params: URLSearchParams, +): string | undefined { + if (scope === "all") return "all" + + const status = parseFilters(params).status + if (status && status !== "all") return status + + const narrowed = ["merchantId", "search", "from", "to"].some((key) => + params.get(key), + ) + return narrowed ? "filtered" : undefined +} diff --git a/build-battle/merchant-console/src/app/payments/export-dialog.tsx b/build-battle/merchant-console/src/app/payments/export-dialog.tsx new file mode 100644 index 00000000..95def7da --- /dev/null +++ b/build-battle/merchant-console/src/app/payments/export-dialog.tsx @@ -0,0 +1,165 @@ +"use client" + +import { Button } from "@/components/Button" +import { + Drawer, + DrawerBody, + DrawerContent, + DrawerFooter, + DrawerHeader, + DrawerTitle, + DrawerTrigger, +} from "@/components/Drawer" +import { + DEFAULT_EXPORT_COLUMNS, + EXPORT_COLUMNS, + ExportColumn, +} from "@/lib/csv" +import { Download } from "lucide-react" +import { useState } from "react" + +const LABELS: Record = { + 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", +} + +export function PaymentsExportDialog({ + query, + filteredTotal, + allTotal, +}: { + /** The active filter params, already serialized by the page. */ + query: string + filteredTotal: number + allTotal: number +}) { + const [columns, setColumns] = useState([ + ...DEFAULT_EXPORT_COLUMNS, + ]) + const [scope, setScope] = useState<"filtered" | "all">("filtered") + + const toggle = (column: ExportColumn) => + setColumns((current) => + current.includes(column) + ? current.filter((c) => c !== column) + : // Keep the canonical order rather than click order. + EXPORT_COLUMNS.filter((c) => c === column || current.includes(c)), + ) + + const href = () => { + const params = new URLSearchParams(scope === "all" ? "" : query) + params.set("columns", columns.join(",")) + params.set("scope", scope) + return `/api/payments/export?${params.toString()}` + } + + const rowCount = scope === "all" ? allTotal : filteredTotal + + return ( + + + + + + + + Export payments + + + +
+ + Scope + +
+ {( + [ + ["filtered", "Current filter", filteredTotal], + ["all", "All payments", allTotal], + ] as const + ).map(([value, label, total]) => ( + + ))} +
+
+ +
+ + Columns + +
+ {EXPORT_COLUMNS.map((column) => ( + + ))} +
+ {columns.length === 0 && ( +

+ Select at least one column to export. +

+ )} +
+
+ + +

+ {rowCount.toLocaleString()} rows · {columns.length} columns +

+ {columns.length === 0 ? ( + + ) : ( + + )} +
+
+
+ ) +} diff --git a/build-battle/merchant-console/src/app/payments/page.tsx b/build-battle/merchant-console/src/app/payments/page.tsx index f97f4896..b7b9884c 100644 --- a/build-battle/merchant-console/src/app/payments/page.tsx +++ b/build-battle/merchant-console/src/app/payments/page.tsx @@ -14,8 +14,8 @@ import { queryPayments } from "@/data/queries" import { PaymentFilters, 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 { PaymentsExportDialog } from "./export-dialog" import { PaymentsFilterBar } from "./filter-bar" const STATUSES: (PaymentStatus | "all")[] = [ @@ -43,6 +43,8 @@ export default async function PaymentsPage({ } const { rows, total, page, pageCount } = queryPayments(filters) + // Same builder, no filters: the row count the "all payments" scope would export. + const allTotal = queryPayments({}).total const query = new URLSearchParams( Object.entries(params).filter(([, v]) => Boolean(v)) as [string, string][], ) @@ -65,15 +67,11 @@ export default async function PaymentsPage({ search: filters.search ?? "", }} /> - + diff --git a/build-battle/merchant-console/src/lib/csv.test.ts b/build-battle/merchant-console/src/lib/csv.test.ts index e28d8359..140773a6 100644 --- a/build-battle/merchant-console/src/lib/csv.test.ts +++ b/build-battle/merchant-console/src/lib/csv.test.ts @@ -1,6 +1,12 @@ import { describe, expect, it } from "vitest" import { Payment } from "@/data/types" -import { EXPORT_COLUMNS, exportFilename, toCsv } from "./csv" +import { + DEFAULT_EXPORT_COLUMNS, + EXPORT_COLUMNS, + exportFilename, + parseExportColumns, + toCsv, +} from "./csv" /** * The export is the file ops hands to a merchant, so a broken cell is a @@ -74,6 +80,52 @@ describe("toCsv", () => { it("emits a header even with no rows", () => { expect(toCsv([], ["id"])).toBe("id") }) + + it("follows the caller's column order, not the canonical one", () => { + expect(toCsv([payment], ["status", "currency", "id"])).toBe( + ["status,currency,id", "captured,USD,pay_0001"].join("\n"), + ) + }) + + it("produces a blank file for an empty selection, which is why the route refuses one", () => { + // No header, no cells — just the row separator. Never a download path. + expect(toCsv([payment], [])).toBe("\n") + expect(toCsv([], [])).toBe("") + }) +}) + +describe("DEFAULT_EXPORT_COLUMNS", () => { + it("leaves the card last four out, so a merchant file is clean by default", () => { + expect(DEFAULT_EXPORT_COLUMNS).not.toContain("last4") + expect(DEFAULT_EXPORT_COLUMNS).toEqual( + EXPORT_COLUMNS.filter((column) => column !== "last4"), + ) + }) +}) + +describe("parseExportColumns", () => { + it("falls back to the default set when the client sends no columns", () => { + expect(parseExportColumns(null)).toEqual([...DEFAULT_EXPORT_COLUMNS]) + }) + + it("keeps the requested order", () => { + expect(parseExportColumns("amount,id,status")).toEqual([ + "amount", + "id", + "status", + ]) + }) + + it("drops names that are not columns and de-dupes the rest", () => { + expect(parseExportColumns("id, id ,merchant,../etc/passwd,DROP TABLE")).toEqual( + ["id", "merchant"], + ) + }) + + it("returns an empty selection when nothing survives the allowlist", () => { + expect(parseExportColumns("")).toEqual([]) + expect(parseExportColumns("nope,also_nope")).toEqual([]) + }) }) describe("exportFilename", () => { @@ -82,4 +134,16 @@ describe("exportFilename", () => { "payments-2026-03-14.csv", ) }) + + it("names the scope when there is one", () => { + expect( + exportFilename(new Date("2026-08-13T09:00:00.000Z"), "disputed"), + ).toBe("payments-disputed-2026-08-13.csv") + }) + + it("strips anything a scope segment has no business putting in a filename", () => { + expect(exportFilename(new Date("2026-08-13T09:00:00.000Z"), "../all")).toBe( + "payments-all-2026-08-13.csv", + ) + }) }) diff --git a/build-battle/merchant-console/src/lib/csv.ts b/build-battle/merchant-console/src/lib/csv.ts index 62be9e93..6ef75114 100644 --- a/build-battle/merchant-console/src/lib/csv.ts +++ b/build-battle/merchant-console/src/lib/csv.ts @@ -5,9 +5,8 @@ import { formatMoney } from "./money" /** * CSV export for the payments table. * - * The column set is fixed. Ops has asked for control over it — that is - * NWP-101 — but today everyone gets every column, including the card - * last four, whether or not the file is going to a merchant. + * Ops picks the columns (NWP-101). The card last four is available but off by + * default, so a file only carries it when someone asked for it. */ export const EXPORT_COLUMNS = [ @@ -25,6 +24,31 @@ export const EXPORT_COLUMNS = [ export type ExportColumn = (typeof EXPORT_COLUMNS)[number] +/** What ops gets without choosing: everything except the card last four. */ +export const DEFAULT_EXPORT_COLUMNS: readonly ExportColumn[] = + EXPORT_COLUMNS.filter((column) => column !== "last4") + +function isExportColumn(value: string): value is ExportColumn { + return (EXPORT_COLUMNS as readonly string[]).includes(value) +} + +/** + * Column names arrive from the client, so they are checked against the + * allowlist before they reach a serializer or a filename. Unknown names are + * dropped rather than rejected; an absent param means "the default set", and an + * empty result means the caller selected nothing, which the route refuses. + */ +export function parseExportColumns(raw: string | null): ExportColumn[] { + if (raw === null) return [...DEFAULT_EXPORT_COLUMNS] + + const seen = new Set() + for (const name of raw.split(",")) { + const trimmed = name.trim() + if (isExportColumn(trimmed)) seen.add(trimmed) + } + return [...seen] +} + function escapeCell(value: string): string { if (/[",\n]/.test(value)) return `"${value.replace(/"/g, '""')}"` return value @@ -66,6 +90,14 @@ export function toCsv( return [header, ...rows].join("\n") } -export function exportFilename(date = new Date()): string { - return `payments-${date.toISOString().slice(0, 10)}.csv` +/** + * `payments-disputed-2026-08-13.csv`. The scope segment is slugified before it + * reaches a filename, because it is derived from client input. + */ +export function exportFilename(date = new Date(), scope?: string): string { + const day = date.toISOString().slice(0, 10) + const segment = scope?.toLowerCase().replace(/[^a-z0-9-]/g, "") + return segment + ? `payments-${segment}-${day}.csv` + : `payments-${day}.csv` }