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
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

- 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.
53 changes: 45 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,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
}
165 changes: 165 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,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<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",
}

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<ExportColumn[]>([
...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 (
<Drawer>
<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 className="sm:max-w-md">
<DrawerHeader>
<DrawerTitle>Export payments</DrawerTitle>
</DrawerHeader>

<DrawerBody className="space-y-6">
<fieldset>
<legend className="text-sm font-medium text-gray-900 dark:text-gray-50">
Scope
</legend>
<div className="mt-2 space-y-2">
{(
[
["filtered", "Current filter", filteredTotal],
["all", "All payments", allTotal],
] as const
).map(([value, label, total]) => (
<label
key={value}
htmlFor={`scope-${value}`}
className="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300"
>
<input
type="radio"
id={`scope-${value}`}
name="scope"
value={value}
checked={scope === value}
onChange={() => setScope(value)}
className="size-4 accent-blue-500"
/>
{label}
<span className="text-gray-500">
({total.toLocaleString()} rows)
</span>
</label>
))}
</div>
</fieldset>

<fieldset>
<legend className="text-sm font-medium text-gray-900 dark:text-gray-50">
Columns
</legend>
<div className="mt-2 space-y-2">
{EXPORT_COLUMNS.map((column) => (
<label
key={column}
htmlFor={`column-${column}`}
className="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300"
>
<input
type="checkbox"
id={`column-${column}`}
checked={columns.includes(column)}
onChange={() => toggle(column)}
className="size-4 rounded accent-blue-500"
/>
{LABELS[column]}
</label>
))}
</div>
{columns.length === 0 && (
<p className="mt-2 text-sm text-red-600 dark:text-red-500">
Select at least one column to export.
</p>
)}
</fieldset>
</DrawerBody>

<DrawerFooter>
<p className="mr-auto self-center text-sm text-gray-500">
{rowCount.toLocaleString()} rows · {columns.length} columns
</p>
{columns.length === 0 ? (
<Button disabled>Download</Button>
) : (
<Button asChild>
<a href={href()} download>
Download
</a>
</Button>
)}
</DrawerFooter>
</DrawerContent>
</Drawer>
)
}
18 changes: 8 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 { PaymentsExportDialog } from "./export-dialog"
import { PaymentsFilterBar } from "./filter-bar"

const STATUSES: (PaymentStatus | "all")[] = [
Expand Down Expand Up @@ -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][],
)
Expand All @@ -65,15 +67,11 @@ 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>
<PaymentsExportDialog
query={query.toString()}
filteredTotal={total}
allTotal={allTotal}
/>
</div>

<TableRoot className="border-t border-gray-200 dark:border-gray-800">
Expand Down
66 changes: 65 additions & 1 deletion 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 @@ -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", () => {
Expand All @@ -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",
)
})
})
Loading