Skip to content

Plan 4 — Accounting Core 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: An organization provisioned for Malaysia has a chart of accounts, journals, SST tax codes, analytic axes and an open fiscal year; posting rules registered by later modules turn workflow accrual / payout / reversal events into balanced, append-only, gaplessly numbered journal entries in the same transaction as the state change; foreign-currency entries freeze their base amounts and post rate movement to an FX gain/loss account; periods and years close only through a reconciliation set that names the rows it rejects.

Architecture: A family of platform packages under internal/platform/accounting/ — chart, analytic, fiscal, fx (+ fx/bnm), ledger, reconcile, closing — plus the root package accounting, which owns the posting-rule registry and the posting.Poster implementation. The database is the last line of defence for every ledger invariant: a deferred constraint trigger checks balance and analytic totals at commit, a BEFORE INSERT guard refuses lines on an entry posted by an earlier transaction and entries in a non-open period, and UPDATE/DELETE are revoked. Country packs live in internal/platform/countrypack (interface, generic seeder, provisioning steps 70–72) with Malaysia in countrypack/my. internal/modules/accounting is the HTTP shell and the boot-time posting.Register.

Tech Stack: Go 1.26 · Gin · pgx v5 · goose · sqlc (models only) · oapi-codegen · testify · testcontainers (Postgres 17) · shopspring/decimal behind kernel/money

Specs:

Every task’s requirements implicitly include this section.

  • Go floor is 1.26.0. CI derives its version from go.mod; never restate a version.
  • Ownership (contract §2). Go code only under internal/platform/accounting/…, internal/modules/accounting/…, internal/platform/countrypack/…, plus the named exceptions in the Rulings (kernel/money/rate.go, kernel/config/bnm.go, platform/company/company.go’s hasPostedEntries, cmd/worker). Never edit internal/platform/posting. Never import internal/platform/workflow or internal/modules/workflow.
  • Migrations 00050–00069 only. This plan uses 00050–00053. Provisioning step orders 70–79 only (this plan uses 70–74). Permissions accounting.* only. OpenAPI paths /accounting/… only (served under /v1).
  • Shared files are append-only (contract §3): constants.yaml permissions list, api/openapi.yaml, internal/app/app.go, the blank imports in cmd/api, cmd/migrate, cmd/orgadmin, internal/kernel/db/retention_test.go lists, docs/ROADMAP.md (tick only Plan 4 lines). Generated files (internal/gen/…, internal/kernel/vocab/vocab.gen.go, packages/contracts/vocab.ts) are regenerated with make generate, never hand-merged.
  • Tenant isolation, both layers. Every table carries org_id UUID NOT NULL (or is listed with a justification in db/rls_exempt.txt), has ENABLE and FORCE ROW LEVEL SECURITY and a policy named org_isolation. Every tenant query in Go carries an explicit org_id = $n predicate for every org-scoped table it names, and every INSERT names org_id (TestEveryTenantQueryCarriesAnExplicitOrgPredicate). Every FK between two org-scoped tables is composite (org_id, x) REFERENCES t (org_id, id) (TestEveryTenantForeignKeyIsCompositeOnOrgID).
  • Every new table appears in exactly one list in internal/kernel/db/retention_test.go. 00001_init.sql’s default privileges grant DELETE the instant a table is created; only an explicit REVOKE removes it.
  • The ledger is append-only. REVOKE UPDATE, DELETE on journal_entry, journal_line, analytic_distribution, close_log, exchange_rate, fx_reference_rate from erp_app. Balance is enforced by a deferred constraint trigger, not only in Go. Corrections are reversals. There is no code path that updates or deletes a ledger row.
  • Every posting, reversal, period close, reopen and year close writes audit.Write on the same pgx.Tx. So does every chart/journal/axis/tax-code/fiscal-year/rate-override mutation.
  • Advisory locks: this plan adds no advisory-lock class (Ruling R4). Serialization is by row locks (fiscal_period … FOR SHARE / FOR UPDATE, number_sequence). Any future lock must be taken before the transaction’s first audit.Write.
  • No nested db.InTx. Platform functions take (ctx, tx pgx.Tx, …); only handlers, provisioning callers and the worker open transactions.
  • Money is money.Amount (NUMERIC(19,4)); rates and percentages are money.Rate (NUMERIC(19,10)). Never float64, including when decoding Bank Negara’s JSON (decode with UseNumber). On the wire both are strings; api/openapi.yaml’s Money maps to money.Amount via x-go-type.
  • The business date comes from kernel/clock (clock.TodayUTC(c)); every DATE value handled in Go is midnight UTC. Never call time.Now().
  • Record-bearing free text (account/journal/tax-code names, entry descriptions, memos) is English or Bahasa Malaysia (§11A.3). zh exists only as translation rows.
  • Errors are RFC 7807. Handlers c.Error(apperr.New(...)) and return; they never write an error body.
  • Presence before absence (AGENTS.md). Every test asserting something is refused/absent first shows it accepted/present without the mechanism. Every drift guard is watched failing once (sabotage step), and the red output is quoted in the task report.
  • Tests: during development run only the packages you changed (go test ./internal/platform/accounting/chart/ -count=1). make generate after any migration, OpenAPI or constants.yaml change. One full go test -p 2 -count=1 ./... in Task 21 only.
  • Git commits end with: Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Decisions taken where the spec is silent or ambiguous. Format: Ruling: <decision> — <why> — <cost if wrong>.

  • R1 Ruling: Packages are internal/platform/accounting/{chart,analytic,fiscal,fx,fx/bnm,ledger,reconcile,closing}, root package accounting (rule registry + Poster), and internal/platform/countrypack{,/my} — not the spec’s platform/tax — the wave-1 contract fixes package paths and splitting by responsibility keeps each task’s files disjoint — renaming a package later is a mechanical move.
  • R2 Ruling: Accounting SQL is hand-written pgx in Go, not sqlc queries; make generate still refreshes internal/gen/sqlc/models.go, and sqlc.yaml gains per-column overrides mapping rate/percentage columns to money.Rate — every platform package (orgstruct, company, numbering) uses raw pgx and the tenant-predicate guard reads Go literals — if the team later mandates sqlc, the queries port one-for-one.
  • R3 Ruling: Add money.Rate in a new file internal/kernel/money/rate.go (outside the ownership table) — Amount’s decimal is unexported, so Amount × Rate can only live in package money, and a float must never carry a rate — Plan 3 touching the same package would at worst add a second new file.
  • R4 Ruling: Accounting takes no advisory lock; it serializes with row locks only — posting.Dispatch runs inside Plan 3’s transition, which may already have called audit.Write, and any advisory lock taken after that breaks the “class 1 last” rule; a test proves Poster.Post acquires no class but 1 — if a future ledger operation needs an advisory lock, it must be front-loaded by every caller (the orgstruct.LockTree pattern).
  • R5 Ruling: An entry has one transaction currency (journal_entry.currency); normal lines must use it; an fx_difference line carries zero transaction amounts and a non-zero base amount; the trigger checks SUM(debit)=SUM(credit) and SUM(base_debit)=SUM(base_credit) — this is the only reading under which §10.2 invariant 1 holds together with an FX line on a payout — a cross-currency settlement (USD accrual paid from a MYR bank) needs a two-entry pattern or a relaxed per-currency check later.
  • R6 Ruling: For a foreign-currency entry the engine posts any base imbalance (rate movement plus conversion rounding) as one fx_difference line to the fx_gain_loss control account; for a base-currency entry any base imbalance is an error — §10.5 says the difference is never absorbed into the expense, and base-currency entries have no rate to move — a rule with a wrong frozen base would book its error as FX; reconciliation in Plans 5–8 catches that.
  • R7 Ruling: Posting rules register only for accrual and payout; reversal on rejected/cancelled is engine-generic: it mirrors every unreversed accrual/payout entry of the instance, newest first, from the stored lines, and errors if PastAccrual is set but nothing is found — §10.3 says reversal is “the mirrored entry from the original, never a recomputation”, which needs no module code — a module wanting a non-mirror reversal needs a new rule semantic.
  • R8 Ruling: A second accrual (or payout) for an instance that already has an unreversed entry of that semantic is refused (ErrAlreadyPosted) — double-dispatch would double-count; the spec’s rejected/cancelled are terminal — if Plan 3’s dispute resolution re-enters accrual on the same instance, the rule must become “reverse then re-post”.
  • R9 Ruling: An accrual event with an empty ControlKind is refused — §5.5 makes semantic_params.control_account required on accrual — none.
  • R10 Ruling: Bank Negara reference rates live in a global, RLS-exempt, append-only table fx_reference_rate; per-org manual overrides live in the tenant table exchange_rate (append-only, newest row wins, with actor and reason) — published BNM rates are public reference data identical for every tenant, and a global row cannot be written through a tenant WITH CHECK policy; the worker already writes RLS-exempt tables from the pool (session prune) — a per-tenant copy of rates would need an org-enumeration mechanism the worker lacks.
  • R11 Ruling: Rate resolution order: org override for the exact (from, to, date); else the BNM middle rate on the latest rate_date ≤ date within 7 days; cross rates via MYR; otherwise ErrNoRate and the posting is refused — BNM does not publish on weekends/holidays, and a silent stale rate is worse than a refusal — an org posting foreign currency during a long outage must enter an override.
  • R12 Ruling: No River yet: the worker runs a daily ticker (the session-prune pattern) that fetches BNM’s latest rates when BNM_RATES_URL is set; the URL must be https on a bnm.gov.my host; no historical backfill — River does not exist in the tree; §11A.1 wants an official Malaysian source; sync must be optional — gaps before first deployment are covered by overrides.
  • R13 Ruling: The BNM client requests quote=rm&session=1130, uses middle_rate, divides by unit, and decodes numbers with json.Decoder.UseNumber — the API returns JSON numbers and a float must never touch a rate — the implementer verifies the live response shape against testdata/bnm_latest.json before merge and fixes the fixture, not the decoder rule, if it differs.
  • R14 Ruling: Reconciliation runs on demand (API) and as a close precondition; nightly runs are deferred, and results are not stored except in close_log at close — the worker cannot enumerate tenants under RLS today — Plan 9/River adds the nightly job with no schema change.
  • R15 Ruling: Built-in reconciliation checks are trial_balance, entry_balance, entry_period, reversal_mirror and audit_chain; float, advances, AP-control and orphan checks are registered by Plans 5–8 through reconcile.Register — their subject tables do not exist yet — none.
  • R16 Ruling: Retained earnings is designated by account.system_role = 'retained_earnings' (one active per org), not a new control kind — controlKinds is the workflow’s vocabulary (§10.1) and retained earnings is never a workflow credit side — a second system role (e.g. current-year earnings) is one more CHECK value.
  • R17 Ruling: Year-end close requires every period but the last to be closed; it posts the P&L-to-retained-earnings entry dated ends_on into the last period (misc journal, semantic year_end_close), closes that period, locks every period, closes the year and writes close_log; it does not create the next year and a closed year cannot reopen — the closing entry must land in an open period of that year, and “locked” is the only state that makes the year’s numbers final — a restatement needs a year-reopen feature later.
  • R18 Ruling: A closed period may reopen (closed→open) with a mandatory reason while its year is open; locked is final; closing periods out of order is allowed — accountants reopen for late adjustments; ordering adds nothing the reconciliation does not already check — none.
  • R19 Ruling: A fiscal year is 12 monthly periods; years are contiguous; provisioning creates the anchor-aligned year containing today; the anchor day must be ≤ 28 — the company profile stores one start date; irregular first years are rare in Phase 1 — an 18-month first year needs a CreateYear variant.
  • R20 Ruling: entry_no is allocated from numbering scope "journal:"+sequence_key, period = the fiscal year’s end calendar year (unique per org), formatted numbering.Format(sequence_key, endYear, n) — gapless per journal sequence per fiscal year (§10.2.4, §20.1.3) with the existing primitive — two fiscal years ending in one calendar year (a change of year-end) are refused.
  • R21 Ruling: A rule names a journal by id or by type; by type resolves the org’s active is_default_for_type journal; journal code and sequence_key are immutable — modules must not hardcode codes (§10.1) and entry numbers depend on the key — none.
  • R22 Ruling: Names follow the repo pattern: a name column holding the record-language name (the company’s default_locale, en or ms) plus translation rows for en, ms, zh, written by accounting’s own INSERTs; no i18n.Put is added — platform/i18n is outside this plan’s ownership — the two small insert helpers fold into one when an i18n writer exists.
  • R23 Ruling: Account type and parent_id are fixed at creation; currency restriction is allowed only on asset/liability accounts; a DB trigger refuses changing control_kind, currency or making an account non-postable once it has lines; only one active account per control kind except bank — keeps normal-balance semantics and subledger meaning stable after posting — reparenting the chart is a later feature.
  • R24 Ruling: Manual entries may not post to the subledger control kinds ap, employee_advances, petty_cash_ap, ar; bank and fx_gain_loss are allowed — a manual line on AP control breaks the AP-control reconciliation Plans 5–8 add — an accountant correcting a control account posts through the owning module.
  • R25 Ruling: Inactive accounts refuse new postings except reversals and the year-end closing entry — deactivating an account must not make its history uncorrectable or unclosable — none.
  • R26 Ruling: analytic_axis.applies_to is a non-empty set of account types; a required axis binds normal lines of entries other than year_end_close; the seeded cost_centre and project axes apply to income and expense and are not required; the last share on each axis absorbs the base-amount rounding residue — §10.2.6 names the rule but not the domain — orgs opt into required axes.
  • R27 Ruling: The ledger treats tax codes as opaque (pack_kind, rate, optional account_id); a tax code’s rate is immutable (a new rate is a new code with valid_from); Plan 4 computes no tax — SST computation belongs to the claim line (Plan 5); “never edited in place” (§6.2) — none.
  • R28 Ruling: countrypack.Pack is data-only (Code, ChartOfAccounts, Journals, TaxCodes); StatutoryReports, ComputeTax, EInvoicePayload, FilingPeriods are deferred to Plan 9 / Phase 2 — YAGNI; the seeding logic lives once in countrypack so CLI and API provisioning share it (§20.2) — adding methods later touches one interface and one pack.
  • R29 Ruling: Provisioning steps seed a chart / journals / tax codes only when the org has none; otherwise the step is done and reports warnings (unmapped control kind, no retained-earnings account, a journal type without a default) — §20.2’s idempotency: never overwrite an accountant’s edits — a half-seeded chart from a crash cannot happen (one transaction), so none.
  • R30 Ruling: Spec §20.2 step 9 (number sequences) is not a step — numbering.Allocate upserts on first use — none.
  • R31 Ruling: posting.Register(accounting.NewPoster(clock.System(), accounting.DefaultRules())) runs in internal/modules/accounting’s init(); cmd/worker also blank-imports that module — every binary that can run a workflow transition must have the Poster, and binaries_test.go already forces api/migrate/orgadmin to link every module — a Plan 3 test binary importing this module cannot register a fake Poster without posting.ResetForTest().
  • R32 Ruling: New permissions accounting.ledger.read, accounting.period.close, accounting.fx.manage join the existing accounting.account.manage and accounting.journal.post; running reconciliation needs only ledger.read — reconciliation is read-only — none.
  • R33 Ruling: Plan 4 adds x-go-type: money.Amount to the existing Money schema (an in-place edit of a shared file) and ticks the Phase 0 carried item “configure a sqlc and oapi-codegen type override” — this plan adds the first monetary API field — if Plan 3 makes the same edit, the merge is identical text.
  • R34 Ruling: An entry may be dated any day inside an open period, including future days; manual entry and reversal dates default to clock.TodayUTC — accountants post month-end accruals ahead — none.
  • R35 Ruling: A reversal entry and a year-end closing entry cannot themselves be reversed — reversing a reversal is a re-post, and a closing entry is undone only by reopening a year, which R17 excludes — none.
  • R36 Ruling: “Posted in this transaction” is enforced by a posted_txid xid8 DEFAULT pg_current_xact_id() column checked by the line and distribution insert guards — without it a balanced pair of lines appended to an old entry passes the balance trigger and mutates a posted entry — Postgres ≥ 13 required (the harness runs 17).
  • R37 Ruling: company.hasPostedEntries becomes EXISTS (SELECT 1 FROM journal_entry WHERE org_id = $1) and its test fixture posts a real balanced entry — the company package names Plan 4 as the owner of this change, and the old fixture creates and later drops a table named journal_entry — none.

File Task Responsibility
00050_accounting_reference.sql 2 account, tax_code, journal, analytic_axis, analytic_account, fiscal_year, fiscal_period. RLS, composite FKs, REVOKE DELETE.
00051_accounting_fx.sql 3 fx_reference_rate (global, RLS-exempt), exchange_rate (tenant overrides). Append-only.
00052_accounting_ledger.sql 9 journal_entry, journal_line, analytic_distribution, guard and balance triggers, account ledger guard. Append-only.
00053_accounting_close_log.sql 9 close_log. Append-only.
Path Task(s) Responsibility
internal/platform/accounting/accountingtest/ 2, 4–8, 11 Test fixtures (org + company profile, chart, journals, year, axes, rates). Non-test package, like identitytest.
internal/platform/accounting/chart/ 4, 7 Accounts (tree, control kinds, retained earnings), journals, tax codes.
internal/platform/accounting/analytic/ 5 Axes, analytic accounts, distribution planning; provisioning step 73.
internal/platform/accounting/fiscal/ 6 Fiscal years and periods, posting-period resolution; provisioning step 74.
internal/platform/accounting/fx/ 8 Rate resolution, overrides, reference-rate storage.
internal/platform/accounting/fx/bnm/ 10 Bank Negara feed client.
internal/platform/accounting/ledger/ 9, 11, 12 Schema tests; Post, Reverse, queries, SourceBalance.
internal/platform/accounting/reconcile/ 13 Check registry and built-in checks.
internal/platform/accounting/closing/ 17 Period close / reopen, year-end close, close_log.
internal/platform/accounting/ (root) 16 PostingRule, RuleSet, RegisterPostingRule, Poster.
internal/platform/countrypack/ 14 Pack interface, pack registry, validator, seeder, provisioning steps 70–72.
internal/platform/countrypack/my/ 15 Malaysia: chart, journals, SST codes.
internal/modules/accounting/ 18–20 HTTP surface, permissions, posting.Register.
Path Task Change
internal/kernel/money/rate.go (new file) 1 money.Rate, Amount.MulRate, Amount.Percent, RateOf.
sqlc.yaml 2, 3, 9 Per-column money.Rate overrides (append to overrides).
internal/kernel/db/retention_test.go 2, 3, 9 Append accounting tables to appendOnly / amendable.
db/rls_exempt.txt 3 fx_reference_rate with justification.
internal/platform/company/company.go, company_test.go 9 hasPostedEntries with an org predicate; real posted-entry fixture.
internal/kernel/config/bnm.go (new file), .env.example, cmd/worker/main.go 10 BNM_RATES_URL; daily sync loop.
constants.yaml (+ generated vocab) 18 Three accounting.* permissions.
api/openapi.yaml (+ internal/gen/api) 18, 19, 20 /accounting/… operations; Money x-go-type.
internal/app/app.go, internal/app/routes_test.go, cmd/{api,migrate,orgadmin,worker}/main.go, cmd/{migrate,orgadmin}/wiring_test.go 18 Compose the module.
docs/ROADMAP.md 21 Tick Plan 4 lines and the NUMERIC-override carried item.

Task 1: money.Rate — a decimal multiplier that is not money

Section titled “Task 1: money.Rate — a decimal multiplier that is not money”

Files:

  • Create: internal/kernel/money/rate.go
  • Test: internal/kernel/money/rate_test.go, internal/kernel/money/pgx_test.go

Interfaces:

  • Consumes: money.Amount (existing; unexported field d decimal.Decimal, Scale = 4).

  • Produces — every later task uses these exact names:

    const RateScale = 10
    type Rate struct{ /* d decimal.Decimal */ }
    func ParseRate(s string) (Rate, error)
    func MustParseRate(s string) Rate
    func OneRate() Rate
    func (r Rate) String() string // StringFixed(RateScale)
    func (r Rate) IsPositive() bool
    func (r Rate) IsZero() bool
    func (r Rate) Cmp(o Rate) int
    func (r Rate) Add(o Rate) Rate
    func (r Rate) Div(o Rate) (Rate, error) // ErrDivideByZero
    func (r Rate) MarshalJSON() ([]byte, error) // a JSON string
    func (r *Rate) UnmarshalJSON(b []byte) error // refuses a JSON number
    func (r Rate) Value() (driver.Value, error)
    func (r *Rate) Scan(src any) error
    func (a Amount) MulRate(r Rate) Amount // a×r, rounded half away from zero to Scale
    func (a Amount) Percent(p Rate) Amount // a×p/100, rounded to Scale
    func (a Amount) IsPositive() bool
    func RateOf(num, den Amount) (Rate, error) // num/den rounded to RateScale; ErrDivideByZero
    var ErrDivideByZero = errors.New("money: division by zero")
  • Step 1: Write the failing tests

internal/kernel/money/rate_test.go:

package money_test
func TestRateParsesToTenPlaces(t *testing.T) {
r := money.MustParseRate("4.21355555555555")
require.Equal(t, "4.2135555556", r.String(), "half away from zero at the 10th place")
}
func TestMulRateRoundsHalfAwayFromZero(t *testing.T) {
// 100.00 USD at 4.21355 = 421.355 -> 421.3550 (exact); 0.00005 cases:
require.Equal(t, "421.3550", money.MustParse("100").MulRate(money.MustParseRate("4.21355")).String())
require.Equal(t, "0.0001", money.MustParse("0.0001").MulRate(money.MustParseRate("0.5")).String(),
"0.00005 rounds away from zero, the same rule Parse applies")
require.Equal(t, "-0.0001", money.MustParse("-0.0001").MulRate(money.MustParseRate("0.5")).String())
}
func TestPercentSplitsAnAmount(t *testing.T) {
require.Equal(t, "60.0000", money.MustParse("100").Percent(money.MustParseRate("60")).String())
require.Equal(t, "33.3333", money.MustParse("100").Percent(money.MustParseRate("33.3333333333")).String())
}
func TestRateOfIsTheImpliedRate(t *testing.T) {
r, err := money.RateOf(money.MustParse("4200"), money.MustParse("1000"))
require.NoError(t, err)
require.Equal(t, "4.2000000000", r.String())
_, err = money.RateOf(money.MustParse("1"), money.Zero())
require.ErrorIs(t, err, money.ErrDivideByZero)
}
func TestRateJSONIsAStringAndRefusesANumber(t *testing.T) {
b, err := json.Marshal(money.MustParseRate("4.2"))
require.NoError(t, err)
require.Equal(t, `"4.2000000000"`, string(b))
var r money.Rate
require.NoError(t, json.Unmarshal([]byte(`"4.2"`), &r)) // PRESENCE: the string form is accepted
require.Error(t, json.Unmarshal([]byte(`4.2`), &r), "a JSON number is a double on the frontend")
}
func TestRateDivCrossesCurrencies(t *testing.T) {
// USD->MYR 4.2, SGD->MYR 3.15: USD->SGD = 4.2/3.15
r, err := money.MustParseRate("4.2").Div(money.MustParseRate("3.15"))
require.NoError(t, err)
require.Equal(t, "1.3333333333", r.String())
}

internal/kernel/money/pgx_test.go — the round trip through a real NUMERIC column, because nothing in the repository has scanned a NUMERIC into money.Amount yet and the first ledger task must not be the one that discovers pgx disagrees:

package money_test
func TestAmountAndRateRoundTripThroughPostgresNumeric(t *testing.T) {
h := testdb.Start(t)
ctx := context.Background()
var a money.Amount
require.NoError(t, h.App.QueryRow(ctx, `SELECT $1::numeric(19,4)`, money.MustParse("1234.5678")).Scan(&a))
require.Equal(t, "1234.5678", a.String())
var r money.Rate
require.NoError(t, h.App.QueryRow(ctx, `SELECT $1::numeric(19,10)`, money.MustParseRate("0.0305120000")).Scan(&r))
require.Equal(t, "0.0305120000", r.String())
// A value with more places than the column: Postgres rounds, and the Go
// type must agree with what Postgres stored rather than what was sent.
require.NoError(t, h.App.QueryRow(ctx, `SELECT $1::numeric(19,4)`, "1.23456").Scan(&a))
require.Equal(t, "1.2346", a.String())
}
  • Step 2: Run and watch fail. Run: go test ./internal/kernel/money/ -count=1. Expected: FAIL — undefined: money.Rate.

  • Step 3: Write rate.go

