Skip to content

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.

  • Money never touches a float; shares use money.Rate (money.MustParseRate("100") is 100%).
  • Only the imports package changes. No migration, no API change (the kind’s field list is served by /v1/imports/kinds; the axis. columns are taken as they come, like cf.).
  • A file with any problem posts nothing (existing behaviour, TestAFileWithAnyBadEntryPostsNothing).
  • Database tests: testdb-backed via expensestest.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:

  1. Imports: add "github.com/mcom/erp/internal/platform/accounting/analytic".
  2. 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,
}
  1. In saveJournals, after the parties map is built and before var 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")
  1. In the per-row loop, right after the tax_code block and before if 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})
}
  1. In journalProblem, add before the final return 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:

Terminal window
go test ./internal/platform/imports/... -count=1
git add internal/platform/imports/journals.go internal/platform/imports/journals_test.go
git 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, new trialAmountCell)
  • Test: internal/platform/imports/balances_test.go

Interfaces:

  • Produces: both kinds gain an optional balance field. Positive = debit, negative = credit. A row with balance and a non-blank debit or credit is a problem on column balance (“give a balance or a debit and credit, not both”). required stays account (+ period for movements).
  • amountCell stays 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 and finish are set up and reuse them):
    1. A file account,balance\n<asset acct>,1000.00\n<liability/equity acct>,-1000.00 imports as one balanced entry (debit on the first, credit on the second). Presence: the same data as debit/credit columns gives the same ledger balances (assert both).
    2. Float noise: account,debit,credit\n<acct>,77777.437999999995,\n<acct2>,,77777.44 imports (rounds to 77777.44 both sides).
    3. A residual -2E-3 in balance rounds to zero and the row is skipped like any zero row.
    4. balance together with debit on one row is a problem on column balance (assert error type as the existing tests do, *imports.ProblemsError).
    5. Journals still refuse three decimals: one journal_entries row with debit 1.005 yields the existing “more than two decimal places” problem (guards that amountCell was not loosened).
    6. period_movements accepts balance the same way (one test).
  • Step 2: Run, expect FAIL (go test ./internal/platform/imports/ -run 'Trial|Balance|Movements' -count=1 -p 1).
  • Step 3: Implement. Add "balance" to both fields lists. In resolve, replace the two amountCell calls:
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 trialAmountCell skip Cents(), run test 2, read the failure, restore.
  • Step 6: Commit balances.go and balances_test.go (Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>).

  • 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, hundred defined in Step 3 and used only there.