Skip to content

Table Export Foundation Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: A shared “choose your columns” export: a server-side column catalogue per export kind, user-saved private/shareable presets, CSV and XLSX output, one ExportDialog in the web app — proven end to end on the expense-claims export.

Architecture: platform/tableexport owns the types (Column, Choice, Kind), the kind registry, Resolve (a user’s choice against a catalogue) and Write (rows to CSV/XLSX through reporting/render). platform/exportpreset stores presets (a copy of the savedreport pattern). A new modules/exports serves the catalogue and preset routes. Row sourcing stays in each owning module (modules may not import each other), so each module’s existing export route takes columns, headings and format query parameters. Claims is the first kind; journal entries, petty cash and parties follow in a later plan.

Tech Stack: Go (gin, pgx, goose, oapi-codegen), React/TypeScript (react-query, openapi-fetch), reporting/render.

Spec: ../specs/2026-10-09-table-export-design.md. Deviation from the spec, to be reflected in it: the spec’s single POST /v1/exports/{kind} becomes GET <owning module's existing export route>?columns=…&headings=…&format=…, because row sources need module internals (claim visibility rules live in modules/expenses) and modules/ may not import modules/. Catalogue and presets stay generic.

  • Dependencies point modules/ → platform/ → kernel/ only; a module never imports another module.
  • A float never touches money: money cells are money.Amount strings (render.Money); XLSX holds the decimal.
  • Every table has org_id, ENABLE and FORCE ROW LEVEL SECURITY, policy named org_isolation.
  • Every request runs in a transaction, reads included (InTx / InReadOnlyTx).
  • Endpoints are declared in api/openapi.yaml first; run make generate after editing it.
  • Every user-facing string is en/ms/zh; ms/zh unreviewed strings are noted in ROADMAP.
  • Rows capped at 20,000 per export (exportMaxClaims precedent); over the cap → refuse, never truncate silently.
  • Migration numbers: new range 1190–1199 (add to migrationRanges).
  • Execute in a worktree cut from a base that contains the owner’s in-flight claims work (web/src/features/claims/*, internal/modules/expenses/claim_handlers.go) — it touches the same files.
  • Absence assertions need a presence assertion first (AGENTS.md); watch each new guard fail once.
  • While iterating test only touched packages; full go test ./... -p 1 once before the last commit.

Task 1: Plain tables in the CSV and XLSX writers

Section titled “Task 1: Plain tables in the CSV and XLSX writers”

The writers print a title row, key/value lines and a blank row above the header. A data export needs its header on row 1 (spreadsheet tools, D365 and re-import all expect that).

Files:

  • Modify: internal/platform/reporting/render/render.go (Meta)
  • Modify: internal/platform/reporting/render/csv.go (Begin)
  • Modify: internal/platform/reporting/render/xlsx.go (Begin)
  • Test: internal/platform/reporting/render/plain_test.go

Interfaces:

  • Produces: render.Meta.Plain bool — when true, CSV and XLSX write only the header row and data rows.

  • Step 1: Failing test

package render_test
import (
"archive/zip"
"bytes"
"io"
"testing"
"github.com/stretchr/testify/require"
"github.com/mcom/erp/internal/kernel/money"
"github.com/mcom/erp/internal/platform/reporting/render"
)
func build(t *testing.T, f render.Format, plain bool) []byte {
t.Helper()
w, _, _, err := render.New(f)
require.NoError(t, err)
require.NoError(t, w.Begin(render.Meta{Title: "Claims", Plain: plain}, []render.Column{{Title: "Ref"}, {Title: "Amount", Numeric: true}}))
require.NoError(t, w.Row([]render.Cell{render.Text("C-1"), render.Money(money.MustParse("12.5"))}))
out, err := w.Finish()
require.NoError(t, err)
return out
}
func TestPlainCSVStartsWithTheHeader(t *testing.T) {
// PRESENCE: without Plain the title row is there.
require.Contains(t, string(build(t, render.FormatCSV, false)), "Report,Claims")
got := string(build(t, render.FormatCSV, true))
require.Equal(t, "\xef\xbb\xbfRef,Amount\r\nC-1,12.5000\r\n", got)
}
func TestPlainXLSXHasNoTitleRow(t *testing.T) {
sheet := func(b []byte) string {
zr, err := zip.NewReader(bytes.NewReader(b), int64(len(b)))
require.NoError(t, err)
for _, f := range zr.File {
if f.Name == "xl/worksheets/sheet1.xml" {
rc, _ := f.Open()
defer rc.Close()
s, _ := io.ReadAll(rc)
return string(s)
}
}
t.Fatal("no sheet1")
return ""
}
require.Contains(t, sheet(build(t, render.FormatXLSX, false)), "Claims") // presence
require.NotContains(t, sheet(build(t, render.FormatXLSX, true)), "Claims")
require.Contains(t, sheet(build(t, render.FormatXLSX, true)), `<row r="1">`)
}

(money.MustParse — if it does not exist, use a, err := money.Parse("12.5"); require.NoError(t, err).)

  • Step 2: Run, expect FAIL — go test ./internal/platform/reporting/render/ -run Plain -count=1 → compile error unknown field Plain.

  • Step 3: Implement

render.go, in Meta add:

// Plain writes only the header row and the data rows (CSV and XLSX), for a table another tool
// reads: no title, no key/value lines, no blank row.
Plain bool

csv.go Begin: wrap the title/fields/blank-row writes:

if !m.Plain {
if err := c.w.Write([]string{"Report", safeText(m.Title)}); err != nil {
return err
}
for _, f := range m.Fields {
if err := c.w.Write([]string{safeText(f[0]), safeText(f[1])}); err != nil {
return err
}
}
if err := c.w.Write([]string{""}); err != nil {
return err
}
}

xlsx.go Begin: wrap the title write, the fields loop and the blank-row write in if !m.Plain { … } (keep the header write after it unconditional).

  • Step 4: Run, expect PASS — same command, plus go test ./internal/platform/reporting/... -count=1 (existing report output unchanged).

  • Step 5: Commit

Terminal window
git add internal/platform/reporting/render
git commit -m "render: Meta.Plain writes a bare header-and-rows table
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>"

Task 2: platform/tableexport — catalogue, choice, writer

Section titled “Task 2: platform/tableexport — catalogue, choice, writer”

Files:

  • Create: internal/platform/tableexport/tableexport.go
  • Test: internal/platform/tableexport/tableexport_test.go

Interfaces:

  • Consumes: render.New, render.Meta{Plain:true}, render.Text, render.Money, money.Parse.
  • Produces:
type Type string
const (TypeText Type = "text"; TypeMoney Type = "money")
type Column struct { Key, Label string; Type Type; Default bool } // Label = built-in English heading
type Choice struct { Key string `json:"key"`; Heading string `json:"heading,omitempty"` }
type Kind struct {
Key string
Permission string // the read key of the data; also gates the catalogue and presets of this kind
Columns func(ctx context.Context, tx pgx.Tx) ([]Column, error)
}
func Register(k Kind) // panics on duplicate key (init-time programming error)
func Lookup(key string) (Kind, bool)
func Kinds() []Kind // sorted by key
func Defaults(cat []Column) []Choice // the Default columns, catalogue order
func Resolve(cat []Column, chosen []Choice) ([]Column, error) // Label replaced by the chosen heading
func Write(f render.Format, cols []Column, rows []map[string]string) (body []byte, contentType, ext string, err error)
var ErrNoColumns, ErrUnknownColumn, ErrDuplicateColumn, ErrHeading, ErrTooManyRows error
const MaxRows = 20000
func ProblemCode(err error) (code string, ok bool) // errors.go: the 422 code for a refusal above
  • Step 1: Failing tests
package tableexport_test
import (
"testing"
"github.com/stretchr/testify/require"
"github.com/mcom/erp/internal/platform/reporting/render"
"github.com/mcom/erp/internal/platform/tableexport"
)
var cat = []tableexport.Column{
{Key: "ref", Label: "Claim no.", Type: tableexport.TypeText, Default: true},
{Key: "amount", Label: "Amount", Type: tableexport.TypeMoney, Default: true},
{Key: "remarks", Label: "Remarks", Type: tableexport.TypeText},
}
func TestDefaultsAreTheDefaultColumnsInCatalogueOrder(t *testing.T) {
require.Equal(t, []tableexport.Choice{{Key: "ref"}, {Key: "amount"}}, tableexport.Defaults(cat))
}
func TestResolveKeepsChosenOrderAndRenamesHeadings(t *testing.T) {
cols, err := tableexport.Resolve(cat, []tableexport.Choice{{Key: "amount", Heading: "Jumlah"}, {Key: "ref"}})
require.NoError(t, err)
require.Equal(t, "Jumlah", cols[0].Label)
require.Equal(t, "Claim no.", cols[1].Label)
}
func TestResolveRefusals(t *testing.T) {
_, err := tableexport.Resolve(cat, nil)
require.ErrorIs(t, err, tableexport.ErrNoColumns)
_, err = tableexport.Resolve(cat, []tableexport.Choice{{Key: "nope"}})
require.ErrorIs(t, err, tableexport.ErrUnknownColumn)
_, err = tableexport.Resolve(cat, []tableexport.Choice{{Key: "ref"}, {Key: "ref"}})
require.ErrorIs(t, err, tableexport.ErrDuplicateColumn)
_, err = tableexport.Resolve(cat, []tableexport.Choice{{Key: "ref", Heading: string(make([]byte, 101))}})
require.ErrorIs(t, err, tableexport.ErrHeading)
}
func TestWriteMoneyIsExactAndTextIsGuarded(t *testing.T) {
cols, err := tableexport.Resolve(cat, tableexport.Defaults(cat))
require.NoError(t, err)
body, ct, ext, err := tableexport.Write(render.FormatCSV, cols, []map[string]string{
{"ref": "=cmd", "amount": "-1234.5"},
{"ref": "C-2", "amount": ""},
})
require.NoError(t, err)
require.Equal(t, "csv", ext)
require.Contains(t, ct, "text/csv")
require.Equal(t, "\xef\xbb\xbfClaim no.,Amount\r\n'=cmd,-1234.5000\r\nC-2,\r\n", string(body))
}
func TestWriteXLSX(t *testing.T) {
cols, _ := tableexport.Resolve(cat, tableexport.Defaults(cat))
body, _, ext, err := tableexport.Write(render.FormatXLSX, cols, []map[string]string{{"ref": "C-1", "amount": "12.5"}})
require.NoError(t, err)
require.Equal(t, "xlsx", ext)
require.Greater(t, len(body), 100)
}
func TestWriteRefusesPDFAndTooManyRows(t *testing.T) {
cols, _ := tableexport.Resolve(cat, tableexport.Defaults(cat))
_, _, _, err := tableexport.Write(render.FormatPDF, cols, nil)
require.Error(t, err)
_, _, _, err = tableexport.Write(render.FormatCSV, cols, make([]map[string]string, tableexport.MaxRows+1))
require.ErrorIs(t, err, tableexport.ErrTooManyRows)
}
  • Step 2: Run, expect FAIL (package missing): go test ./internal/platform/tableexport/ -count=1.

  • Step 3: Implement tableexport.go:

// Package tableexport is the shared half of "export this list as a spreadsheet": a column catalogue
// per export kind, a user's choice of columns against it, and the CSV/XLSX writer. It reads no
// records: each owning module supplies rows, because what a caller may see is that module's rule.
package tableexport
import (
"context"
"errors"
"fmt"
"sort"
"unicode/utf8"
"github.com/jackc/pgx/v5"
"github.com/mcom/erp/internal/kernel/money"
"github.com/mcom/erp/internal/platform/reporting/render"
)
// MaxRows bounds one export; the file is built in memory.
// ponytail: in-memory, 20,000 rows; stream or run as a background job if it must grow.
const MaxRows = 20000
type Type string
const (
TypeText Type = "text"
TypeMoney Type = "money"
)
// Column is one column an export kind can carry. Label is the built-in English heading.
type Column struct {
Key string
Label string
Type Type
Default bool
}
// Choice is a user's pick: a catalogue key and, optionally, their own heading.
type Choice struct {
Key string `json:"key"`
Heading string `json:"heading,omitempty"`
}
// Kind is one thing that can be exported. Permission is the read key of its data; the catalogue and
// presets of the kind need it too, so choosing columns never widens what a caller can see.
type Kind struct {
Key string
Permission string
Columns func(ctx context.Context, tx pgx.Tx) ([]Column, error)
}
var (
ErrNoColumns = errors.New("tableexport: choose at least one column")
ErrUnknownColumn = errors.New("tableexport: no such column")
ErrDuplicateColumn = errors.New("tableexport: a column is chosen twice")
ErrHeading = errors.New("tableexport: a heading is at most 100 characters")
ErrTooManyRows = errors.New("tableexport: too many rows; narrow the filters")
)
var kinds = map[string]Kind{}
// Register adds a kind; called from init(), so a duplicate key is a programming error.
func Register(k Kind) {
if _, dup := kinds[k.Key]; dup {
panic("tableexport: kind registered twice: " + k.Key)
}
kinds[k.Key] = k
}
func Lookup(key string) (Kind, bool) { k, ok := kinds[key]; return k, ok }
func Kinds() []Kind {
out := make([]Kind, 0, len(kinds))
for _, k := range kinds {
out = append(out, k)
}
sort.Slice(out, func(i, j int) bool { return out[i].Key < out[j].Key })
return out
}
// Defaults is the catalogue's default columns in catalogue order.
func Defaults(cat []Column) []Choice {
var out []Choice
for _, c := range cat {
if c.Default {
out = append(out, Choice{Key: c.Key})
}
}
return out
}
// Resolve checks chosen against the catalogue and returns the columns in the chosen order, each
// with the chosen heading (or its built-in one).
func Resolve(cat []Column, chosen []Choice) ([]Column, error) {
if len(chosen) == 0 {
return nil, ErrNoColumns
}
byKey := make(map[string]Column, len(cat))
for _, c := range cat {
byKey[c.Key] = c
}
seen := map[string]bool{}
out := make([]Column, 0, len(chosen))
for _, ch := range chosen {
c, ok := byKey[ch.Key]
if !ok {
return nil, fmt.Errorf("%w: %s", ErrUnknownColumn, ch.Key)
}
if seen[ch.Key] {
return nil, fmt.Errorf("%w: %s", ErrDuplicateColumn, ch.Key)
}
seen[ch.Key] = true
if utf8.RuneCountInString(ch.Heading) > 100 {
return nil, ErrHeading
}
if ch.Heading != "" {
c.Label = ch.Heading
}
out = append(out, c)
}
return out, nil
}
// Write renders rows (cell text by column key) as CSV or XLSX under cols' headings. A money cell
// that is not a decimal is written as text, never converted through a float.
func Write(f render.Format, cols []Column, rows []map[string]string) ([]byte, string, string, error) {
if f != render.FormatCSV && f != render.FormatXLSX {
return nil, "", "", fmt.Errorf("tableexport: format %q is not a table format", f)
}
if len(rows) > MaxRows {
return nil, "", "", ErrTooManyRows
}
w, contentType, ext, err := render.NewKind(f, render.KindExport)
if err != nil {
return nil, "", "", err
}
rc := make([]render.Column, len(cols))
for i, c := range cols {
rc[i] = render.Column{Title: c.Label, Numeric: c.Type == TypeMoney, Weight: 1}
}
if err := w.Begin(render.Meta{Plain: true}, rc); err != nil {
return nil, "", "", err
}
for _, r := range rows {
cells := make([]render.Cell, len(cols))
for i, c := range cols {
v := r[c.Key]
if c.Type == TypeMoney && v != "" {
if a, err := money.Parse(v); err == nil {
cells[i] = render.Money(a)
continue
}
}
cells[i] = render.Text(v)
}
if err := w.Row(cells); err != nil {
return nil, "", "", err
}
}
body, err := w.Finish()
return body, contentType, ext, err
}

Add ErrTooManyRows is already declared above. If render.KindExport is not exported under that name, grep -n "Kind" internal/platform/reporting/render/*.go | head and use the actual constant.

Also create internal/platform/tableexport/errors.go (used by Tasks 4 and 5, with a one-line test per code):

package tableexport
import "errors"
// ProblemCode is the API problem code for a refusal of a column choice or an export size.
func ProblemCode(err error) (string, bool) {
switch {
case errors.Is(err, ErrNoColumns):
return "export_no_columns", true
case errors.Is(err, ErrUnknownColumn):
return "export_unknown_column", true
case errors.Is(err, ErrDuplicateColumn):
return "export_duplicate_column", true
case errors.Is(err, ErrHeading):
return "export_heading", true
case errors.Is(err, ErrTooManyRows):
return "export_too_many_rows", true
}
return "", false
}
  • Step 4: Run, expect PASS: go test ./internal/platform/tableexport/ -count=1. If the CSV money expectation differs (-1234.5000) because Amount.String() formats differently, correct the test to the real output — do not change the writer.

  • Step 5: Watch it fail once — remove the safeText apostrophe expectation by editing the test’s first row to "ref": "x"? No: instead temporarily make Resolve skip the duplicate check, run TestResolveRefusals, read the failure, restore.

  • Step 6: Commit git add internal/platform/tableexport && git commit -m "tableexport: column catalogue, choice and CSV/XLSX writer" (with the Co-Authored-By trailer).


Task 3: Presets store (platform/exportpreset, migration)

Section titled “Task 3: Presets store (platform/exportpreset, migration)”

Files:

  • Create: db/migrations/01190_export_preset.sql
  • Modify: internal/kernel/db/migrationranges_test.go (add {1190, 1199, "Table export presets (docs/superpowers/specs/2026-10-09-table-export-design.md)"},)
  • Modify: internal/kernel/db/retention_test.go (add "export_preset" with a comment // 01190: a saved column choice, a convenience; what an export carried is not kept. next to "saved_report")
  • Create: internal/platform/exportpreset/exportpreset.go
  • Test: internal/platform/exportpreset/exportpreset_test.go

Interfaces:

  • Consumes: tableexport.Choice.
  • Produces:
type Preset struct { ID, OwnerID uuid.UUID; Kind, Name string; Columns []tableexport.Choice; Shared bool; Revision int; CreatedAt, UpdatedAt time.Time }
func List(ctx, tx pgx.Tx, viewer uuid.UUID, kind string) ([]Preset, error) // own + shared, by name
func Create(ctx, tx, c clock.Clock, owner uuid.UUID, kind, name string, cols []tableexport.Choice, shared bool) (Preset, error)
func Update(ctx, tx, c clock.Clock, id, by uuid.UUID, expectedRevision int, name string, cols []tableexport.Choice, shared bool) (Preset, error)
func Delete(ctx, tx, id, by uuid.UUID) error
var ErrNotFound, ErrNotOwner, ErrName, ErrDuplicate, ErrColumns error
type RevisionMismatchError struct{ Current int }

Validation of columns against a catalogue is the caller’s (module’s) job via tableexport.Resolve; the store only requires 1–200 choices.

  • Step 1: Migration
-- +goose Up
-- A user's saved column choice for an export kind, private to its owner unless shared with the
-- organization (docs/superpowers/specs/2026-10-09-table-export-design.md). A saved query, not a
-- record: its owner edits and deletes it freely.
CREATE TABLE export_preset (
id UUID PRIMARY KEY,
org_id UUID NOT NULL REFERENCES organization (id),
owner_id UUID NOT NULL,
kind TEXT NOT NULL CONSTRAINT export_preset_kind_shape CHECK (kind ~ '^[a-z][a-z0-9_]{0,59}$'),
name TEXT NOT NULL CONSTRAINT export_preset_name_shape CHECK (btrim(name) <> '' AND length(name) <= 100),
columns JSONB NOT NULL CONSTRAINT export_preset_columns_array CHECK (jsonb_typeof(columns) = 'array'),
shared BOOLEAN NOT NULL DEFAULT false,
revision INT NOT NULL DEFAULT 1,
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL,
CONSTRAINT export_preset_name_once UNIQUE (org_id, owner_id, kind, name),
CONSTRAINT export_preset_owner_fk FOREIGN KEY (org_id, owner_id) REFERENCES organization_member (org_id, id)
);
ALTER TABLE export_preset ENABLE ROW LEVEL SECURITY;
ALTER TABLE export_preset FORCE ROW LEVEL SECURITY;
CREATE POLICY org_isolation ON export_preset
USING (org_id = current_setting('app.current_org', true)::uuid)
WITH CHECK (org_id = current_setting('app.current_org', true)::uuid);
-- +goose Down
DROP TABLE export_preset;

Run make migrate (or the repo’s migrate command) against the dev DB only if the repo’s tests do not apply migrations themselves — testdb.Start applies them.

  • Step 2: Failing test (exportpreset_test.go) — use the same world helper other platform tests use for an org + two members: expensestest.New(t) gives Env with DB, Ctx, Clock, Org, Owner; for a second member find a helper with grep -n "func.*Member" internal/platform/expenses/expensestest/*.go. Tests to write, each asserting presence before absence:
func TestPrivatePresetIsInvisibleToOthersUntilShared(t *testing.T) {
// owner creates {kind:"claims", name:"mine"} not shared
// presence: List(owner) has it
// absence: List(other) is empty
// Update shared=true (expectedRevision 1) -> List(other) has it
// other's Update -> ErrNotOwner; other's Delete -> ErrNotOwner
// after unsharing, other's Update -> ErrNotFound (cannot see it)
}
func TestDuplicateNameForTheSameOwnerAndKind(t *testing.T) { /* second Create same name -> ErrDuplicate; same name other kind OK */ }
func TestStaleRevisionIsRefused(t *testing.T) { /* Update with revision 99 -> *RevisionMismatchError{Current:1} */ }
func TestNameAndColumnsValidation(t *testing.T) { /* "" -> ErrName; 101 chars -> ErrName; zero choices -> ErrColumns */ }