package money
import (
"database/sql/driver"
"encoding/json"
"errors"
"fmt"
"github.com/shopspring/decimal"
)
// Rate is a dimensionless decimal multiplier — an exchange rate or a
// percentage. It is not money: it is what turns one amount into another, so it
// has its own scale (NUMERIC(19,10)) and its own type. An Amount is only ever
// multiplied by a Rate, never by a float, which is the whole reason this type
// exists: Bank Negara quotes JPY per 100 units, and 0.030512 does not fit in
// an Amount's four places.
type Rate struct {
d decimal.Decimal
}
// RateScale matches NUMERIC(19,10).
const RateScale = 10
var ErrDivideByZero = errors.New("money: division by zero")
func ParseRate(s string) (Rate, error) {
d, err := decimal.NewFromString(s)
if err != nil {
return Rate{}, fmt.Errorf("parse rate %q: %w", s, err)
}
return Rate{d: d.Round(RateScale)}, nil
}
func MustParseRate(s string) Rate {
r, err := ParseRate(s)
if err != nil {
panic(err)
}
return r
}
func OneRate() Rate { return Rate{d: decimal.New(1, 0)} }
func (r Rate) String() string { return r.d.StringFixed(RateScale) }
func (r Rate) IsPositive() bool { return r.d.IsPositive() }
func (r Rate) IsZero() bool { return r.d.IsZero() }
func (r Rate) Cmp(o Rate) int { return r.d.Cmp(o.d) }
func (r Rate) Add(o Rate) Rate { return Rate{d: r.d.Add(o.d)} }
// Div returns r/o at RateScale. Used for cross rates through MYR.
func (r Rate) Div(o Rate) (Rate, error) {
if o.d.IsZero() {
return Rate{}, ErrDivideByZero
}
return Rate{d: r.d.DivRound(o.d, RateScale)}, nil
}
// MulRate converts an amount: a × r, rounded half away from zero to Scale —
// the same rounding rule Parse applies, so a converted amount and a typed one
// can never disagree about the fourth place.
func (a Amount) MulRate(r Rate) Amount { return Amount{d: a.d.Mul(r.d).Round(Scale)} }
// Percent is a × p / 100 at Scale. Analytic distributions use it.
func (a Amount) Percent(p Rate) Amount {
return Amount{d: a.d.Mul(p.d).DivRound(decimal.New(100, 0), Scale)}
}
func (a Amount) IsPositive() bool { return a.d.IsPositive() }
// RateOf is the rate implied by a frozen base amount: num / den.
func RateOf(num, den Amount) (Rate, error) {
if den.d.IsZero() {
return Rate{}, ErrDivideByZero
}
return Rate{d: num.d.DivRound(den.d, RateScale)}, nil
}

MarshalJSON, UnmarshalJSON, Value, Scan copy Amount’s four methods verbatim with ParseRate in place of Parse — including the refusal of a JSON number and its comment.

  • Step 4: Run. go test ./internal/kernel/money/ -count=1 -v — PASS. If TestAmountAndRateRoundTripThroughPostgresNumeric fails on scan, the fix is in Scan (accept whatever pgx hands a sql.Scanner for numeric — confirm by printing %T), never a change to the column type.

  • Step 5: Commit

Terminal window
git add internal/kernel/money/rate.go internal/kernel/money/rate_test.go internal/kernel/money/pgx_test.go
git commit -m "Add money.Rate for exchange rates and percentages"

Task 2: Migration 00050 — chart, journals, tax codes, axes, fiscal calendar

Section titled “Task 2: Migration 00050 — chart, journals, tax codes, axes, fiscal calendar”

Files:

  • Create: db/migrations/00050_accounting_reference.sql
  • Create: internal/platform/accounting/accountingtest/env.go
  • Modify: internal/kernel/db/retention_test.go, sqlc.yaml, regenerated internal/gen/sqlc/models.go
  • Test: existing drift tests (rlscoverage_test.go, retention_test.go, tenantfk_test.go, internal/gen/drift_test.go) plus internal/platform/accounting/accountingtest/env_test.go

Interfaces:

  • Consumes: money.Rate (Task 1) for the sqlc override.

  • Produces: the seven tables below, with exactly these column names; and

    package accountingtest
    var T0 = time.Date(2026, 9, 20, 10, 0, 0, 0, time.UTC) // a Sunday; FY 2026 period 9
    type Env struct {
    H *testdb.Handles
    DB *db.DB
    Ctx context.Context // carries the org
    Org uuid.UUID
    Clock clock.Clock // clock.Fixed(T0)
    }
    func New(t *testing.T) *Env // base MYR, country MY, FY start 2026-01-01, default_locale en
    func NewWithCurrency(t *testing.T, base string) *Env
    func (e *Env) Another(t *testing.T, name string) *Env // second org, same database
    func (e *Env) Tx(t *testing.T, fn func(ctx context.Context, tx pgx.Tx) error) // require.NoError(InTx)
    func (e *Env) TxErr(fn func(ctx context.Context, tx pgx.Tx) error) error
    func (e *Env) SeedMember(t *testing.T, name string) uuid.UUID // party + organization_member via admin
    func Date(s string) time.Time // "2026-09-20" -> midnight UTC; panics on bad input
  • Step 1: Write the migration

