Conversation
The payments export was fixed: every column, current filter only. The card last four shipped in every file, so anything going to a merchant was edited by hand first. Adds an options dialog on the existing Export button: - Columns are selectable, with the card last four off by default (DEFAULT_EXPORT_COLUMNS in src/lib/csv.ts). - Scope is current filter or all payments, with both row counts shown before download. Both scopes go through the existing query builder, so an export is never limited to the current page. - parseExportColumns validates column names against the allowlist server side; the route returns 400 rather than serving an empty file. - The filename names the scope: payments-disputed-2026-08-13.csv. Amounts stay in minor units and are still formatted once, in cell(), beside their own currency column. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Records the rules we are working to: no direct commits to main, test evidence attached before merging, and a one-line business impact summary on every pull request. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Ticket
Closes NWP-101
What changed
The payments export was fixed — every column, current filter only — so the card last four shipped in every file and anything going to a merchant was cleaned up by hand first. The Export button on
/paymentsnow opens an options dialog: ops picks the columns (card last four off by default) and the scope (current filter or all payments), sees the row count for both before downloading, and gets a filename that names the scope. Column names are validated server-side against an allowlist, and both scopes run through the existing query builder rather than a second filter path.Business impact: removes the 3–4 hours a month ops spends hand-editing exports, and removes the class of near-miss where an unedited file carrying card last four goes to the wrong merchant.
How I verified it
npm test— 37 passed (37), 3 files. Baseline before the change was 28; the 9 new tests are all insrc/lib/csv.test.ts.npx next lintandnpx tsc --noEmitboth clean.Route, via
curl:?columns=nope400 {"message":"Select at least one column to export."}columnsparamid,created_at,merchant,description,status,method,card_brand,amount,currency— nolast4?columns=amount,currency,id$473.78formatted once withcurrencybeside it?status=disputedpayments-disputed-2026-09-10.csv?scope=allpayments-all-2026-09-10.csv?search=orderpayments-filtered-2026-09-10.csvscope=allrow countIn the browser on
/payments?status=disputed: Export opens the dialog;Card last fourstarts unchecked; scope defaults to Current filter with both counts visible (33 / 1,658). Unticking every column turns Download into a disabled button with an inline message — there is no clickable path to an empty file. The Download href readscolumns=id%2Cstatus&scope=filtered, and switching to All payments drops the filter params. Escape closes the dialog and focus returns to the trigger.npm testpassesAcceptance criteria
payments-disputed-2026-08-13.csv.Bugs fixed along the way
None fixed — but three found, all outside this ticket, all pre-existing:
src/data/metrics.ts:25— bucket keys come fromlastUtcDays()(UTC) on line 18, but payments are bucketed withnew Date(...).toLocaleDateString("en-CA"), which is server local time. West of UTC, late-evening payments land in the wrong key or match no bucket at all and hit thecontinueon line 27 — silently dropped from the volume chart.utcDayKeyinsrc/lib/dates.tsis the fix.src/data/metrics.ts:31,34— money accumulated as a float in major units (payment.amount / 100) and re-multiplied viaMath.round(bucket.captured * 100). The rounding hides drift instead of preventing it; summing the integer minor units needs no rounding.src/data/queries.ts:81—sortPaymentscompares amounts as strings:String(a.amount).localeCompare(String(b.amount)), so9000sorts before25000. The export reusessortPayments, so it inherits this. The inline comment claims it makes the order "match what the table shows," which is a rationalisation — the table is wrong too.Left all three alone: none are in NWP-101's acceptance criteria, and fixing the sort would change the ordering of every existing export in the same PR that changes its columns.
Notes for the reviewer
Drawer, notDialog..claude/rules/components.mdsayssrc/components/has aDialog— it does not.Drawer.tsxis the Radix Dialog wrapper that exists, and it had no consumers before this; I'm the first. Worth correcting the rule file or adding the component.Checkboxcomponent exists and@radix-ui/react-checkboxis not a dependency, so these are native inputs styled with Tailwind rather than a new dependency. Every one has anhtmlForlabel and both groups have a<legend>.parseExportColumnsreturning[]is what actually refuses the request. Unknown column names are dropped rather than erroring, so a stale bookmark degrades to fewer columns instead of failing.alldrops the filter params client-side and the route also ignores filters whenscope=all, so the two agree whichever way the URL is constructed.toCsv([payment], [])returns"\n", not""— no header, no cells, just the row separator. I pinned the real behaviour rather than changing the serializer, since it is not a reachable download path.src/app/payments/page.tsx:36-45re-implements the status allowlist inline instead of callingparseFilters, so there are two allowlists for one field. Pre-existing and not a security issue (the values only reach in-memory predicates), but it will drift. Left as-is.docs:) is unrelated to the ticket — it adds a Release Standards section to the rootCLAUDE.md. Happy to split it out if you would rather review it separately.🤖 Generated with Claude Code