Write these out fully against the helpers found; each calls the store inside env.DB.InTx(env.Ctx, …).

  • Step 3: Run, expect FAIL (package missing).

  • Step 4: Implement exportpreset.go by copying internal/platform/savedreport/savedreport.go and changing: table export_preset; add kind column to columns const, Preset, scan, INSERT (kind is $4, shift others) and the List filter AND kind = $3; Definition export.Params → Columns []tableexport.Choice (pgx marshals a slice to JSONB); check(name, cols) returns ErrName for blank/over-100-rune names and ErrColumns for len(cols)==0 || len(cols)>200; constraint name in mapWrite is export_preset_name_once; error prefix exportpreset:; Update does not change kind (a preset cannot move between kinds). Keep lockOwned, RevisionMismatchError, Delete identical. (Errors and messages: ErrColumns = errors.New("exportpreset: a preset needs between 1 and 200 columns").)

  • Step 5: Run, expect PASS: go test ./internal/platform/exportpreset/ ./internal/kernel/db/ -run 'Preset|Migration|Retention|RLS|Tenant' -count=1. The every-table tenant and retention tests must pass with the new table (they fail if the policy is wrong or the table is unlisted).

  • Step 6: Commit the migration, the two test-list edits and the package.


Task 4: modules/exports — permission, catalogue and preset routes