-- +goose Up
-- THE CHART OF ACCOUNTS (spec 10.1).
--
-- A tree with a materialized path, exactly like org_unit: '/<root>/.../<self>/'
-- of UUIDs, so "everything under Travel" is one indexed prefix match and a
-- rename never rewrites a subtree. Only leaves are postable; posting to a
-- parent makes the tree useless for reporting.
--
-- name is the record-language name (en or ms, spec 11A.3). Other renderings
-- are translation rows (entity_kind 'account', field 'name').
CREATE TABLE account (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID NOT NULL REFERENCES organization (id),
code TEXT NOT NULL CHECK (code ~ '^[A-Za-z0-9][A-Za-z0-9._-]{0,31}$'),
name TEXT NOT NULL CHECK (btrim(name) <> ''),
parent_id UUID,
path TEXT NOT NULL,
depth INT NOT NULL CHECK (depth BETWEEN 0 AND 9),
type TEXT NOT NULL CHECK (type IN ('asset','liability','equity','income','expense')),
-- Stored so no report needs a CASE. The CHECK below makes it a derivation,
-- not a second source of truth.
normal_balance TEXT NOT NULL,
-- What semantic_params.control_account resolves against (spec 5.5, 10.1).
-- Mirrors constants.yaml controlKinds; chart_test pins the two together.
control_kind TEXT CHECK (control_kind IN
('ap','employee_advances','petty_cash_ap','ar','bank','fx_gain_loss')),
is_control BOOLEAN GENERATED ALWAYS AS (control_kind IS NOT NULL) STORED,
-- Plan 4 Ruling R16: retained earnings is a system role, not a control kind.
system_role TEXT CHECK (system_role IN ('retained_earnings')),
is_postable BOOLEAN NOT NULL DEFAULT true,
-- NULL: any currency. Only balance-sheet accounts may be restricted
-- (Ruling R23): the year-end closing entry posts P&L accounts in base.
currency TEXT CHECK (currency ~ '^[A-Z]{3}$'),
is_active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (org_id, id),
UNIQUE (org_id, code),
FOREIGN KEY (org_id, parent_id) REFERENCES account (org_id, id),
CHECK (normal_balance = CASE WHEN type IN ('asset','expense') THEN 'debit' ELSE 'credit' END),
CHECK (control_kind IS NULL
OR (control_kind IN ('ap','petty_cash_ap') AND type = 'liability')
OR (control_kind IN ('employee_advances','ar','bank') AND type = 'asset')
OR (control_kind = 'fx_gain_loss' AND type IN ('income','expense'))),
CHECK (system_role IS NULL OR type = 'equity'),
CHECK (currency IS NULL OR type IN ('asset','liability')),
-- A header account cannot be a control account or retained earnings: both
-- are posted to.
CHECK (is_postable OR (control_kind IS NULL AND system_role IS NULL))
);
CREATE INDEX account_path_idx ON account (org_id, path text_pattern_ops);
-- One active account answers each control kind — except bank, which an org
-- has one of per bank account and which is reached through a journal instead.
CREATE UNIQUE INDEX account_one_control_per_kind ON account (org_id, control_kind)
WHERE is_active AND control_kind IS NOT NULL AND control_kind <> 'bank';
CREATE UNIQUE INDEX account_one_retained_earnings ON account (org_id)
WHERE is_active AND system_role = 'retained_earnings';
-- TAX CODES. Opaque to the ledger (Ruling R27): pack_kind is the country
-- pack's own classification, and nothing outside internal/platform/countrypack/my
-- names SST (spec 11). A rate change is a new code, never an edit.
CREATE TABLE tax_code (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID NOT NULL REFERENCES organization (id),
code TEXT NOT NULL CHECK (code ~ '^[A-Z0-9][A-Z0-9_-]{0,15}$'),
name TEXT NOT NULL CHECK (btrim(name) <> ''),
pack_kind TEXT NOT NULL,
rate NUMERIC(19,10) NOT NULL CHECK (rate >= 0 AND rate <= 100),
account_id UUID,
valid_from DATE NOT NULL,
valid_to DATE,
is_active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (org_id, id),
UNIQUE (org_id, code),
FOREIGN KEY (org_id, account_id) REFERENCES account (org_id, id),
CHECK (valid_to IS NULL OR valid_to > valid_from)
);
-- JOURNALS (spec 10.2, 18.2 item 1). Every entry belongs to one. Bank and cash
-- accounts are reached through a journal, which is why a bank or cash journal
-- must name its default account.
CREATE TABLE journal (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID NOT NULL REFERENCES organization (id),
code TEXT NOT NULL CHECK (code ~ '^[A-Z0-9]{2,10}$'),
name TEXT NOT NULL CHECK (btrim(name) <> ''),
type TEXT NOT NULL CHECK (type IN ('sales','purchase','bank','cash','misc')),
default_account_id UUID,
-- The numbering scope (Ruling R20). Immutable once created, because every
-- entry_no already issued names it.
sequence_key TEXT NOT NULL CHECK (sequence_key ~ '^[A-Z0-9]{2,10}$'),
currency TEXT CHECK (currency ~ '^[A-Z]{3}$'),
is_default_for_type BOOLEAN NOT NULL DEFAULT false,
is_active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (org_id, id),
UNIQUE (org_id, code),
FOREIGN KEY (org_id, default_account_id) REFERENCES account (org_id, id),
CHECK (type NOT IN ('bank','cash') OR default_account_id IS NOT NULL)
);
CREATE UNIQUE INDEX journal_one_default_per_type ON journal (org_id, type)
WHERE is_default_for_type AND is_active;
-- ANALYTIC AXES (spec 10.2, 18.2 item 2): dimensions are rows, not columns.
-- applies_to is the set of account types the axis may tag (Ruling R26).
CREATE TABLE analytic_axis (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID NOT NULL REFERENCES organization (id),
key TEXT NOT NULL CHECK (key ~ '^[a-z][a-z0-9_]{0,31}$'),
name TEXT NOT NULL CHECK (btrim(name) <> ''),
is_required BOOLEAN NOT NULL DEFAULT false,
applies_to TEXT[] NOT NULL CHECK (cardinality(applies_to) > 0
AND applies_to <@ ARRAY['asset','liability','equity','income','expense']::text[]),
is_active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (org_id, id),
UNIQUE (org_id, key)
);
CREATE TABLE analytic_account (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID NOT NULL REFERENCES organization (id),
axis_id UUID NOT NULL,
code TEXT NOT NULL CHECK (code ~ '^[A-Za-z0-9][A-Za-z0-9._-]{0,31}$'),
name TEXT NOT NULL CHECK (btrim(name) <> ''),
parent_id UUID,
is_active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (org_id, id),
-- The target of analytic_distribution's three-column FK, which is what
-- makes a distribution's denormalized axis_id impossible to get wrong.
UNIQUE (org_id, axis_id, id),
UNIQUE (org_id, axis_id, code),
FOREIGN KEY (org_id, axis_id) REFERENCES analytic_axis (org_id, id),
-- A parent on the same axis, enforced by the key rather than by Go.
FOREIGN KEY (org_id, axis_id, parent_id) REFERENCES analytic_account (org_id, axis_id, id)
);
-- FISCAL CALENDAR (spec 10.4). end_year is the year's label and its numbering
-- period (Ruling R20); Go computes it and the CHECK keeps it honest.
CREATE TABLE fiscal_year (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID NOT NULL REFERENCES organization (id),
name TEXT NOT NULL CHECK (btrim(name) <> ''),
starts_on DATE NOT NULL,
ends_on DATE NOT NULL,
end_year INT NOT NULL,
status TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open','closed')),
closed_at TIMESTAMPTZ,
closed_by UUID,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (org_id, id),
UNIQUE (org_id, end_year),
FOREIGN KEY (org_id, closed_by) REFERENCES organization_member (org_id, id),
CHECK (ends_on > starts_on),
CHECK (end_year = date_part('year', ends_on)::int),
CHECK ((status = 'closed') = (closed_at IS NOT NULL)),
EXCLUDE USING gist (org_id WITH =, daterange(starts_on, ends_on, '[]') WITH &&)
);
CREATE TABLE fiscal_period (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID NOT NULL REFERENCES organization (id),
fiscal_year_id UUID NOT NULL,
seq INT NOT NULL CHECK (seq BETWEEN 1 AND 12),
starts_on DATE NOT NULL,
ends_on DATE NOT NULL,
status TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open','closed','locked')),
closed_at TIMESTAMPTZ,
closed_by UUID,
UNIQUE (org_id, id),
UNIQUE (org_id, fiscal_year_id, seq),
FOREIGN KEY (org_id, fiscal_year_id) REFERENCES fiscal_year (org_id, id),
FOREIGN KEY (org_id, closed_by) REFERENCES organization_member (org_id, id),
CHECK (ends_on >= starts_on),
CHECK ((status = 'open') = (closed_at IS NULL)),
EXCLUDE USING gist (org_id WITH =, daterange(starts_on, ends_on, '[]') WITH &&)
);
-- Every table above: forced RLS, one org_isolation policy.
ALTER TABLE account ENABLE ROW LEVEL SECURITY; ALTER TABLE account FORCE ROW LEVEL SECURITY;
ALTER TABLE tax_code ENABLE ROW LEVEL SECURITY; ALTER TABLE tax_code FORCE ROW LEVEL SECURITY;
ALTER TABLE journal ENABLE ROW LEVEL SECURITY; ALTER TABLE journal FORCE ROW LEVEL SECURITY;
ALTER TABLE analytic_axis ENABLE ROW LEVEL SECURITY; ALTER TABLE analytic_axis FORCE ROW LEVEL SECURITY;
ALTER TABLE analytic_account ENABLE ROW LEVEL SECURITY; ALTER TABLE analytic_account FORCE ROW LEVEL SECURITY;
ALTER TABLE fiscal_year ENABLE ROW LEVEL SECURITY; ALTER TABLE fiscal_year FORCE ROW LEVEL SECURITY;
ALTER TABLE fiscal_period ENABLE ROW LEVEL SECURITY; ALTER TABLE fiscal_period FORCE ROW LEVEL SECURITY;
CREATE POLICY org_isolation ON account
USING (org_id = current_setting('app.current_org', true)::uuid)
WITH CHECK (org_id = current_setting('app.current_org', true)::uuid);
CREATE POLICY org_isolation ON tax_code
USING (org_id = current_setting('app.current_org', true)::uuid)
WITH CHECK (org_id = current_setting('app.current_org', true)::uuid);
CREATE POLICY org_isolation ON journal
USING (org_id = current_setting('app.current_org', true)::uuid)
WITH CHECK (org_id = current_setting('app.current_org', true)::uuid);
CREATE POLICY org_isolation ON analytic_axis
USING (org_id = current_setting('app.current_org', true)::uuid)
WITH CHECK (org_id = current_setting('app.current_org', true)::uuid);
CREATE POLICY org_isolation ON analytic_account
USING (org_id = current_setting('app.current_org', true)::uuid)
WITH CHECK (org_id = current_setting('app.current_org', true)::uuid);
CREATE POLICY org_isolation ON fiscal_year
USING (org_id = current_setting('app.current_org', true)::uuid)
WITH CHECK (org_id = current_setting('app.current_org', true)::uuid);
CREATE POLICY org_isolation ON fiscal_period
USING (org_id = current_setting('app.current_org', true)::uuid)
WITH CHECK (org_id = current_setting('app.current_org', true)::uuid);
-- Retire, never delete (spec 20.1.4, 11A.2). Default privileges granted
-- DELETE at CREATE TABLE; only a REVOKE removes it (see 00018's comment).
REVOKE DELETE ON account, tax_code, journal, analytic_axis, analytic_account,
fiscal_year, fiscal_period FROM erp_app;
-- +goose Down
DROP TABLE fiscal_period;
DROP TABLE fiscal_year;
DROP TABLE analytic_account;
DROP TABLE analytic_axis;
DROP TABLE journal;
DROP TABLE tax_code;
DROP TABLE account;
  • Step 2: Retention lists. In internal/kernel/db/retention_test.go, append to amendable:
// Plan 4 reference data. Amended in place (renamed, retired, a
// period's status), never deleted: every posted line names one of
// them, and ITA s.82A wants that line legible for seven years.
"account",
"tax_code",
"journal",
"analytic_axis",
"analytic_account",
"fiscal_year",
"fiscal_period",
  • Step 3: sqlc overrides. Append to sqlc.yaml’s overrides list (a numeric column otherwise generates as money.Amount, scale 4, which is wrong for a rate):
- column: "tax_code.rate"
go_type: github.com/mcom/erp/internal/kernel/money.Rate

Run make generate and commit the regenerated internal/gen/sqlc/models.go.

  • Step 4: Write accountingtest/env.go implementing the Interfaces block. SeedOrg inserts through h.Admin:
_, err := h.Admin.Exec(ctx, `INSERT INTO organization (id, org_id, name) VALUES ($1,$1,$2)`, org, name)
_, err = h.Admin.Exec(ctx, `
INSERT INTO company_profile (org_id, legal_name, country_code, base_currency,
fiscal_year_starts_on, default_locale)
VALUES ($1, $2, 'MY', $3, DATE '2026-01-01', 'en')`, org, name, base)

SeedMember follows internal/modules/orgstructure/handlers_test.go’s seedMember exactly. The package comment says what identitytest‘s says: a non-test package so several packages’ tests share one fixture, and its SQL is scanned by the tenant-predicate guard like any other platform code.

env_test.go: TestNewSeedsAnOrgWithACompanyProfile — company.Get inside e.Tx returns country MY, base MYR; e.Another returns a different org id whose profile is invisible from e.Ctx.

  • Step 5: Run the drift tests and watch them pass for the right reason

Run: go test ./internal/kernel/db/ ./internal/gen/ ./internal/platform/accounting/accountingtest/ -count=1 Expected: PASS. Then sabotage twice, restoring after each: (a) delete FORCE ROW LEVEL SECURITY for journal → TestEveryTableIsTenantIsolated red naming journal; (b) change journal’s default-account FK to single-column REFERENCES account (id) → TestEveryTenantForeignKeyIsCompositeOnOrgID red naming it. Quote both messages in the report.

  • Step 6: Commit
Terminal window
git add db/migrations/00050_accounting_reference.sql internal/kernel/db/retention_test.go sqlc.yaml \
internal/gen/sqlc internal/platform/accounting/accountingtest
git commit -m "Add the chart, journal, tax, analytic and fiscal tables"

Task 3: Migration 00051 — exchange rates

Section titled “Task 3: Migration 00051 — exchange rates”

Files:

  • Create: db/migrations/00051_accounting_fx.sql
  • Modify: db/rls_exempt.txt, internal/kernel/db/retention_test.go, sqlc.yaml, regenerated internal/gen/sqlc/models.go
  • Test: existing drift tests

Interfaces:

  • Produces: tables fx_reference_rate and exchange_rate with the columns below. Task 8 owns every query against them.

  • Step 1: Write the migration

-- +goose Up
-- PUBLISHED REFERENCE RATES (spec 6.5, 10.5; Ruling R10).
--
-- Global, not per tenant: Bank Negara's published rate for a day is the same
-- fact for every organization, carries no tenant data, and is written by the
-- worker, which has no organization to assert. It is listed in
-- db/rls_exempt.txt with that argument. Nothing under internal/modules may
-- name it; internal/platform/accounting/fx is the only reader and writer.
--
-- rate is MYR per ONE unit of currency: the feed's per-100 quotes (JPY, IDR,
-- ...) are divided by unit before they are stored, so no reader ever has to
-- remember which currencies are quoted per hundred.
CREATE TABLE fx_reference_rate (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
source TEXT NOT NULL CHECK (source IN ('bnm')),
currency TEXT NOT NULL CHECK (currency ~ '^[A-Z]{3}$'),
quote TEXT NOT NULL DEFAULT 'MYR' CHECK (quote = 'MYR'),
rate_date DATE NOT NULL,
session TEXT NOT NULL,
rate NUMERIC(19,10) NOT NULL CHECK (rate > 0),
fetched_at TIMESTAMPTZ NOT NULL,
UNIQUE (source, currency, rate_date, session)
);
CREATE INDEX fx_reference_rate_lookup ON fx_reference_rate (currency, rate_date DESC);
-- A published rate is evidence of what a posting used. It is never edited:
-- a wrong feed day is superseded by an org override, not rewritten.
REVOKE UPDATE, DELETE ON fx_reference_rate FROM erp_app;
-- PER-ORGANIZATION OVERRIDES ("manual override recorded with actor and
-- reason", spec 10.5). Append-only: a corrected override is a newer row, and
-- resolution takes the newest (Ruling R11).
CREATE TABLE exchange_rate (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID NOT NULL REFERENCES organization (id),
from_currency TEXT NOT NULL CHECK (from_currency ~ '^[A-Z]{3}$'),
to_currency TEXT NOT NULL CHECK (to_currency ~ '^[A-Z]{3}$'),
rate_date DATE NOT NULL,
rate NUMERIC(19,10) NOT NULL CHECK (rate > 0),
reason TEXT NOT NULL CHECK (btrim(reason) <> ''),
created_by UUID,
created_at TIMESTAMPTZ NOT NULL,
UNIQUE (org_id, id),
FOREIGN KEY (org_id, created_by) REFERENCES organization_member (org_id, id),
CHECK (from_currency <> to_currency)
);
CREATE INDEX exchange_rate_lookup ON exchange_rate (org_id, from_currency, to_currency, rate_date, created_at DESC);
ALTER TABLE exchange_rate ENABLE ROW LEVEL SECURITY;
ALTER TABLE exchange_rate FORCE ROW LEVEL SECURITY;
CREATE POLICY org_isolation ON exchange_rate
USING (org_id = current_setting('app.current_org', true)::uuid)
WITH CHECK (org_id = current_setting('app.current_org', true)::uuid);
REVOKE UPDATE, DELETE ON exchange_rate FROM erp_app;
-- +goose Down
DROP TABLE exchange_rate;
DROP TABLE fx_reference_rate;
  • Step 2: db/rls_exempt.txt — append:
# fx_reference_rate (00051): Bank Negara Malaysia's published exchange rates.
# Public reference data, identical for every tenant, containing no tenant or
# personal data, written by the worker (which has no organization to assert —
# see cmd/worker's session prune for the same shape) and read by every tenant.
# A per-tenant copy would need the worker to enumerate organizations, which RLS
# forbids by design. Condition of the exemption, as for the identity tables:
# no package under internal/modules names it; internal/platform/accounting/fx
# is its only reader and writer. Append-only: erp_app has no UPDATE or DELETE.
fx_reference_rate
  • Step 3: Retention. Append to appendOnly in retention_test.go:
// Plan 4. A rate is evidence of what a posting used (spec 11A.4):
// superseded by a newer row, never edited.
"fx_reference_rate",
"exchange_rate",
  • Step 4: sqlc overrides — append fx_reference_rate.rate and exchange_rate.rate → money.Rate exactly as in Task 2 Step 3. make generate.

  • Step 5: Run and sabotage. go test ./internal/kernel/db/ ./internal/kernel/layering/ ./internal/gen/ -count=1 → PASS. Sabotage: remove the REVOKE UPDATE, DELETE ON exchange_rate line → retention test red naming exchange_rate; restore. Remove the rls_exempt.txt entry → TestEveryTableIsTenantIsolated red naming fx_reference_rate; restore.

  • Step 6: Commit

Terminal window
git add db/migrations/00051_accounting_fx.sql db/rls_exempt.txt internal/kernel/db/retention_test.go sqlc.yaml internal/gen/sqlc
git commit -m "Add reference exchange rates and per-organization overrides"

Task 4: chart — accounts, control kinds, retained earnings

Section titled “Task 4: chart — accounts, control kinds, retained earnings”

Files:

  • Create: internal/platform/accounting/chart/doc.go, account.go, errors.go
  • Create: internal/platform/accounting/accountingtest/chart.go
  • Test: internal/platform/accounting/chart/account_test.go

Interfaces:

  • Consumes: table account (Task 2); accountingtest.Env (Task 2); audit.Write, tenancy.OrgFromContext, vocab.ControlKinds.
  • Produces:
    package chart
    const (TypeAsset = "asset"; TypeLiability = "liability"; TypeEquity = "equity"; TypeIncome = "income"; TypeExpense = "expense")
    const RoleRetainedEarnings = "retained_earnings"
    const MaxDepth = 10
    type Account struct {
    ID uuid.UUID
    ParentID *uuid.UUID
    Code, Name string
    Path string
    Depth int
    Type string
    NormalBalance string // "debit" | "credit"
    ControlKind *string
    SystemRole *string
    IsPostable bool
    Currency *string
    IsActive bool
    }
    type AccountInput struct {
    ParentID *uuid.UUID
    Code, Name string
    Type string
    Header bool // true: not postable (a grouping node)
    ControlKind *string
    SystemRole *string
    Currency *string
    }
    type AccountPatch struct {
    Code, Name *string
    ControlKind **string // nil: leave; &nil: clear
    Currency **string
    IsActive *bool
    }
    func CreateAccount(ctx context.Context, tx pgx.Tx, c clock.Clock, in AccountInput) (Account, error)
    func GetAccount(ctx context.Context, tx pgx.Tx, id uuid.UUID) (Account, error)
    func GetAccountByCode(ctx context.Context, tx pgx.Tx, code string) (Account, error)
    func ListAccounts(ctx context.Context, tx pgx.Tx) ([]Account, error) // ORDER BY path
    func UpdateAccount(ctx context.Context, tx pgx.Tx, c clock.Clock, id uuid.UUID, p AccountPatch) (Account, error)
    func ResolveControl(ctx context.Context, tx pgx.Tx, kind string) (Account, error)
    func RetainedEarnings(ctx context.Context, tx pgx.Tx) (Account, error)
    func IsSubledgerKind(kind string) bool // ap, employee_advances, petty_cash_ap, ar (Ruling R24)
    var ErrNotFound, ErrDuplicateCode, ErrTooDeep, ErrParentMismatch, ErrParentIsControl,
    ErrUnknownControlKind, ErrNoRetainedEarnings, ErrInvalidAccount error
    type NoControlAccountError struct{ Kind string } // Error(): names the kind
    type AmbiguousControlError struct{ Kind string; Codes []string }
    package accountingtest
    type Chart struct {
    Assets, Liabilities, Equity, Income, Expenses chart.Account // headers
    Bank, Cash, AR, Advances chart.Account
    AP, PettyCashAP chart.Account
    RetainedEarnings chart.Account
    Revenue, FX, Travel, Meals chart.Account
    }
    func (e *Env) SeedChart(t *testing.T) Chart

Rules this task implements (each has a test):

  1. Tree. path = parent.Path + id + "/" (root: "/" + id + "/"), depth = parent.Depth + 1, refused past MaxDepth with ErrTooDeep. A child’s Type must equal its parent’s (ErrParentMismatch). A parent that is a control account or has a system role cannot take children (ErrParentIsControl). Creating a child sets the parent is_postable = false in the same transaction (Task 9’s trigger will refuse that UPDATE if the parent already has lines — that is the database answering, not this package).
  2. Normal balance is derived in Go (debit for asset/expense, else credit) and the migration’s CHECK agrees.
  3. Control kinds. ControlKind must be one of vocab.ControlKinds (ErrUnknownControlKind) — checked in Go for a clean error; the DB CHECK is the backstop. ResolveControl(kind) returns the one active account with that kind; zero → *NoControlAccountError{Kind}; more than one (possible only for bank) → *AmbiguousControlError.
  4. Retained earnings. RetainedEarnings returns the active system_role = 'retained_earnings' account or ErrNoRetainedEarnings.
  5. Updates. Type, ParentID are not in the patch (Ruling R23). Unique violations on (org_id, code) map to ErrDuplicateCode; unique violations on account_one_control_per_kind / account_one_retained_earnings map to *AmbiguousControlError — translate SQLSTATE 23505 by constraint name with pgconn.PgError, never pre-check.
  6. Audit. CreateAccount and UpdateAccount each write one audit.Entry{EntityKind: "account", Action: "create"|"update", Before, After} as their last statement.
  7. Every query carries org_id = $n. GetAccount of an unknown id or another org’s id returns ErrNotFound, never a zero value.
  • Step 1: Write the failing tests (account_test.go, package chart_test, each starting with e := accountingtest.New(t)):
func TestCreateBuildsPathAndDepth(t *testing.T) // root "/<id>/" depth 0; child path extends, depth 1
func TestNormalBalanceFollowsType(t *testing.T) // table over the five types
func TestChildMustShareItsParentsType(t *testing.T) // PRESENCE: expense under expense ok; ABSENCE: income under expense -> ErrParentMismatch
func TestAParentStopsBeingPostable(t *testing.T) // leaf postable; after a child is created, GetAccount(parent).IsPostable == false
func TestDepthIsCapped(t *testing.T) // 10 nested ok, 11th ErrTooDeep
func TestControlKindsMatchTheVocabulary(t *testing.T) {
// Every vocab.ControlKinds value is accepted by BOTH Go and the DB CHECK
// (create one account per kind with the matching type), and "cash" is
// refused by Go with ErrUnknownControlKind. This pins the migration's
// CHECK list to constants.yaml: a kind added to one and not the other
// fails here.
}
func TestResolveControlFindsTheOneAccount(t *testing.T) // PRESENCE ap resolves; deactivate it -> *NoControlAccountError{Kind:"ap"} (errors.As, assert Kind)
func TestASecondActiveControlAccountIsRefused(t *testing.T) // second active "ap" -> *AmbiguousControlError; a second "bank" is ACCEPTED (presence of the exception)
func TestTwoBankAccountsMakeBankAmbiguous(t *testing.T) // ResolveControl("bank") with two -> *AmbiguousControlError naming both codes
func TestRetainedEarningsIsDesignatedOnce(t *testing.T)
func TestCurrencyRestrictionOnlyOnBalanceSheetAccounts(t *testing.T) // USD on asset ok; USD on expense -> ErrInvalidAccount (Go)
func TestDuplicateCodeIsAConflict(t *testing.T)
func TestAnotherOrgsAccountIsInvisible(t *testing.T) {
// PRESENCE: GetAccount from e.Ctx returns it. ABSENCE: from e.Another's
// ctx, GetAccount and ListAccounts do not return it, and ResolveControl
// in the other org reports NoControlAccountError rather than finding it.
}
func TestMutationsWriteOneAuditRowEach(t *testing.T) {
// Count audit_log rows with entity_kind='account' through e.H.Admin
// before and after one create and one update: +1 each. Then run a create
// in a transaction that returns an error after CreateAccount: +0.
}
  • Step 2: Run and watch fail. go test ./internal/platform/accounting/chart/ -count=1 → does not compile.

  • Step 3: Implement doc.go, errors.go, account.go. doc.go carries the package comment: this package owns the chart, journals and tax codes (spec 10.1, 10.2, 11); modules never hardcode an account code, they name a control kind and ResolveControl answers. The insert:

err := tx.QueryRow(ctx, `
INSERT INTO account (id, org_id, code, name, parent_id, path, depth, type, normal_balance,
control_kind, system_role, is_postable, currency)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)
RETURNING created_at`, id, orgID, in.Code, in.Name, in.ParentID, path, depth, in.Type,
normalBalance(in.Type), in.ControlKind, in.SystemRole, !in.Header, in.Currency).Scan(&createdAt)

The id is generated in Go (uuid.New()) before the insert because the path contains it.

  • Step 4: Write accountingtest/chart.go — SeedChart creates, in this order, through chart.CreateAccount inside one e.Tx: headers 1000 Assets, 2000 Liabilities, 3000 Equity, 4000 Income, 6000 Expenses; leaves 1520 Bank (bank), 1510 Cash in hand, 1540 Trade receivables (ar), 1550 Employee advances (employee_advances), 2110 Trade and other payables (ap), 2130 Petty cash payable (petty_cash_ap), 3200 Retained earnings (retained_earnings), 4100 Revenue, 6100 Travelling, 6110 Meals, 6900 Foreign exchange (gain)/loss (fx_gain_loss, type expense).

  • Step 5: Run. PASS. Sabotage: delete the org_id = $n predicate from GetAccount’s query; go test ./internal/kernel/layering/ -run TenantQuery -count=1 goes red naming chart/account.go:GetAccount:account; restore.

  • Step 6: Commit

Terminal window
git add internal/platform/accounting/chart internal/platform/accounting/accountingtest/chart.go
git commit -m "Add the chart of accounts with control kinds"

Task 5: analytic — axes, analytic accounts, distribution planning

Section titled “Task 5: analytic — axes, analytic accounts, distribution planning”

Files:

  • Create: internal/platform/accounting/analytic/analytic.go, plan.go, step.go, errors.go
  • Create: internal/platform/accounting/accountingtest/analytic.go
  • Test: internal/platform/accounting/analytic/analytic_test.go, plan_test.go, step_test.go

Interfaces:

  • Consumes: analytic_axis, analytic_account (Task 2); money.Rate, Amount.Percent (Task 1); provisioning.Register, provisioning.Warning.
  • Produces:
    package analytic
    type Axis struct {
    ID uuid.UUID
    Key, Name string
    IsRequired bool
    AppliesTo []string // account types
    IsActive bool
    }
    type Account struct {
    ID, AxisID uuid.UUID
    ParentID *uuid.UUID
    Code, Name string
    IsActive bool
    }
    type AxisInput struct { Key, Name string; IsRequired bool; AppliesTo []string }
    type AxisPatch struct { Name *string; IsRequired *bool; AppliesTo []string /* nil: leave */; IsActive *bool }
    type AccountInput struct { AxisID uuid.UUID; ParentID *uuid.UUID; Code, Name string }
    type AccountPatch struct { Name *string; IsActive *bool }
    func CreateAxis(ctx context.Context, tx pgx.Tx, c clock.Clock, in AxisInput) (Axis, error)
    func UpdateAxis(ctx context.Context, tx pgx.Tx, c clock.Clock, id uuid.UUID, p AxisPatch) (Axis, error)
    func ListAxes(ctx context.Context, tx pgx.Tx) ([]Axis, error)
    func GetAxis(ctx context.Context, tx pgx.Tx, id uuid.UUID) (Axis, error)
    func CreateAccount(ctx context.Context, tx pgx.Tx, c clock.Clock, in AccountInput) (Account, error)
    func UpdateAccount(ctx context.Context, tx pgx.Tx, c clock.Clock, id uuid.UUID, p AccountPatch) (Account, error)
    func ListAccounts(ctx context.Context, tx pgx.Tx, axisID uuid.UUID) ([]Account, error)
    // Share is one requested slice of a line: "60% to project A".
    type Share struct { AnalyticAccountID uuid.UUID; Percentage money.Rate }
    // PlannedShare is a validated share with its frozen base amount.
    type PlannedShare struct {
    AnalyticAccountID, AxisID uuid.UUID
    Percentage money.Rate
    BaseAmount money.Amount
    }
    // Plan validates shares for a line on an account of accountType whose base
    // amount is lineBase, and computes each share's base amount. enforceRequired
    // is false for fx_difference lines and year-end closing lines (Ruling R26).
    func Plan(ctx context.Context, tx pgx.Tx, accountType string, lineBase money.Amount,
    shares []Share, enforceRequired bool) ([]PlannedShare, error)
    const StepKeyAxes = "analytic_axes"; const StepOrderAxes = 73
    var ErrNotFound, ErrDuplicateKey, ErrDuplicateCode, ErrInvalidAppliesTo, ErrParentOtherAxis,
    ErrAccountInactive, ErrAxisNotApplicable, ErrDuplicateShare error
    type TotalError struct { AxisKey string; Total money.Rate } // not 100
    type RequiredError struct { AxisKey string }
    package accountingtest
    type Axes struct { CostCentre analytic.Axis; HQ, Ops analytic.Account; Project analytic.Axis; ProjA, ProjB analytic.Account }
    func (e *Env) SeedAxes(t *testing.T, costCentreRequired bool) Axes

Plan’s algorithm, which Task 9’s trigger re-checks at commit:

  1. Load each share’s analytic account (active, this org) → its axis. Unknown → ErrNotFound; inactive account or axis → ErrAccountInactive. The same analytic account twice → ErrDuplicateShare.
  2. Group by axis in input order. An axis whose AppliesTo does not contain accountType → ErrAxisNotApplicable.
  3. Each group’s percentages must sum to exactly 100 (money.Rate compare) → else *TotalError.
  4. If enforceRequired: every active IsRequired axis whose AppliesTo contains accountType must have a group → else *RequiredError{AxisKey}.
  5. BaseAmount = lineBase.Percent(p) for all but the group’s last share; the last gets lineBase − sum(others), so each axis’s base amounts sum to exactly the line’s base (Ruling R26).

The provisioning step (key analytic_axes, order 73), registered from this package’s init() with provisioning.Register:

  • Check: done when axes with keys cost_centre and project exist (active or not — a retired seeded axis is the org’s decision, not a gap).
  • Run: creates each missing one through CreateAxis with AppliesTo = {income, expense}, IsRequired = false, Name = the English name when the company’s default_locale is en, the Malay one when ms; and inserts translation rows for all three locales:
var seededAxes = []struct{ key string; names map[string]string }{
{"cost_centre", map[string]string{"en": "Cost centre", "ms": "Pusat kos", "zh": "成本中心"}},
{"project", map[string]string{"en": "Project", "ms": "Projek", "zh": "项目"}},
}
// writeTranslation is the INSERT i18n.Load reads. Kept here rather than added
// to platform/i18n because that package is outside this plan's ownership
// (Ruling R22).
_, err := tx.Exec(ctx, `
INSERT INTO translation (org_id, entity_kind, entity_id, field, locale, value)
VALUES ($1, $2, $3, 'name', $4, $5)
ON CONFLICT (org_id, entity_kind, entity_id, field, locale) WHERE org_id IS NOT NULL DO NOTHING`,
orgID, "analytic_axis", id, locale, value)
  • Step 1: Write the failing tests

analytic_test.go: create/list axes and accounts; applies_to outside the five types → ErrInvalidAppliesTo; a parent on another axis → ErrParentOtherAxis (and the DB’s three-column FK refuses the same row inserted through e.H.Admin with a forged parent — presence: a same-axis parent inserts fine); cross-org invisibility with its presence half; one audit row per mutation.

plan_test.go — table-driven, each case against e.SeedAxes(t, true):

func TestPlanSplitsSixtyForty(t *testing.T) {
// 100.0000 base, ProjA 60 / ProjB 40 on project + HQ 100 on cost_centre:
// base amounts 60.0000 / 40.0000 / 100.0000.
}
func TestTheLastShareAbsorbsTheResidue(t *testing.T) {
// base 100.0000, three shares 33.3333333333 / 33.3333333333 / 33.3333333334:
// 33.3333 / 33.3333 / 33.3334 — the axis sums to 100.0000 exactly.
}
func TestAPartialAxisIsRefused(t *testing.T) // 60 on project alone -> *TotalError{AxisKey:"project"}
func TestARequiredAxisMustBePresent(t *testing.T) {
// PRESENCE: with enforceRequired=false and no cost_centre share, Plan
// succeeds. ABSENCE: enforceRequired=true -> *RequiredError{"cost_centre"}.
}
func TestAnAxisNotApplicableToTheAccountTypeIsRefused(t *testing.T) // cost_centre share on an "asset" line -> ErrAxisNotApplicable
func TestAnInactiveAnalyticAccountIsRefused(t *testing.T)

step_test.go: run the step twice through a fresh provisioning.NewRegistry() holding only the axes step (plus a stub done company_profile): first run creates two axes and six translation rows; second run creates nothing (idempotent); retiring project and re-running does not recreate it.

  • Step 2: Run and watch fail.

  • Step 3: Implement. Every mutation audits (EntityKind "analytic_axis" / "analytic_account").

  • Step 4: Write accountingtest/analytic.go — SeedAxes creates both axes (cost centre IsRequired per the argument) and accounts HQ, OPS on cost centre and PA, PB on project.

  • Step 5: Run. go test ./internal/platform/accounting/analytic/ -count=1 -v → PASS.

  • Step 6: Commit

Terminal window
git add internal/platform/accounting/analytic internal/platform/accounting/accountingtest/analytic.go
git commit -m "Add analytic axes, accounts and ratio distribution planning"

Task 6: fiscal — years, periods, posting-period resolution

Section titled “Task 6: fiscal — years, periods, posting-period resolution”

Files:

  • Create: internal/platform/accounting/fiscal/fiscal.go, calendar.go, step.go, errors.go
  • Create: internal/platform/accounting/accountingtest/fiscal.go
  • Test: internal/platform/accounting/fiscal/calendar_test.go, fiscal_test.go, step_test.go

Interfaces:

  • Consumes: fiscal_year, fiscal_period (Task 2); company.Get (for the anchor); provisioning.Register.
  • Produces:
    package fiscal
    const (StatusOpen = "open"; StatusClosed = "closed"; StatusLocked = "locked")
    type Year struct {
    ID uuid.UUID
    Name string
    StartsOn, EndsOn time.Time // midnight UTC; EndsOn inclusive
    EndYear int
    Status string // open | closed
    ClosedAt *time.Time
    ClosedBy *uuid.UUID
    }
    type Period struct {
    ID, FiscalYearID uuid.UUID
    Seq int
    StartsOn, EndsOn time.Time
    Status string // open | closed | locked
    ClosedAt *time.Time
    ClosedBy *uuid.UUID
    }
    // Pure calendar arithmetic.
    func YearStartContaining(anchor, day time.Time) (time.Time, error) // ErrAnchorDay if anchor day > 28
    func MonthlyPeriods(start time.Time) [12][2]time.Time // [i] = {starts_on, ends_on}
    func CreateYear(ctx context.Context, tx pgx.Tx, c clock.Clock, startsOn time.Time) (Year, []Period, error)
    func GetYear(ctx context.Context, tx pgx.Tx, id uuid.UUID) (Year, error)
    func ListYears(ctx context.Context, tx pgx.Tx) ([]Year, error) // ORDER BY starts_on
    func ListPeriods(ctx context.Context, tx pgx.Tx, yearID uuid.UUID) ([]Period, error) // ORDER BY seq
    func GetPeriod(ctx context.Context, tx pgx.Tx, id uuid.UUID) (Period, error)
    func YearContaining(ctx context.Context, tx pgx.Tx, day time.Time) (Year, error)
    // PeriodForPosting resolves day to its period and takes FOR SHARE on the
    // row, so a concurrent close waits for this transaction (Ruling R4).
    func PeriodForPosting(ctx context.Context, tx pgx.Tx, day time.Time) (Period, error)
    // LockPeriod / LockYear take FOR UPDATE. Used by closing (Task 17).
    func LockPeriod(ctx context.Context, tx pgx.Tx, id uuid.UUID) (Period, error)
    func LockYear(ctx context.Context, tx pgx.Tx, id uuid.UUID) (Year, error)
    func LockYearPeriods(ctx context.Context, tx pgx.Tx, yearID uuid.UUID) ([]Period, error) // FOR UPDATE, ORDER BY seq
    // Status writers WITHOUT audit: closing owns the audited operation.
    func SetPeriodStatus(ctx context.Context, tx pgx.Tx, c clock.Clock, id uuid.UUID, status string, by *uuid.UUID) error
    func SetYearClosed(ctx context.Context, tx pgx.Tx, c clock.Clock, id uuid.UUID, by *uuid.UUID) error
    const StepKeyFiscalYear = "fiscal_year"; const StepOrderFiscalYear = 74
    var ErrNotFound, ErrAnchorDay, ErrOverlap, ErrNotContiguous, ErrDuplicateEndYear,
    ErrNoFiscalPeriod error
    type PeriodNotOpenError struct { PeriodID uuid.UUID; Status string }
    package accountingtest
    func (e *Env) OpenYear(t *testing.T) (fiscal.Year, []fiscal.Period) // FY 2026-01-01..2026-12-31

Rules:

  1. YearStartContaining(anchor, day): the date with anchor’s month and day in day’s year, or the previous year if that is after day. Anchor day > 28 → ErrAnchorDay (Ruling R19).
  2. MonthlyPeriods(start): period i starts start.AddDate(0, i, 0) and ends the day before period i+1 starts; period 12 ends start.AddDate(1, 0, -1).
  3. CreateYear: name "FY" + endYear; if any year exists, startsOn must equal the latest year’s EndsOn + 1 day (ErrNotContiguous); overlap maps SQLSTATE 23P01 → ErrOverlap; (org_id, end_year) unique → ErrDuplicateEndYear. Writes the year and 12 periods, then one audit.Entry{EntityKind: "fiscal_year", Action: "create", After: {year, periods}}.
  4. PeriodForPosting(day): SELECT … FROM fiscal_period WHERE org_id = $1 AND starts_on <= $2 AND ends_on >= $2 FOR SHARE. No row → ErrNoFiscalPeriod; status not open → *PeriodNotOpenError.
  5. SetPeriodStatus writes closed_at = c.Now(), closed_by = by for closed/locked and NULL, NULL for open — the migration’s CHECK ties status to closed_at. SetYearClosed likewise.
  6. Step fiscal_year (order 74), init()-registered: Check done when YearContaining(clock.TodayUTC(c)) exists; Run reads company.Get → YearStartContaining(profile.FiscalYearStartsOn, today) → CreateYear.
  • Step 1: Write the failing tests

calendar_test.go (pure, no database):

func TestYearStartContaining(t *testing.T) {
cases := []struct{ anchor, day, want string }{
{"2026-01-01", "2026-09-20", "2026-01-01"},
{"2025-07-01", "2026-09-20", "2026-07-01"},
{"2025-07-01", "2026-03-15", "2025-07-01"},
{"2020-04-15", "2026-04-14", "2025-04-15"},
{"2020-04-15", "2026-04-15", "2026-04-15"},
}
for _, c := range cases {
got, err := fiscal.YearStartContaining(accountingtest.Date(c.anchor), accountingtest.Date(c.day))
require.NoError(t, err)
require.Equal(t, accountingtest.Date(c.want), got, "anchor %s, day %s", c.anchor, c.day)
}
}
func TestAnAnchorPast28IsRefused(t *testing.T) // "2026-01-31" -> ErrAnchorDay
func TestMonthlyPeriodsTileTheYear(t *testing.T) {
// start 2025-07-01: period 1 = 07-01..07-31, period 8 = 2026-02-01..02-28,
// period 12 = 2026-06-01..06-30; each period starts the day after the
// previous ends — assert all eleven joins, not just the endpoints.
}

fiscal_test.go:

func TestCreateYearWritesTwelvePeriodsAndOneAuditRow(t *testing.T)
func TestYearsMustBeContiguous(t *testing.T) {
// PRESENCE: FY2027 starting 2027-01-01 after FY2026 is accepted.
// ABSENCE: FY2029 starting 2029-01-01 (a gap) -> ErrNotContiguous.
}
func TestOverlappingYearsAreRefusedByTheDatabase(t *testing.T) // insert via e.H.Admin a year overlapping FY2026 -> SQLSTATE 23P01
func TestPeriodForPostingResolvesAndRefusesClosed(t *testing.T) {
// PRESENCE: 2026-09-20 resolves to seq 9, open.
// SetPeriodStatus(seq 9, closed) then PeriodForPosting -> *PeriodNotOpenError{Status:"closed"}.
// 2027-02-01 (no year) -> ErrNoFiscalPeriod.
}
func TestPeriodForPostingBlocksAConcurrentClose(t *testing.T) {
// Tx A: PeriodForPosting(2026-09-20) and hold. Tx B (a second
// connection): LockPeriod(same id) in a goroutine. Assert B has not
// returned after 200ms; commit A; B returns within 2s. This is the row
// lock Ruling R4 relies on instead of an advisory lock.
}
func TestAnotherOrgsYearIsInvisible(t *testing.T)

step_test.go: the step creates FY2026 for an org whose profile anchor is 2026-01-01 and T0 is 2026-09-20; re-running is a no-op; an org with anchor 2025-07-01 gets 2026-07-01..2027-06-30.

  • Step 2: Run and watch fail.
  • Step 3: Implement.
  • Step 4: Write accountingtest/fiscal.go — OpenYear calls fiscal.CreateYear(accountingtest.Date("2026-01-01")).
  • Step 5: Run. go test ./internal/platform/accounting/fiscal/ -count=1 -v → PASS.
  • Step 6: Commit
Terminal window
git add internal/platform/accounting/fiscal internal/platform/accounting/accountingtest/fiscal.go
git commit -m "Add fiscal years and periods, and posting-period resolution"

Files:

  • Create: internal/platform/accounting/chart/journal.go, taxcode.go
  • Create: internal/platform/accounting/accountingtest/journals.go
  • Test: internal/platform/accounting/chart/journal_test.go, taxcode_test.go

Interfaces:

  • Consumes: journal, tax_code (Task 2); chart.GetAccount, ErrNotFound, ErrDuplicateCode (Task 4).
  • Produces:
    package chart
    const (JournalSales = "sales"; JournalPurchase = "purchase"; JournalBank = "bank"; JournalCash = "cash"; JournalMisc = "misc")
    type Journal struct {
    ID uuid.UUID
    Code, Name, Type string
    DefaultAccountID *uuid.UUID
    SequenceKey string
    Currency *string
    IsDefaultForType bool
    IsActive bool
    }
    type JournalInput struct {
    Code, Name, Type string
    DefaultAccountID *uuid.UUID
    SequenceKey string // "" -> Code
    Currency *string
    IsDefaultForType bool
    }
    type JournalPatch struct { Name *string; DefaultAccountID *uuid.UUID; IsDefaultForType *bool; IsActive *bool }
    func CreateJournal(ctx context.Context, tx pgx.Tx, c clock.Clock, in JournalInput) (Journal, error)
    func UpdateJournal(ctx context.Context, tx pgx.Tx, c clock.Clock, id uuid.UUID, p JournalPatch) (Journal, error)
    func GetJournal(ctx context.Context, tx pgx.Tx, id uuid.UUID) (Journal, error)
    func ListJournals(ctx context.Context, tx pgx.Tx) ([]Journal, error)
    func DefaultJournal(ctx context.Context, tx pgx.Tx, typ string) (Journal, error)
    type NoDefaultJournalError struct{ Type string }
    var ErrInvalidJournal error // bad type, bank/cash without account, wrong account kind
    var ErrInvalidTaxCode error // rate outside 0..100, blank code/name/pack_kind, valid_to <= valid_from
    type TaxCode struct {
    ID uuid.UUID
    Code, Name, PackKind string
    Rate money.Rate
    AccountID *uuid.UUID
    ValidFrom time.Time
    ValidTo *time.Time
    IsActive bool
    }
    type TaxCodeInput struct { Code, Name, PackKind string; Rate money.Rate; AccountID *uuid.UUID; ValidFrom time.Time; ValidTo *time.Time }
    func CreateTaxCode(ctx context.Context, tx pgx.Tx, c clock.Clock, in TaxCodeInput) (TaxCode, error)
    func GetTaxCode(ctx context.Context, tx pgx.Tx, id uuid.UUID) (TaxCode, error)
    func ListTaxCodes(ctx context.Context, tx pgx.Tx) ([]TaxCode, error)
    func SetTaxCodeActive(ctx context.Context, tx pgx.Tx, c clock.Clock, id uuid.UUID, active bool) (TaxCode, error)
    package accountingtest
    type Journals struct { Misc, Purchase, Bank chart.Journal } // MSC (default misc), PUR (default purchase), BNK (default bank, account ch.Bank)
    func (e *Env) SeedJournals(t *testing.T, ch Chart) Journals

Rules:

  1. A bank journal’s default account must be an active postable account with control_kind = 'bank'; a cash journal’s must be an active postable asset account; otherwise ErrInvalidJournal. If the journal has a Currency, its default account’s currency (if set) must match.
  2. Code and SequenceKey are immutable (not in the patch) — Ruling R21.
  3. Setting IsDefaultForType true first clears it on every other journal of that type in the org (UPDATE journal SET is_default_for_type = false WHERE org_id = $1 AND type = $2 AND id <> $3), then sets it — the partial unique index would refuse the reverse order.
  4. DefaultJournal(typ) returns the active default journal of that type or *NoDefaultJournalError{Type}.
  5. Tax codes: no rate or validity edit exists (Ruling R27); only active/inactive. Rate must be within 0..100.
  6. Every mutation audits ("journal", "tax_code").
  • Step 1: Write the failing tests
func TestABankJournalNeedsABankAccount(t *testing.T) {
// PRESENCE: BNK with default ch.Bank is accepted.
// ABSENCE: a bank journal defaulting to ch.Revenue -> ErrInvalidJournal;
// a bank journal with no default account -> ErrInvalidJournal (Go), and
// the same row via e.H.Admin violates the migration's CHECK.
}
func TestOnlyOneDefaultPerType(t *testing.T) {
// Make a second misc journal the default: the first stops being the
// default in the same transaction, and DefaultJournal("misc") returns
// the second.
}
func TestNoDefaultJournalNamesTheType(t *testing.T) // errors.As -> Type == "sales"
func TestARateChangeIsANewCode(t *testing.T) {
// Ruling R27, behaviourally: SVT6 (6%, from 2018-09-01) then SVT8 (8%,
// from 2024-03-01); SetTaxCodeActive(SVT6, false). Both list; SVT6 keeps
// Rate 6 and IsActive false; SVT8 has Rate 8. A duplicate code -> ErrDuplicateCode.
}
func TestATaxRateOutsideZeroToHundredIsRefused(t *testing.T) // 100.5 -> ErrInvalidTaxCode (Go); the DB CHECK refuses the same row via e.H.Admin
func TestJournalsAndTaxCodesAreTenantScoped(t *testing.T) // presence/absence across e.Another
  • Step 2: Run and watch fail. go test ./internal/platform/accounting/chart/ -run 'Journal|Tax' -count=1
  • Step 3: Implement journal.go, taxcode.go.
  • Step 4: Write accountingtest/journals.go.
  • Step 5: Run the whole package. PASS.
  • Step 6: Commit
Terminal window
git add internal/platform/accounting/chart/journal.go internal/platform/accounting/chart/taxcode.go \
internal/platform/accounting/chart/journal_test.go internal/platform/accounting/chart/taxcode_test.go \
internal/platform/accounting/accountingtest/journals.go
git commit -m "Add journals and tax codes"

Task 8: fx — rate resolution, overrides, reference storage

Section titled “Task 8: fx — rate resolution, overrides, reference storage”

Files:

  • Create: internal/platform/accounting/fx/fx.go, reference.go, override.go, errors.go
  • Create: internal/platform/accounting/accountingtest/fx.go
  • Test: internal/platform/accounting/fx/fx_test.go

Interfaces:

  • Consumes: fx_reference_rate, exchange_rate (Task 3); money.Rate (Task 1).
  • Produces:
    package fx
    const (SourceIdentity = "identity"; SourceReference = "reference"; SourceOverride = "override"; SourceFrozen = "frozen")
    const MaxStaleness = 7 * 24 * time.Hour // Ruling R11
    const Hub = "MYR" // the reference feed's quote currency
    type ReferenceRate struct {
    Source string // "bnm"
    Currency string // e.g. "USD"
    RateDate time.Time
    Session string
    Rate money.Rate // MYR per ONE unit
    }
    // StoreReference inserts rates, ignoring ones already stored (idempotent re-sync).
    // Runs in ANY transaction — the table is RLS-exempt; the worker calls it on a pool tx.
    func StoreReference(ctx context.Context, tx pgx.Tx, c clock.Clock, rates []ReferenceRate) (inserted int, err error)
    type Override struct {
    ID uuid.UUID
    From, To string
    RateDate time.Time
    Rate money.Rate
    Reason string
    CreatedBy *uuid.UUID
    CreatedAt time.Time
    }
    type OverrideInput struct { From, To string; RateDate time.Time; Rate money.Rate; Reason string; CreatedBy *uuid.UUID }
    func SetOverride(ctx context.Context, tx pgx.Tx, c clock.Clock, in OverrideInput) (Override, error) // audits "exchange_rate"
    type Quote struct { Rate money.Rate; Source string; AsOf time.Time }
    // Resolve returns how many units of `to` one unit of `from` buys on `day`.
    func Resolve(ctx context.Context, tx pgx.Tx, from, to string, day time.Time) (Quote, error)
    type NoRateError struct { From, To string; Day time.Time }
    var ErrInvalidOverride error
    package accountingtest
    func (e *Env) SeedReferenceRate(t *testing.T, currency, day, rate string) // via fx.StoreReference, source "bnm", session "1130"

Resolve(from, to, day):

  1. from == to → {OneRate, identity, day}.
  2. Newest override for exactly (from, to, day) in this org → {rate, override, day}.
  3. Else via the hub: r(from) = 1 if from == MYR, else the reference rate for from on the latest rate_date <= day with rate_date > day − MaxStaleness; the same for to. Missing either → *NoRateError. Result r(from).Div(r(to)), Source = reference, AsOf = the older of the two rate dates.

The override query carries org_id = $1; the reference query names no org-scoped table. Neither file may be imported by internal/modules for its SQL — the module calls these functions.

  • Step 1: Write the failing tests
func TestSameCurrencyIsIdentity(t *testing.T)
func TestReferenceRateOnAWeekendUsesFriday(t *testing.T) {
// SeedReferenceRate("USD","2026-09-18","4.2"): Resolve USD->MYR on
// 2026-09-20 (Sunday) = 4.2, Source reference, AsOf 2026-09-18.
}
func TestAStaleRateIsRefused(t *testing.T) {
// PRESENCE: 2026-09-18's rate resolves on 2026-09-24.
// ABSENCE: on 2026-09-25 (7 days later) -> *NoRateError{USD,MYR}.
}
func TestMYRToForeignInverts(t *testing.T) // MYR->USD = 1/4.2 = 0.2380952381
func TestCrossRateGoesThroughMYR(t *testing.T) // USD 4.2, SGD 3.15 -> USD->SGD 1.3333333333
func TestNewestOverrideWins(t *testing.T) {
// Reference 4.2; override 4.25 at 10:00; override 4.30 at 11:00 (advance
// the fixed clock between them). Resolve -> 4.30, Source override. On the
// next day (no override) -> back to the reference rate.
}
func TestAnOverrideIsTenantScoped(t *testing.T) {
// PRESENCE: org A's override applies in org A. ABSENCE: org B still sees
// the reference rate. Both orgs see the same reference row.
}
func TestStoreReferenceIsIdempotent(t *testing.T) // storing the same batch twice inserts 0 the second time
func TestOverridesAreAppendOnly(t *testing.T) {
// As erp_app (e.H.App with set_config app.current_org), UPDATE and DELETE
// on exchange_rate fail with SQLSTATE 42501 (insufficient_privilege).
// PRESENCE: SELECT on the same row succeeds first.
}
  • Step 2: Run and watch fail.
  • Step 3: Implement. StoreReference uses INSERT … ON CONFLICT (source, currency, rate_date, session) DO NOTHING and counts RowsAffected. SetOverride refuses From == To, non-positive rate, blank reason (ErrInvalidOverride) and writes audit.Entry{EntityKind: "exchange_rate", Action: "override"}.
  • Step 4: Write accountingtest/fx.go.
  • Step 5: Run. go test ./internal/platform/accounting/fx/ ./internal/kernel/layering/ -count=1 → PASS (the layering run confirms no module names fx_reference_rate).
  • Step 6: Commit
Terminal window
git add internal/platform/accounting/fx internal/platform/accounting/accountingtest/fx.go
git commit -m "Resolve exchange rates from Bank Negara references and overrides"

Task 9: Migrations 00052–00053 — the ledger and the close log

Section titled “Task 9: Migrations 00052–00053 — the ledger and the close log”

Files:

  • Create: db/migrations/00052_accounting_ledger.sql, db/migrations/00053_accounting_close_log.sql
  • Create: internal/platform/accounting/ledger/doc.go
  • Modify: internal/kernel/db/retention_test.go, sqlc.yaml, regenerated internal/gen/sqlc/models.go
  • Modify: internal/platform/company/company.go (hasPostedEntries only), internal/platform/company/company_test.go (markPosted only)
  • Test: internal/platform/accounting/ledger/schema_test.go

Interfaces:

  • Consumes: every table from Task 2.
  • Produces: tables journal_entry, journal_line, analytic_distribution, close_log; constraint names Task 11 maps to errors:
Constraint / trigger name (raised as SQLSTATE 23514 USING CONSTRAINT) Meaning
journal_entry_period_open entry date outside any period, or its period is not open
journal_entry_journal_active journal inactive
journal_entry_balanced at commit: fewer than 2 lines, or debits ≠ credits in transaction or base amounts
journal_line_entry_open line on an entry posted by an earlier transaction
journal_line_account_postable account is a header, or inactive outside reversal/closing
journal_line_currency line currency ≠ entry currency, or ≠ a restricted account’s currency
analytic_distribution_entry_open distribution on a line of an earlier transaction
analytic_distribution_axis_applies axis does not apply to the account’s type
analytic_distribution_total an axis’s percentages ≠ 100 or its base amounts ≠ the line’s base
analytic_distribution_required a required axis is missing
account_has_postings changing control_kind/currency, or making non-postable, an account with lines
  • Step 1: Write 00052_accounting_ledger.sql
-- +goose Up
-- THE LEDGER (spec 10.2). Append-only: erp_app may INSERT and SELECT, nothing
-- else. Corrections are reversal entries (reversal_of_id). Every invariant the
-- spec lists is enforced here, not only in Go.
CREATE TABLE journal_entry (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID NOT NULL REFERENCES organization (id),
journal_id UUID NOT NULL,
entry_no TEXT NOT NULL,
entry_date DATE NOT NULL,
fiscal_period_id UUID NOT NULL,
-- One transaction currency per entry (Ruling R5).
currency TEXT NOT NULL CHECK (currency ~ '^[A-Z]{3}$'),
semantic TEXT NOT NULL CHECK (semantic IN
('accrual','payout','reversal','manual','year_end_close')),
-- The path back to the document and the decision (spec 11A.4): the
-- workflow kind, the business document, and the workflow instance.
source_kind TEXT,
source_id UUID,
instance_id UUID,
description TEXT NOT NULL CHECK (btrim(description) <> ''),
reversal_of_id UUID,
posted_at TIMESTAMPTZ NOT NULL,
posted_by UUID,
-- Which transaction wrote this entry. The line and distribution guards
-- refuse rows for an entry some EARLIER transaction wrote (Ruling R36):
-- without this, a balanced pair appended to an old entry would pass the
-- balance trigger and silently mutate a posted entry.
posted_txid xid8 NOT NULL DEFAULT pg_current_xact_id(),
UNIQUE (org_id, id),
UNIQUE (org_id, entry_no),
FOREIGN KEY (org_id, journal_id) REFERENCES journal (org_id, id),
FOREIGN KEY (org_id, fiscal_period_id) REFERENCES fiscal_period (org_id, id),
FOREIGN KEY (org_id, reversal_of_id) REFERENCES journal_entry (org_id, id),
FOREIGN KEY (org_id, posted_by) REFERENCES organization_member (org_id, id),
CHECK ((semantic = 'reversal') = (reversal_of_id IS NOT NULL)),
CHECK (semantic NOT IN ('accrual','payout')
OR (source_kind IS NOT NULL AND source_id IS NOT NULL AND instance_id IS NOT NULL)),
CHECK (semantic <> 'manual' OR posted_by IS NOT NULL),
CHECK (semantic <> 'year_end_close' OR (source_kind = 'fiscal_year' AND source_id IS NOT NULL))
);
-- An entry is reversed at most once.
CREATE UNIQUE INDEX journal_entry_reversed_once ON journal_entry (org_id, reversal_of_id)
WHERE reversal_of_id IS NOT NULL;
CREATE INDEX journal_entry_instance_idx ON journal_entry (org_id, instance_id) WHERE instance_id IS NOT NULL;
CREATE INDEX journal_entry_source_idx ON journal_entry (org_id, source_kind, source_id);
CREATE INDEX journal_entry_period_idx ON journal_entry (org_id, fiscal_period_id);
CREATE TABLE journal_line (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID NOT NULL REFERENCES organization (id),
entry_id UUID NOT NULL,
line_no INT NOT NULL CHECK (line_no >= 1),
account_id UUID NOT NULL,
-- normal: a real debit or credit. fx_difference: the base-currency
-- residue of a foreign-currency entry, with no transaction amount
-- (Rulings R5, R6).
kind TEXT NOT NULL DEFAULT 'normal' CHECK (kind IN ('normal','fx_difference')),
debit NUMERIC(19,4) NOT NULL DEFAULT 0 CHECK (debit >= 0),
credit NUMERIC(19,4) NOT NULL DEFAULT 0 CHECK (credit >= 0),
base_debit NUMERIC(19,4) NOT NULL DEFAULT 0 CHECK (base_debit >= 0),
base_credit NUMERIC(19,4) NOT NULL DEFAULT 0 CHECK (base_credit >= 0),
currency TEXT NOT NULL CHECK (currency ~ '^[A-Z]{3}$'),
fx_rate NUMERIC(19,10) NOT NULL CHECK (fx_rate > 0),
fx_rate_source TEXT NOT NULL CHECK (fx_rate_source IN ('identity','reference','override','frozen')),
party_id UUID,
tax_code_id UUID,
memo TEXT NOT NULL DEFAULT '',
UNIQUE (org_id, id),
UNIQUE (org_id, entry_id, line_no),
FOREIGN KEY (org_id, entry_id) REFERENCES journal_entry (org_id, id),
FOREIGN KEY (org_id, account_id) REFERENCES account (org_id, id),
FOREIGN KEY (org_id, party_id) REFERENCES party (org_id, id),
FOREIGN KEY (org_id, tax_code_id) REFERENCES tax_code (org_id, id),
-- Spec 10.2 invariant 2: exactly one side, never negative.
CHECK (NOT (debit > 0 AND credit > 0)),
CHECK (NOT (base_debit > 0 AND base_credit > 0)),
CHECK (kind <> 'normal' OR (debit > 0) <> (credit > 0)),
CHECK (kind <> 'normal' OR NOT (debit > 0 AND base_credit > 0)),
CHECK (kind <> 'normal' OR NOT (credit > 0 AND base_debit > 0)),
CHECK (kind <> 'fx_difference'
OR (debit = 0 AND credit = 0 AND (base_debit > 0) <> (base_credit > 0)))
);
CREATE INDEX journal_line_account_idx ON journal_line (org_id, account_id);
-- Ratio distributions over analytic axes (spec 10.2, 18.2 item 2).
CREATE TABLE analytic_distribution (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID NOT NULL REFERENCES organization (id),
journal_line_id UUID NOT NULL,
axis_id UUID NOT NULL,
analytic_account_id UUID NOT NULL,
percentage NUMERIC(19,10) NOT NULL CHECK (percentage > 0 AND percentage <= 100),
base_amount NUMERIC(19,4) NOT NULL CHECK (base_amount >= 0),
UNIQUE (org_id, id),
UNIQUE (org_id, journal_line_id, analytic_account_id),
FOREIGN KEY (org_id, journal_line_id) REFERENCES journal_line (org_id, id),
-- axis_id is denormalized for the per-axis checks; this key makes it
-- impossible for it to disagree with the analytic account's own axis.
FOREIGN KEY (org_id, axis_id, analytic_account_id) REFERENCES analytic_account (org_id, axis_id, id)
);
ALTER TABLE journal_entry ENABLE ROW LEVEL SECURITY; ALTER TABLE journal_entry FORCE ROW LEVEL SECURITY;
ALTER TABLE journal_line ENABLE ROW LEVEL SECURITY; ALTER TABLE journal_line FORCE ROW LEVEL SECURITY;
ALTER TABLE analytic_distribution ENABLE ROW LEVEL SECURITY; ALTER TABLE analytic_distribution FORCE ROW LEVEL SECURITY;
CREATE POLICY org_isolation ON journal_entry
USING (org_id = current_setting('app.current_org', true)::uuid)
WITH CHECK (org_id = current_setting('app.current_org', true)::uuid);
CREATE POLICY org_isolation ON journal_line
USING (org_id = current_setting('app.current_org', true)::uuid)
WITH CHECK (org_id = current_setting('app.current_org', true)::uuid);
CREATE POLICY org_isolation ON analytic_distribution
USING (org_id = current_setting('app.current_org', true)::uuid)
WITH CHECK (org_id = current_setting('app.current_org', true)::uuid);
-- APPEND-ONLY (spec 10.2 invariant 3).
REVOKE UPDATE, DELETE ON journal_entry, journal_line, analytic_distribution FROM erp_app;
-- +goose StatementBegin
-- Spec 10.2 invariant 5, in the database: an entry lands only in an open
-- period that contains its date. FOR SHARE makes a concurrent close wait
-- for this transaction (Ruling R4); erp_app holds UPDATE on fiscal_period,
-- which FOR SHARE requires.
CREATE FUNCTION ledger_entry_guard() RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE
p_status TEXT; p_start DATE; p_end DATE; j_active BOOLEAN;
BEGIN
SELECT status, starts_on, ends_on INTO p_status, p_start, p_end
FROM fiscal_period WHERE org_id = NEW.org_id AND id = NEW.fiscal_period_id FOR SHARE;
IF p_status IS DISTINCT FROM 'open' OR NEW.entry_date < p_start OR NEW.entry_date > p_end THEN
RAISE EXCEPTION 'journal entry % dated % is not inside an open fiscal period', NEW.entry_no, NEW.entry_date
USING ERRCODE = '23514', CONSTRAINT = 'journal_entry_period_open';
END IF;
SELECT is_active INTO j_active FROM journal WHERE org_id = NEW.org_id AND id = NEW.journal_id;
IF NOT j_active THEN
RAISE EXCEPTION 'journal % is inactive', NEW.journal_id
USING ERRCODE = '23514', CONSTRAINT = 'journal_entry_journal_active';
END IF;
RETURN NEW;
END $$;
-- +goose StatementEnd
CREATE TRIGGER journal_entry_guard BEFORE INSERT ON journal_entry
FOR EACH ROW EXECUTE FUNCTION ledger_entry_guard();
-- +goose StatementBegin
-- Spec 10.2 invariant 1, checked at COMMIT so the lines of one entry can be
-- inserted one statement at a time.
CREATE FUNCTION ledger_entry_balanced() RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE
n INT; d NUMERIC; c NUMERIC; bd NUMERIC; bc NUMERIC;
BEGIN
SELECT count(*), coalesce(sum(debit),0), coalesce(sum(credit),0),
coalesce(sum(base_debit),0), coalesce(sum(base_credit),0)
INTO n, d, c, bd, bc
FROM journal_line WHERE org_id = NEW.org_id AND entry_id = NEW.id;
IF n < 2 OR d <> c OR bd <> bc THEN
RAISE EXCEPTION 'journal entry % is not balanced: % lines, debit % credit %, base debit % base credit %',
NEW.entry_no, n, d, c, bd, bc
USING ERRCODE = '23514', CONSTRAINT = 'journal_entry_balanced';
END IF;
RETURN NULL;
END $$;
-- +goose StatementEnd
CREATE CONSTRAINT TRIGGER journal_entry_balanced AFTER INSERT ON journal_entry
DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION ledger_entry_balanced();
-- +goose StatementBegin
CREATE FUNCTION ledger_line_guard() RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE
e_txid xid8; e_semantic TEXT; e_currency TEXT;
a_postable BOOLEAN; a_active BOOLEAN; a_currency TEXT;
BEGIN
SELECT posted_txid, semantic, currency INTO e_txid, e_semantic, e_currency
FROM journal_entry WHERE org_id = NEW.org_id AND id = NEW.entry_id;
IF e_txid IS DISTINCT FROM pg_current_xact_id() THEN
RAISE EXCEPTION 'journal entry % is already posted; post a reversal instead', NEW.entry_id
USING ERRCODE = '23514', CONSTRAINT = 'journal_line_entry_open';
END IF;
SELECT is_postable, is_active, currency INTO a_postable, a_active, a_currency
FROM account WHERE org_id = NEW.org_id AND id = NEW.account_id;
-- Ruling R25: an inactive account still takes reversals and the closing entry.
IF NOT a_postable OR (NOT a_active AND e_semantic NOT IN ('reversal','year_end_close')) THEN
RAISE EXCEPTION 'account % is not postable', NEW.account_id
USING ERRCODE = '23514', CONSTRAINT = 'journal_line_account_postable';
END IF;
IF NEW.currency <> e_currency OR (NEW.kind = 'normal' AND a_currency IS NOT NULL AND a_currency <> NEW.currency) THEN
RAISE EXCEPTION 'line currency % does not match its entry or its account', NEW.currency
USING ERRCODE = '23514', CONSTRAINT = 'journal_line_currency';
END IF;
RETURN NEW;
END $$;
-- +goose StatementEnd
CREATE TRIGGER journal_line_guard BEFORE INSERT ON journal_line
FOR EACH ROW EXECUTE FUNCTION ledger_line_guard();
-- +goose StatementBegin
CREATE FUNCTION ledger_distribution_guard() RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE e_txid xid8;
BEGIN
SELECT e.posted_txid INTO e_txid
FROM journal_line l JOIN journal_entry e ON e.org_id = l.org_id AND e.id = l.entry_id
WHERE l.org_id = NEW.org_id AND l.id = NEW.journal_line_id;
IF e_txid IS DISTINCT FROM pg_current_xact_id() THEN
RAISE EXCEPTION 'journal line % is already posted', NEW.journal_line_id
USING ERRCODE = '23514', CONSTRAINT = 'analytic_distribution_entry_open';
END IF;
RETURN NEW;
END $$;
-- +goose StatementEnd
CREATE TRIGGER analytic_distribution_guard BEFORE INSERT ON analytic_distribution
FOR EACH ROW EXECUTE FUNCTION ledger_distribution_guard();
-- +goose StatementBegin
-- Spec 10.2 invariant 6, at COMMIT: per axis, a line's distributions sum to
-- exactly 100% (and to exactly the line's base amount) or are absent; a
-- required axis applicable to the account is present (Ruling R26).
CREATE FUNCTION ledger_line_distributions() RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE
a_type TEXT; e_semantic TEXT; line_base NUMERIC; ax RECORD;
n INT; pct NUMERIC; base NUMERIC;
BEGIN
SELECT type INTO a_type FROM account WHERE org_id = NEW.org_id AND id = NEW.account_id;
SELECT semantic INTO e_semantic FROM journal_entry WHERE org_id = NEW.org_id AND id = NEW.entry_id;
line_base := NEW.base_debit + NEW.base_credit;
FOR ax IN SELECT id, key, is_required, is_active, applies_to FROM analytic_axis WHERE org_id = NEW.org_id LOOP
SELECT count(*), coalesce(sum(percentage),0), coalesce(sum(base_amount),0) INTO n, pct, base
FROM analytic_distribution
WHERE org_id = NEW.org_id AND journal_line_id = NEW.id AND axis_id = ax.id;
IF n > 0 AND NOT (a_type = ANY (ax.applies_to)) THEN
RAISE EXCEPTION 'axis % does not apply to % accounts', ax.key, a_type
USING ERRCODE = '23514', CONSTRAINT = 'analytic_distribution_axis_applies';
END IF;
IF n > 0 AND (pct <> 100 OR base <> line_base) THEN
RAISE EXCEPTION 'axis % on line % sums to % percent / base %, not 100 / %', ax.key, NEW.id, pct, base, line_base
USING ERRCODE = '23514', CONSTRAINT = 'analytic_distribution_total';
END IF;
IF n = 0 AND ax.is_active AND ax.is_required AND a_type = ANY (ax.applies_to)
AND NEW.kind = 'normal' AND e_semantic <> 'year_end_close' THEN
RAISE EXCEPTION 'axis % is required on % accounts', ax.key, a_type
USING ERRCODE = '23514', CONSTRAINT = 'analytic_distribution_required';
END IF;
END LOOP;
RETURN NULL;
END $$;
-- +goose StatementEnd
CREATE CONSTRAINT TRIGGER journal_line_distributions AFTER INSERT ON journal_line
DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION ledger_line_distributions();
-- +goose StatementBegin
-- Ruling R23: once an account carries lines, its subledger meaning, currency
-- and postability are history, not settings.
CREATE FUNCTION account_ledger_guard() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
IF (NEW.control_kind IS DISTINCT FROM OLD.control_kind
OR NEW.currency IS DISTINCT FROM OLD.currency
OR (OLD.is_postable AND NOT NEW.is_postable))
AND EXISTS (SELECT 1 FROM journal_line WHERE org_id = OLD.org_id AND account_id = OLD.id) THEN
RAISE EXCEPTION 'account % has postings; its control kind, currency and postability are fixed', OLD.code
USING ERRCODE = '23514', CONSTRAINT = 'account_has_postings';
END IF;
RETURN NEW;
END $$;
-- +goose StatementEnd
CREATE TRIGGER account_ledger_guard BEFORE UPDATE ON account
FOR EACH ROW EXECUTE FUNCTION account_ledger_guard();
-- +goose Down
DROP TRIGGER account_ledger_guard ON account;
DROP FUNCTION account_ledger_guard();
DROP TABLE analytic_distribution;
DROP TABLE journal_line;
DROP TABLE journal_entry;
DROP FUNCTION ledger_line_distributions();
DROP FUNCTION ledger_distribution_guard();
DROP FUNCTION ledger_line_guard();
DROP FUNCTION ledger_entry_balanced();
DROP FUNCTION ledger_entry_guard();
  • Step 2: Write 00053_accounting_close_log.sql
-- +goose Up
-- What the books said when a period or year was closed (spec 10.4): the
-- balances as they stood and the reconciliation that allowed the close, so a
-- restatement question has an answer. Append-only.
CREATE TABLE close_log (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID NOT NULL REFERENCES organization (id),
fiscal_year_id UUID NOT NULL,
fiscal_period_id UUID,
action TEXT NOT NULL CHECK (action IN ('period_close','period_reopen','year_close')),
reason TEXT NOT NULL DEFAULT '',
balances JSONB NOT NULL,
reconciliation JSONB NOT NULL,
closing_entry_id UUID,
acted_at TIMESTAMPTZ NOT NULL,
acted_by UUID,
UNIQUE (org_id, id),
FOREIGN KEY (org_id, fiscal_year_id) REFERENCES fiscal_year (org_id, id),
FOREIGN KEY (org_id, fiscal_period_id) REFERENCES fiscal_period (org_id, id),
FOREIGN KEY (org_id, closing_entry_id) REFERENCES journal_entry (org_id, id),
FOREIGN KEY (org_id, acted_by) REFERENCES organization_member (org_id, id),
CHECK ((action = 'year_close') = (fiscal_period_id IS NULL)),
CHECK (action <> 'period_reopen' OR btrim(reason) <> '')
);
ALTER TABLE close_log ENABLE ROW LEVEL SECURITY;
ALTER TABLE close_log FORCE ROW LEVEL SECURITY;
CREATE POLICY org_isolation ON close_log
USING (org_id = current_setting('app.current_org', true)::uuid)
WITH CHECK (org_id = current_setting('app.current_org', true)::uuid);
REVOKE UPDATE, DELETE ON close_log FROM erp_app;
-- +goose Down
DROP TABLE close_log;
  • Step 3: Retention, sqlc. Append "journal_entry", "journal_line", "analytic_distribution", "close_log" to appendOnly with the comment // Plan 4: the ledger (spec 10.2 invariant 3) and its close record. Append sqlc overrides for journal_line.fx_rate and analytic_distribution.percentage → money.Rate. make generate.

  • Step 4: Fix company.hasPostedEntries (Ruling R37). Replace the whole body:

// hasPostedEntries reports whether this organization's ledger has been used.
// journal_entry is append-only, so once true it is true forever — which is
// what makes the base-currency and fiscal-year-start guard permanent.
func hasPostedEntries(ctx context.Context, tx pgx.Tx) (bool, error) {
orgID, ok := tenancy.OrgFromContext(ctx)
if !ok {
return false, fmt.Errorf("company: posting check: %w", tenancy.ErrNoOrg)
}
var posted bool
if err := tx.QueryRow(ctx,
`SELECT EXISTS (SELECT 1 FROM journal_entry WHERE org_id = $1)`, orgID).Scan(&posted); err != nil {
return false, fmt.Errorf("company: posting check: %w", err)
}
return posted, nil
}

Replace markPosted in company_test.go — the old one creates, and in cleanup DROPs, a table named journal_entry, which after this migration is the real ledger. The new one posts a real balanced entry through the admin pool in one transaction:

// markPosted writes one real, balanced journal entry for org through the
// admin pool, satisfying every ledger trigger, so company.hasPostedEntries is
// exercised against the table Plan 4 created. semantic 'accrual' because a
// 'manual' entry must name a member and this fixture has none.
func markPosted(t *testing.T, admin *pgxpool.Pool, org uuid.UUID) {
t.Helper()
ctx := context.Background()
tx, err := admin.Begin(ctx)
require.NoError(t, err)
defer func() { _ = tx.Rollback(ctx) }()
mustExec := func(sql string, args ...any) {
t.Helper()
_, err := tx.Exec(ctx, sql, args...)
require.NoError(t, err)
}
a1, a2, j, y, p, e := uuid.New(), uuid.New(), uuid.New(), uuid.New(), uuid.New(), uuid.New()
mustExec(`INSERT INTO account (id, org_id, code, name, path, depth, type, normal_balance)
VALUES ($1,$3,'1000','Cash','/'||$1||'/',0,'asset','debit'),
($2,$3,'4000','Sales','/'||$2||'/',0,'income','credit')`, a1, a2, org)
mustExec(`INSERT INTO journal (id, org_id, code, name, type, sequence_key)
VALUES ($1,$2,'MSC','Misc','misc','MSC')`, j, org)
mustExec(`INSERT INTO fiscal_year (id, org_id, name, starts_on, ends_on, end_year)
VALUES ($1,$2,'FY2026',DATE '2026-01-01',DATE '2026-12-31',2026)`, y, org)
mustExec(`INSERT INTO fiscal_period (id, org_id, fiscal_year_id, seq, starts_on, ends_on)
VALUES ($1,$2,$3,1,DATE '2026-01-01',DATE '2026-12-31')`, p, org, y)
mustExec(`INSERT INTO journal_entry (id, org_id, journal_id, entry_no, entry_date, fiscal_period_id,
currency, semantic, source_kind, source_id, instance_id, description, posted_at)
VALUES ($1,$2,$3,'MSC-2026-0001',DATE '2026-03-01',$4,'MYR','accrual','test',$5,$6,'test',now())`,
e, org, j, p, uuid.New(), uuid.New())
mustExec(`INSERT INTO journal_line (org_id, entry_id, line_no, account_id, debit, credit,
base_debit, base_credit, currency, fx_rate, fx_rate_source)
VALUES ($1,$2,1,$3,10,0,10,0,'MYR',1,'identity'),
($1,$2,2,$4,0,10,0,10,'MYR',1,'identity')`, org, e, a1, a2)
require.NoError(t, tx.Commit(ctx))
}

Remove the DROP TABLE cleanup. Add one test:

func TestPostingInAnotherOrgDoesNotFreezeThisOne(t *testing.T) {
// PRESENCE: markPosted(orgA) makes orgA's base-currency change fail.
// ABSENCE: orgB (no entries) can still change its base currency — the
// old unscoped EXISTS would have refused it.
}
  • Step 5: Write the failing schema tests — internal/platform/accounting/ledger/schema_test.go (package ledger_test). They insert raw SQL through e.DB.InTx (as erp_app, tenant set) against e.SeedChart, e.SeedJournals, e.OpenYear, e.SeedAxes. A local helper insertEntry(ctx, tx, lines ...rawLine) uuid.UUID writes one entry (semantic accrual, fresh source/instance ids) and its lines. Each test is a presence/absence pair:
func TestABalancedEntryCommits(t *testing.T) // presence for everything below
func TestAnUnbalancedEntryFailsAtCommit(t *testing.T) {
// The INSERTs all succeed (the check is deferred); InTx's commit fails
// with a *pgconn.PgError whose ConstraintName is journal_entry_balanced.
}
func TestABaseImbalanceFailsEvenWhenAmountsBalance(t *testing.T)
func TestAOneLineEntryFails(t *testing.T)
func TestAnEntryWithNoLinesFails(t *testing.T)
func TestLinesCannotBeAddedToAPostedEntry(t *testing.T) {
// Commit a balanced entry. In a NEW transaction insert a balanced pair of
// lines on it: journal_line_entry_open. (Without posted_txid this pair
// would pass the balance trigger — say so in the comment.)
}
func TestTheAppRoleCannotUpdateOrDeleteTheLedger(t *testing.T) {
// PRESENCE: SELECT works. UPDATE journal_line / DELETE journal_entry /
// UPDATE analytic_distribution / DELETE close_log as erp_app -> 42501.
}
func TestPostingIntoAClosedPeriodIsRefused(t *testing.T) // period status 'closed' via fiscal.SetPeriodStatus -> journal_entry_period_open
func TestAnEntryDatedOutsideItsPeriodIsRefused(t *testing.T) // period 9, date in period 10
func TestAHeaderAccountIsNotPostable(t *testing.T) // ch.Expenses -> journal_line_account_postable
func TestAnInactiveAccountTakesOnlyReversals(t *testing.T) // semantic accrual refused; semantic reversal (with reversal_of_id) accepted
func TestBothSidesOnOneLineIsRefused(t *testing.T) // CHECK violation
func TestAnFXDifferenceLineHasNoTransactionAmount(t *testing.T)
func TestDistributionsMustTotalAHundred(t *testing.T) // 60 only -> analytic_distribution_total at commit
func TestARequiredAxisIsEnforcedAtCommit(t *testing.T) // SeedAxes(required=true), expense line without cost_centre -> analytic_distribution_required; the same line on a year_end_close entry commits
func TestAnAxisOnTheWrongAccountTypeIsRefused(t *testing.T)
func TestAnAccountWithPostingsKeepsItsControlKind(t *testing.T) // UPDATE account SET control_kind = NULL on ch.AP after a posting -> account_has_postings; the same on an unposted account succeeds
func TestAnEntryIsReversedAtMostOnce(t *testing.T) // two reversal rows for one original -> 23505 journal_entry_reversed_once
func TestReversalRequiresItsTarget(t *testing.T) // semantic reversal with NULL reversal_of_id -> CHECK
func TestAWorkflowPostingMustNameItsSource(t *testing.T) // accrual without instance_id -> CHECK (spec 11A.4)
  • Step 6: Run. go test ./internal/platform/accounting/ledger/ ./internal/platform/company/ ./internal/kernel/db/ ./internal/kernel/layering/ ./internal/gen/ -count=1 → PASS.

  • Step 7: Sabotage the balance trigger. Change journal_entry_balanced to DEFERRABLE INITIALLY IMMEDIATE, re-run TestABalancedEntryCommits — it now fails on the first line insert (showing why deferral is load-bearing); change the function’s IF to IF false, re-run TestAnUnbalancedEntryFailsAtCommit → red; restore both. Quote the outputs.

  • Step 8: Commit

Terminal window
git add db/migrations/00052_accounting_ledger.sql db/migrations/00053_accounting_close_log.sql \
internal/kernel/db/retention_test.go sqlc.yaml internal/gen/sqlc \
internal/platform/company/company.go internal/platform/company/company_test.go \
internal/platform/accounting/ledger
git commit -m "Add the append-only ledger with its database-enforced invariants"

Task 10: Bank Negara feed and the nightly sync

Section titled “Task 10: Bank Negara feed and the nightly sync”

Files:

  • Create: internal/platform/accounting/fx/bnm/bnm.go, internal/platform/accounting/fx/bnm/testdata/bnm_latest.json
  • Create: internal/kernel/config/bnm.go
  • Modify: .env.example, cmd/worker/main.go
  • Test: internal/platform/accounting/fx/bnm/bnm_test.go, internal/kernel/config/bnm_test.go, cmd/worker/fxsync_test.go

Interfaces:

  • Consumes: fx.ReferenceRate, fx.StoreReference (Task 8).

  • Produces:

    package bnm
    // Client fetches Bank Negara Malaysia's published exchange rates. It is the
    // only thing in the system that talks to an exchange-rate source, and it
    // talks only to Bank Negara (spec 11A.1: an official Malaysian source).
    type Client struct { BaseURL string; HTTP *http.Client; Clock clock.Clock }
    func (c Client) Latest(ctx context.Context) ([]fx.ReferenceRate, error)
    var ErrUnexpectedResponse error
    package config
    // BNMRatesURLFromEnv returns BNM_RATES_URL, or "" when unset (sync
    // disabled). A set value must be https on a bnm.gov.my host (Ruling R12).
    func BNMRatesURLFromEnv() (string, error)
    package main // cmd/worker
    func runFXSync(ctx context.Context, database *db.DB, feed interface{ Latest(context.Context) ([]fx.ReferenceRate, error) }, c clock.Clock) (int, error)
  • Step 1: The fixture. testdata/bnm_latest.json mirrors the documented shape of GET {base}/public/exchange-rate?session=1130&quote=rm with Accept: application/vnd.BNM.API.v1+json:

{
"data": [
{"currency_code": "USD", "unit": 1, "rate": {"date": "2026-09-18", "buying_rate": 4.1950, "selling_rate": 4.2050, "middle_rate": 4.2000}},
{"currency_code": "JPY", "unit": 100, "rate": {"date": "2026-09-18", "buying_rate": 3.0400, "selling_rate": 3.0624, "middle_rate": 3.0512}},
{"currency_code": "SGD", "unit": 1, "rate": {"date": "2026-09-18", "buying_rate": 3.1450, "selling_rate": 3.1550, "middle_rate": 3.1500}}
],
"meta": {"quote": "rm", "session": "1130", "last_updated": "2026-09-18 12:00:00", "total_result": 3}
}

Before merging, fetch the live endpoint once from a Malaysian network, compare field names and nesting with this fixture, and fix the fixture if they differ (Ruling R13). Record the comparison in the task report.

  • Step 2: Write the failing tests
func TestLatestParsesWithoutFloats(t *testing.T) {
// httptest server serving the fixture; assert the request path, the
// session and quote query values and the Accept header.
// USD rate "4.2000000000"; JPY "0.0305120000" (3.0512 / 100);
// RateDate 2026-09-18; Session "1130"; Source "bnm".
}
func TestAMissingMiddleRateIsAnError(t *testing.T) // ErrUnexpectedResponse, naming the currency
func TestANon200IsAnError(t *testing.T)
func TestTheDecoderNeverUsesFloat64(t *testing.T) {
// Textual guard on bnm.go: it must contain "UseNumber()" and must not
// contain "float64". PRESENCE: the file is read and is non-empty.
}

config/bnm_test.go: unset → "", nil; https://api.bnm.gov.my → ok; http://api.bnm.gov.my → error; https://example.com → error naming bnm.gov.my.

cmd/worker/fxsync_test.go: runFXSync with a fake feed returning two rates against a testdb database stores 2, and a second run stores 0.

  • Step 3: Implement bnm.go
func (c Client) Latest(ctx context.Context) ([]fx.ReferenceRate, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet,
strings.TrimRight(c.BaseURL, "/")+"/public/exchange-rate?session=1130&quote=rm", nil)
if err != nil {
return nil, err
}
req.Header.Set("Accept", "application/vnd.BNM.API.v1+json")
resp, err := c.HTTP.Do(req)
if err != nil {
return nil, fmt.Errorf("bnm: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("%w: status %d", ErrUnexpectedResponse, resp.StatusCode)
}
var body struct {
Data []struct {
Currency string `json:"currency_code"`
Unit json.Number `json:"unit"`
Rate struct {
Date string `json:"date"`
Middle *json.Number `json:"middle_rate"`
} `json:"rate"`
} `json:"data"`
Meta struct{ Session string `json:"session"` } `json:"meta"`
}
dec := json.NewDecoder(resp.Body)
dec.UseNumber() // a float never touches a rate
if err := dec.Decode(&body); err != nil {
return nil, fmt.Errorf("%w: %v", ErrUnexpectedResponse, err)
}
// A row with no middle rate or a zero unit is an error naming the
// currency — never skipped silently: a missing currency would surface
// weeks later as a posting refused for "no rate".
out := make([]fx.ReferenceRate, 0, len(body.Data))
for _, d := range body.Data {
if d.Rate.Middle == nil {
return nil, fmt.Errorf("%w: %s has no middle_rate", ErrUnexpectedResponse, d.Currency)
}
mid, err := money.ParseRate(d.Rate.Middle.String())
if err != nil {
return nil, fmt.Errorf("%w: %s middle_rate: %v", ErrUnexpectedResponse, d.Currency, err)
}
unit, err := money.ParseRate(d.Unit.String())
if err != nil || !unit.IsPositive() {
return nil, fmt.Errorf("%w: %s unit %q", ErrUnexpectedResponse, d.Currency, d.Unit)
}
perOne, err := mid.Div(unit)
if err != nil {
return nil, err
}
day, err := time.Parse("2006-01-02", d.Rate.Date)
if err != nil {
return nil, fmt.Errorf("%w: %s date %q", ErrUnexpectedResponse, d.Currency, d.Rate.Date)
}
out = append(out, fx.ReferenceRate{
Source: "bnm", Currency: d.Currency, RateDate: day.UTC(),
Session: body.Meta.Session, Rate: perOne,
})
}
if len(out) == 0 {
return nil, fmt.Errorf("%w: no rates in the response", ErrUnexpectedResponse)
}
return out, nil
}
  • Step 4: config/bnm.go
func BNMRatesURLFromEnv() (string, error) {
raw := os.Getenv("BNM_RATES_URL")
if raw == "" {
return "", nil
}
u, err := url.Parse(raw)
if err != nil || u.Scheme != "https" || u.Host == "" {
return "", fmt.Errorf("BNM_RATES_URL must be an https URL: %q", raw)
}
host := u.Hostname()
if host != "bnm.gov.my" && !strings.HasSuffix(host, ".bnm.gov.my") {
return "", fmt.Errorf("BNM_RATES_URL must be a bnm.gov.my host (spec 11A.1: an official Malaysian source), got %q", host)
}
return strings.TrimRight(raw, "/"), nil
}

.env.example — append:

# Bank Negara Malaysia exchange rates. When set, the worker fetches BNM's
# published rates once a day into fx_reference_rate, which is what foreign-
# currency postings convert at. Empty disables the sync; postings in a foreign
# currency then need a manual override for each date (POST
# /v1/accounting/exchange-rates). Must be https on a bnm.gov.my host: rates
# are part of the tax record and come from the official Malaysian source only.
BNM_RATES_URL=https://api.bnm.gov.my
  • Step 5: cmd/worker/main.go — after the prune loop starts, if config.BNMRatesURLFromEnv() returns a URL, run runFXSyncLoop in a goroutine: once at start, then every 24 h, each pass runFXSync(ctx, database, bnm.Client{BaseURL: url, HTTP: &http.Client{Timeout: 30 * time.Second}, Clock: clock.System()}, clock.System()). runFXSync fetches outside any transaction, then opens database.Pool().Begin (the table is RLS-exempt; the same argument runPrune’s comment makes), calls fx.StoreReference, commits, and returns the count. A failed pass is logged and retried at the next tick. If the env value is invalid, the worker exits at boot with the error — a misconfigured rate source must not look like a disabled one.

  • Step 6: Run. go test ./internal/platform/accounting/fx/bnm/ ./internal/kernel/config/ ./cmd/worker/ -count=1 → PASS (the config package’s env drift tests confirm .env.example and the read agree).

  • Step 7: Commit

Terminal window
git add internal/platform/accounting/fx/bnm internal/kernel/config/bnm.go internal/kernel/config/bnm_test.go \
.env.example cmd/worker
git commit -m "Sync Bank Negara reference rates nightly"

Task 11: ledger.Post — the one way an entry is written

Section titled “Task 11: ledger.Post — the one way an entry is written”

Files:

  • Create: internal/platform/accounting/ledger/post.go, types.go, errors.go, pgerr.go
  • Create: internal/platform/accounting/accountingtest/books.go
  • Test: internal/platform/accounting/ledger/post_test.go

Interfaces:

  • Consumes: chart.GetAccount, chart.GetJournal, chart.DefaultJournal, chart.ResolveControl, chart.IsSubledgerKind (Tasks 4, 7); analytic.Plan (Task 5); fiscal.PeriodForPosting, fiscal.GetYear (Task 6); fx.Resolve (Task 8); company.Get; numbering.Allocate, numbering.Format; audit.Write.
  • Produces:
    package ledger
    const (
    SemanticAccrual = "accrual"; SemanticPayout = "payout"; SemanticReversal = "reversal"
    SemanticManual = "manual"; SemanticYearEndClose = "year_end_close"
    KindNormal = "normal"; KindFXDifference = "fx_difference"
    )
    type Line struct {
    AccountID uuid.UUID
    Debit money.Amount // exactly one of Debit/Credit is positive
    Credit money.Amount
    Base *money.Amount // frozen base amount (spec 6.5); nil: convert at the entry date's rate
    PartyID *uuid.UUID
    TaxCodeID *uuid.UUID
    Memo string
    Distribution []analytic.Share
    }
    type EntryInput struct {
    JournalID *uuid.UUID // either this ...
    JournalType string // ... or the org's default journal of this type (Ruling R21)
    EntryDate time.Time
    Currency string // "": the journal's currency, else the base currency
    Description string
    Semantic string // accrual | payout | manual | year_end_close (reversal: use Reverse)
    SourceKind string
    SourceID *uuid.UUID
    InstanceID *uuid.UUID
    PostedBy *uuid.UUID
    Lines []Line
    }
    type Entry struct {
    ID, JournalID, FiscalPeriodID uuid.UUID
    EntryNo string
    EntryDate time.Time
    Currency, Semantic, Description string
    SourceKind *string
    SourceID, InstanceID *uuid.UUID
    ReversalOfID, PostedBy *uuid.UUID
    PostedAt time.Time
    Lines []PostedLine
    }
    type PostedLine struct {
    ID, AccountID uuid.UUID
    LineNo int
    Kind string
    Debit, Credit, BaseDebit, BaseCredit money.Amount
    Currency string
    FXRate money.Rate
    FXRateSource string
    PartyID, TaxCodeID *uuid.UUID
    Memo string
    Distribution []analytic.PlannedShare
    }
    func Post(ctx context.Context, tx pgx.Tx, c clock.Clock, in EntryInput) (Entry, error)
    var ErrTooFewLines, ErrDescriptionRequired, ErrInvalidSemantic, ErrLineSides, ErrJournalInactive,
    ErrJournalCurrency, ErrAccountNotPostable, ErrAccountInactive, ErrCurrencyMismatch,
    ErrUnbalanced, ErrBaseMismatch, ErrMissingSource error
    type ControlAccountManualError struct{ Code, Kind string } // Ruling R24
    package accountingtest
    type Books struct { Chart Chart; Journals Journals; Year fiscal.Year; Periods []fiscal.Period; Axes Axes }
    func (e *Env) SeedBooks(t *testing.T) Books // SeedChart + SeedJournals + OpenYear + SeedAxes(false) + USD 4.2000 and SGD 3.1500 on 2026-09-18

Post, in order — every step’s refusal is an error, and nothing writes before step 8:

  1. Tenant from ctx; company.Get → base currency.
  2. Validate: Semantic ∈ {accrual, payout, manual, year_end_close} (ErrInvalidSemantic); Description non-blank; len(Lines) >= 2; accrual/payout need SourceKind, SourceID, InstanceID (ErrMissingSource); manual needs PostedBy.
  3. Journal: by id, else chart.DefaultJournal(JournalType); inactive → ErrJournalInactive. Currency: in.Currency, else the journal’s, else base; a journal with a currency refuses another (ErrJournalCurrency).
  4. Date: normalize EntryDate to midnight UTC; fiscal.PeriodForPosting (row lock FOR SHARE) and fiscal.GetYear.
  5. Lines, in order, each:
    • account via chart.GetAccount: not postable → ErrAccountNotPostable; inactive → ErrAccountInactive (year-end close excepted, Ruling R25); semantic == manual && ControlKind != nil && chart.IsSubledgerKind(*ControlKind) → *ControlAccountManualError; account currency set and ≠ entry currency → ErrCurrencyMismatch.
    • sides: both non-negative, exactly one positive → else ErrLineSides.
    • base: Base != nil → that amount, rate money.RateOf(base, amount), source frozen; else entry currency == base → base = amount, rate 1, identity (a Base that differs from the amount in a base-currency entry → ErrBaseMismatch); else fx.Resolve(currency, base, date) → base = amount.MulRate(rate), source from the quote.
    • distribution: analytic.Plan(ctx, tx, account.Type, base, line.Distribution, semantic != year_end_close).
  6. Totals: Σdebit == Σcredit or ErrUnbalanced. diff = Σbase_debit − Σbase_credit. diff ≠ 0: base-currency entry → ErrUnbalanced; foreign-currency entry → append one fx_difference line to chart.ResolveControl("fx_gain_loss") — base_credit = diff if diff > 0 (a gain), else base_debit = −diff (a loss); currency = entry currency, rate 1, source identity (Rulings R5, R6).
  7. Number: n := numbering.Allocate(ctx, tx, "journal:"+j.SequenceKey, year.EndYear); entryNo := numbering.Format(j.SequenceKey, year.EndYear, n).
  8. Insert the entry (posted_at = c.Now()), then lines (line_no from 1), then each line’s planned shares.
  9. audit.Write(audit.Entry{EntityKind: "journal_entry", EntityID: id, Action: "post", After: entry}) — last.
  10. Map any *pgconn.PgError from steps 8–9 by ConstraintName (pgerr.go) to the matching sentinel, keeping the original wrapped, so a database refusal Go missed still reads as a domain error.
  • Step 1: Write the failing tests (each against b := e.SeedBooks(t)):
func TestAManualEntryPostsNumbersAndAudits(t *testing.T) {
// Dr Travel 100 / Cr Bank 100, MYR, dated 2026-09-20, PostedBy a member.
// EntryNo "MSC-2026-0001"; a second -> "MSC-2026-0002"; one audit row
// (entity journal_entry, action post) per entry; lines identity rate 1.
}
func TestARolledBackPostingGivesItsNumberBack(t *testing.T) {
// Post in a tx that then returns an error; the next committed post is
// still 0001 (spec 20.1.3: gapless).
}
func TestAForeignCurrencyEntryConvertsAtTheReferenceRate(t *testing.T) {
// USD 100 Dr Travel / Cr AP (semantic accrual, source set): base 420.0000
// both sides, fx_rate 4.2, source "reference", no fx line.
}
func TestAFrozenBaseProducesAnFXDifference(t *testing.T) {
// The spec 10.5 case. USD 1000 payout: Dr AP with Base 4200.0000 (frozen
// at accrual) / Cr Bank at today's rate, SeedReferenceRate USD 4.3 on
// 2026-09-18 -> base credit 4300.0000. Expect a third line on ch.FX,
// kind fx_difference, base_debit 100.0000 (a loss), debit=credit=0; the
// entry balances in both USD and MYR. PRESENCE: the same entry with
// rate 4.2 produces no third line.
}
func TestAGainIsACreditOnTheFXAccount(t *testing.T) // rate fell: base_credit on ch.FX
func TestABaseCurrencyImbalanceIsRefused(t *testing.T) // MYR entry with a Base override that differs -> ErrBaseMismatch
func TestUnbalancedLinesAreRefusedBeforeAnyWrite(t *testing.T) {
// ErrUnbalanced, and number_sequence has no row for journal:MSC (nothing
// was allocated — the refusal came before step 7).
}
func TestNoRateRefusesThePosting(t *testing.T) // EUR with no reference -> *fx.NoRateError
func TestManualEntriesCannotTouchSubledgerControls(t *testing.T) {
// PRESENCE: manual Dr Bank / Cr Cash is accepted (bank is not a
// subledger kind). ABSENCE: manual Dr Travel / Cr AP ->
// *ControlAccountManualError{Kind:"ap"}; the same lines with semantic
// accrual and a source are accepted.
}
func TestDistributionsArePersistedWithBaseAmounts(t *testing.T) // 60/40 on project; rows read back via Get-by-SQL
func TestAClosedPeriodRefusesThePosting(t *testing.T) // *fiscal.PeriodNotOpenError
func TestAHeaderAccountIsRefusedInGo(t *testing.T) // ErrAccountNotPostable, before the trigger would
func TestJournalByTypeUsesTheDefault(t *testing.T) // JournalType "purchase" -> PUR numbering "PUR-2026-0001"
func TestOneLineIsRefused(t *testing.T)
  • Step 2: Run and watch fail.
  • Step 3: Implement types.go (the types above), errors.go, pgerr.go (constraint name → sentinel table from Task 9), post.go.
  • Step 4: Write accountingtest/books.go.
  • Step 5: Run. go test ./internal/platform/accounting/ledger/ -count=1 -v → PASS.
  • Step 6: Sabotage. Delete step 6’s fx-line branch so the foreign entry is inserted unbalanced in base: TestAFrozenBaseProducesAnFXDifference fails at commit with journal_entry_balanced (the database caught what Go missed); restore.
  • Step 7: Commit
Terminal window
git add internal/platform/accounting/ledger internal/platform/accounting/accountingtest/books.go
git commit -m "Post journal entries with frozen base amounts and FX differences"

Files:

  • Create: internal/platform/accounting/ledger/reverse.go, query.go
  • Test: internal/platform/accounting/ledger/reverse_test.go, query_test.go

Interfaces:

  • Consumes: Task 11’s types and pgerr.go.
  • Produces:
    type ReverseInput struct {
    EntryID uuid.UUID
    EntryDate time.Time // the reversal's own date (spec 10.2.3)
    Description string // "": "Reversal of <entry_no>"
    PostedBy *uuid.UUID
    }
    func Reverse(ctx context.Context, tx pgx.Tx, c clock.Clock, in ReverseInput) (Entry, error)
    func Get(ctx context.Context, tx pgx.Tx, id uuid.UUID) (Entry, error) // with lines and distributions
    type Filter struct {
    FiscalPeriodID, JournalID, SourceID, InstanceID *uuid.UUID
    SourceKind *string
    Limit, Offset int // Limit 1..200, default 50
    }
    func List(ctx context.Context, tx pgx.Tx, f Filter) ([]Entry, error) // headers only; ORDER BY entry_date DESC, entry_no DESC
    // Unreversed returns the instance's accrual/payout entries that have no
    // reversal, newest first — what the Poster mirrors on rejection (Ruling R7).
    func Unreversed(ctx context.Context, tx pgx.Tx, instanceID uuid.UUID) ([]Entry, error)
    type Balance struct { Currency string; Amount, Base money.Amount } // signed: debit positive
    // SourceBalance sums a source's lines on one account: what a payout rule
    // reads to clear an accrual at its frozen base.
    func SourceBalance(ctx context.Context, tx pgx.Tx, sourceKind string, sourceID, accountID uuid.UUID) (Balance, error)
    var ErrNotFound, ErrAlreadyReversed, ErrCannotReverseReversal, ErrCannotReverseClosing, ErrMixedCurrencies error

Reverse:

  1. Get(EntryID); semantic reversal → ErrCannotReverseReversal; year_end_close → ErrCannotReverseClosing (Ruling R35).
  2. EXISTS (… reversal_of_id = $2 AND org_id = $1) → ErrAlreadyReversed (the unique index journal_entry_reversed_once is the race backstop, mapped to the same error in pgerr.go).
  3. fiscal.PeriodForPosting(EntryDate), year, numbering exactly as Post steps 4 and 7, on the original’s journal.
  4. Insert entry: semantic = 'reversal', reversal_of_id, the original’s currency, source_kind, source_id, instance_id.
  5. Insert each original line mirrored — debit↔credit and base_debit↔base_credit swapped; same account, kind, currency, fx_rate, fx_rate_source, party, tax code, memo — and each distribution with the same percentage and base amount. No conversion, no rate lookup, no analytic.Plan: the reversal is the original’s mirror, never a recomputation (spec 10.3).
  6. audit.Write{EntityKind: "journal_entry", Action: "reverse", After: {reversal, reversal_of: original.ID}}.
  • Step 1: Write the failing tests
func TestReversalMirrorsTheOriginalExactly(t *testing.T) {
// Post the USD FX-difference entry from Task 11, then SeedReferenceRate
// USD 9.99 (a rate that would change everything if anything recomputed),
// then Reverse. Every reversal line equals its original with sides
// swapped — base amounts, fx_rate, kind, distributions — and the sum of
// original+reversal per account is zero in both currencies.
}
func TestReversalCarriesItsOwnDateAndNumber(t *testing.T) // original 2026-09-10, reversal 2026-09-20 -> next MSC number, entry_date 2026-09-20
func TestAnEntryIsReversedOnce(t *testing.T) // PRESENCE first reversal ok; second -> ErrAlreadyReversed
func TestAReversalCannotBeReversed(t *testing.T)
func TestReversalIntoAClosedPeriodIsRefused(t *testing.T) // original in period 9 (open), reversal dated into a closed period 8 -> *fiscal.PeriodNotOpenError
func TestReversalOfAnInactiveAccountsLinesSucceeds(t *testing.T) // deactivate Travel after posting; Reverse still commits (Ruling R25)
func TestUnreversedListsNewestFirstAndSkipsReversed(t *testing.T)
func TestSourceBalanceNetsAccrualAndPayout(t *testing.T) {
// accrual Cr AP USD 1000 base 4200; SourceBalance(AP) = -1000 / -4200.
// After a payout Dr AP 1000 base 4200: 0 / 0.
}
func TestListFiltersAndPaginates(t *testing.T)
func TestGetInAnotherOrgIsNotFound(t *testing.T) // presence/absence
  • Step 2: Run and watch fail.
  • Step 3: Implement. Every query joins journal_line and analytic_distribution with l.org_id = e.org_id and e.org_id = $1 (the tenant-predicate guard checks every joined table).
  • Step 4: Run. PASS.
  • Step 5: Commit
Terminal window
git add internal/platform/accounting/ledger/reverse.go internal/platform/accounting/ledger/query.go \
internal/platform/accounting/ledger/reverse_test.go internal/platform/accounting/ledger/query_test.go
git commit -m "Reverse entries by mirroring them, and read the ledger"

Task 13: reconcile — the check registry and the built-in checks

Section titled “Task 13: reconcile — the check registry and the built-in checks”

Files:

  • Create: internal/platform/accounting/reconcile/reconcile.go, builtin.go
  • Test: internal/platform/accounting/reconcile/reconcile_test.go, builtin_test.go

Interfaces:

  • Consumes: ledger tables (Task 9); accountingtest chart/journal/year fixtures (Tasks 4, 6, 7); audit.VerifyChain.
  • Produces:
    package reconcile
    // Finding names one offending row. A check that can only say "out of
    // balance" is not a control (spec 10.6).
    type Finding struct {
    Check string `json:"check"`
    Ref uuid.UUID `json:"ref"` // the entry, line, account or audit row
    Detail string `json:"detail"` // English; names the numbers
    }
    type Check interface {
    Key() string
    Run(ctx context.Context, tx pgx.Tx) ([]Finding, error)
    }
    type CheckResult struct { Key string `json:"key"`; Passed bool `json:"passed"`; Findings []Finding `json:"findings"` }
    type Result struct { Passed bool `json:"passed"`; Checks []CheckResult `json:"checks"` }
    type Set struct { /* mu, checks map[string]Check */ }
    func NewSet() *Set
    func (s *Set) Register(c Check) // panics on a duplicate key
    func (s *Set) Run(ctx context.Context, tx pgx.Tx) (Result, error) // checks in key order; runs all even after a failure
    func Register(c Check) // into Default()
    func Default() *Set
    const (KeyTrialBalance = "trial_balance"; KeyEntryBalance = "entry_balance"; KeyEntryPeriod = "entry_period";
    KeyReversalMirror = "reversal_mirror"; KeyAuditChain = "audit_chain")
    func Builtins() []Check // the five, for tests that build their own Set

Built-ins, registered into Default() from init():

Key Query (all WHERE … org_id = $1 on every table) Finding per
trial_balance SUM(base_debit), SUM(base_credit) over journal_line one, Ref = uuid.Nil, detail with both totals
entry_balance entries LEFT JOIN lines grouped: count < 2 or Σdebit ≠ Σcredit or Σbase_debit ≠ Σbase_credit entry (detail: entry_no and the four sums)
entry_period entries whose entry_date is outside their period’s [starts_on, ends_on] entry
reversal_mirror per reversal, per account: original Σ(base_debit − base_credit) + reversal’s ≠ 0, or the same in transaction amounts reversal entry
audit_chain audit.VerifyChain(ctx, tx) the first broken audit row
  • Step 1: Write the failing tests

builtin_test.go — every check gets a presence/absence pair where the presence is produced by tampering through the superuser (e.H.Admin), which is the only way a balanced, append-only ledger can go wrong, and exactly the threat these checks exist for. The fixture is e.SeedChart, e.SeedJournals, e.OpenYear (which also write audit rows), and entries written with a local raw-SQL helper as erp_app — the same shape as Task 9’s insertEntry, including one reversal row mirroring an accrual — so this task does not wait for ledger.Post:

func TestACleanLedgerPasses(t *testing.T) // two balanced entries + a mirrored reversal; Result.Passed
func TestTrialBalanceNamesTheTotals(t *testing.T) {
// Admin: UPDATE journal_line SET base_debit = base_debit + 1 WHERE id = <one line>.
// trial_balance fails with both totals in Detail; entry_balance fails
// naming THAT entry's id — and no other entry.
}
func TestEntryPeriodNamesTheEntry(t *testing.T) // admin moves entry_date outside its period
func TestReversalMirrorNamesTheReversal(t *testing.T) // admin alters one reversal line's base amount (keeping the reversal itself balanced by altering its partner line too)
func TestAuditChainNamesTheBrokenRow(t *testing.T) // admin UPDATE audit_log SET action = 'x' on one row
func TestChecksAreTenantScoped(t *testing.T) {
// Tamper org B's ledger; org A's Result still passes (PRESENCE: org B's fails).
}

reconcile_test.go: a Set runs every check even after one fails; duplicate Register panics; results are in key order.

  • Step 2: Run and watch fail.
  • Step 3: Implement.
  • Step 4: Run. go test ./internal/platform/accounting/reconcile/ -count=1 -v → PASS.
  • Step 5: Commit
Terminal window
git add internal/platform/accounting/reconcile
git commit -m "Add the reconciliation set with row-naming findings"

Task 14: countrypack — the interface, the seeder, provisioning steps 70–72

Section titled “Task 14: countrypack — the interface, the seeder, provisioning steps 70–72”

Files:

  • Create: internal/platform/countrypack/countrypack.go, validate.go, seed.go, steps.go
  • Test: internal/platform/countrypack/validate_test.go, seed_test.go, steps_test.go, testpack_test.go

Interfaces:

  • Consumes: chart.CreateAccount, GetAccountByCode, ListAccounts, ResolveControl, RetainedEarnings, CreateJournal, ListJournals, DefaultJournal, CreateTaxCode, ListTaxCodes (Tasks 4, 7); company.Get; provisioning.Register, provisioning.Warning; vocab.ControlKinds.
  • Produces:
    package countrypack
    // Names holds one name per locale. "en", "ms" and "zh" are all required:
    // en/ms because either may be the record language (spec 11A.3), zh because
    // seeded data ships every UI locale together (spec 20.1.6).
    type Names map[string]string
    type AccountSeed struct {
    Code, ParentCode string // ParentCode "" for a root
    Type string // chart.Type*
    Header bool
    ControlKind string // "" or one of vocab.ControlKinds
    SystemRole string // "" or chart.RoleRetainedEarnings
    Names Names
    }
    type JournalSeed struct {
    Code, Type string
    DefaultAccountCode string
    IsDefaultForType bool
    Names Names
    }
    type TaxCodeSeed struct {
    Code, PackKind string
    Rate string // decimal percentage, e.g. "8"
    AccountCode string // "" for none
    ValidFrom string // YYYY-MM-DD
    ValidTo string // "" open-ended
    Names Names
    }
    // Pack is one country's localization (spec 11, narrowed per Ruling R28).
    type Pack interface {
    Code() string // ISO 3166-1 alpha-2, matches company_profile.country_code
    ChartOfAccounts() []AccountSeed
    Journals() []JournalSeed
    TaxCodes() []TaxCodeSeed
    }
    func Register(p Pack) // panics on a duplicate code or a pack failing Validate
    func Lookup(code string) (Pack, bool)
    func Registered() []Pack // sorted by code
    func Validate(p Pack) error // pure; see rules
    type NoPackError struct{ Country string }
    func SeedChart(ctx context.Context, tx pgx.Tx, c clock.Clock, p Pack, recordLocale string) error
    func SeedJournals(ctx context.Context, tx pgx.Tx, c clock.Clock, p Pack, recordLocale string) error
    func SeedTaxCodes(ctx context.Context, tx pgx.Tx, c clock.Clock, p Pack, recordLocale string) error
    const (StepChart = "chart_of_accounts"; StepJournals = "journals"; StepTaxCodes = "tax_codes")
    const (OrderChart = 70; OrderJournals = 71; OrderTaxCodes = 72)
    const (WarnControlKindUnmapped = "control_kind_unmapped"; WarnNoRetainedEarnings = "retained_earnings_unset";
    WarnJournalTypeNoDefault = "journal_type_without_default")

Validate(p) — a pack that fails it can never be registered, so a broken pack fails at boot and in CI rather than in a customer’s provisioning:

  1. Account codes unique; every ParentCode names an earlier seed; a child’s Type equals its parent’s; a parent is Header.
  2. Every vocab.ControlKinds kind appears on at least one seed, and every kind except bank on exactly one; exactly one SystemRole = retained_earnings.
  3. Every seed, journal and tax code has non-blank en, ms and zh names.
  4. Journal codes unique; each DefaultAccountCode names a seed; bank/cash journals have one; at most one IsDefaultForType per type, and misc has one (the year-end close needs it, Ruling R17).
  5. Tax codes: codes unique; Rate parses as money.Rate in 0..100; AccountCode (if set) names a seed; dates parse.

Seeding: each Seed* creates rows in the seed order through the chart functions (so each writes its own audit row, and the chart’s rules apply to seeded rows exactly as to hand-made ones), with Name = Names[recordLocale], then writes one translation row per locale per row (entity_kind account / journal / tax_code, field name) with the same INSERT … ON CONFLICT … WHERE org_id IS NOT NULL DO NOTHING shape as Task 5’s helper — its own copy (Ruling R22).

Steps (registered in init()), all reading the pack by company.Get(...).CountryCode and the record locale from DefaultLocale:

Step Check done when Warnings when done Run
chart_of_accounts (70) the org has ≥ 1 account one control_kind_unmapped per kind in vocab.ControlKinds whose ResolveControl fails (for bank: none active); retained_earnings_unset no pack → *NoPackError; else SeedChart
journals (71) ≥ 1 journal journal_type_without_default for misc and bank when DefaultJournal fails SeedJournals
tax_codes (72) ≥ 1 tax code none SeedTaxCodes

(Ruling R29: never seed over what exists.)

  • Step 1: Write the failing tests. testpack_test.go declares testPack (country ZZ) and registers it from the test file’s init() — a minimal valid pack: headers for the five types, the six control-kind leaves, retained earnings, a revenue and an expense leaf, journals MSC (misc, default) and BNK (bank, default), one tax code. Then:
func TestValidateAcceptsTheTestPack(t *testing.T) // presence for every refusal below
func TestValidateRefusesAChildBeforeItsParent(t *testing.T)
func TestValidateRefusesAMissingControlKind(t *testing.T) // drop employee_advances -> error names it
func TestValidateRefusesAMissingLocale(t *testing.T) // delete Names["zh"] on one seed -> error names the code
func TestValidateRefusesTwoDefaultsForAType(t *testing.T)
func TestRegisterPanicsOnAnInvalidPack(t *testing.T) // use a fresh registry via an unexported newRegistry() test hook
func TestEveryRegisteredPackIsValid(t *testing.T) {
// Drift guard (spec 20.1.1): every pack linked into this test binary
// passes Validate. PRESENCE: this file blank-imports countrypack/my
// once Task 15 lands; until then it asserts len(Registered()) >= 0 and
// is tightened to >= 1 by Task 15.
}
func TestSeedChartThenJournalsThenTaxes(t *testing.T) {
// accountingtest.New with a ZZ company (admin UPDATE company_profile SET
// country_code = 'ZZ'); run the three seeders: ResolveControl succeeds
// for every kind; RetainedEarnings resolves; DefaultJournal("misc") is
// MSC; names are English; translation rows exist for en, ms and zh.
}
func TestSeedingInMalayRecordsMalayNames(t *testing.T) // recordLocale "ms" -> Account.Name is the ms name
func TestTheStepsAreIdempotentAndKeepEdits(t *testing.T) {
// Run the three steps through a fresh provisioning.Registry (plus a stub
// done company_profile step). Rename account 6100 and deactivate the SAL
// journal. Run again: nothing is created, the rename and deactivation
// survive (spec 20.2 property 1), and the report carries no error.
}
func TestAMissingControlAccountIsAWarningNotARepair(t *testing.T) {
// Deactivate the ap account; Readiness reports chart_of_accounts done
// with a control_kind_unmapped warning naming "ap"; Run does not create
// a second ap account.
}
func TestAnUnknownCountryFailsTheStepNamingIt(t *testing.T) // country "XX" -> error containing "XX"
  • Step 2: Run and watch fail. go test ./internal/platform/countrypack/ -count=1
  • Step 3: Implement.
  • Step 4: Run. PASS. Then go test ./internal/platform/provisioning/ ./internal/platform/onboarding/ -count=1 — these do not import countrypack, so their registries are unchanged; confirm they still pass.
  • Step 5: Commit
Terminal window
git add internal/platform/countrypack
git commit -m "Add country packs, their validator and provisioning steps 70 to 72"

Files:

  • Create: internal/platform/countrypack/my/my.go
  • Modify: internal/platform/countrypack/validate_test.go (tighten TestEveryRegisteredPackIsValid and blank-import my)
  • Test: internal/platform/countrypack/my/my_test.go, internal/platform/accounting/nosst_test.go

Interfaces:

  • Consumes: countrypack.Pack, Register, seed types (Task 14).
  • Produces: my.Pack registered under "MY" from init(); nothing else is exported.

The Malaysian chart (Ruling R28; an SME chart an accountant can adopt and edit, not a statutory one — Malaysia prescribes none):

Code Parent Type Header Kind / role en ms zh
1000 asset ✓ Assets Aset 资产
1100 1000 asset ✓ Non-current assets Aset bukan semasa 非流动资产
1110 1100 asset Property, plant and equipment Hartanah, loji dan peralatan 物业、厂房及设备
1120 1100 asset Accumulated depreciation Susut nilai terkumpul 累计折旧
1500 1000 asset ✓ Current assets Aset semasa 流动资产
1510 1500 asset Cash in hand Tunai dalam tangan 库存现金
1520 1500 asset bank Bank – current account Bank – akaun semasa 银行往来账户
1530 1500 asset ✓ Petty cash floats Apungan tunai runcit 零用金
1540 1500 asset ar Trade receivables Penghutang perdagangan 应收账款
1550 1500 asset employee_advances Employee advances Pendahuluan pekerja 员工预支款
1560 1500 asset Other receivables and deposits Penghutang lain dan deposit 其他应收款及押金
1570 1500 asset Prepayments Bayaran terdahulu 预付款项
2000 liability ✓ Liabilities Liabiliti 负债
2100 2000 liability ✓ Current liabilities Liabiliti semasa 流动负债
2110 2100 liability ap Trade and other payables Pemiutang perdagangan dan lain-lain 应付账款及其他应付款
2120 2100 liability Accruals Akruan 应计费用
2130 2100 liability petty_cash_ap Petty cash payable Tunai runcit belum bayar 应付零用金
2140 2100 liability Sales and service tax payable Cukai jualan dan perkhidmatan belum bayar 应付销售与服务税
2150 2100 liability Other payables Pemiutang lain 其他应付款
3000 equity ✓ Equity Ekuiti 权益
3100 3000 equity Share capital Modal saham 股本
3200 3000 equity retained_earnings Retained earnings Pendapatan tertahan 保留盈余
4000 income ✓ Income Pendapatan 收入
4100 4000 income Revenue Hasil 营业收入
4200 4000 income Other income Pendapatan lain 其他收入
5000 expense ✓ Cost of sales Kos jualan 销售成本
5100 5000 expense Purchases Belian 采购
6000 expense ✓ Operating expenses Perbelanjaan operasi 营业费用
6100 6000 expense Travelling and accommodation Perjalanan dan penginapan 差旅及住宿费
6110 6000 expense Meals and entertainment Makan dan hiburan 餐饮及招待费
6120 6000 expense Transport, parking and toll Pengangkutan, letak kereta dan tol 交通、停车及过路费
6130 6000 expense Telephone and internet Telefon dan internet 电话及网络费
6140 6000 expense Printing and stationery Percetakan dan alat tulis 印刷及文具
6150 6000 expense Staff welfare Kebajikan kakitangan 员工福利
6160 6000 expense Upkeep and repairs Penyelenggaraan dan pembaikan 维修保养
6170 6000 expense Professional fees Yuran profesional 专业费用
6180 6000 expense Bank charges Caj bank 银行手续费
6190 6000 expense Sundry expenses Perbelanjaan pelbagai 杂项费用
6900 6000 expense fx_gain_loss Foreign exchange (gain)/loss (Keuntungan)/kerugian pertukaran asing 汇兑(收益)/损失

Journals: SAL sales (default) Sales / Jualan / 销售; PUR purchase (default) Purchases / Belian / 采购; BNK bank (default, 1520) Bank / Bank / 银行; CSH cash (default, 1510) Cash / Tunai / 现金; MSC misc (default) Miscellaneous / Pelbagai / 杂项.

SST tax codes — rates and scope must be confirmed by a Malaysian tax agent before a paying customer relies on them (spec 11A; the July 2025 scope changes in particular); say so in a comment above the slice:

Code pack_kind Rate Account Valid from en ms zh
ST10 sales_tax 10 2140 2018-09-01 Sales tax 10% Cukai jualan 10% 销售税 10%
ST5 sales_tax 5 2140 2018-09-01 Sales tax 5% Cukai jualan 5% 销售税 5%
SVT6 service_tax 6 2140 2018-09-01 Service tax 6% Cukai perkhidmatan 6% 服务税 6%
SVT8 service_tax 8 2140 2024-03-01 Service tax 8% Cukai perkhidmatan 8% 服务税 8%
EXEMPT exempt 0 2018-09-01 Exempt Dikecualikan 豁免
OS out_of_scope 0 2018-09-01 Out of scope Di luar skop 不适用
  • Step 1: Write the failing tests

my/my_test.go:

func TestTheMalaysianPackIsRegisteredAndValid(t *testing.T) {
p, ok := countrypack.Lookup("MY")
require.True(t, ok)
require.NoError(t, countrypack.Validate(p))
require.Len(t, p.ChartOfAccounts(), 39)
require.Len(t, p.Journals(), 5)
require.Len(t, p.TaxCodes(), 6)
}
func TestProvisioningAMalaysianOrgGivesAWorkingLedger(t *testing.T) {
// accountingtest.New (country MY); run countrypack's three steps and
// analytic/fiscal's through provisioning.DefaultRegistry().Run with the
// imports this test links (countrypack, my, analytic, fiscal) plus a
// company profile. Then ledger.Post a manual MYR entry Dr 6100 / Cr 1520
// with PostedBy a member: it commits as MSC-2026-0001.
}

internal/platform/accounting/nosst_test.go (package accounting_test) — spec 11’s “nothing outside the country pack names SST, LHDN or MyInvois”, enforced where this plan can enforce it:

func TestTheLedgerNamesNoMalaysianTax(t *testing.T) {
// Walk every non-test .go file under internal/platform/accounting and
// internal/modules/accounting. Fail on any match of
// `\b(SST|LHDN|MyInvois)\b` (case-insensitive). PRESENCE: the walk read
// > 10 files, and the same regexp matches countrypack/my/my.go.
}
  • Step 2: Run and watch fail.
  • Step 3: Write my.go — one unexported pack type with the three slices as package-level vars exactly as the tables above, and func init() { countrypack.Register(pack{}) }. Tighten TestEveryRegisteredPackIsValid to require.NotEmpty(t, countrypack.Registered()) and add _ "…/countrypack/my" to that test file.
  • Step 4: Run. go test ./internal/platform/countrypack/... ./internal/platform/accounting/ -count=1 → PASS. Sabotage: delete the ms name from account 6190 → TestTheMalaysianPackIsRegisteredAndValid panics at init() naming 6190; restore.
  • Step 5: Commit
Terminal window
git add internal/platform/countrypack internal/platform/accounting/nosst_test.go
git commit -m "Add the Malaysia country pack: chart, journals and SST codes"

Task 16: accounting — posting rules and the Poster

Section titled “Task 16: accounting — posting rules and the Poster”

Files:

  • Create: internal/platform/accounting/rules.go, poster.go, errors.go
  • Test: internal/platform/accounting/poster_test.go, rules_test.go, testrules_test.go

Interfaces:

  • Consumes: posting.Event, posting.Semantic, posting.Poster, posting.Dispatch, posting.Register, posting.ResetForTest (seam — read-only); ledger.Post, ledger.Reverse, ledger.Unreversed, ledger.SourceBalance (Tasks 11–12); chart.ResolveControl; company.Get.
  • Produces — the API Plans 5–8 call:
    package accounting
    // Source is what a rule builds from.
    type Source struct {
    Event posting.Event
    BaseCurrency string
    // Control is the org's account for Event.ControlKind, resolved before
    // Build runs; nil when the event names no control kind (payouts).
    Control *chart.Account
    }
    // Draft is a rule's output; the engine turns it into a posted entry.
    type Draft struct {
    JournalID *uuid.UUID
    JournalType string
    Currency string
    Description string
    Lines []ledger.Line
    }
    // PostingRule (spec 10.3). Semantic is Accrual or Payout only: reversal is
    // the engine's, never a rule's (Ruling R7).
    type PostingRule interface {
    Kind() string
    Semantic() posting.Semantic
    Build(ctx context.Context, tx pgx.Tx, src Source) (Draft, error)
    }
    type RuleSet struct { /* mu sync.RWMutex; rules map[ruleKey]PostingRule */ }
    func NewRuleSet() *RuleSet
    func (s *RuleSet) Register(r PostingRule) // panics: empty kind, semantic not accrual/payout, duplicate (kind, semantic)
    func (s *RuleSet) Lookup(kind string, sem posting.Semantic) (PostingRule, bool)
    func RegisterPostingRule(r PostingRule) // into DefaultRules(); modules call this from init()
    func DefaultRules() *RuleSet
    type Poster struct { /* clock clock.Clock; rules *RuleSet */ }
    func NewPoster(c clock.Clock, rules *RuleSet) *Poster
    var _ posting.Poster = (*Poster)(nil)
    func (p *Poster) Post(ctx context.Context, tx pgx.Tx, ev posting.Event) error
    var ErrOrgMismatch, ErrIncompleteEvent, ErrControlKindRequired, ErrAlreadyPosted, ErrNothingToReverse error
    type NoPostingRuleError struct{ Kind string; Semantic posting.Semantic }

Poster.Post(ctx, tx, ev):

  1. !ev.PostsToLedger() → nil (the same filter as Dispatch, so a direct call agrees with it).
  2. Tenant from ctx must equal ev.OrgID → else ErrOrgMismatch. Kind, InstanceID, SourceID, BusinessDate non-zero → else ErrIncompleteEvent.
  3. postedBy := &ev.ActorMemberID unless it is uuid.Nil (a system transition).
  4. Rejected / Cancelled (necessarily PastAccrual): ledger.Unreversed(ev.InstanceID); empty → ErrNothingToReverse (the ledger and the workflow disagree; abort the transition); else ledger.Reverse each, newest first, dated ev.BusinessDate, description "Reversal on <semantic> of <kind> <source_id>". Done.
  5. Accrual / Payout: rule by (Kind, Semantic) → else *NoPostingRuleError. An unreversed entry of the same semantic for the instance → ErrAlreadyPosted (Ruling R8). Accrual with empty ControlKind → ErrControlKindRequired (Ruling R9); a non-empty ControlKind resolves through chart.ResolveControl into Source.Control.
  6. draft := rule.Build(...); ledger.Post with Semantic: string(ev.Semantic), SourceKind: ev.Kind, SourceID, InstanceID, EntryDate: ev.BusinessDate, PostedBy, and the draft’s journal, currency, description, lines. Any error returns wrapped — the caller’s transaction must not commit (spec 10.3: “a state change that cannot post does not commit”).
  • Step 1: Write the test rules — testrules_test.go. A fake document store (map[uuid.UUID]doc, doc = {Currency string; Amount money.Amount}) keyed by SourceID:

    • testAccrual{kind:"test_claim"}: Dr Travel amount / Cr src.Control amount, journal type purchase, currency the doc’s.
    • testPayout{kind:"test_claim"}: reads ledger.SourceBalance("test_claim", sourceID, ap.ID); Dr AP for −balance.Amount with Base = −balance.Base (the frozen accrual base) / Cr Bank for the same amount at today’s rate (no Base); journal type bank.
  • Step 2: Write the failing tests — poster_test.go, each against b := e.SeedBooks(t) and a fresh NewRuleSet() holding the test rules:

func TestAnAccrualPostsInTheCallersTransaction(t *testing.T) {
// Inside ONE e.Tx: Post(accrual, ControlKind "ap"); read the entry back
// in the same tx. Then do the same in a tx that returns an error after
// Post: afterwards no entry for that instance exists — the posting rolled
// back with the "state change" (spec 10.3).
}
func TestDispatchReachesTheRegisteredPoster(t *testing.T) {
posting.ResetForTest()
t.Cleanup(posting.ResetForTest)
posting.Register(accounting.NewPoster(e.Clock, rules))
// posting.Dispatch(accrual) inside e.Tx -> an entry exists.
// posting.Dispatch(pending) -> nil and no entry (PRESENCE of the
// filter's other half).
}
func TestAPayoutClearsTheAccrualAtItsFrozenBaseAndPostsFX(t *testing.T) {
// USD 1000 accrual on 2026-09-18 at 4.2 (base 4200). Seed USD 4.3 on
// 2026-09-21, advance BusinessDate to 2026-09-21, Post(payout): lines
// Dr AP 1000/4200, Cr Bank 1000/4300, fx_difference Dr FX 100.
// SourceBalance(AP) is 0/0 afterwards: the control account is clear in
// both currencies, and the expense account still says 4200 (never
// absorbed, spec 10.5).
}
func TestRejectionAfterAccrualMirrorsIt(t *testing.T) // Rejected, PastAccrual -> one reversal; Travel and AP net to 0
func TestCancellationAfterPayoutReversesBothNewestFirst(t *testing.T) // two reversals; the payout's is posted first (lower entry_no), and every account nets to 0
func TestRejectionWithNothingToReverseAborts(t *testing.T) // ErrNothingToReverse
func TestAMissingRuleAbortsTheTransition(t *testing.T) // *NoPostingRuleError{Kind:"unknown_kind",Semantic:accrual}; no entry
func TestAnAccrualNeedsAControlKind(t *testing.T) // ErrControlKindRequired
func TestAnAccrualIsPostedOnce(t *testing.T) // PRESENCE first ok; second -> ErrAlreadyPosted
func TestAnEventForAnotherOrgIsRefused(t *testing.T) // ErrOrgMismatch
func TestAnUnresolvableControlKindAborts(t *testing.T) // deactivate AP -> *chart.NoControlAccountError
func TestEveryPostingIsTraceable(t *testing.T) {
// Spec 11A.7's drift rule for posting rules: for every entry the tests
// above created (collect ids), source_kind = "test_claim", source_id and
// instance_id are the event's, and an audit_log row with
// entity_kind = 'journal_entry' and entity_id = the entry exists.
}
func TestPostingTakesNoAdvisoryLockOtherThanTheAuditChain(t *testing.T) {
// Ruling R4. Inside one e.Tx: audit.Write first (standing in for Plan
// 3's transition audit), then Poster.Post(accrual). Then:
// SELECT DISTINCT classid FROM pg_locks
// WHERE locktype = 'advisory' AND pid = pg_backend_pid()
// PRESENCE: the set contains 1 (the audit chain — the query can see
// advisory locks). ABSENCE: it contains nothing else.
}

rules_test.go: Register panics on an empty kind, on posting.Rejected, and on a duplicate; Lookup misses on an unregistered pair; DefaultRules() is shared across calls.

  • Step 3: Run and watch fail. go test ./internal/platform/accounting/ -count=1
  • Step 4: Implement rules.go, poster.go, errors.go. The package comment in rules.go is the contract statement for Plans 5–8: a module registers a rule for (kind, accrual) and (kind, payout) in its init(), names control kinds rather than account codes, reads its own document by Source.Event.SourceID, and never writes a journal line.
  • Step 5: Run. PASS. Sabotage: remove the ErrAlreadyPosted check → TestAnAccrualIsPostedOnce red; restore.
  • Step 6: Commit
Terminal window
git add internal/platform/accounting/rules.go internal/platform/accounting/poster.go internal/platform/accounting/errors.go \
internal/platform/accounting/poster_test.go internal/platform/accounting/rules_test.go internal/platform/accounting/testrules_test.go
git commit -m "Implement posting.Poster over a posting-rule registry"

Task 17: closing — period close, reopen, year-end close

Section titled “Task 17: closing — period close, reopen, year-end close”

Files:

  • Create: internal/platform/accounting/closing/closing.go, balances.go, errors.go
  • Test: internal/platform/accounting/closing/closing_test.go

Interfaces:

  • Consumes: fiscal.LockPeriod, LockYear, LockYearPeriods, SetPeriodStatus, SetYearClosed, GetYear (Task 6); reconcile.Set, Result (Task 13); ledger.Post (Task 11); chart.RetainedEarnings, chart.DefaultJournal (Tasks 4, 7); audit.Write.
  • Produces:
    package closing
    type AccountBalance struct {
    AccountID uuid.UUID `json:"account_id"`
    Code string `json:"code"`
    BaseDebit money.Amount `json:"base_debit"`
    BaseCredit money.Amount `json:"base_credit"`
    }
    type CloseResult struct {
    CloseLogID uuid.UUID
    Reconciliation reconcile.Result
    ClosingEntryID *uuid.UUID
    }
    func ClosePeriod(ctx context.Context, tx pgx.Tx, c clock.Clock, checks *reconcile.Set, periodID uuid.UUID, by *uuid.UUID) (CloseResult, error)
    func ReopenPeriod(ctx context.Context, tx pgx.Tx, c clock.Clock, periodID uuid.UUID, by *uuid.UUID, reason string) (uuid.UUID, error)
    func CloseYear(ctx context.Context, tx pgx.Tx, c clock.Clock, checks *reconcile.Set, yearID uuid.UUID, by *uuid.UUID) (CloseResult, error)
    // BalancesAsOf is the cumulative trial balance per account through day
    // (non-zero accounts only, ORDER BY code). Plan 9 may reuse it.
    func BalancesAsOf(ctx context.Context, tx pgx.Tx, day time.Time) ([]AccountBalance, error)
    var ErrPeriodNotOpen, ErrPeriodNotClosed, ErrPeriodLocked, ErrYearClosed, ErrLastPeriodNotOpen, ErrReasonRequired error
    type EarlierPeriodsOpenError struct{ Seqs []int }
    type ReconciliationFailedError struct{ Result reconcile.Result } // Error() lists failing check keys

Lock discipline (Ruling R4): every row lock this function will need is taken first, then reconciliation, then writes, and audit.Write last.

ClosePeriod: LockPeriod (status must be open, else ErrPeriodNotOpen); GetYear must be open; checks.Run → any failure → *ReconciliationFailedError (nothing written); BalancesAsOf(period.EndsOn); SetPeriodStatus(closed); insert close_log{action: period_close, balances, reconciliation}; audit.Write{EntityKind: "fiscal_period", Action: "close"}.

ReopenPeriod: blank reason → ErrReasonRequired; LockPeriod; locked → ErrPeriodLocked; not closed → ErrPeriodNotClosed; year closed → ErrYearClosed; SetPeriodStatus(open); close_log{action: period_reopen, reason, balances: BalancesAsOf(ends_on), reconciliation: {"passed": null}}; audit "reopen".

CloseYear (Ruling R17): LockYear (open, else ErrYearClosed); LockYearPeriods; periods 1…n−1 not closed → *EarlierPeriodsOpenError; period n not open → ErrLastPeriodNotOpen; checks.Run → failure → *ReconciliationFailedError; pre := BalancesAsOf(year.EndsOn); re := chart.RetainedEarnings (→ chart.ErrNoRetainedEarnings); P&L per account over the year (SUM(base_debit − base_credit) for income/expense accounts, entries dated within the year); if any non-zero, ledger.Post{Semantic: year_end_close, JournalType: misc, Currency: base, EntryDate: year.EndsOn, SourceKind: "fiscal_year", SourceID: year.ID, PostedBy: by, Description: "Year-end close " + year.Name} with one line per non-zero account on its opposite side and one balancing line on re; SetPeriodStatus(n, closed); every period → locked; SetYearClosed; close_log{action: year_close, balances: pre, closing_entry_id}; audit fiscal_year "close".

  • Step 1: Write the failing tests (b := e.SeedBooks(t), entries through ledger.Post):
func TestClosingAPeriodSnapshotsAndRefusesLaterPostings(t *testing.T) {
// Post into Sep; ClosePeriod(Sep): close_log row with the balances and a
// passing reconciliation; one audit row. PRESENCE: a posting dated
// 2026-09-10 succeeded before the close. ABSENCE: after it, the same
// posting -> *fiscal.PeriodNotOpenError.
}
func TestAFailingReconciliationRefusesTheClose(t *testing.T) {
// A Set with one check that always returns a Finding: ClosePeriod ->
// *ReconciliationFailedError whose Result names that check and its
// Finding.Ref; the period is still open and no close_log row exists.
// PRESENCE: the same call with reconcile.Default()'s built-ins succeeds.
}
func TestReopenNeedsAReasonAndAnOpenYear(t *testing.T)
func TestYearCloseMovesProfitToRetainedEarnings(t *testing.T) {
// Revenue 1000 (Cr 4100 / Dr Bank), Travel 300 (Dr 6100 / Cr Bank), in
// Jan and Sep. Close periods 1..11 (each through ClosePeriod), then
// CloseYear: the closing entry has Dr 4100 1000, Cr 6100 300,
// Cr 3200 700; afterwards BalancesAsOf(2026-12-31) shows 4100 and 6100
// at zero and 3200 at 700 credit; every period is locked; the year is
// closed; close_log.balances is the PRE-close snapshot (4100 still 1000).
}
func TestYearCloseRefusesWhileEarlierPeriodsAreOpen(t *testing.T) // *EarlierPeriodsOpenError{Seqs:[...]} lists every open one
func TestYearCloseWithNoProfitPostsNoEntry(t *testing.T) // ClosingEntryID nil, year closed
func TestALockedPeriodCannotReopen(t *testing.T) // after CloseYear, ReopenPeriod -> ErrPeriodLocked
func TestTheClosingEntryIgnoresRequiredAxes(t *testing.T) // SeedAxes(required) + expense lines carrying cost_centre; CloseYear succeeds (Ruling R26)
func TestYearCloseNeedsRetainedEarnings(t *testing.T) // deactivate 3200 -> chart.ErrNoRetainedEarnings, nothing closed
  • Step 2: Run and watch fail.
  • Step 3: Implement.
  • Step 4: Run. go test ./internal/platform/accounting/closing/ -count=1 -v → PASS.
  • Step 5: Commit
Terminal window
git add internal/platform/accounting/closing
git commit -m "Close periods and years through the reconciliation set"

Task 18: modules/accounting — composition, permissions, the Poster at boot, chart routes

Section titled “Task 18: modules/accounting — composition, permissions, the Poster at boot, chart routes”

Files:

  • Create: internal/modules/accounting/permissions.go, routes.go, boot.go, http.go, chart_handlers.go, errors.go
  • Modify: constants.yaml (append 3 permissions) → make generate (internal/kernel/vocab/vocab.gen.go, packages/contracts/vocab.ts)
  • Modify: api/openapi.yaml (append paths and schemas; add x-go-type to Money) → make generate (internal/gen/api/api.gen.go)
  • Modify: internal/app/app.go, internal/app/routes_test.go (composedModules), cmd/api/main.go, cmd/migrate/main.go, cmd/orgadmin/main.go, cmd/worker/main.go, cmd/migrate/wiring_test.go, cmd/orgadmin/wiring_test.go
  • Test: internal/modules/accounting/chart_handlers_test.go, routes_test.go, boot_test.go

Interfaces:

  • Consumes: chart.*, analytic.* (Tasks 4, 5, 7); accounting.NewPoster, accounting.DefaultRules (Task 16); the provisioning steps registered by countrypack, countrypack/my, analytic, fiscal (Tasks 5, 6, 14, 15).
  • Produces:
    package accounting // internal/modules/accounting
    const (
    PermissionLedgerRead = "accounting.ledger.read"
    PermissionAccountManage = "accounting.account.manage"
    PermissionJournalPost = "accounting.journal.post"
    PermissionPeriodClose = "accounting.period.close"
    PermissionFXManage = "accounting.fx.manage"
    )
    type Deps struct { DB *db.DB; Clock clock.Clock }
    func Register(g *gin.RouterGroup, deps Deps)
    // http.go — shared request plumbing for Tasks 19 and 20:
    func decodeStrict(c *gin.Context, v any) error // copy of orgstructure's, unknown fields refused
    type optional[T any] struct{ Set bool; Value *T } // copy of orgstructure's
    func parseID(c *gin.Context, name string) (uuid.UUID, error)
    func parseDate(s string) (time.Time, error) // "2006-01-02" -> midnight UTC
    func actor(ctx context.Context) *uuid.UUID // audit.ActorFromContext's member, nil if none
    func labels(ctx context.Context, tx pgx.Tx, kind string, ids []uuid.UUID) (map[uuid.UUID]string, error) // i18n.Load(..., "name")

permissions.go registers the five with member.RegisterPermission in init() (descriptions: “Read the chart of accounts, journals, entries, periods, rates and reconciliation”, “Create and edit accounts, journals, tax codes and analytic axes”, “Post manual journal entries and reversals”, “Create fiscal years; close, reopen and year-end close periods”, “Record manual exchange-rate overrides”). constants.yaml’s permissions: list gains accounting.ledger.read, accounting.period.close, accounting.fx.manage appended at its end (the other two exist).

boot.go:

package accounting
import (
"github.com/mcom/erp/internal/kernel/clock"
platformaccounting "github.com/mcom/erp/internal/platform/accounting"
"github.com/mcom/erp/internal/platform/posting"
// Provisioning steps 70–74 register from these packages' init(). A binary
// that links this module therefore provisions accounting; one that does
// not, does not — which is why cmd/api, cmd/migrate, cmd/orgadmin and
// cmd/worker all link it (internal/app/binaries_test.go).
_ "github.com/mcom/erp/internal/platform/accounting/analytic"
_ "github.com/mcom/erp/internal/platform/accounting/fiscal"
_ "github.com/mcom/erp/internal/platform/countrypack"
_ "github.com/mcom/erp/internal/platform/countrypack/my"
)
// The ledger's side of the posting seam (wave-1 contract §1, Ruling R31):
// every workflow transition in this process that enters accrual, payout, or
// a terminal state past accrual posts through this Poster, in the
// transition's own transaction. Posting rules are added by Plans 5–8 with
// accounting.RegisterPostingRule from their own init().
func init() {
posting.Register(platformaccounting.NewPoster(clock.System(), platformaccounting.DefaultRules()))
}

Routes (every one through httpx.Protected; routes_test.go pins each route’s permission, following internal/modules/orgstructure/routes_test.go):

Method Path (under /v1) Permission operationId Request 2xx body
GET /accounting/accounts ledger.read listAccounts — AccountList
POST /accounting/accounts account.manage createAccount CreateAccountRequest 201 Account
PATCH /accounting/accounts/{id} account.manage updateAccount UpdateAccountRequest Account
GET /accounting/journals ledger.read listJournals — JournalList
POST /accounting/journals account.manage createJournal CreateJournalRequest 201 Journal
PATCH /accounting/journals/{id} account.manage updateJournal UpdateJournalRequest Journal
GET /accounting/tax-codes ledger.read listTaxCodes — TaxCodeList
POST /accounting/tax-codes account.manage createTaxCode CreateTaxCodeRequest 201 TaxCode
PATCH /accounting/tax-codes/{id} account.manage updateTaxCode {active} TaxCode
GET /accounting/analytic-axes ledger.read listAnalyticAxes — AnalyticAxisList (each with its accounts)
POST /accounting/analytic-axes account.manage createAnalyticAxis CreateAnalyticAxisRequest 201 AnalyticAxis
PATCH /accounting/analytic-axes/{id} account.manage updateAnalyticAxis UpdateAnalyticAxisRequest AnalyticAxis
POST /accounting/analytic-axes/{id}/accounts account.manage createAnalyticAccount CreateAnalyticAccountRequest 201 AnalyticAccount
PATCH /accounting/analytic-accounts/{id} account.manage updateAnalyticAccount {name?, active?} AnalyticAccount

Wire shapes (snake_case; path is not exposed, as in orgstructure; every list item carries label, the request locale’s translation falling back to name):

  • Account: id, parent_id, code, name, label, type, normal_balance, control_kind (nullable), system_role (nullable), postable, currency (nullable), active.
  • CreateAccountRequest: parent_id?, code, name, type, header?, control_kind?, system_role?, currency?. UpdateAccountRequest: code?, name?, control_kind? (nullable), currency? (nullable), active? — optional[T] distinguishes absent from null.
  • Journal: id, code, name, label, type, default_account_id, sequence_key, currency, default_for_type, active.
  • TaxCode: id, code, name, label, pack_kind, rate (Rate string), account_id, valid_from (date), valid_to (date, nullable), active.
  • AnalyticAxis: id, key, name, label, required, applies_to[], active, accounts[AnalyticAccount]. AnalyticAccount: id, axis_id, parent_id, code, name, active.

Error mapping (errors.go, mapErr), extended by Tasks 19–20:

Error Kind Status
chart.ErrNotFound, analytic.ErrNotFound on the path id not-found 404
an id in the body that resolves to nothing validation 422 (detail names the field)
chart.ErrDuplicateCode, analytic.ErrDuplicateKey/Code, *chart.AmbiguousControlError, account_has_postings conflict 409
chart.ErrParentMismatch, ErrParentIsControl, ErrTooDeep, ErrUnknownControlKind, ErrInvalidAccount, ErrInvalidJournal, ErrInvalidTaxCode, analytic.ErrInvalidAppliesTo, ErrParentOtherAxis validation 422

api/openapi.yaml: append the 14 operations under paths: and their schemas under components.schemas, in the house style of /org/units (every operation lists 401 via Unauthenticated, 403 naming its permission, 400 via BadRequestBody where there is a body, 404/409/422 where the table above produces them). Add two schemas used from here on:

Rate:
type: string
description: A decimal rate or percentage as a string, up to 10 places. Never a JSON number.
example: "4.2000000000"
x-go-type: money.Rate
x-go-type-import:
path: github.com/mcom/erp/internal/kernel/money
Date:
type: string
format: date

and add to the existing Money schema (Ruling R33):

x-go-type: money.Amount
x-go-type-import:
path: github.com/mcom/erp/internal/kernel/money

The first path, written out as the pattern for the rest:

/accounting/accounts:
get:
operationId: listAccounts
summary: The chart of accounts
description: >
Every account in tree (pre-)order, retired ones included — they still
anchor the lines posted to them. Requires accounting.ledger.read.
responses:
'200':
description: The chart
content:
application/json:
schema:
$ref: '#/components/schemas/AccountList'
'401':
$ref: '#/components/responses/Unauthenticated'
'403':
description: The caller does not hold accounting.ledger.read.
content:
application/problem+json:
schema:
$ref: '#/components/schemas/Problem'

Composition:

  • internal/app/app.go: Accounting accounting.Deps on Deps; accounting.Register(api, deps.Accounting) after activity.Register.

  • internal/app/routes_test.go: append "accounting": {accounting.PermissionLedgerRead, accounting.PermissionAccountManage, accounting.PermissionJournalPost, accounting.PermissionPeriodClose, accounting.PermissionFXManage} to composedModules.

  • cmd/api/main.go: import and pass Accounting: accounting.Deps{DB: database, Clock: clock.System()}.

  • cmd/migrate/main.go, cmd/orgadmin/main.go, cmd/worker/main.go: _ "github.com/mcom/erp/internal/modules/accounting". Their wiring_test.go lists (migrate, orgadmin) gain the module path and the five permission keys.

  • Step 1: Write the failing tests

boot_test.go:

func TestLinkingTheModuleRegistersThePoster(t *testing.T) {
// posting.Dispatch of an accrual for kind "no_such_kind" inside a tx
// returns *platformaccounting.NoPostingRuleError — NOT posting.ErrNoPoster.
// That difference is the proof init() registered the Poster.
}
func TestLinkingTheModuleRegistersTheAccountingSteps(t *testing.T) {
// provisioning.Steps() contains chart_of_accounts(70), journals(71),
// tax_codes(72), analytic_axes(73), fiscal_year(74).
}

chart_handlers_test.go — the orgstructure newEnv pattern (httpx.NewRouter, a token.Signer, seedOrg, seedMember, grant, mint), plus the company profile and the MY provisioning run through provisioning.Run so the chart exists:

func TestListAccountsNeedsLedgerRead(t *testing.T) // PRESENCE 200 with the grant; ABSENCE 403 without
func TestListAccountsReturnsLabelsInTheRequestLocale(t *testing.T) // Accept-Language zh -> 6100's label is 差旅及住宿费, name still English
func TestCreateAccountUnderAHeader(t *testing.T) // 201; the header stays non-postable
func TestCreateAccountRefusesAnUnknownControlKind(t *testing.T) // 422
func TestUpdateAccountDistinguishesNullFromAbsent(t *testing.T) // {"control_kind": null} clears it; {} leaves it
func TestMoneyAndRatesAreStringsOnTheWire(t *testing.T) // GET /tax-codes: "rate" is a JSON string "8.0000000000"
func TestAnUnknownFieldIsRefused(t *testing.T) // 400
func TestAnotherOrgsAccountIs404(t *testing.T) // PATCH with org B's id -> 404 (PRESENCE: org A's id -> 200)
  • Step 2: Run and watch fail. go test ./internal/modules/accounting/ -count=1
  • Step 3: Implement the module files, then constants.yaml, then api/openapi.yaml, then make generate.
  • Step 4: Compose (app.go, routes_test.go, the four cmd/*/main.go, the two wiring_test.go).
  • Step 5: Run the composition’s guards and the touched packages

Run: go test ./internal/modules/accounting/ ./internal/app/ ./cmd/... ./internal/kernel/vocab/ ./internal/gen/ ./internal/kernel/layering/ -count=1 Expected: PASS — including TestEveryRouteIsAuthorized, TestEveryModuleContributesRoutesAndPermissions, TestTheOpenAPIDocumentSaysWhatTheRouterEnforces, TestEveryBinaryLinksTheSameModules, TestGeneratedVocabularyIsUpToDate, TestGeneratedAPIIsUpToDate. Then confirm internal/gen/api/api.gen.go declares type Money = money.Amount (or the equivalent named type) and no monetary field is float32/float64: grep -n 'float' internal/gen/api/api.gen.go prints nothing.

  • Step 6: Run the provisioning consumers — linking the module adds five steps to every binary’s registry: go test ./internal/platform/onboarding/ ./internal/modules/identity/ ./cmd/orgadmin/ ./cmd/migrate/ -count=1. A readiness or create-org test that enumerates step keys must now list them: update those expectations (they are the tests telling the truth). Any failure in a step’s Run for an MY org is a Task 14–15 defect: fix it there, not by loosening the test.

  • Step 7: Commit

Terminal window
git add internal/modules/accounting constants.yaml internal/kernel/vocab packages/contracts api/openapi.yaml internal/gen/api \
internal/app cmd
git commit -m "Compose the accounting module and serve the chart"

Task 19: Ledger routes — entries, manual posting, reversal

Section titled “Task 19: Ledger routes — entries, manual posting, reversal”

Files:

  • Create: internal/modules/accounting/ledger_handlers.go
  • Modify: internal/modules/accounting/routes.go, errors.go, routes_test.go; api/openapi.yaml → make generate
  • Test: internal/modules/accounting/ledger_handlers_test.go

Interfaces:

  • Consumes: ledger.Post, Reverse, Get, List (Tasks 11–12); http.go helpers (Task 18).
  • Produces: four routes.
Method Path Permission operationId Request 2xx
GET /accounting/entries ledger.read listJournalEntries query fiscal_period_id?, journal_id?, source_kind?, source_id?, limit? (1–200, default 50), offset? JournalEntryList (headers)
GET /accounting/entries/{id} ledger.read getJournalEntry — JournalEntry (with lines and distributions)
POST /accounting/entries journal.post postJournalEntry PostJournalEntryRequest 201 JournalEntry
POST /accounting/entries/{id}/reversal journal.post reverseJournalEntry ReverseJournalEntryRequest 201 JournalEntry
  • PostJournalEntryRequest: journal_id (required — a person posting chooses the journal), entry_date? (default clock.TodayUTC, Ruling R34), currency?, description, lines[] of {account_id, debit? (Money), credit? (Money), party_id?, tax_code_id?, memo?, distribution?: [{analytic_account_id, percentage (Rate)}]}. Always Semantic: manual, PostedBy: actor(ctx) — a manual entry through the API always names its member; there is no way to post accrual/payout over HTTP (those come only from posting.Dispatch).
  • ReverseJournalEntryRequest: entry_date? (default today), description?.
  • JournalEntry: id, entry_no, journal_id, entry_date, fiscal_period_id, currency, semantic, source_kind, source_id, instance_id, description, reversal_of_id, reversed_by_id (nullable), posted_at, posted_by, lines[]; line: line_no, account_id, kind, debit, credit, base_debit, base_credit, currency, fx_rate (Rate), fx_rate_source, party_id, tax_code_id, memo, distribution[{analytic_account_id, axis_id, percentage, base_amount}]. reversed_by_id is one extra SELECT id FROM journal_entry WHERE org_id = $1 AND reversal_of_id = $2 in Get’s handler — add it to ledger.Get’s Entry as ReversedByID *uuid.UUID in this task.

mapErr additions: ledger.ErrUnbalanced, ErrLineSides, ErrTooFewLines, ErrDescriptionRequired, ErrAccountNotPostable, ErrAccountInactive, ErrCurrencyMismatch, ErrJournalCurrency, *analytic.TotalError, *analytic.RequiredError, analytic.ErrAxisNotApplicable, *ledger.ControlAccountManualError → 422, each detail naming the rule; *fiscal.PeriodNotOpenError, fiscal.ErrNoFiscalPeriod, ErrJournalInactive, ledger.ErrAlreadyReversed, ErrCannotReverseReversal, ErrCannotReverseClosing → 409; *fx.NoRateError → 422 with detail “no exchange rate for USD→MYR on 2026-09-20; record an override”.

  • Step 1: Write the failing tests
func TestPostAManualEntry(t *testing.T) {
// 201; entry_no "MSC-2026-0001"; semantic "manual"; posted_by is the
// caller's member id; amounts are strings ("100.0000").
}
func TestANumericAmountIsRefused(t *testing.T) // "debit": 100 (JSON number) -> 400 (money.Amount refuses it)
func TestAnUnbalancedEntryIs422AndPostsNothing(t *testing.T)
func TestPostingToAPControlIs422(t *testing.T) // Ruling R24 over HTTP
func TestPostingNeedsJournalPost(t *testing.T) // PRESENCE with grant 201; ABSENCE with only ledger.read 403
func TestReverseAnEntry(t *testing.T) // 201, reversal_of_id set; GET original shows reversed_by_id; second reversal 409
func TestListEntriesFiltersByPeriod(t *testing.T)
func TestAForeignEntryWithoutARateSaysWhatToDo(t *testing.T) // 422 detail mentions the override
  • Step 2: Run and watch fail.
  • Step 3: Implement; add the four operations and the schemas (JournalEntry, JournalEntryList, JournalLine, Distribution, PostJournalEntryRequest, PostJournalLine, ReverseJournalEntryRequest) to api/openapi.yaml; make generate.
  • Step 4: Run. go test ./internal/modules/accounting/ ./internal/app/ ./internal/gen/ ./internal/platform/accounting/ledger/ -count=1 → PASS.
  • Step 5: Commit
Terminal window
git add internal/modules/accounting internal/platform/accounting/ledger api/openapi.yaml internal/gen/api
git commit -m "Serve journal entries, manual posting and reversal"

Task 20: Fiscal, closing, exchange-rate and reconciliation routes

Section titled “Task 20: Fiscal, closing, exchange-rate and reconciliation routes”

Files:

  • Create: internal/modules/accounting/fiscal_handlers.go, fx_handlers.go, reconcile_handlers.go
  • Modify: internal/modules/accounting/routes.go, errors.go, routes_test.go; api/openapi.yaml → make generate
  • Test: internal/modules/accounting/fiscal_handlers_test.go, fx_handlers_test.go, reconcile_handlers_test.go

Interfaces:

  • Consumes: fiscal.* (Task 6), closing.* (Task 17), fx.Resolve, fx.SetOverride (Task 8), reconcile.Default() (Task 13).
Method Path Permission operationId Request 2xx
GET /accounting/fiscal-years ledger.read listFiscalYears — FiscalYearList (each with periods[])
POST /accounting/fiscal-years period.close createFiscalYear {starts_on} 201 FiscalYear
POST /accounting/fiscal-periods/{id}/close period.close closeFiscalPeriod — CloseResult
POST /accounting/fiscal-periods/{id}/reopen period.close reopenFiscalPeriod {reason} FiscalPeriod
POST /accounting/fiscal-years/{id}/close period.close closeFiscalYear — CloseResult
GET /accounting/exchange-rates ledger.read resolveExchangeRate query from, to, date? (default today) ExchangeRateQuote {from, to, date, rate, source, as_of}
POST /accounting/exchange-rates fx.manage overrideExchangeRate {from, to, date, rate, reason} 201 ExchangeRateOverride
GET /accounting/reconciliation ledger.read runReconciliation — ReconciliationResult
  • CloseResult: close_log_id, closing_entry_id (nullable), reconciliation: ReconciliationResult. ReconciliationResult: passed, checks[{key, passed, findings[{check, ref, detail}]}].

  • A refused close (*closing.ReconciliationFailedError) is 409 with the findings in the body: record apperr.New(apperr.KindConflict, "the reconciliation set failed; the period stays open") and set the problem’s extension member reconciliation to the result. If kernel/errors.Problem has no extension field, return 409 with the ReconciliationResult as a normal JSON body instead and document that in the OpenAPI response — do not edit kernel/errors (outside ownership).

  • GET /accounting/reconciliation is a GET because it changes nothing (R14: results are not stored); it runs reconcile.Default() in the request’s read transaction.

  • mapErr additions: closing.ErrPeriodNotOpen, ErrPeriodNotClosed, ErrPeriodLocked, ErrYearClosed, ErrLastPeriodNotOpen, *closing.EarlierPeriodsOpenError (detail lists the sequences), fiscal.ErrOverlap, ErrNotContiguous, ErrDuplicateEndYear → 409; closing.ErrReasonRequired, fiscal.ErrAnchorDay, fx.ErrInvalidOverride, chart.ErrNoRetainedEarnings → 422; *fx.NoRateError → 404 on the GET.

  • Step 1: Write the failing tests

func TestClosePeriodOverHTTP(t *testing.T) // 200, close_log_id; a following manual post dated in it -> 409
func TestARefusedCloseReturnsTheFindings(t *testing.T) {
// PRESENCE: GET /reconciliation passes. Tamper via admin (UPDATE
// journal_line SET base_debit = base_debit + 1 on one line). GET
// /reconciliation now fails, naming check "entry_balance" and that
// entry's id. POST close -> 409 whose body carries the same finding, and
// the period is still open.
}
func TestReopenRequiresAReason(t *testing.T) // {} -> 422; {"reason":"late invoice"} -> 200, status open
func TestYearCloseOverHTTP(t *testing.T) // close periods 1..11 then the year -> closing_entry_id set
func TestResolveAndOverrideARate(t *testing.T) {
// GET USD->MYR on 2026-09-20 with no data -> 404. POST override 4.25
// with fx.manage -> 201. GET -> rate "4.2500000000", source "override".
// POST override with only ledger.read -> 403.
}
func TestReconciliationIsReadOnly(t *testing.T) // GET twice; audit_log row count unchanged; close_log empty
  • Step 2: Run and watch fail.
  • Step 3: Implement; OpenAPI operations and schemas (FiscalYear, FiscalYearList, FiscalPeriod, CreateFiscalYearRequest, ReopenFiscalPeriodRequest, CloseResult, ReconciliationResult, ReconciliationCheck, Finding, ExchangeRateQuote, ExchangeRateOverride, OverrideExchangeRateRequest); make generate.
  • Step 4: Run. go test ./internal/modules/accounting/ ./internal/app/ ./internal/gen/ -count=1 → PASS.
  • Step 5: Commit
Terminal window
git add internal/modules/accounting api/openapi.yaml internal/gen/api
git commit -m "Serve fiscal years, closing, exchange rates and reconciliation"

Task 21: End to end, the roadmap, and the one full run

Section titled “Task 21: End to end, the roadmap, and the one full run”

Files:

  • Create: internal/modules/accounting/e2e_test.go

  • Modify: docs/ROADMAP.md

  • Step 1: Write the end-to-end test — one test, the whole plan in sequence, over HTTP where an HTTP surface exists and through posting.Dispatch where the workflow would call:

func TestAMalaysianOrganizationsFirstYear(t *testing.T) {
// 1. Create an org through onboarding.CreateOrganization (country MY,
// base MYR, FY start 2026-01-01) — provisioning seeds chart, journals,
// SST codes, axes and FY2026 (steps 70–74); GET /org/readiness shows
// them done with no warnings.
// 2. Register a test posting rule pair for kind "e2e_claim" on
// accounting.DefaultRules() (t.Cleanup cannot unregister: use a kind
// unique to this test).
// 3. Seed a BNM reference rate USD 4.2000 on 2026-09-18 via
// fx.StoreReference (the worker path), and USD 4.3000 on 2026-09-21.
// 4. posting.Dispatch accrual (ControlKind "ap") for a USD 1000 claim
// inside a tx that ALSO writes an audit row first (as Plan 3 will).
// 5. posting.Dispatch payout on 2026-09-21: an fx_difference line of
// 100.0000 on 6900.
// 6. A second claim: accrual, then Dispatch rejected with PastAccrual:
// its accrual is mirrored.
// 7. POST a manual entry (Dr 6180 bank charges / Cr 1520).
// 8. GET /accounting/reconciliation passes.
// 9. Close periods 1..11, then the year: 3200 carries the year's result;
// every period is locked; a manual post dated 2026-06-01 is 409.
// 10. audit.VerifyChain is intact, and every entry's source path
// resolves (semantic accrual/payout/reversal entries name
// e2e_claim + source + instance; manual names a member).
}
  • Step 2: Run it. go test ./internal/modules/accounting/ -run TestAMalaysianOrganizationsFirstYear -count=1 -v → PASS.

  • Step 3: Tick the roadmap. In docs/ROADMAP.md, under Plan 4 — Accounting core, add the plan link line Plan: [2026-09-21-plan-4-accounting-core.md](superpowers/plans/2026-09-21-plan-4-accounting-core.md) (21 tasks) and tick all nine bullets; after the Bank Negara bullet add an indented note: “Sync runs from the worker when BNM_RATES_URL is set; nightly reconciliation is deferred to River (Ruling R14)”. Tick the Phase 0 carried item “The first task adding a NUMERIC column or a monetary API field must configure a sqlc and oapi-codegen type override” with “— done in Plan 4 Task 18 (Money → money.Amount, Rate → money.Rate)”. Touch no other plan’s lines.

  • Step 4: The one full run.

Run: make lint && go test -p 2 -count=1 ./... Expected: every package passes. Any failure outside the packages this plan created is reported with its package and test name, not fixed by editing another plan’s code.

  • Step 5: Commit
Terminal window
git add internal/modules/accounting/e2e_test.go docs/ROADMAP.md
git commit -m "Prove a Malaysian organization's first year end to end; tick Plan 4"

Tasks in one wave touch disjoint files and may run in parallel; waves run in order. “Needs” lists the tasks whose output a task consumes.

Wave Tasks Needs Why they are disjoint
1 1 money.Rate — kernel/money/rate.go only. Alone because Task 2’s sqlc override imports money.Rate.
2 2 migration 00050 + accountingtest/env.go 1 The only task touching migrations, the retention list, sqlc.yaml and generated models.
3 3 migration 00051 · 4 chart accounts · 5 analytic · 6 fiscal 2 (3 also 1) 3: migration, retention, rls_exempt.txt, sqlc. 4: chart/account.go… + accountingtest/chart.go. 5: analytic/ + accountingtest/analytic.go. 6: fiscal/ + accountingtest/fiscal.go.
4 7 journals + tax codes · 8 fx 7: 4 · 8: 1, 3 7: chart/journal.go, chart/taxcode.go, accountingtest/journals.go. 8: fx/ + accountingtest/fx.go.
5 9 migrations 00052–53 + company fix · 10 BNM + worker · 14 countrypack 9: 2–7 (its tests use the chart, journal, year and axis fixtures) · 10: 8 · 14: 4, 7 9: migrations, retention, sqlc, company/, ledger/doc.go, ledger/schema_test.go. 10: fx/bnm/, config/bnm.go, .env.example, cmd/worker. 14: countrypack/ (not my/).
6 11 ledger.Post · 13 reconcile 11: 4–9 · 13: 4, 6, 7, 9 11: ledger/{post,types,errors,pgerr}.go + accountingtest/books.go. 13: reconcile/.
7 12 reversal + reads · 15 Malaysia pack 12: 11 · 15: 11, 14 12: ledger/reverse.go, ledger/query.go. 15: countrypack/my/, countrypack/validate_test.go, accounting/nosst_test.go.
8 16 Poster · 17 closing 16: 12 · 17: 6, 12, 13 16: accounting/{rules,poster,errors}.go + tests. 17: closing/.
9 18 module + composition + chart routes 5, 6, 7, 14, 15, 16 Touches every shared file (app.go, cmd/*, constants.yaml, openapi.yaml); runs alone.
10 19 ledger routes 12, 18 openapi.yaml + module files; alone.
11 20 fiscal / fx / reconciliation routes 8, 13, 17, 19 openapi.yaml + module files; alone.
12 21 end to end + roadmap + full run all Alone.

Critical path: 1 → 2 → 4 → 7 → 9 → 11 → 12 → 16 → 18 → 19 → 20 → 21.


Checked against the contract and the spec before execution.

Spec coverage.

  • ROADMAP “Journals (§10.2)” → Tasks 2, 7, 14–15. “Chart of accounts with control kinds (§10.1)” → Tasks 2, 4. “Analytic axes and ratio distributions” → Tasks 2, 5, 9 (DB check), 11 (persisted). “Journal entries, balanced by deferred trigger, append-only” → Task 9 (trigger, revokes, posted_txid), 11–12. “Posting engine keyed on (kind, semantic), same transaction” → Task 16 (+ R4’s lock test). “Fiscal periods and year-end close” → Tasks 6, 17. “FX: frozen base amounts, gain/loss account, Bank Negara sync” → Tasks 1, 3, 8, 10, 11 (fx_difference), 16 (payout test). “Reconciliation set” → Task 13 (+ close precondition in 17). “Malaysia country pack: CoA, journals, SST codes” → Tasks 14–15.
  • §10.2 invariants 1–6 → Task 9 triggers/checks, each with a test; invariant 4 (gapless numbering in-transaction) → Task 11’s rollback test. §10.3 reversal symmetry → Task 12 (no recomputation test) and Task 16. §10.4 “close is a checked operation” and close_log → Task 17. §10.5 “never absorbed into the expense” → Task 16’s payout test asserts the expense stays at the accrual base. §10.6 “each failure names the offending rows” → Task 13’s Finding.Ref tests. §10.6 float/advances/AP/orphans checks → deferred to Plans 5–8 (R15). §10.6 “nightly” → deferred (R14). §10.7 reports → Plan 9, not here.
  • §11 “nothing outside the pack names SST” → Task 15’s guard. §11A.1 → R12 host check. §11A.2 retention → every table in a retention list (Tasks 2, 3, 9). §11A.4 traceability → CHECKs in Task 9 and TestEveryPostingIsTraceable (Task 16). §11A.5 → audit_chain check (Task 13). §20.2 idempotent, API-repairable, one code path → Task 14 steps (the readiness endpoint and /org/provision already exist). §6.5 money triple and frozen base → Tasks 1, 11.
  • Contract: migrations 00050–00053 ✓; steps 70–74 ✓; permissions accounting.* ✓; paths /accounting/… ✓; posting untouched, Poster registered at boot (Task 18), tested with hand-built events (Task 16) ✓; shared files append-only ✓ (the one in-place edit, Money’s x-go-type, is R33).
  • company.hasPostedEntries — the company package names Plan 4 as the owner of this change → Task 9 (R37).

Placeholder scan. No “TBD”/“TODO”/“similar to”. Two places deliberately name an existing file as the pattern to copy rather than repeating it (decodeStrict/optional from orgstructure; the orgstructure handler-test harness): both are copies of real code the implementer can open, not references to unwritten code.

Type consistency. ledger.Line.Distribution []analytic.Share (Task 11) matches Task 5; analytic.Plan’s enforceRequired is driven by semantic != year_end_close (Task 11) and matches the trigger’s exemption (Task 9) and R26. fiscal.PeriodNotOpenError is a pointer type used with errors.As in Tasks 11, 12, 17, 19. chart.NoControlAccountError / AmbiguousControlError are pointer types in Tasks 4, 14, 16. accounting.NoPostingRuleError is used in Tasks 16 and 18. reconcile.Set.Run (Task 13) is what closing.* takes (Task 17) and GET /reconciliation calls (Task 20). ledger.Entry.ReversedByID is added in Task 19 and used only there. Semantic strings stored in journal_entry.semantic equal string(posting.Accrual) / string(posting.Payout), which Task 16 relies on when it writes string(ev.Semantic).