Skip to content

NWP-101: add export options - #165

Open
aatumdesai-create wants to merge 3 commits into
JJFromTenex:mainfrom
aatumdesai-create:NWP-101-export-options
Open

NWP-101: add export options#165
aatumdesai-create wants to merge 3 commits into
JJFromTenex:mainfrom
aatumdesai-create:NWP-101-export-options

Conversation

@aatumdesai-create

Copy link
Copy Markdown

Ticket

Closes NWP-101

What changed

Ops can now choose what goes into a payments export instead of always getting all ten columns for whatever filter happens to be on screen. The Export button on /payments opens a drawer with a checkbox per column, a scope choice between the current filter and all payments with the row count next to each, and a Download button that stays disabled until at least one column is ticked. The card last four is unticked by default, so a file can go to a merchant without being cleaned up by hand first, and the filename now says what is in it — payments-disputed-2026-09-10.csv rather than payments-2026-09-10.csv.

Server side, parseExportColumns in src/lib/csv.ts validates the columns parameter against EXPORT_COLUMNS, keeps the order requested, drops names that are not columns, and returns an empty list for an empty selection so the route can answer 400 instead of writing a file with no columns in it. scope=all runs filterPayments({}), so "all payments" still goes through the one query builder rather than reaching for the store. toCsv is unchanged — its signature and its all-columns default both stay, which is why the tests pinning the existing contract still pass.

How I verified it

npm test — 44 passed across 4 files (was 28 across 3):

✓ src/lib/money.test.ts     (12 tests)
✓ src/lib/dates.test.ts      (7 tests)
✓ src/lib/csv.test.ts       (22 tests)
✓ src/data/queries.test.ts   (3 tests)
Test Files  4 passed (4)      Tests  44 passed (44)

npx tsc --noEmit clean, npx next lint reports no warnings or errors.

Against the dev server on :3000, by URL:

Request Result
/api/payments/export 9 columns, no last4
?columns=amount,id exactly those two, in that order
?columns=id,shoe_size,amount 200, unknown name dropped
?columns= and ?columns=nope 400 {"message":"Select at least one column to export."}
?status=disputed payments-disputed-2026-09-10.csv
?scope=all&status=disputed payments-all-2026-09-10.csv, 1658 rows
?merchantId=mch_05 payments-filtered-2026-09-10.csv
no filters payments-2026-09-10.csv
?sort=amount&direction=asc $7.96 first; descending starts £475.55

Row counts were compared against GET /api/payments totals across five filter combinations — none, status=disputed, merchantId=mch_05, from=2026-08-01, search=order&status=captured — and the page total, the API total and the exported row count agree in every case.

  • npm test passes
  • New behavior is covered by a test
  • Checked it in the browser

The browser pass was not done, so the drawer itself is unexercised. Everything above was verified over HTTP and by unit test. What that leaves unconfirmed: that the drawer opens and renders, that last4 appears unticked, that unticking every column visibly disables Download, that the two row counts display correctly, and that Escape closes the drawer and returns focus to the Export button. The underlying route contract behind each of those is tested; the rendering is not.

Acceptance criteria

  • Ops can choose which columns are included. Card last-four is off by default. — parseExportColumns unit tests cover the default set, the opt-in, ordering, unknown names, duplicates and whitespace; confirmed over HTTP. Checkbox rendering not visually confirmed.
  • Ops can choose scope: current filter or all payments. Current filter is the default, and the row count is visible before download. — scope confirmed over HTTP; counts are passed from the server page and match the API totals. The rendered count was not visually confirmed.
  • The filename reflects the scope and the date. — unit tested and confirmed in Content-Disposition for all four cases.
  • Amounts stay in minor units internally and are formatted once on the way out, with currency in its own column. — unchanged behavior; formatMoney is still the only formatter and the existing toCsv tests pass untouched.
  • Deselecting every column disables Download rather than producing an empty file. — the server returns 400, which is tested. The disabled button is the UX half and was not visually confirmed.

Bugs fixed along the way

