Skip to content

NWP-101: add export options - #162

Open
douglasgimli wants to merge 11 commits into
JJFromTenex:mainfrom
douglasgimli:NWP-101-export-options
Open

NWP-101: add export options#162
douglasgimli wants to merge 11 commits into
JJFromTenex:mainfrom
douglasgimli:NWP-101-export-options

Conversation

@douglasgimli

Copy link
Copy Markdown

Ticket

Closes NWP-101

What changed

Ops can now decide what goes into a payments export before the file is created. The Export button on the payments page opens a panel with two choices: which columns to include, with the card last four switched off unless someone turns it on, and whether to export the current filter or every payment. The panel shows how many rows the file will contain, the filename says which scope and date it covers, and an empty column selection cannot produce a file.

How I verified it

All commands run in this session from build-battle/merchant-console on this branch at 6c9d36d.

  • npm testTest Files 4 passed (4) / Tests 50 passed (50). Main had 28 passing in 3 files; nothing was removed or skipped.

  • npm run lint✔ No ESLint warnings or errors

  • npm run build✓ Compiled successfully, exit code 0

  • Browser, http://localhost:3000/payments?status=disputed — Export opens a panel titled "Export payments". With "Current filter" selected it reads "33 rows", matching the table footer; switching to "All payments" reads "1,658 rows". "Card last four" is the only unchecked column. Unchecking every column turns Download into a disabled button and shows "Select at least one column to download." Escape closes the panel. The download itself was not clicked; file contents were checked with curl instead.

  • curl -sI "http://localhost:3000/api/payments/export?status=disputed"content-disposition: attachment; filename="payments-disputed-2026-09-10.csv"; with ?scope=all the filename is payments-all-2026-09-10.csv; with ?merchantId=mch_01 it is payments-filtered-2026-09-10.csv.

  • curl -s ".../export?columns=id,pan"{"message":"Unknown column \"pan\""} with status 400; ?columns= returns 400 Select at least one column; ?scope=nope returns 400 Unknown scope "nope".

  • curl -s ".../export?scope=all&status=disputed" | wc -l — 1658 rows against 33 for ?status=disputed, so scope=all ignores the filter and nothing is paginated.

  • npm test passes

  • New behavior is covered by a test

  • Checked it in the browser

Acceptance criteria

  • Ops can choose which columns are included. Card last-four is off by default. — csv.test.ts "defaults to every column except the card last four, in the standard order" and "accepts a subset and keeps the requested order"; route.test.ts "defaults to every column except the card last four"; browser: last four unchecked on open.
  • Ops can choose scope: current filter or all payments. Current filter is the default, and the row count is visible before download. — csv.test.ts "defaults to the current filter when the param is absent"; route.test.ts "ignores the filters when scope is all"; browser: "Current filter" preselected, "33 rows" then "1,658 rows".
  • The filename reflects the scope and the date, for example payments-disputed-2026-08-13.csv. — csv.test.ts "puts the scope token before the date, as in the ticket example" asserts that exact string; route.test.ts "names the file after the status filter and the UTC date"; curl output above.
  • Amounts stay in minor units internally and are formatted once on the way out, with currency in its own column. — route.test.ts "formats the amount once and keeps the currency in its own column"; the serializer still formats through the existing money helper and the existing $250.00 and "$1,234,567.89" tests are unchanged.
  • Deselecting every column disables Download rather than producing an empty file. — browser: disabled Download plus message; route.test.ts "rejects an empty column selection with a 400" so a hand-built URL cannot produce one either.

Definition of done: the column serializer tests live in the existing src/lib/csv.test.ts (9 tests before, 22 after); branch and every commit subject carry NWP-101.

Bugs fixed along the way

  • build-battle/merchant-console/tailwind.config.ts — both drawer keyframes moved the panel from translateX(-100%), so a Drawer anchored on the right slid in from the left edge and crossed the screen. Root cause: the sign is flipped relative to Tremor's upstream Drawer. This is the first feature to open the Drawer on desktop, which is why nobody had seen it; the mobile sidebar uses the same panel and benefits too. Now 100%, verified by reading the live @keyframes rules in the browser.

Deliberately not done

  • Amount sorting compares amounts as strings in src/data/queries.ts, so sort=amount orders 9 above 100. The export inherits it when that sort is requested. Out of this ticket; it needs its own failing-then-passing test and PR.
  • The three metrics defects tracked as NWP-102 are untouched.
  • CSV header names stay as the existing machine keys (created_at, last4), because the existing tests pin that contract; the panel shows readable labels instead.
  • Nothing remembers the last-used column set. There is no persistence layer yet (NWP-203).
  • The payments page still does its own status check instead of calling the shared filter parser. Pre-existing and unrelated to the export, so left alone to keep the diff reviewable.

Notes for the reviewer

  • The plan is in docs/specs/NWP-101-export-options.md and was written before the code.
  • Unknown columns, an empty list, or an unknown scope return 400 with a message rather than silently defaulting the way the list endpoint's filter parser does. That is deliberate, per the api-routes rule, and noted in the spec.
  • scope=all drops the filter fields but still goes through the shared filter and sort functions, so there is one query path, and sort/direction are still honoured.
  • An independent mutation check during review found the UTC filename test could not tell UTC from local time on a machine west of Greenwich; the final commit adds a 00:30Z instant so it can.

🤖 Generated with Claude Code

douglasgimli and others added 11 commits September 10, 2026 10:19
Plan for the payments export options dialog: server-side export with
validated columns and scope, reusing the query builder behind
GET /api/payments. Follows docs/specs/TEMPLATE.md.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Add DEFAULT_EXPORT_COLUMNS (every column except last4) and
parseExportColumns, which checks the client-supplied column list against
EXPORT_COLUMNS and rejects empty, unknown, or repeated names with a
user-safe message. Tests extend src/lib/csv.test.ts.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Add EXPORT_SCOPES with parseExportScope (absent means current filter,
anything but current/all is rejected) and exportScopeToken, which turns
the validated scope and filters into the word for the filename: the
status when one is set, 'filtered' for other filters, otherwise 'all'.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
exportFilename now takes the scope token, producing
payments-<token>-<UTC date>.csv as the ticket asks. The route passes
the current-filter token so the build stays green; the full scope and
column handling lands in the next commit.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
GET /api/payments/export now reads columns and scope from the query,
checks both against the allowlists in src/lib/csv.ts, and answers 400
with a user-safe message on anything else. scope=all drops the filter
fields but still runs through filterPayments and sortPayments, so there
is one query path. Rows are never paginated. Route tests cover the
happy path, both scopes, the filename header, and every rejection.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Client component on the existing Drawer: scope radios (current filter
by default), a live row count read from GET /api/payments, column
checkboxes with the card last four off by default, and a Download link
to GET /api/payments/export that is disabled while no column is
selected. Every input has a label; Escape and focus return come from
Radix.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The Export button on /payments now opens the options dialog instead of
downloading straight away. The current filter query is passed through
without the page number, so the export and the row count always cover
the whole filtered set.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Mark the spec done and record what actually happened: the route test
file stayed because NextRequest runs under vitest, npm test went from
28 to 50 passing, and both open questions were decided before code.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Both drawer keyframes moved the panel from translateX(-100%), so a
panel anchored on the right entered from the left edge and crossed the
screen. Use 100%, matching Tremor's upstream Drawer. Surfaced by the
export options dialog; the mobile sidebar uses the same content
positioning and benefits too.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The single 23:00Z instant passed under a local-date implementation on
any machine at or west of UTC. Adding a 00:30Z instant makes the pair
fail for every non-zero offset. Found by the verifier's mutation check.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant