Skip to content

Table export with user-chosen columns — design

Date: 2026-10-09. Status: awaiting owner review.

Two triggers. (1) D365 Finance journal uploads (salary JV, reclass, project fee template) carry ten-odd dimension columns (LOB, SUBLOB, DEPT, project, region, …) that the journal_entries import ignores. (2) Exports are inconsistent: the claim export has an org-wide admin column layout (CSV only), reports have fixed columns, petty cash and parties each have their own download code. Users cannot choose columns when they export.

  • The user picks columns per export, at export time.
  • Presets are private by default, optionally shared with the organization; only the owner edits.
  • First batch: expense claims, journal entries, petty cash, customers and suppliers, plus axis.<key> dimension columns on the journal import. “Everything” is reached module by module, made visible by a drift test.
  • Synchronous, row cap 20,000 (the claim export’s precedent); no background jobs.

Each export kind registers: key; required read permission; column catalogue (key, default heading, type text|date|money, default-on); a row source taking the list’s filters and streaming rows. Dynamic columns (analytic axes, custom fields) come from the organization at request time. claims moves its catalogue here; the org claim layout becomes its default selection. journal_entries is one row per line with an axis.<key> column per analytic axis. Writers are the existing reporting/render csv.go and xlsx.go.

  • GET /v1/exports/kinds/{kind}/columns — catalogue incl. dynamic columns.
  • The file itself comes from each owning module’s existing export route (modules may not import each other, and row visibility rules live in the module), which takes columns, headings and format=csv|xlsx query parameters; absent columns = the kind’s default selection. Over 20,000 rows: refused (422 export_too_many_rows) with a message to narrow the filters. (Amended 2026-10-09 while planning; replaces a single POST /v1/exports/{kind}.)
  • GET/POST/PUT/DELETE /v1/exports/presets — {kind, name, columns, headings, shared}. New table with org_id, ENABLE+FORCE RLS, policy org_isolation. Shared presets are readable org-wide; edit/delete by owner only.
  • /expenses/claims/export stays as a thin wrapper.
  • Columns the caller may not read are neither listed nor served; the kind’s existing read permission gates everything.

One ExportDialog (kind, current filters): column checklist, reorder, editable headings, CSV/XLSX, save as preset with share toggle. Replaces the claim Export button, petty cash and party download code. en/ms/zh strings (ms/zh need native review).

journal_entries import accepts axis.<key> columns (same prefix mechanism as cf.). Each cell is an analytic account code on that axis, posted as a 100% share; unknown code → per-line error naming the column. Export and import share column names, so an exported file imports back unchanged. D365 headings (LOB, SUBLOB, DEPT, …) are heading aliases on a built-in preset.

  • Money is a decimal string, never a float, in CSV and XLSX (test both).
  • Preset table covered by the every-table tenant-isolation test.
  • Unauthorised column/kind: assert present for an authorised user first, then absent (presence before absence); watch it fail once.
  • Drift: every registered kind has a catalogue and permission; a second test lists list pages without an export.
  • Round trip: export journals → import → identical ledger lines and distributions.

Background/scheduled/emailed exports; PDF (stays with reports); reversing-entry column on journal import; project billing-lines import (Capcom fee template); multi-currency/tax columns of D365.