Section titled “Task 4: modules/exports — permission, catalogue and preset routes”

Files:

  • Create: internal/modules/exports/permissions.go, routes.go, handlers.go, handlers_test.go
  • Modify: internal/app/app.go (Deps field Exports exports.Deps; exports.Register(api, deps.Exports) after reports.Register)
  • Modify: cmd/api/main.go (Exports: exports.Deps{DB: database, Clock: <the clock reports uses>}, next to Activity:)
  • Modify: api/openapi.yaml (paths and schemas below)
  • Modify: docs/superpowers/specs/2026-10-09-table-export-design.md (API section: reflect the deviation)

Interfaces:

  • Consumes: tableexport.Lookup/Resolve, exportpreset.*, member.Can(ctx, tx, memberID, key) (bool, error), audit.ActorFromContext(ctx) (.MemberID), httpx.Protected(g, perms, method, path, permission, handler), apperr.New(kind, msg).With("code", …).

  • Produces routes (all exports.use; each also needs the kind’s own Permission, checked in the handler, 403 export_kind_forbidden; unknown kind 404 export_kind_not_found):

    • GET /v1/exports/kinds/{kind}/columns → { "columns": [{key,label,type,default}] }
    • GET /v1/exports/presets?kind= → { "presets": [ExportPreset] }
    • POST /v1/exports/presets, PUT /v1/exports/presets/{id}, DELETE /v1/exports/presets/{id}
  • Step 1: permissions.go

