Skip to content

NWP-101: add column and scope options to the payments export - #225

Open
JemReal wants to merge 2 commits into
JJFromTenex:mainfrom
JemReal:NWP-101-export-options
Open

JemReal wants to merge 2 commits into
JJFromTenex:mainfrom
JemReal:NWP-101-export-options

Conversation

@JemReal

@JemReal JemReal commented Sep 23, 2026

Copy link
Copy Markdown

Ticket

Closes NWP-101

What changed

Export on /payments now opens a dialog instead of downloading straight away. Ops picks which columns go in the file, with card last four off by default. They also pick the scope, either the current filter or all payments, and each option shows how many rows it will produce. The server checks the columns and scope against allowlists before reading anything. The file is named after the scope and the UTC date, for example payments-disputed-2026-09-23.csv. Both scopes go through the existing query builder without paging, so the file holds every matching row, not just the page on screen.

Business impact

Ops can send merchant exports without deleting card numbers by hand first, which removes 3–4 hours a month of cleanup and the risk of the near-miss from last quarter.

How I verified it

  • npm test: 28 passing before the change, and 35 passing after (Test Files 3 passed (3) · Tests 35 passed (35)). The 9 existing csv.test.ts cases pass unchanged, and 7 new ones cover the column parser, the default set, the empty selection, the scope and the filename.

  • npx tsc --noEmit is clean, and npm run lint reports no warnings or errors.

  • In the running dev server, on /payments?status=disputed:

    • Clicked Export. The dialog "Export payments" opened with focus inside it and "Card last four" unchecked. "Current filter · 33 rows" matched the table footer ("33 payments · page 1 of 2"), and "All payments" showed 1,658 rows.
    • Unchecked every column. Download became a disabled button and "Choose at least one column." appeared. Pressing Escape closed the dialog and returned focus to Export.
  • Called the route directly:

    • columns=amount,id,status returned headers amount,id,status in that order and 33 rows, not the 20 on page 1. The filename was payments-disputed-2026-09-23.csv and the file contained no 4242.
    • With page=2 in the query and no columns, it still returned all 33 rows with the default nine columns (no last4).
    • scope=all returned 1,658 rows in payments-all-2026-09-23.csv.
    • columns=id,drop%20table, scope=bogus and columns= each returned 400 with a JSON error message.
  • npm test passes

  • New behavior is covered by a test (column parsing, scope and filename. The route and dialog were checked by hand; see notes)

  • Checked it in the browser

Acceptance criteria

  • Ops can choose which columns are included. Card last four is off by default. This is enforced on the server too: with no columns param, the default set excludes last4.
  • Ops can choose scope: current filter or all payments. Current filter is the default, and the row count for each scope is visible before download.
  • The filename reflects the scope and the date, e.g. payments-disputed-2026-09-23.csv. A current filter with no status gives payments-filtered-…, and all payments gives payments-all-…. Merchant IDs and search text are never put into the filename.
  • Amounts stay in minor units internally and are formatted once on the way out, with currency in its own column. This was already true of toCsv and is unchanged; see notes.
  • Deselecting every column disables Download rather than producing an empty file. The route also rejects an empty columns= with a 400.

Bugs fixed along the way

  • src/app/payments/page.tsx built its filters by hand instead of calling parseFilters, so it dropped sort, direction, from and to, which the export route honours. The row count in the dialog could therefore disagree with the rows in the file. The page now uses parseFilters, the same parser as the route.
  • .claude/rules/components.md listed a Dialog component that didn't exist. I added src/components/Dialog.tsx, built on the existing @radix-ui/react-dialog dependency the same way Drawer.tsx is, and corrected the rule.
  • The merchant console CLAUDE.md and README.md said seed data is JSON. It's generated in src/data/generate.ts. Both are corrected.

Notes for the reviewer

  • Known bug left alone: sortPayments in src/data/queries.ts:81 sorts amounts as strings, so amount order is wrong in both the table and the export. It's outside this ticket, and I didn't touch it.
  • Amount cell format: the amount cell is still formatted with a symbol ($250.00), beside a separate currency column. csv.test.ts locks in that format and says NWP-101 "changes which columns ship, not how a cell is written", so I kept it.
  • Defaults: toCsv's own default is still all ten columns, because an existing test locks that in. The last-four-off default lives in DEFAULT_EXPORT_COLUMNS, which both the route and the dialog use.
  • No automated tests for the route or dialog: Vitest runs in plain Node with no DOM, and there are no route tests in the repo. I checked those by hand as described above.
  • Plan location: I planned this in Claude Code's plan mode, not with /spec, so there's no docs/specs/NWP-101-*.md.
  • Second commit: the docs commit is separate from the feature. It adds a Release Standards section to the root CLAUDE.md and the Business impact field this PR uses to the PR template. Drop it if you'd rather review it on its own branch.
  • Out of scope: /ship-ready flagged problems that predate this ticket: float money math and local-time day grouping in src/data/metrics.ts, and missing labels on the filter-bar inputs. They're outside this ticket and not addressed here.
  • Not committed: package-lock.json was already modified before this work and isn't part of this PR.

🤖 Generated with Claude Code

jemreal-afncorp and others added 2 commits September 22, 2026 18:31
Export now opens a dialog where ops chooses columns (card last four off
by default) and scope (current filter or all payments), with row counts
shown before download. The route validates columns and scope against
allowlists, returns 400 on bad input, and names the file by scope and
UTC date. Both scopes go through the existing query builder, unpaged.

Adds a Dialog primitive on @radix-ui/react-dialog, and switches the
payments page to parseFilters so the dialog's count matches the file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Merchant console CLAUDE.md and README described seed data as JSON; it is
generated in src/data/generate.ts. Adds test, lint and build commands and
how pages reach the data. components.md listed a Dialog that did not
exist. Root CLAUDE.md gains Release Standards, and the PR template gains
a Business impact field.

Co-Authored-By: Claude Opus 5.5 <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.

2 participants