Journal Import Axis Columns 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: The journal_entries import accepts axis.<key> columns so a D365-style journal (LOB, SUBLOB, DEPT, Project, Region, …) posts with its analytic dimensions.
Architecture: journalsKind gains extra: "axis." (the same hook parties use for cf.). saveJournals loads the organization’s axes and their accounts once, turns each axis.<key> cell into a 100% analytic.Share on the line, and lets ledger.Post validate it (applicability, required axes, inactive accounts). Ledger refusals about analytics become per-line problems.
Tech Stack: Go, pgx, testify, internal/platform/imports, internal/platform/accounting/analytic.
Spec: ../specs/2026-10-09-table-export-design.md § “Journal import dimensions”. This plan is independent of the export work (Plan: table-export foundation) and can ship first.
Global Constraints
Section titled “Global Constraints”- Money never touches a float; shares use
money.Rate(money.MustParseRate("100")is 100%). - Only the
importspackage changes. No migration, no API change (the kind’s field list is served by/v1/imports/kinds; theaxis.columns are taken as they come, likecf.). - A file with any problem posts nothing (existing behaviour,
TestAFileWithAnyBadEntryPostsNothing). - Database tests:
testdb-backed viaexpensestest.New; run only./internal/platform/imports/...while iterating. - Watch each new test fail once before the implementation (AGENTS.md drift-test rule).
Task 1: axis. columns on the journal import
Section titled “Task 1: axis. columns on the journal import”Files:
- Modify:
internal/platform/imports/journals.go(kind definition,saveJournals,journalProblem) - Test:
internal/platform/imports/journals_test.go
Interfaces:
-
Consumes:
analytic.ListAxes(ctx, tx) ([]analytic.Axis, error),analytic.ListAccounts(ctx, tx, axisID) ([]analytic.Account, error),analytic.Share{AnalyticAccountID uuid.UUID; Percentage money.Rate},ledger.Line.Distribution []analytic.Share. -
Produces: import columns named
axis.<axis key>(case-insensitive header, axis key as stored). Cell value = analytic account code on that axis. Blank cell = no share on that axis. -
Step 1: Write the failing tests
Append to internal/platform/imports/journals_test.go (add imports analytic = github.com/mcom/erp/internal/platform/accounting/analytic, and github.com/google/uuid if not present):
// axisWorld makes one axis "lob" with accounts PS and QA, applying to every account type.func (w *partyWorld) axisWorld(t *testing.T) { t.Helper() w.tx(t, func(ctx context.Context, tx pgx.Tx) error { ax, err := analytic.CreateAxis(ctx, tx, w.e.Clock, analytic.AxisInput{Key: "lob", Name: "Line of business", AppliesTo: []string{"asset", "liability", "equity", "income", "expense"}}) if err != nil { return err } for _, code := range []string{"PS", "QA"} { if _, err := analytic.CreateAccount(ctx, tx, w.e.Clock, analytic.AccountInput{AxisID: ax.ID, Code: code, Name: code}); err != nil { return err } } return nil })}
// shareCodes is the analytic account code of every share on lines of the account, by line.func (w *partyWorld) shareCodes(t *testing.T, account string) []string { t.Helper() var out []string w.tx(t, func(ctx context.Context, tx pgx.Tx) error { rows, err := tx.Query(ctx, `SELECT aa.code FROM journal_line_analytic jla JOIN journal_line jl ON jl.org_id = jla.org_id AND jl.id = jla.line_id JOIN analytic_account aa ON aa.org_id = jla.org_id AND aa.id = jla.analytic_account_id JOIN account a ON a.org_id = jl.org_id AND a.id = jl.account_id WHERE jl.org_id = $1 AND a.code = $2 ORDER BY aa.code`, w.e.Org, account) if err != nil { return err } defer rows.Close() for rows.Next() { var c string if err := rows.Scan(&c); err != nil { return err } out = append(out, c) } return rows.Err() }) return out}
func TestJournalAxisColumnTagsTheLine(t *testing.T) { w := newPartyWorld(t) grantJournalPost(t, w) w.axisWorld(t) j := w.miscJournal(t) // PRESENCE: the same file without the column posts and tags nothing. _, err := w.run(t, imports.KindJournalEntries, "entry,date,journal,description,account,debit,credit\n"+ "A,2026-09-01,"+j+",Plain,6100,10,\nA,,,,1520,,10\n") require.NoError(t, err) require.Empty(t, w.shareCodes(t, "6100"), "fixture: an untagged file must leave no shares")
res, err := w.run(t, imports.KindJournalEntries, "entry,date,journal,description,account,debit,credit,AXIS.LOB\n"+ "B,2026-09-02,"+j+",Tagged,6100,20,,PS\nB,,,,1520,,20,\n") require.NoError(t, err) require.Equal(t, map[string]int{"entries": 1, "lines": 2}, res.Counts) require.Equal(t, []string{"PS"}, w.shareCodes(t, "6100"))}
func TestJournalAxisColumnProblems(t *testing.T) { w := newPartyWorld(t) grantJournalPost(t, w) w.axisWorld(t) j := w.miscJournal(t) before := w.entryCount(t)
res, err := w.run(t, imports.KindJournalEntries, "entry,date,journal,description,account,debit,credit,axis.lob\n"+ "A,2026-09-01,"+j+",Bad code,6100,10,,NOPE\nA,,,,1520,,10,\n") require.NoError(t, err) require.Equal(t, before, w.entryCount(t), "a refused file posts nothing") require.Len(t, res.Problems, 1) require.Equal(t, "axis.lob", res.Problems[0].Column) require.Equal(t, 2, res.Problems[0].Line)
res, err = w.run(t, imports.KindJournalEntries, "entry,date,journal,description,account,debit,credit,axis.region\n"+ "A,2026-09-01,"+j+",Bad axis,6100,10,,ASI\nA,,,,1520,,10,\n") require.NoError(t, err) require.Len(t, res.Problems, 1) require.Equal(t, "axis.region", res.Problems[0].Column) require.Equal(t, before, w.entryCount(t))}- Step 2: Run to verify they fail
Run: go test ./internal/platform/imports/ -run 'TestJournalAxis' -count=1
Expected: TestJournalAxisColumnTagsTheLine FAILS at the final require.Equal(t, []string{"PS"}, …) (got empty: the column is ignored). If it fails earlier on the SQL in shareCodes (table or column name wrong), fix the helper against db/migrations/ first (grep -l journal_line_analytic db/migrations/*) — the helper is part of the test, not the feature. The problems test FAILS with “expected 1 problem, got 0” for the same reason.
- Step 3: Implement
In internal/platform/imports/journals.go:
- Imports: add
"github.com/mcom/erp/internal/platform/accounting/analytic". - Kind definition — add the prefix:
// axisPrefix names an analytic axis's column: axis.<key>, the cell an analytic account code on// that axis (the line is 100% on it).const axisPrefix = "axis."
var journalsKind = kind{ fields: []string{"entry", "date", "journal", "description", "account", "debit", "credit", "memo", "party", "tax_code"}, required: []string{"entry", "date", "journal", "description", "account"}, extra: axisPrefix, save: saveJournals,}- In
saveJournals, after thepartiesmap is built and beforevar out outcome, load the axes:
axes, err := analytic.ListAxes(ctx, tx) if err != nil { return outcome{}, fmt.Errorf("imports: journals: %w", err) } axisByKey := map[string]map[string]uuid.UUID{} // axis key -> account code -> id for _, ax := range axes { accs, err := analytic.ListAccounts(ctx, tx, ax.ID) if err != nil { return outcome{}, fmt.Errorf("imports: journals: %w", err) } byCode := map[string]uuid.UUID{} for _, a := range accs { byCode[strings.ToUpper(a.Code)] = a.ID } axisByKey[strings.ToLower(ax.Key)] = byCode } hundred := money.MustParseRate("100")- In the per-row loop, right after the
tax_codeblock and beforeif ok { g.lines = append(...) }, add (iterate names sorted so problems come out in a stable order — add"sort"to imports):
names := make([]string, 0, len(r.cells)) for name := range r.cells { if strings.HasPrefix(name, axisPrefix) { names = append(names, name) } } sort.Strings(names) for _, name := range names { code := r.cells[name] if code == "" { continue } byCode, known := axisByKey[strings.TrimPrefix(name, axisPrefix)] if !known { bad(name, "no analytic axis %s", strings.TrimPrefix(name, axisPrefix)) ok = false continue } id, found := byCode[strings.ToUpper(code)] if !found { bad(name, "no analytic account %s on axis %s", code, strings.TrimPrefix(name, axisPrefix)) ok = false continue } line.Distribution = append(line.Distribution, analytic.Share{AnalyticAccountID: id, Percentage: hundred}) }- In
journalProblem, add before the finalreturn nil, err:
var required *analytic.RequiredError switch { case errors.As(err, &required): return &Problem{Line: line, Column: axisPrefix + required.AxisKey, Reason: "axis " + required.AxisKey + " is required on this line"}, nil case errors.Is(err, analytic.ErrAxisNotApplicable), errors.Is(err, analytic.ErrAccountInactive): return &Problem{Line: line, Column: "account", Reason: strings.TrimPrefix(err.Error(), "analytic: ")}, nil }(Place it as a second switch after the existing one; the existing one ends with return nil, err — keep that last.)
- Step 4: Run to verify they pass
Run: go test ./internal/platform/imports/ -run 'TestJournal|TestAFileWithAnyBadEntryPostsNothing' -count=1
Expected: PASS (existing journal tests still green).
- Step 5: Prove the guard bites (watch it fail once)
Temporarily change axisPrefix to "axisx.", run go test ./internal/platform/imports/ -run TestJournalAxis -count=1, read the failure, restore "axis.", re-run green.
- Step 6: Required-axis and not-applicable refusals
Add and run (these pin the journalProblem mapping; if either errors with an internal error instead of a Problem, ledger.Post is not wrapping the analytic error with %w — wrap it where it calls analytic.Plan, in internal/platform/accounting/ledger/, and say so in the commit):
func TestJournalRequiredAxisIsAPerLineProblem(t *testing.T) { w := newPartyWorld(t) grantJournalPost(t, w) j := w.miscJournal(t) w.tx(t, func(ctx context.Context, tx pgx.Tx) error { _, err := analytic.CreateAxis(ctx, tx, w.e.Clock, analytic.AxisInput{Key: "dept", Name: "Department", IsRequired: true, AppliesTo: []string{"expense"}}) return err }) res, err := w.run(t, imports.KindJournalEntries, "entry,date,journal,description,account,debit,credit\n"+ "A,2026-09-01,"+j+",No dept,6100,10,\nA,,,,1520,,10\n") require.NoError(t, err) require.Len(t, res.Problems, 1) require.Equal(t, "axis.dept", res.Problems[0].Column)}Run: go test ./internal/platform/imports/ -run TestJournalRequiredAxis -count=1 → PASS.
- Step 7: Roadmap and commit
In docs/ROADMAP.md, under the “Manual journal entries in bulk” bullet add a sub-line: axis.<key> columns: an analytic account code per axis, 100% share; ledger refusals (required axis, not applicable) are per-line problems. Then:
go test ./internal/platform/imports/... -count=1git add internal/platform/imports/journals.go internal/platform/imports/journals_test.gogit commit -m "Journal import: axis.<key> columns tag lines with analytic accounts
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>"(docs/ROADMAP.md carries the owner’s uncommitted edits; commit it only after they commit theirs, or use git add -p for the one line.)
Task 2: Trial balance files from D365 (signed balance, rounding)
Section titled “Task 2: Trial balance files from D365 (signed balance, rounding)”A D365 trial balance export (TB 30.4.2025.xlsx) has MainAccount, Name, Opening balance, Debit, Credit, Closing balance: balances are one signed column, and values carry float noise (77777.437999999995, -2E-3, 2232.1999999999998). The opening_trial_balance and period_movements kinds take non-negative debit/credit of at most two decimals, so the file would need hand-editing.
Files:
- Modify:
internal/platform/imports/balances.go(openingKind,movementsKind,resolve, newtrialAmountCell) - Test:
internal/platform/imports/balances_test.go
Interfaces:
- Produces: both kinds gain an optional
balancefield. Positive = debit, negative = credit. A row withbalanceand a non-blankdebitorcreditis a problem on columnbalance(“give a balance or a debit and credit, not both”).requiredstaysaccount(+periodfor movements). amountCellstays unchanged (journals, documents, archive, fixed assets keep refusing finer than a cent).- New in
balances.go:
// trialAmountCell is a trial-balance cell: blank is zero, thousands commas are dropped, and a// value from a spreadsheet's floating point (77777.437999999995, -2E-3) is rounded to the cent.// signed=false refuses a negative, as amountCell does.func trialAmountCell(s string, signed bool) (money.Amount, error)Parse with money.Parse after stripping commas; E notation must be accepted (check money.Parse("-2E-3"); if it refuses, parse with shopspring/decimal’s NewFromString via whatever money exposes, else add a tiny helper in money — keep the diff minimal and say so). Round with a.Cents().
- Step 1: Failing tests in
balances_test.go, in the style of the existing opening/movement tests (newPartyWorld,w.run(t, imports.KindOpeningTrialBalance, csv); look at the existing tests for how accounts, cut-over date andfinishare set up and reuse them):- A file
account,balance\n<asset acct>,1000.00\n<liability/equity acct>,-1000.00imports as one balanced entry (debit on the first, credit on the second). Presence: the same data asdebit/creditcolumns gives the same ledger balances (assert both). - Float noise:
account,debit,credit\n<acct>,77777.437999999995,\n<acct2>,,77777.44imports (rounds to 77777.44 both sides). - A residual
-2E-3inbalancerounds to zero and the row is skipped like any zero row. balancetogether withdebiton one row is a problem on columnbalance(assert error type as the existing tests do,*imports.ProblemsError).- Journals still refuse three decimals: one
journal_entriesrow with debit1.005yields the existing “more than two decimal places” problem (guards thatamountCellwas not loosened). period_movementsacceptsbalancethe same way (one test).
- A file
- Step 2: Run, expect FAIL (
go test ./internal/platform/imports/ -run 'Trial|Balance|Movements' -count=1 -p 1). - Step 3: Implement. Add
"balance"to bothfieldslists. Inresolve, replace the twoamountCellcalls:
debit, err := trialAmountCell(r.cells["debit"], false) ... credit, err := trialAmountCell(r.cells["credit"], false) ... if b := r.cells["balance"]; b != "" { if debit.IsPositive() || credit.IsPositive() { return bad("balance", "give a balance or a debit and credit, not both") } bal, err := trialAmountCell(b, true) if err != nil { return bad("balance", err.Error()) } if bal.IsNegative() { credit = bal.Neg() } else { debit = bal } }(a balance that rounds to zero falls through to the existing “neither debit nor credit: skipped” case.)
- Step 4: Run, expect PASS, then
go test ./internal/platform/imports/... -count=1 -p 1. - Step 5: Watch it fail once: make
trialAmountCellskipCents(), run test 2, read the failure, restore. - Step 6: Commit
balances.goandbalances_test.go(Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>).
Self-review
Section titled “Self-review”- Spec “Journal import dimensions”: covered by Task 1 (prefix, 100% share, unknown code → per-line error naming the column, ledger refusals).
- Round trip with the export is verified in the foundation plan’s journal-export task (later plan), not here.
- Types:
axisPrefix,axisByKey,hundreddefined in Step 3 and used only there.