package exports
import "github.com/mcom/erp/internal/platform/identity/member"
// PermissionUse gates choosing columns and saving presets. It adds nothing to what a caller can
// read: each kind's own read key is checked on top (handlers.go).
const PermissionUse = "exports.use"
func init() {
member.RegisterPermission(PermissionUse, "Choose the columns of an export and save column presets")
}

Then go run ./tools/vocabgen (part of make generate) so vocab.gen.go lists it; commit the generated diff.

  • Step 2: routes.go — copy internal/modules/activity/routes.go’s Register, permissionLoader verbatim with package exports, Deps{DB *db.DB; Clock clock.Clock}, and:
httpx.Protected(g, perms, "GET", "/exports/kinds/:kind/columns", PermissionUse, h.columns)
httpx.Protected(g, perms, "GET", "/exports/presets", PermissionUse, h.listPresets)
httpx.Protected(g, perms, "POST", "/exports/presets", PermissionUse, h.createPreset)
httpx.Protected(g, perms, "PUT", "/exports/presets/:id", PermissionUse, h.updatePreset)
httpx.Protected(g, perms, "DELETE", "/exports/presets/:id", PermissionUse, h.deletePreset)
  • Step 3: handlers.go — model on internal/modules/reports/custom_handlers.go (saved report handlers, quoted in the repo) with these specifics:

    • decodeStrict: copy the helper from internal/modules/reports/http.go:132 (disallow unknown fields, 400 on empty body).
    • kindFor(ctx, tx, c, name) (tableexport.Kind, error): Lookup → else apperr.New(apperr.KindNotFound, "no such export").With("code","export_kind_not_found"); then member.Can(ctx, tx, by.MemberID, k.Permission) false → apperr.New(apperr.KindForbidden, "you cannot read this data").With("code","export_kind_forbidden").
    • columns: in InReadOnlyTx, kindFor, then k.Columns(ctx, tx); DTO {key,label,type,default}.
    • createPreset / updatePreset: body {kind, name, columns:[{key,heading}], shared, revision}; in InTx: kindFor, k.Columns then tableexport.Resolve(cat, body.Columns) (a bad key → 422 export_unknown_column, none chosen → 422 export_no_columns, duplicate → 422 export_duplicate_column, heading → 422 export_heading), then exportpreset.Create/Update. updatePreset reads the preset’s kind from the store (a preset cannot change kind; ignore body kind).
    • listPresets: query kind required; kindFor; exportpreset.List(ctx, tx, by.MemberID, kind).
    • mapErr for preset refusals: ErrNotFound 404 export_preset_not_found; ErrNotOwner 403 export_preset_not_owner; ErrName 422 export_preset_name; ErrColumns 422 export_no_columns; ErrDuplicate 409 export_preset_duplicate; *RevisionMismatchError 409 revision_mismatch.
    • DTO exportPresetDTO{ID, OwnerID, Kind, Name, Columns []tableexport.Choice, Shared, Revision, UpdatedAt}.
  • Step 4: openapi.yaml — add the five operations (getExportColumns, listExportPresets, createExportPreset, updateExportPreset, deleteExportPreset) modelled on listSavedReports…deleteSavedReport (api/openapi.yaml ~7478–7570), schemas ExportColumn {key,label,type: text|money,default}, ExportChoice {key, heading?}, ExportPreset, ExportPresetInput {kind,name,columns,shared,revision?}. Then make generate and go build ./....

  • Step 5: Tests (handlers_test.go): copy newEnv from internal/modules/activity/handlers_test.go (router + signer + token minting), change Register to exports.Register, register a throwaway kind in the test’s init():