src/data/queries.tssortPayments ordered amounts as text. It compared with String(a.amount).localeCompare(String(b.amount)), so 900 ranked above 1000. Amounts are integer minor units, so they subtract. The export reuses this sort, so an amount-sorted CSV was wrong in the same way the table was. Covered by a regression test in the new src/data/queries.test.ts.

src/app/payments/page.tsx — a second validation path. The page hand-built its PaymentFilters with its own copy of the status allowlist and read only status, merchantId, search and page, ignoring from, to, sort and direction, all of which the export route's parseFilters honours. That was harmless until this ticket put a row count on screen: at ?from=2026-08-01 the page reported 1,658 payments while the export wrote 167. The page now calls parseFilters, and STATUSES is exported from queries.ts as the single list. Side effect worth knowing: from, to, sort and direction now actually filter and order the table.

Notes for the reviewer

The drawer has no automated test. Vitest here runs environment: "node" with an src/**/*.test.ts include glob — no jsdom, no testing-library, and .tsx is not matched. Rather than add those for one ticket, the testable logic went into pure functions in csv.ts and the server-side 400 is the enforcement behind the disabled button.

disabled on an anchor does nothing, which nearly cost the last acceptance criterion. <Button asChild> forwards disabled onto the <a>, where it is not a valid attribute, and the variant's disabled:pointer-events-none keys off a pseudo-class an anchor never matches — the link stays clickable and looks normal. The footer swaps the element instead, rendering a real <button disabled> when nothing is selected, which is the pattern the pagination controls twelve lines below the Export button already use.

Reused rather than added. There is no Dialog component in src/components.claude/rules/components.md says there is, and that is wrong. Drawer.tsx wraps @radix-ui/react-dialog, so it is the dialog primitive, and it brings the focus trap, Escape handling and close button already wired. No Checkbox, Radio or Label component exists and none of those Radix packages are installed, so the controls are native inputs with real <label htmlFor>, which keeps package.json untouched.

Behavior change on an existing URL. A bookmarked /api/payments/export?status=disputed returned ten columns including last4 before this change and returns nine without it now. That is the intent of the ticket, but it changes what an existing link does.

Found and deliberately not fixed: filterPayments compares full ISO timestamps against from/to with string < and >, so a date-only to=2026-08-13 excludes all of 13 August. Not reachable from the filter bar, which has no date inputs, so it is outside this ticket. Separately, sorting by amount orders across mixed currencies by raw minor units, which is pre-existing and arguably meaningless, but changing it is a product decision rather than a bug fix.

Commits are split three ways — the lockfile name correction, the sort fix, then the feature — so the bug fix can be read on its own.

🤖 Generated with Claude Code

aatumdesai-create and others added 3 commits September 10, 2026 10:52
npm install rewrote the stale "template-planner" name left over from the
Tremor template the console was built on.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
sortPayments compared amounts with String(a.amount).localeCompare(...),
which orders them lexicographically: 900 ranked above 1000. Amounts are
integer minor units, so they subtract.

The export reuses this sort, so an amount-sorted CSV was wrong in the same
way the table was. Adds queries.test.ts to cover it.

Also exports STATUSES, so the payments page can stop keeping a second copy
of the status allowlist.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Ops can now pick the columns and the scope before downloading the payments
CSV, instead of always getting all ten columns for the current filter.

- parseExportColumns validates the columns parameter against EXPORT_COLUMNS
  server-side, keeps the requested order, and drops unknown names
- the card last four is out of the default set and has to be opted into
- scope=all runs filterPayments({}) so "all payments" still goes through the
  one query builder
- the filename carries the scope: payments-disputed-2026-08-13.csv
- an empty column selection returns 400 rather than a headerless file
- the Export button opens a Drawer (Radix Dialog) with the column checkboxes,
  the scope radios and a row count for each

toCsv keeps its existing signature and its all-columns default, so the tests
pinning the current contract still pass.

Co-Authored-By: Claude Opus 5 (1M context) <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