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..70aa8f36 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,47 @@ import { filterPayments, parseFilters, sortPayments } from "@/data/queries" -import { exportFilename, toCsv } from "@/lib/csv" -import { NextRequest } from "next/server" +import { exportFilename, ExportScope, parseExportColumns, toCsv } from "@/lib/csv" +import { NextRequest, NextResponse } from "next/server" + +function parseScope(param: string | null): ExportScope { + return param === "all" ? "all" : "current" +} /** * 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. + * Reuses the query builder behind GET /api/payments. `scope=all` drops the + * status/merchant/search/date filters but keeps sort, so it is still one + * query path, not a second one. Column names are validated against the + * allowlist in parseExportColumns before they reach toCsv or the filename. */ export function GET(request: NextRequest) { - const filters = parseFilters(request.nextUrl.searchParams) + const params = request.nextUrl.searchParams + const filters = parseFilters(params) + const scope = parseScope(params.get("scope")) + const columns = parseExportColumns(params.get("columns")) + + if (columns.length === 0) { + return NextResponse.json( + { error: "Select at least one column to export." }, + { status: 400 }, + ) + } + + const scopedFilters = + scope === "all" + ? { sort: filters.sort, direction: filters.direction } + : filters + const rows = sortPayments( - filterPayments(filters), + filterPayments(scopedFilters), 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(scope, filters.status)}"`, }, }) } 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..19fed03a --- /dev/null +++ b/build-battle/merchant-console/src/app/payments/export-dialog.tsx @@ -0,0 +1,172 @@ +"use client" + +import { Button } from "@/components/Button" +import { + Drawer, + DrawerBody, + DrawerContent, + DrawerDescription, + DrawerFooter, + DrawerHeader, + DrawerTitle, + DrawerTrigger, +} from "@/components/Drawer" +import { DEFAULT_EXPORT_COLUMNS, EXPORT_COLUMNS, ExportColumn } from "@/lib/csv" +import { Download } from "lucide-react" +import { useEffect, useState } from "react" + +const COLUMN_LABELS: Record = { + id: "Payment ID", + created_at: "Date", + merchant: "Merchant", + description: "Description", + status: "Status", + method: "Method", + card_brand: "Card brand", + last4: "Card last 4", + amount: "Amount", + currency: "Currency", +} + +type Scope = "current" | "all" + +export function ExportDialog({ query }: { query: string }) { + const [open, setOpen] = useState(false) + const [columns, setColumns] = useState>( + () => new Set(DEFAULT_EXPORT_COLUMNS), + ) + const [scope, setScope] = useState("current") + const [counts, setCounts] = useState<{ current?: number; all?: number }>({}) + + useEffect(() => { + if (!open) return + + let cancelled = false + async function loadCounts() { + const [current, all] = await Promise.all([ + fetch(`/api/payments?${query}`).then((r) => r.json()), + fetch(`/api/payments`).then((r) => r.json()), + ]) + if (!cancelled) { + setCounts({ current: current.total, all: all.total }) + } + } + loadCounts() + return () => { + cancelled = true + } + }, [open, query]) + + function toggleColumn(column: ExportColumn) { + setColumns((prev) => { + const next = new Set(prev) + if (next.has(column)) next.delete(column) + else next.add(column) + return next + }) + } + + const exportParams = new URLSearchParams(query) + exportParams.set("scope", scope) + exportParams.set( + "columns", + EXPORT_COLUMNS.filter((column) => columns.has(column)).join(","), + ) + const rowCount = scope === "all" ? counts.all : counts.current + + return ( + + + + + + + Export payments + + Choose which columns to include and how much of the table to export. + + + +
+ + Columns + + {EXPORT_COLUMNS.map((column) => ( + + ))} +
+ +
+ + Scope + + + +
+
+ + + +
+
+ ) +} diff --git a/build-battle/merchant-console/src/app/payments/page.tsx b/build-battle/merchant-console/src/app/payments/page.tsx index f97f4896..cc3f780c 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 { ExportDialog } from "./export-dialog" import { PaymentsFilterBar } from "./filter-bar" const STATUSES: (PaymentStatus | "all")[] = [ @@ -65,15 +65,7 @@ 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..4b383783 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 @@ -76,10 +82,52 @@ describe("toCsv", () => { }) }) +describe("parseExportColumns", () => { + it("resolves a subset of columns in the order given, ignoring the fixed column order", () => { + expect(parseExportColumns("amount,id")).toEqual(["amount", "id"]) + }) + + it("excludes last4 by default, when no columns param is given", () => { + expect(parseExportColumns(null)).toEqual(DEFAULT_EXPORT_COLUMNS) + expect(parseExportColumns(null)).not.toContain("last4") + }) + + it("returns an empty list for an empty selection, rather than falling back to the default", () => { + expect(parseExportColumns("")).toEqual([]) + }) + + it("drops unknown or invalid column names instead of trusting the client", () => { + expect(parseExportColumns("id,dr0p table,amount")).toEqual(["id", "amount"]) + }) + + it("dedupes a repeated column, keeping its first position", () => { + expect(parseExportColumns("amount,id,amount")).toEqual(["amount", "id"]) + }) +}) + describe("exportFilename", () => { + const date = new Date("2026-03-14T23:00:00.000Z") + it("stamps the UTC date, so two exports on the same day collide by design", () => { - expect(exportFilename(new Date("2026-03-14T23:00:00.000Z"))).toBe( - "payments-2026-03-14.csv", + expect(exportFilename("all", undefined, date)).toBe("payments-all-2026-03-14.csv") + }) + + it("labels the file with the active status filter", () => { + expect(exportFilename("current", "disputed", date)).toBe( + "payments-disputed-2026-03-14.csv", + ) + }) + + it("labels the file 'filtered' when scoped to the current filter without a status", () => { + expect(exportFilename("current", "all", date)).toBe( + "payments-filtered-2026-03-14.csv", + ) + expect(exportFilename("current", undefined, date)).toBe( + "payments-filtered-2026-03-14.csv", ) }) + + it("labels the file 'all' for the all-payments scope regardless of status", () => { + expect(exportFilename("all", "disputed", date)).toBe("payments-all-2026-03-14.csv") + }) }) diff --git a/build-battle/merchant-console/src/lib/csv.ts b/build-battle/merchant-console/src/lib/csv.ts index 62be9e93..93576f26 100644 --- a/build-battle/merchant-console/src/lib/csv.ts +++ b/build-battle/merchant-console/src/lib/csv.ts @@ -1,13 +1,13 @@ import { merchantById } from "@/data/merchants" -import { Payment } from "@/data/types" +import { Payment, PaymentStatus } from "@/data/types" 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 column set and the scope (NWP-101). Column names arrive from + * the client as a comma-separated list, so parseExportColumns is the + * allowlist gate — nothing past it reaches toCsv or a filename unvalidated. */ export const EXPORT_COLUMNS = [ @@ -25,6 +25,35 @@ export const EXPORT_COLUMNS = [ export type ExportColumn = (typeof EXPORT_COLUMNS)[number] +/** Every column except the card last four, which ops must opt into. */ +export const DEFAULT_EXPORT_COLUMNS = EXPORT_COLUMNS.filter( + (column) => column !== "last4", +) + +function isExportColumn(value: string): value is ExportColumn { + return (EXPORT_COLUMNS as readonly string[]).includes(value) +} + +/** + * Validates the client-supplied `columns` param against the allowlist. + * + * `null` (no param) means "not specified" and returns the default set. + * Anything else — including an empty string — returns only the requested + * columns that are actually valid, deduped, in the order given. A selection + * that resolves to nothing is returned as `[]` rather than falling back to + * the default, so the caller can tell "unspecified" from "chose none." + */ +export function parseExportColumns(param: string | null): ExportColumn[] { + if (param === null) return [...DEFAULT_EXPORT_COLUMNS] + + const seen = new Set() + for (const raw of param.split(",")) { + const trimmed = raw.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 +95,19 @@ export function toCsv( return [header, ...rows].join("\n") } -export function exportFilename(date = new Date()): string { - return `payments-${date.toISOString().slice(0, 10)}.csv` +export type ExportScope = "current" | "all" + +/** + * `scope: "all"` labels the file "all"; `scope: "current"` labels it with + * the active status filter, or "filtered" when the status filter is "all" + * (or unset) but some other filter narrowed the rows. + */ +export function exportFilename( + scope: ExportScope, + status: PaymentStatus | "all" | undefined, + date = new Date(), +): string { + const label = + scope === "all" ? "all" : status && status !== "all" ? status : "filtered" + return `payments-${label}-${date.toISOString().slice(0, 10)}.csv` }