func init() {
tableexport.Register(tableexport.Kind{Key: "testkind", Permission: "exports.testread",
Columns: func(ctx context.Context, tx pgx.Tx) ([]tableexport.Column, error) {
return []tableexport.Column{{Key: "a", Label: "A", Type: tableexport.TypeText, Default: true}}, nil
}})
member.RegisterPermission("exports.testread", "test")
}

Cases (grant keys through the same role helper the activity test uses to give activity.read):

  1. holder of exports.use + exports.testread gets the catalogue (200, one column) — presence;
  2. holder of exports.use only gets 403 export_kind_forbidden — absence, meaningful because (1) passed;
  3. holder of neither gets 403 from Authorize;
  4. create → list → update (stale revision 409) → delete round trip; a second member sees a shared preset, not a private one; unknown column key → 422 export_unknown_column.
  • Step 6: Drift tests — run go test ./internal/app/... -count=1 (route/permission/openapi drift: every route behind httpx.Authorize, every env var, generated code matches). Fix what they report; do not edit api/public_routes.txt (these routes are authenticated).

  • Step 7: Commit (git add internal/modules/exports internal/app cmd/api api/openapi.yaml internal/gen internal/kernel/vocab docs/...). Update ROADMAP sub-bullets only for the owner-uncommitted caveat (see Task 6).


