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
38 changes: 30 additions & 8 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,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)}"`,
},
})
}
172 changes: 172 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,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<ExportColumn, string> = {
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<Set<ExportColumn>>(
() => new Set(DEFAULT_EXPORT_COLUMNS),
)
const [scope, setScope] = useState<Scope>("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 (
<Drawer open={open} onOpenChange={setOpen}>
<DrawerTrigger 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>
</DrawerTrigger>
<DrawerContent>
<DrawerHeader>
<DrawerTitle>Export payments</DrawerTitle>
<DrawerDescription>
Choose which columns to include and how much of the table to export.
</DrawerDescription>
</DrawerHeader>
<DrawerBody className="flex flex-col gap-6">
<fieldset className="flex flex-col gap-2">
<legend className="text-sm font-medium text-gray-900 dark:text-gray-50">
Columns
</legend>
{EXPORT_COLUMNS.map((column) => (
<label
key={column}
className="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300"
>
<input
type="checkbox"
className="size-4 rounded border-gray-300 text-blue-500 focus:ring-blue-500 dark:border-gray-700"
checked={columns.has(column)}
onChange={() => toggleColumn(column)}
/>
{COLUMN_LABELS[column]}
{column === "last4" && (
<span className="text-xs text-gray-400">off by default</span>
)}
</label>
))}
</fieldset>

<fieldset className="flex flex-col gap-2">
<legend className="text-sm font-medium text-gray-900 dark:text-gray-50">
Scope
</legend>
<label className="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
<input
type="radio"
name="export-scope"
className="size-4 border-gray-300 text-blue-500 focus:ring-blue-500 dark:border-gray-700"
checked={scope === "current"}
onChange={() => setScope("current")}
/>
Current filter
<span className="text-xs text-gray-400">
{counts.current === undefined
? "…"
: `${counts.current.toLocaleString()} rows`}
</span>
</label>
<label className="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
<input
type="radio"
name="export-scope"
className="size-4 border-gray-300 text-blue-500 focus:ring-blue-500 dark:border-gray-700"
checked={scope === "all"}
onChange={() => setScope("all")}
/>
All payments
<span className="text-xs text-gray-400">
{counts.all === undefined ? "…" : `${counts.all.toLocaleString()} rows`}
</span>
</label>
</fieldset>
</DrawerBody>
<DrawerFooter>
<Button
variant="primary"
className="gap-2"
disabled={columns.size === 0}
asChild={columns.size > 0}
>
{columns.size > 0 ? (
<a href={`/api/payments/export?${exportParams.toString()}`}>
Download{rowCount !== undefined ? ` (${rowCount.toLocaleString()})` : ""}
</a>
) : (
<span>Download</span>
)}
</Button>
</DrawerFooter>
</DrawerContent>
</Drawer>
)
}
12 changes: 2 additions & 10 deletions build-battle/merchant-console/src/app/payments/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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")[] = [
Expand Down Expand Up @@ -65,15 +65,7 @@ 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={query.toString()} />
</div>

<TableRoot className="border-t border-gray-200 dark:border-gray-800">
Expand Down
54 changes: 51 additions & 3 deletions build-battle/merchant-console/src/lib/csv.test.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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")
})
})
Loading