Task 5: Claims export on the shared mechanism

Section titled “Task 5: Claims export on the shared mechanism”

Files:

  • Modify: internal/modules/expenses/export_handlers.go
  • Create: internal/modules/expenses/export_kind.go (the claims kind)
  • Modify: internal/platform/printing/exportlayout.go (only if a helper is needed; see Step 3)
  • Modify: api/openapi.yaml (exportExpenseClaims: add columns, headings, format; response content types)
  • Test: internal/modules/expenses/export_handlers_test.go

Interfaces:

  • Consumes: printing.ClaimExportCatalog, printing.GetClaimExport(ctx, tx) (StoredExport, error), platformexpenses.ExportLines (values by catalogue key), tableexport.*.

  • Produces: kind claims, Permission: "expenses.claim.read". Catalogue = ClaimExportCatalog mapped to tableexport.Column with Type: TypeMoney for keys amount, base_amount, tax_amount, else text; Default = the organization’s layout (enabled columns of GetClaimExport), so existing behaviour is the default selection.

  • New query parameters on GET /v1/expenses/claims/export: columns (comma-separated keys, order kept; absent → the organization’s layout, today’s behaviour), headings (comma-separated, same length as columns, empty entries keep the default; urlencoded; commas inside a heading are not supported — document it), format (csv default | xlsx).

  • Step 1: Failing tests in export_handlers_test.go (reuse the file’s existing fixture that creates claims and reads the CSV):

    1. Existing behaviour holds: the existing tests keep passing with no new parameters (run first, they must be green before and after — except Step 3’s money note).
    2. ?columns=reference,amount → header exactly Claim no.,Amount (headings from the catalogue’s labels / org layout labels), two columns per row.
    3. ?columns=amount,reference&headings=Jumlah → header Jumlah,Claim no..
    4. ?format=xlsx → Content-Type is the XLSX type and the body starts with PK.
    5. ?columns=nope → 422 problem with code export_unknown_column.
    6. A caller without expenses.claim.read_all asking scope=all still gets 403 — present-then-absent: first assert the same request by a holder returns rows.
  • Step 2: Run, expect FAIL (columns ignored).

  • Step 3: Implement

export_kind.go:

package expenses
import (
"context"
"github.com/jackc/pgx/v5"
"github.com/mcom/erp/internal/platform/printing"
"github.com/mcom/erp/internal/platform/tableexport"
)
const kindClaims = "claims"
var claimMoney = map[string]bool{"amount": true, "base_amount": true, "tax_amount": true}
func init() {
tableexport.Register(tableexport.Kind{Key: kindClaims, Permission: PermissionClaimRead, Columns: claimColumns})
}
// claimColumns is the catalogue; the organization's export layout decides which are on by default
// and what they are called, so the Document layouts screen keeps working as the default selection.
func claimColumns(ctx context.Context, tx pgx.Tx) ([]tableexport.Column, error) {
layout, err := printing.GetClaimExport(ctx, tx)
if err != nil {
return nil, err
}
out := make([]tableexport.Column, 0, len(layout.Columns))
for _, col := range layout.Columns {
t := tableexport.TypeText
if claimMoney[col.Key] {
t = tableexport.TypeMoney
}
out = append(out, tableexport.Column{Key: col.Key, Label: col.Label, Type: t, Default: col.Enabled})
}
return out, nil
}

(PermissionClaimRead: use the module’s existing constant for expenses.claim.read — grep -n '"expenses.claim.read"' internal/modules/expenses/permissions.go. layout.Columns is in the org’s saved order, so Defaults reproduces today’s order.)

In export_handlers.go replace the CSV-building block (from var buf bytes.Buffer to c.Data(...)) with:

cat, err := h.claimCatalogue(c) // opens a read tx and calls claimColumns
…
choices, err := chosenColumns(c, cat) // parses columns/headings; absent -> tableexport.Defaults(cat)
cols, err := tableexport.Resolve(cat, choices)
format := render.Format(c.DefaultQuery("format", "csv"))
rows := make([]map[string]string, len(lines))
for i, l := range lines { rows[i] = l.Values }
body, ct, ext, err := tableexport.Write(format, cols, rows)
if errors.Is(err, tableexport.ErrTooManyRows) { … 422 code export_too_many_rows }
c.Header("Content-Disposition", `attachment; filename="`+name+`.`+ext+`"`)
c.Data(http.StatusOK, ct, body)

Delete guard, formulaStart, numericColumns (the render writers guard text and write money exactly) and the bytes, encoding/csv, regexp imports if unused. chosenColumns splits columns on commas, headings likewise (len must equal columns’ or be empty → 422 export_headings_mismatch), builds []tableexport.Choice. Map tableexport errors to 422 problems with codes export_unknown_column, export_no_columns, export_duplicate_column, export_heading (same codes as Task 4 — put the mapping function in platform/tableexport/errors.go as func ProblemCode(err error) (string, bool) and use it from both modules to avoid two copies; add it to Task 4’s handlers).

Behaviour change to call out in the commit and ROADMAP: amounts in the claims CSV now print with four decimals (12.5000) because render.Money writes Amount.String(); XLSX holds numbers. If the existing tests assert two decimals, update them to the new strings in the same commit.

openapi.yaml: add the three parameters to exportExpenseClaims; add application/vnd.openxmlformats-officedocument.spreadsheetml.sheet to its 200 content; add 422 export_* codes to its description.

  • Step 4: Run, expect PASS: go test ./internal/modules/expenses/ -run Export -count=1 then make generate && go build ./....

  • Step 5: Commit.


Task 6: Web ExportDialog and the claims button

Section titled “Task 6: Web ExportDialog and the claims button”

Files:

  • Create: web/src/features/exports/api.ts, ExportDialog.tsx, ExportDialog.test.tsx
  • Create: web/src/lib/i18n/locales/{en,ms,zh}/exports.json; register the exports namespace wherever claims is registered (grep -rn "claimDetail" web/src/lib/i18n/*.ts)
  • Modify: web/src/features/claims/exportClaims.ts, web/src/features/claims/ClaimsListPage.tsx (Export button opens the dialog)
  • Generated: web/src/lib/api/schema.d.ts (pnpm -C web run generate via make generate)

Interfaces:

  • Consumes: generated types Schemas['ExportColumn'], Schemas['ExportPreset'], api.GET('/exports/kinds/{kind}/columns'), api.GET('/exports/presets'), api.POST/PUT/DELETE.
  • Produces:
export function useExportColumns(kind: string): UseQueryResult<ExportColumn[]>
export function useExportPresets(kind: string): UseQueryResult<ExportPreset[]>
export function useSavePreset(kind: string): UseMutationResult // create or update (revision when updating)
export function useDeletePreset(kind: string): UseMutationResult
export interface ExportRequest { columns: string[]; headings: string[]; format: 'csv' | 'xlsx' }
export function ExportDialog(props: { kind: string; title: string; onExport: (r: ExportRequest) => Promise<void>; onClose: () => void }): JSX.Element
  • Step 1: api.ts following web/src/features/printing/api.ts: query keys ['exports', kind, 'columns'] and ['exports', kind, 'presets']; unwrap(await api.GET(…, { params: { path: { kind } }, signal })); mutations invalidate the presets key.

  • Step 2: Failing test (ExportDialog.test.tsx, vitest + testing-library, mocking the api module the way ClaimsListPage.test.tsx does):

    • renders one checkbox per catalogue column, the default ones ticked, in catalogue order — presence;
    • unticking a column and pressing Export calls onExport with only the ticked keys in order, format: 'csv';
    • choosing XLSX passes format: 'xlsx';
    • editing a heading passes it at the same index, others '';
    • Export is disabled with no columns ticked;
    • choosing a saved preset ticks exactly its columns and applies its headings.
  • Step 3: Implement ExportDialog.tsx with the repo’s Dialog, Button, Field, Input, Select (see ClaimsFilterDialog.tsx for the props and footer pattern): state = ordered list of {key, label, on, heading} initialised from the catalogue (defaults on); each row has a checkbox, a heading input, and up/down buttons (aria-label translated) to reorder; a format Select (CSV/XLSX); a preset Select (own and shared, shared marked) that overwrites the state; “Save as preset” row (name Input, share checkbox, Save button calling useSavePreset); Export button calls onExport then onClose; show a ProblemError message inline using the existing error display used by other dialogs.

  • Step 4: exportClaims.ts — change the signature to downloadClaimsExport(s: ClaimListSearch, r: ExportRequest): add q.set('columns', r.columns.join(',')), q.set('headings', r.headings.join(',')) (only when any heading is non-empty), q.set('format', r.format), and name the download claims-….${r.format}. In ClaimsListPage.tsx the Export button sets exportOpen state; render <ExportDialog kind="claims" title={t('export.title')} onExport={(r) => downloadClaimsExport(search, r)} onClose=… />. Keep the existing busy/error handling at the call site.

  • Step 5: Strings exports.json (en): columns, heading, format, preset, savePreset, presetName, share, moveUp, moveDown, export, noColumns; ms and zh translations; note in ROADMAP that ms/zh are unreviewed.

  • Step 6: Run pnpm -C web test -- ExportDialog ClaimsListPage (update ClaimsListPage.test.tsx where it clicked Export and expected an immediate download), pnpm -C web run typecheck, pnpm -C web run lint.

  • Step 7: Browser check — start the dev stack per scripts/dev-stack.sh / the preview config, open Expense claims, Export: untick two columns, rename one heading, XLSX, open the file in a spreadsheet; save a preset, reload, pick it. Report what you saw; if you could not run it, say so.

  • Step 8: Commit; ROADMAP — tick “Spec reviewed … plan written”; add sub-bullets for what landed and “Not verified: …”.


  • Spec coverage: registry + catalogue (T2, T4, T5); XLSX/CSV (T1, T2); presets private/shared (T3, T4); dialog (T6); claims first (T5, T6); money exact (T2 test, T1); tenant/RLS tables (T3 + existing drift tests); permission non-widening (T4 case 2). Not in this plan (later plan, after this lands): journal-entries export with axis.<key> columns and the D365 preset, petty-cash and party kinds, the “list pages without an export” drift test.
  • Deviation recorded: per-module export routes instead of one POST /v1/exports/{kind}; Task 4 Step “modify the spec” edits that section.
  • Type consistency: Choice, Column, Kind, Resolve, Write, Defaults, ProblemCode are all defined in T2.
  • Known gaps to verify while executing: helper names flagged in-line (money.MustParse, render.KindExport, the second-member test helper, PermissionClaimRead); each has a grep to resolve it.