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:
docs/superpowers/specs/2026-09-21-phase1-wave1-contracts.md— binding for paths, migration numbers, step orders, permission and API prefixes, the shared-file rule and thepostingseam.docs/superpowers/specs/2026-09-20-erp-architecture-design.md— §10 (ledger), §11 (country pack), §11A (statutory), §6.5 (money/FX), §17, §18, §20.
Global Constraints
Section titled “Global Constraints”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’shasPostedEntries,cmd/worker). Never editinternal/platform/posting. Never importinternal/platform/workfloworinternal/modules/workflow. - Migrations
00050–00069only. This plan uses00050–00053. Provisioning step orders70–79only (this plan uses 70–74). Permissionsaccounting.*only. OpenAPI paths/accounting/…only (served under/v1). - Shared files are append-only (contract §3):
constants.yamlpermissions list,api/openapi.yaml,internal/app/app.go, the blank imports incmd/api,cmd/migrate,cmd/orgadmin,internal/kernel/db/retention_test.golists,docs/ROADMAP.md(tick only Plan 4 lines). Generated files (internal/gen/…,internal/kernel/vocab/vocab.gen.go,packages/contracts/vocab.ts) are regenerated withmake generate, never hand-merged. - Tenant isolation, both layers. Every table carries
org_id UUID NOT NULL(or is listed with a justification indb/rls_exempt.txt), hasENABLEandFORCE ROW LEVEL SECURITYand a policy namedorg_isolation. Every tenant query in Go carries an explicitorg_id = $npredicate for every org-scoped table it names, and every INSERT namesorg_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 grantDELETEthe instant a table is created; only an explicitREVOKEremoves it. - The ledger is append-only.
REVOKE UPDATE, DELETEonjournal_entry,journal_line,analytic_distribution,close_log,exchange_rate,fx_reference_ratefromerp_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.Writeon the samepgx.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 firstaudit.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 aremoney.Rate(NUMERIC(19,10)). Neverfloat64, including when decoding Bank Negara’s JSON (decode withUseNumber). On the wire both are strings;api/openapi.yaml’sMoneymaps tomoney.Amountviax-go-type. - The business date comes from
kernel/clock(clock.TodayUTC(c)); everyDATEvalue handled in Go is midnight UTC. Never calltime.Now(). - Record-bearing free text (account/journal/tax-code names, entry descriptions, memos) is English or Bahasa Malaysia (§11A.3).
zhexists 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 generateafter any migration, OpenAPI orconstants.yamlchange. One fullgo test -p 2 -count=1 ./...in Task 21 only. - Git commits end with:
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Rulings
Section titled “Rulings”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 packageaccounting(rule registry + Poster), andinternal/platform/countrypack{,/my}— not the spec’splatform/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 generatestill refreshesinternal/gen/sqlc/models.go, andsqlc.yamlgains per-column overrides mapping rate/percentage columns tomoney.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.Ratein a new fileinternal/kernel/money/rate.go(outside the ownership table) —Amount’s decimal is unexported, soAmount × Ratecan only live in packagemoney, 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.Dispatchruns inside Plan 3’s transition, which may already have calledaudit.Write, and any advisory lock taken after that breaks the “class 1 last” rule; a test provesPoster.Postacquires 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; anfx_differenceline carries zero transaction amounts and a non-zero base amount; the trigger checksSUM(debit)=SUM(credit)andSUM(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_differenceline to thefx_gain_losscontrol 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
accrualandpayout; reversal onrejected/cancelledis engine-generic: it mirrors every unreversed accrual/payout entry of the instance, newest first, from the stored lines, and errors ifPastAccrualis 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(orpayout) 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
accrualevent with an emptyControlKindis refused — §5.5 makessemantic_params.control_accountrequired 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 tableexchange_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 tenantWITH CHECKpolicy; 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 latestrate_date ≤ datewithin 7 days; cross rates via MYR; otherwiseErrNoRateand 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_URLis set; the URL must behttpson abnm.gov.myhost; 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, usesmiddle_rate, divides byunit, and decodes numbers withjson.Decoder.UseNumber— the API returns JSON numbers and a float must never touch a rate — the implementer verifies the live response shape againsttestdata/bnm_latest.jsonbefore 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_logat 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_mirrorandaudit_chain; float, advances, AP-control and orphan checks are registered by Plans 5–8 throughreconcile.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 —controlKindsis 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_oninto the last period (misc journal, semanticyear_end_close), closes that period, locks every period, closes the year and writesclose_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;
lockedis 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
CreateYearvariant. - R20 Ruling:
entry_nois allocated fromnumberingscope"journal:"+sequence_key, period = the fiscal year’s end calendar year (unique per org), formattednumbering.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_typejournal; journalcodeandsequence_keyare immutable — modules must not hardcode codes (§10.1) and entry numbers depend on the key — none. - R22 Ruling: Names follow the repo pattern: a
namecolumn holding the record-language name (the company’sdefault_locale,enorms) plustranslationrows foren,ms,zh, written by accounting’s own INSERTs; noi18n.Putis added —platform/i18nis outside this plan’s ownership — the two small insert helpers fold into one when an i18n writer exists. - R23 Ruling: Account
typeandparent_idare fixed at creation;currencyrestriction is allowed only on asset/liability accounts; a DB trigger refuses changingcontrol_kind,currencyor making an account non-postable once it has lines; only one active account per control kind exceptbank— 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;bankandfx_gain_lossare 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_tois a non-empty set of account types; a required axis bindsnormallines of entries other thanyear_end_close; the seededcost_centreandprojectaxes 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, optionalaccount_id); a tax code’s rate is immutable (a new rate is a new code withvalid_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.Packis data-only (Code,ChartOfAccounts,Journals,TaxCodes);StatutoryReports,ComputeTax,EInvoicePayload,FilingPeriodsare deferred to Plan 9 / Phase 2 — YAGNI; the seeding logic lives once incountrypackso 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.Allocateupserts on first use — none. - R31 Ruling:
posting.Register(accounting.NewPoster(clock.System(), accounting.DefaultRules()))runs ininternal/modules/accounting’sinit();cmd/workeralso blank-imports that module — every binary that can run a workflow transition must have the Poster, andbinaries_test.goalready forces api/migrate/orgadmin to link every module — a Plan 3 test binary importing this module cannot register a fake Poster withoutposting.ResetForTest(). - R32 Ruling: New permissions
accounting.ledger.read,accounting.period.close,accounting.fx.managejoin the existingaccounting.account.manageandaccounting.journal.post; running reconciliation needs onlyledger.read— reconciliation is read-only — none. - R33 Ruling: Plan 4 adds
x-go-type: money.Amountto the existingMoneyschema (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.hasPostedEntriesbecomesEXISTS (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 namedjournal_entry— none.
File Structure
Section titled “File Structure”Migrations (db/migrations/)
Section titled “Migrations (db/migrations/)”| 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. |
New Go packages
Section titled “New Go packages”| 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. |
Modified
Section titled “Modified”| 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 fieldd decimal.Decimal,Scale = 4). -
Produces — every later task uses these exact names:
const RateScale = 10type Rate struct{ /* d decimal.Decimal */ }func ParseRate(s string) (Rate, error)func MustParseRate(s string) Ratefunc OneRate() Ratefunc (r Rate) String() string // StringFixed(RateScale)func (r Rate) IsPositive() boolfunc (r Rate) IsZero() boolfunc (r Rate) Cmp(o Rate) intfunc (r Rate) Add(o Rate) Ratefunc (r Rate) Div(o Rate) (Rate, error) // ErrDivideByZerofunc (r Rate) MarshalJSON() ([]byte, error) // a JSON stringfunc (r *Rate) UnmarshalJSON(b []byte) error // refuses a JSON numberfunc (r Rate) Value() (driver.Value, error)func (r *Rate) Scan(src any) errorfunc (a Amount) MulRate(r Rate) Amount // a×r, rounded half away from zero to Scalefunc (a Amount) Percent(p Rate) Amount // a×p/100, rounded to Scalefunc (a Amount) IsPositive() boolfunc RateOf(num, den Amount) (Rate, error) // num/den rounded to RateScale; ErrDivideByZerovar 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. IfTestAmountAndRateRoundTripThroughPostgresNumericfails on scan, the fix is inScan(accept whatever pgx hands asql.Scannerfornumeric— confirm by printing%T), never a change to the column type. -
Step 5: Commit
git add internal/kernel/money/rate.go internal/kernel/money/rate_test.go internal/kernel/money/pgx_test.gogit 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, regeneratedinternal/gen/sqlc/models.go - Test: existing drift tests (
rlscoverage_test.go,retention_test.go,tenantfk_test.go,internal/gen/drift_test.go) plusinternal/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 accountingtestvar T0 = time.Date(2026, 9, 20, 10, 0, 0, 0, time.UTC) // a Sunday; FY 2026 period 9type Env struct {H *testdb.HandlesDB *db.DBCtx context.Context // carries the orgOrg uuid.UUIDClock clock.Clock // clock.Fixed(T0)}func New(t *testing.T) *Env // base MYR, country MY, FY start 2026-01-01, default_locale enfunc NewWithCurrency(t *testing.T, base string) *Envfunc (e *Env) Another(t *testing.T, name string) *Env // second org, same databasefunc (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) errorfunc (e *Env) SeedMember(t *testing.T, name string) uuid.UUID // party + organization_member via adminfunc 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 DownDROP 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 toamendable:
// 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’soverrideslist (a numeric column otherwise generates asmoney.Amount, scale 4, which is wrong for a rate):
- column: "tax_code.rate" go_type: github.com/mcom/erp/internal/kernel/money.RateRun make generate and commit the regenerated internal/gen/sqlc/models.go.
- Step 4: Write
accountingtest/env.goimplementing the Interfaces block.SeedOrginserts throughh.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
git add db/migrations/00050_accounting_reference.sql internal/kernel/db/retention_test.go sqlc.yaml \ internal/gen/sqlc internal/platform/accounting/accountingtestgit 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, regeneratedinternal/gen/sqlc/models.go - Test: existing drift tests
Interfaces:
-
Produces: tables
fx_reference_rateandexchange_ratewith 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 DownDROP 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
appendOnlyinretention_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.rateandexchange_rate.rate→money.Rateexactly 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 theREVOKE UPDATE, DELETE ON exchange_rateline → retention test red namingexchange_rate; restore. Remove therls_exempt.txtentry →TestEveryTableIsTenantIsolatedred namingfx_reference_rate; restore. -
Step 6: Commit
git add db/migrations/00051_accounting_fx.sql db/rls_exempt.txt internal/kernel/db/retention_test.go sqlc.yaml internal/gen/sqlcgit 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 chartconst (TypeAsset = "asset"; TypeLiability = "liability"; TypeEquity = "equity"; TypeIncome = "income"; TypeExpense = "expense")const RoleRetainedEarnings = "retained_earnings"const MaxDepth = 10type Account struct {ID uuid.UUIDParentID *uuid.UUIDCode, Name stringPath stringDepth intType stringNormalBalance string // "debit" | "credit"ControlKind *stringSystemRole *stringIsPostable boolCurrency *stringIsActive bool}type AccountInput struct {ParentID *uuid.UUIDCode, Name stringType stringHeader bool // true: not postable (a grouping node)ControlKind *stringSystemRole *stringCurrency *string}type AccountPatch struct {Code, Name *stringControlKind **string // nil: leave; &nil: clearCurrency **stringIsActive *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 pathfunc 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 errortype NoControlAccountError struct{ Kind string } // Error(): names the kindtype AmbiguousControlError struct{ Kind string; Codes []string }package accountingtesttype Chart struct {Assets, Liabilities, Equity, Income, Expenses chart.Account // headersBank, Cash, AR, Advances chart.AccountAP, PettyCashAP chart.AccountRetainedEarnings chart.AccountRevenue, FX, Travel, Meals chart.Account}func (e *Env) SeedChart(t *testing.T) Chart
Rules this task implements (each has a test):
- Tree.
path = parent.Path + id + "/"(root:"/" + id + "/"),depth = parent.Depth + 1, refused pastMaxDepthwithErrTooDeep. A child’sTypemust 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 parentis_postable = falsein the same transaction (Task 9’s trigger will refuse thatUPDATEif the parent already has lines — that is the database answering, not this package). - Normal balance is derived in Go (
debitfor asset/expense, elsecredit) and the migration’s CHECK agrees. - Control kinds.
ControlKindmust be one ofvocab.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 forbank) →*AmbiguousControlError. - Retained earnings.
RetainedEarningsreturns the activesystem_role = 'retained_earnings'account orErrNoRetainedEarnings. - Updates.
Type,ParentIDare not in the patch (Ruling R23). Unique violations on(org_id, code)map toErrDuplicateCode; unique violations onaccount_one_control_per_kind/account_one_retained_earningsmap to*AmbiguousControlError— translate SQLSTATE23505by constraint name withpgconn.PgError, never pre-check. - Audit.
CreateAccountandUpdateAccounteach write oneaudit.Entry{EntityKind: "account", Action: "create"|"update", Before, After}as their last statement. - Every query carries
org_id = $n.GetAccountof an unknown id or another org’s id returnsErrNotFound, never a zero value.
- Step 1: Write the failing tests (
account_test.go, packagechart_test, each starting withe := accountingtest.New(t)):
func TestCreateBuildsPathAndDepth(t *testing.T) // root "/<id>/" depth 0; child path extends, depth 1func TestNormalBalanceFollowsType(t *testing.T) // table over the five typesfunc TestChildMustShareItsParentsType(t *testing.T) // PRESENCE: expense under expense ok; ABSENCE: income under expense -> ErrParentMismatchfunc TestAParentStopsBeingPostable(t *testing.T) // leaf postable; after a child is created, GetAccount(parent).IsPostable == falsefunc TestDepthIsCapped(t *testing.T) // 10 nested ok, 11th ErrTooDeepfunc 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 codesfunc 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.gocarries 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 andResolveControlanswers. 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—SeedChartcreates, in this order, throughchart.CreateAccountinside onee.Tx: headers1000 Assets,2000 Liabilities,3000 Equity,4000 Income,6000 Expenses; leaves1520 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 = $npredicate fromGetAccount’s query;go test ./internal/kernel/layering/ -run TenantQuery -count=1goes red namingchart/account.go:GetAccount:account; restore. -
Step 6: Commit
git add internal/platform/accounting/chart internal/platform/accounting/accountingtest/chart.gogit 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 analytictype Axis struct {ID uuid.UUIDKey, Name stringIsRequired boolAppliesTo []string // account typesIsActive bool}type Account struct {ID, AxisID uuid.UUIDParentID *uuid.UUIDCode, Name stringIsActive 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.UUIDPercentage money.RateBaseAmount 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 = 73var ErrNotFound, ErrDuplicateKey, ErrDuplicateCode, ErrInvalidAppliesTo, ErrParentOtherAxis,ErrAccountInactive, ErrAxisNotApplicable, ErrDuplicateShare errortype TotalError struct { AxisKey string; Total money.Rate } // not 100type RequiredError struct { AxisKey string }package accountingtesttype 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:
- Load each share’s analytic account (active, this org) → its axis. Unknown →
ErrNotFound; inactive account or axis →ErrAccountInactive. The same analytic account twice →ErrDuplicateShare. - Group by axis in input order. An axis whose
AppliesTodoes not containaccountType→ErrAxisNotApplicable. - Each group’s percentages must sum to exactly
100(money.Ratecompare) → else*TotalError. - If
enforceRequired: every activeIsRequiredaxis whoseAppliesTocontainsaccountTypemust have a group → else*RequiredError{AxisKey}. BaseAmount = lineBase.Percent(p)for all but the group’s last share; the last getslineBase − 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 keyscost_centreandprojectexist (active or not — a retired seeded axis is the org’s decision, not a gap).Run: creates each missing one throughCreateAxiswithAppliesTo = {income, expense},IsRequired = false,Name= the English name when the company’sdefault_localeisen, the Malay one whenms; 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 -> ErrAxisNotApplicablefunc 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—SeedAxescreates both axes (cost centreIsRequiredper the argument) and accountsHQ,OPSon cost centre andPA,PBon project. -
Step 5: Run.
go test ./internal/platform/accounting/analytic/ -count=1 -v→ PASS. -
Step 6: Commit
git add internal/platform/accounting/analytic internal/platform/accounting/accountingtest/analytic.gogit 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 fiscalconst (StatusOpen = "open"; StatusClosed = "closed"; StatusLocked = "locked")type Year struct {ID uuid.UUIDName stringStartsOn, EndsOn time.Time // midnight UTC; EndsOn inclusiveEndYear intStatus string // open | closedClosedAt *time.TimeClosedBy *uuid.UUID}type Period struct {ID, FiscalYearID uuid.UUIDSeq intStartsOn, EndsOn time.TimeStatus string // open | closed | lockedClosedAt *time.TimeClosedBy *uuid.UUID}// Pure calendar arithmetic.func YearStartContaining(anchor, day time.Time) (time.Time, error) // ErrAnchorDay if anchor day > 28func 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_onfunc ListPeriods(ctx context.Context, tx pgx.Tx, yearID uuid.UUID) ([]Period, error) // ORDER BY seqfunc 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) errorfunc SetYearClosed(ctx context.Context, tx pgx.Tx, c clock.Clock, id uuid.UUID, by *uuid.UUID) errorconst StepKeyFiscalYear = "fiscal_year"; const StepOrderFiscalYear = 74var ErrNotFound, ErrAnchorDay, ErrOverlap, ErrNotContiguous, ErrDuplicateEndYear,ErrNoFiscalPeriod errortype PeriodNotOpenError struct { PeriodID uuid.UUID; Status string }package accountingtestfunc (e *Env) OpenYear(t *testing.T) (fiscal.Year, []fiscal.Period) // FY 2026-01-01..2026-12-31
Rules:
YearStartContaining(anchor, day): the date withanchor’s month and day inday’s year, or the previous year if that is afterday. Anchor day > 28 →ErrAnchorDay(Ruling R19).MonthlyPeriods(start): period i startsstart.AddDate(0, i, 0)and ends the day before period i+1 starts; period 12 endsstart.AddDate(1, 0, -1).CreateYear: name"FY" + endYear; if any year exists,startsOnmust equal the latest year’sEndsOn + 1 day(ErrNotContiguous); overlap maps SQLSTATE23P01→ErrOverlap;(org_id, end_year)unique →ErrDuplicateEndYear. Writes the year and 12 periods, then oneaudit.Entry{EntityKind: "fiscal_year", Action: "create", After: {year, periods}}.PeriodForPosting(day):SELECT … FROM fiscal_period WHERE org_id = $1 AND starts_on <= $2 AND ends_on >= $2 FOR SHARE. No row →ErrNoFiscalPeriod; status notopen→*PeriodNotOpenError.SetPeriodStatuswritesclosed_at = c.Now(),closed_by = byforclosed/lockedandNULL,NULLforopen— the migration’s CHECK ties status toclosed_at.SetYearClosedlikewise.- Step
fiscal_year(order 74),init()-registered:Checkdone whenYearContaining(clock.TodayUTC(c))exists;Runreadscompany.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" -> ErrAnchorDayfunc 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 23P01func 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—OpenYearcallsfiscal.CreateYear(accountingtest.Date("2026-01-01")). - Step 5: Run.
go test ./internal/platform/accounting/fiscal/ -count=1 -v→ PASS. - Step 6: Commit
git add internal/platform/accounting/fiscal internal/platform/accounting/accountingtest/fiscal.gogit commit -m "Add fiscal years and periods, and posting-period resolution"Task 7: chart — journals and tax codes
Section titled “Task 7: chart — journals and tax codes”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 chartconst (JournalSales = "sales"; JournalPurchase = "purchase"; JournalBank = "bank"; JournalCash = "cash"; JournalMisc = "misc")type Journal struct {ID uuid.UUIDCode, Name, Type stringDefaultAccountID *uuid.UUIDSequenceKey stringCurrency *stringIsDefaultForType boolIsActive bool}type JournalInput struct {Code, Name, Type stringDefaultAccountID *uuid.UUIDSequenceKey string // "" -> CodeCurrency *stringIsDefaultForType 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 kindvar ErrInvalidTaxCode error // rate outside 0..100, blank code/name/pack_kind, valid_to <= valid_fromtype TaxCode struct {ID uuid.UUIDCode, Name, PackKind stringRate money.RateAccountID *uuid.UUIDValidFrom time.TimeValidTo *time.TimeIsActive 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 accountingtesttype 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:
- A
bankjournal’s default account must be an active postable account withcontrol_kind = 'bank'; acashjournal’s must be an active postableassetaccount; otherwiseErrInvalidJournal. If the journal has aCurrency, its default account’s currency (if set) must match. CodeandSequenceKeyare immutable (not in the patch) — Ruling R21.- Setting
IsDefaultForTypetrue 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. DefaultJournal(typ)returns the active default journal of that type or*NoDefaultJournalError{Type}.- Tax codes: no rate or validity edit exists (Ruling R27); only active/inactive.
Ratemust be within0..100. - 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.Adminfunc 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
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.gogit 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 fxconst (SourceIdentity = "identity"; SourceReference = "reference"; SourceOverride = "override"; SourceFrozen = "frozen")const MaxStaleness = 7 * 24 * time.Hour // Ruling R11const Hub = "MYR" // the reference feed's quote currencytype ReferenceRate struct {Source string // "bnm"Currency string // e.g. "USD"RateDate time.TimeSession stringRate 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.UUIDFrom, To stringRateDate time.TimeRate money.RateReason stringCreatedBy *uuid.UUIDCreatedAt 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 errorpackage accountingtestfunc (e *Env) SeedReferenceRate(t *testing.T, currency, day, rate string) // via fx.StoreReference, source "bnm", session "1130"
Resolve(from, to, day):
from == to→{OneRate, identity, day}.- Newest override for exactly
(from, to, day)in this org →{rate, override, day}. - Else via the hub:
r(from)=1iffrom == MYR, else the reference rate forfromon the latestrate_date <= daywithrate_date > day − MaxStaleness; the same forto. Missing either →*NoRateError. Resultr(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.2380952381func TestCrossRateGoesThroughMYR(t *testing.T) // USD 4.2, SGD 3.15 -> USD->SGD 1.3333333333func 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 timefunc 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.
StoreReferenceusesINSERT … ON CONFLICT (source, currency, rate_date, session) DO NOTHINGand countsRowsAffected.SetOverriderefusesFrom == To, non-positive rate, blank reason (ErrInvalidOverride) and writesaudit.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 namesfx_reference_rate). - Step 6: Commit
git add internal/platform/accounting/fx internal/platform/accounting/accountingtest/fx.gogit 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, regeneratedinternal/gen/sqlc/models.go - Modify:
internal/platform/company/company.go(hasPostedEntriesonly),internal/platform/company/company_test.go(markPostedonly) - 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 StatementEndCREATE 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 StatementEndCREATE CONSTRAINT TRIGGER journal_entry_balanced AFTER INSERT ON journal_entry DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION ledger_entry_balanced();
-- +goose StatementBeginCREATE 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 StatementEndCREATE TRIGGER journal_line_guard BEFORE INSERT ON journal_line FOR EACH ROW EXECUTE FUNCTION ledger_line_guard();
-- +goose StatementBeginCREATE 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 StatementEndCREATE 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 StatementEndCREATE 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 StatementEndCREATE TRIGGER account_ledger_guard BEFORE UPDATE ON account FOR EACH ROW EXECUTE FUNCTION account_ledger_guard();
-- +goose DownDROP 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 DownDROP TABLE close_log;-
Step 3: Retention, sqlc. Append
"journal_entry","journal_line","analytic_distribution","close_log"toappendOnlywith the comment// Plan 4: the ledger (spec 10.2 invariant 3) and its close record.Append sqlc overrides forjournal_line.fx_rateandanalytic_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(packageledger_test). They insert raw SQL throughe.DB.InTx(aserp_app, tenant set) againste.SeedChart,e.SeedJournals,e.OpenYear,e.SeedAxes. A local helperinsertEntry(ctx, tx, lines ...rawLine) uuid.UUIDwrites one entry (semanticaccrual, fresh source/instance ids) and its lines. Each test is a presence/absence pair:
func TestABalancedEntryCommits(t *testing.T) // presence for everything belowfunc 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_openfunc TestAnEntryDatedOutsideItsPeriodIsRefused(t *testing.T) // period 9, date in period 10func TestAHeaderAccountIsNotPostable(t *testing.T) // ch.Expenses -> journal_line_account_postablefunc TestAnInactiveAccountTakesOnlyReversals(t *testing.T) // semantic accrual refused; semantic reversal (with reversal_of_id) acceptedfunc TestBothSidesOnOneLineIsRefused(t *testing.T) // CHECK violationfunc TestAnFXDifferenceLineHasNoTransactionAmount(t *testing.T)func TestDistributionsMustTotalAHundred(t *testing.T) // 60 only -> analytic_distribution_total at commitfunc TestARequiredAxisIsEnforcedAtCommit(t *testing.T) // SeedAxes(required=true), expense line without cost_centre -> analytic_distribution_required; the same line on a year_end_close entry commitsfunc 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 succeedsfunc TestAnEntryIsReversedAtMostOnce(t *testing.T) // two reversal rows for one original -> 23505 journal_entry_reversed_oncefunc TestReversalRequiresItsTarget(t *testing.T) // semantic reversal with NULL reversal_of_id -> CHECKfunc 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_balancedtoDEFERRABLE INITIALLY IMMEDIATE, re-runTestABalancedEntryCommits— it now fails on the first line insert (showing why deferral is load-bearing); change the function’sIFtoIF false, re-runTestAnUnbalancedEntryFailsAtCommit→ red; restore both. Quote the outputs. -
Step 8: Commit
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/ledgergit 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 errorpackage 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/workerfunc 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.jsonmirrors the documented shape ofGET {base}/public/exchange-rate?session=1130"e=rmwithAccept: 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 currencyfunc 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"e=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, ifconfig.BNMRatesURLFromEnv()returns a URL, runrunFXSyncLoopin a goroutine: once at start, then every 24 h, each passrunFXSync(ctx, database, bnm.Client{BaseURL: url, HTTP: &http.Client{Timeout: 30 * time.Second}, Clock: clock.System()}, clock.System()).runFXSyncfetches outside any transaction, then opensdatabase.Pool().Begin(the table is RLS-exempt; the same argumentrunPrune’s comment makes), callsfx.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.exampleand the read agree). -
Step 7: Commit
git add internal/platform/accounting/fx/bnm internal/kernel/config/bnm.go internal/kernel/config/bnm_test.go \ .env.example cmd/workergit 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 ledgerconst (SemanticAccrual = "accrual"; SemanticPayout = "payout"; SemanticReversal = "reversal"SemanticManual = "manual"; SemanticYearEndClose = "year_end_close"KindNormal = "normal"; KindFXDifference = "fx_difference")type Line struct {AccountID uuid.UUIDDebit money.Amount // exactly one of Debit/Credit is positiveCredit money.AmountBase *money.Amount // frozen base amount (spec 6.5); nil: convert at the entry date's ratePartyID *uuid.UUIDTaxCodeID *uuid.UUIDMemo stringDistribution []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.TimeCurrency string // "": the journal's currency, else the base currencyDescription stringSemantic string // accrual | payout | manual | year_end_close (reversal: use Reverse)SourceKind stringSourceID *uuid.UUIDInstanceID *uuid.UUIDPostedBy *uuid.UUIDLines []Line}type Entry struct {ID, JournalID, FiscalPeriodID uuid.UUIDEntryNo stringEntryDate time.TimeCurrency, Semantic, Description stringSourceKind *stringSourceID, InstanceID *uuid.UUIDReversalOfID, PostedBy *uuid.UUIDPostedAt time.TimeLines []PostedLine}type PostedLine struct {ID, AccountID uuid.UUIDLineNo intKind stringDebit, Credit, BaseDebit, BaseCredit money.AmountCurrency stringFXRate money.RateFXRateSource stringPartyID, TaxCodeID *uuid.UUIDMemo stringDistribution []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 errortype ControlAccountManualError struct{ Code, Kind string } // Ruling R24package accountingtesttype 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:
- Tenant from ctx;
company.Get→ base currency. - Validate:
Semantic ∈ {accrual, payout, manual, year_end_close}(ErrInvalidSemantic);Descriptionnon-blank;len(Lines) >= 2; accrual/payout needSourceKind,SourceID,InstanceID(ErrMissingSource); manual needsPostedBy. - 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). - Date: normalize
EntryDateto midnight UTC;fiscal.PeriodForPosting(row lock FOR SHARE) andfiscal.GetYear. - 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, ratemoney.RateOf(base, amount), sourcefrozen; else entry currency == base → base = amount, rate 1,identity(aBasethat differs from the amount in a base-currency entry →ErrBaseMismatch); elsefx.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).
- account via
- Totals:
Σdebit == ΣcreditorErrUnbalanced.diff = Σbase_debit − Σbase_credit.diff ≠ 0: base-currency entry →ErrUnbalanced; foreign-currency entry → append onefx_differenceline tochart.ResolveControl("fx_gain_loss")—base_credit = diffifdiff > 0(a gain), elsebase_debit = −diff(a loss); currency = entry currency, rate 1, sourceidentity(Rulings R5, R6). - Number:
n := numbering.Allocate(ctx, tx, "journal:"+j.SequenceKey, year.EndYear);entryNo := numbering.Format(j.SequenceKey, year.EndYear, n). - Insert the entry (
posted_at = c.Now()), then lines (line_nofrom 1), then each line’s planned shares. audit.Write(audit.Entry{EntityKind: "journal_entry", EntityID: id, Action: "post", After: entry})— last.- Map any
*pgconn.PgErrorfrom steps 8–9 byConstraintName(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.FXfunc TestABaseCurrencyImbalanceIsRefused(t *testing.T) // MYR entry with a Base override that differs -> ErrBaseMismatchfunc 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.NoRateErrorfunc 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-SQLfunc TestAClosedPeriodRefusesThePosting(t *testing.T) // *fiscal.PeriodNotOpenErrorfunc TestAHeaderAccountIsRefusedInGo(t *testing.T) // ErrAccountNotPostable, before the trigger wouldfunc 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:
TestAFrozenBaseProducesAnFXDifferencefails at commit withjournal_entry_balanced(the database caught what Go missed); restore. - Step 7: Commit
git add internal/platform/accounting/ledger internal/platform/accounting/accountingtest/books.gogit commit -m "Post journal entries with frozen base amounts and FX differences"Task 12: ledger — reversal and reads
Section titled “Task 12: ledger — reversal and reads”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.UUIDEntryDate 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 distributionstype Filter struct {FiscalPeriodID, JournalID, SourceID, InstanceID *uuid.UUIDSourceKind *stringLimit, 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:
Get(EntryID); semanticreversal→ErrCannotReverseReversal;year_end_close→ErrCannotReverseClosing(Ruling R35).EXISTS (… reversal_of_id = $2 AND org_id = $1)→ErrAlreadyReversed(the unique indexjournal_entry_reversed_onceis the race backstop, mapped to the same error inpgerr.go).fiscal.PeriodForPosting(EntryDate), year, numbering exactly asPoststeps 4 and 7, on the original’s journal.- Insert entry:
semantic = 'reversal',reversal_of_id, the original’scurrency,source_kind,source_id,instance_id. - 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, noanalytic.Plan: the reversal is the original’s mirror, never a recomputation (spec 10.3). 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-20func TestAnEntryIsReversedOnce(t *testing.T) // PRESENCE first reversal ok; second -> ErrAlreadyReversedfunc TestAReversalCannotBeReversed(t *testing.T)func TestReversalIntoAClosedPeriodIsRefused(t *testing.T) // original in period 9 (open), reversal dated into a closed period 8 -> *fiscal.PeriodNotOpenErrorfunc 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_lineandanalytic_distributionwithl.org_id = e.org_idande.org_id = $1(the tenant-predicate guard checks every joined table). - Step 4: Run. PASS.
- Step 5: Commit
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.gogit 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);
accountingtestchart/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 rowDetail string `json:"detail"` // English; names the numbers}type Check interface {Key() stringRun(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() *Setfunc (s *Set) Register(c Check) // panics on a duplicate keyfunc (s *Set) Run(ctx context.Context, tx pgx.Tx) (Result, error) // checks in key order; runs all even after a failurefunc Register(c Check) // into Default()func Default() *Setconst (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.Passedfunc 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 periodfunc 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 rowfunc 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
git add internal/platform/accounting/reconcilegit 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]stringtype AccountSeed struct {Code, ParentCode string // ParentCode "" for a rootType string // chart.Type*Header boolControlKind string // "" or one of vocab.ControlKindsSystemRole string // "" or chart.RoleRetainedEarningsNames Names}type JournalSeed struct {Code, Type stringDefaultAccountCode stringIsDefaultForType boolNames Names}type TaxCodeSeed struct {Code, PackKind stringRate string // decimal percentage, e.g. "8"AccountCode string // "" for noneValidFrom string // YYYY-MM-DDValidTo string // "" open-endedNames 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_codeChartOfAccounts() []AccountSeedJournals() []JournalSeedTaxCodes() []TaxCodeSeed}func Register(p Pack) // panics on a duplicate code or a pack failing Validatefunc Lookup(code string) (Pack, bool)func Registered() []Pack // sorted by codefunc Validate(p Pack) error // pure; see rulestype NoPackError struct{ Country string }func SeedChart(ctx context.Context, tx pgx.Tx, c clock.Clock, p Pack, recordLocale string) errorfunc SeedJournals(ctx context.Context, tx pgx.Tx, c clock.Clock, p Pack, recordLocale string) errorfunc SeedTaxCodes(ctx context.Context, tx pgx.Tx, c clock.Clock, p Pack, recordLocale string) errorconst (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:
- Account codes unique; every
ParentCodenames an earlier seed; a child’sTypeequals its parent’s; a parent isHeader. - Every
vocab.ControlKindskind appears on at least one seed, and every kind exceptbankon exactly one; exactly oneSystemRole = retained_earnings. - Every seed, journal and tax code has non-blank
en,msandzhnames. - Journal codes unique; each
DefaultAccountCodenames a seed;bank/cashjournals have one; at most oneIsDefaultForTypeper type, andmischas one (the year-end close needs it, Ruling R17). - Tax codes: codes unique;
Rateparses asmoney.Ratein0..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.godeclarestestPack(countryZZ) and registers it from the test file’sinit()— 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 belowfunc TestValidateRefusesAChildBeforeItsParent(t *testing.T)func TestValidateRefusesAMissingControlKind(t *testing.T) // drop employee_advances -> error names itfunc TestValidateRefusesAMissingLocale(t *testing.T) // delete Names["zh"] on one seed -> error names the codefunc TestValidateRefusesTwoDefaultsForAType(t *testing.T)func TestRegisterPanicsOnAnInvalidPack(t *testing.T) // use a fresh registry via an unexported newRegistry() test hookfunc 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 namefunc 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 importcountrypack, so their registries are unchanged; confirm they still pass. - Step 5: Commit
git add internal/platform/countrypackgit commit -m "Add country packs, their validator and provisioning steps 70 to 72"Task 15: countrypack/my — Malaysia
Section titled “Task 15: countrypack/my — Malaysia”Files:
- Create:
internal/platform/countrypack/my/my.go - Modify:
internal/platform/countrypack/validate_test.go(tightenTestEveryRegisteredPackIsValidand blank-importmy) - 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.Packregistered under"MY"frominit(); 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 unexportedpacktype with the three slices as package-levelvars exactly as the tables above, andfunc init() { countrypack.Register(pack{}) }. TightenTestEveryRegisteredPackIsValidtorequire.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 themsname from account 6190 →TestTheMalaysianPackIsRegisteredAndValidpanics atinit()naming6190; restore. - Step 5: Commit
git add internal/platform/countrypack internal/platform/accounting/nosst_test.gogit 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.EventBaseCurrency 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.UUIDJournalType stringCurrency stringDescription stringLines []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() stringSemantic() posting.SemanticBuild(ctx context.Context, tx pgx.Tx, src Source) (Draft, error)}type RuleSet struct { /* mu sync.RWMutex; rules map[ruleKey]PostingRule */ }func NewRuleSet() *RuleSetfunc (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() *RuleSettype Poster struct { /* clock clock.Clock; rules *RuleSet */ }func NewPoster(c clock.Clock, rules *RuleSet) *Postervar _ posting.Poster = (*Poster)(nil)func (p *Poster) Post(ctx context.Context, tx pgx.Tx, ev posting.Event) errorvar ErrOrgMismatch, ErrIncompleteEvent, ErrControlKindRequired, ErrAlreadyPosted, ErrNothingToReverse errortype NoPostingRuleError struct{ Kind string; Semantic posting.Semantic }
Poster.Post(ctx, tx, ev):
!ev.PostsToLedger()→nil(the same filter asDispatch, so a direct call agrees with it).- Tenant from ctx must equal
ev.OrgID→ elseErrOrgMismatch.Kind,InstanceID,SourceID,BusinessDatenon-zero → elseErrIncompleteEvent. postedBy := &ev.ActorMemberIDunless it isuuid.Nil(a system transition).Rejected/Cancelled(necessarilyPastAccrual):ledger.Unreversed(ev.InstanceID); empty →ErrNothingToReverse(the ledger and the workflow disagree; abort the transition); elseledger.Reverseeach, newest first, datedev.BusinessDate, description"Reversal on <semantic> of <kind> <source_id>". Done.Accrual/Payout: rule by(Kind, Semantic)→ else*NoPostingRuleError. An unreversed entry of the same semantic for the instance →ErrAlreadyPosted(Ruling R8).Accrualwith emptyControlKind→ErrControlKindRequired(Ruling R9); a non-emptyControlKindresolves throughchart.ResolveControlintoSource.Control.draft := rule.Build(...);ledger.PostwithSemantic: 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 bySourceID:testAccrual{kind:"test_claim"}:Dr Travel amount / Cr src.Control amount, journal typepurchase, currency the doc’s.testPayout{kind:"test_claim"}: readsledger.SourceBalance("test_claim", sourceID, ap.ID);Dr APfor−balance.AmountwithBase = −balance.Base(the frozen accrual base) /Cr Bankfor the same amount at today’s rate (noBase); journal typebank.
-
Step 2: Write the failing tests —
poster_test.go, each againstb := e.SeedBooks(t)and a freshNewRuleSet()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 0func TestCancellationAfterPayoutReversesBothNewestFirst(t *testing.T) // two reversals; the payout's is posted first (lower entry_no), and every account nets to 0func TestRejectionWithNothingToReverseAborts(t *testing.T) // ErrNothingToReversefunc TestAMissingRuleAbortsTheTransition(t *testing.T) // *NoPostingRuleError{Kind:"unknown_kind",Semantic:accrual}; no entryfunc TestAnAccrualNeedsAControlKind(t *testing.T) // ErrControlKindRequiredfunc TestAnAccrualIsPostedOnce(t *testing.T) // PRESENCE first ok; second -> ErrAlreadyPostedfunc TestAnEventForAnotherOrgIsRefused(t *testing.T) // ErrOrgMismatchfunc TestAnUnresolvableControlKindAborts(t *testing.T) // deactivate AP -> *chart.NoControlAccountErrorfunc 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 inrules.gois the contract statement for Plans 5–8: a module registers a rule for(kind, accrual)and(kind, payout)in itsinit(), names control kinds rather than account codes, reads its own document bySource.Event.SourceID, and never writes a journal line. - Step 5: Run. PASS. Sabotage: remove the
ErrAlreadyPostedcheck →TestAnAccrualIsPostedOncered; restore. - Step 6: Commit
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.gogit 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 closingtype 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.UUIDReconciliation reconcile.ResultClosingEntryID *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 errortype 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 throughledger.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 onefunc TestYearCloseWithNoProfitPostsNoEntry(t *testing.T) // ClosingEntryID nil, year closedfunc TestALockedPeriodCannotReopen(t *testing.T) // after CloseYear, ReopenPeriod -> ErrPeriodLockedfunc 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
git add internal/platform/accounting/closinggit 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; addx-go-typetoMoney) →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 bycountrypack,countrypack/my,analytic,fiscal(Tasks 5, 6, 14, 15). - Produces:
package accounting // internal/modules/accountingconst (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 refusedtype optional[T any] struct{ Set bool; Value *T } // copy of orgstructure'sfunc parseID(c *gin.Context, name string) (uuid.UUID, error)func parseDate(s string) (time.Time, error) // "2006-01-02" -> midnight UTCfunc actor(ctx context.Context) *uuid.UUID // audit.ActorFromContext's member, nil if nonefunc 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: dateand add to the existing Money schema (Ruling R33):
x-go-type: money.Amount x-go-type-import: path: github.com/mcom/erp/internal/kernel/moneyThe 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.DepsonDeps;accounting.Register(api, deps.Accounting)afteractivity.Register. -
internal/app/routes_test.go: append"accounting": {accounting.PermissionLedgerRead, accounting.PermissionAccountManage, accounting.PermissionJournalPost, accounting.PermissionPeriodClose, accounting.PermissionFXManage}tocomposedModules. -
cmd/api/main.go: import and passAccounting: 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". Theirwiring_test.golists (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 withoutfunc TestListAccountsReturnsLabelsInTheRequestLocale(t *testing.T) // Accept-Language zh -> 6100's label is 差旅及住宿费, name still Englishfunc TestCreateAccountUnderAHeader(t *testing.T) // 201; the header stays non-postablefunc TestCreateAccountRefusesAnUnknownControlKind(t *testing.T) // 422func TestUpdateAccountDistinguishesNullFromAbsent(t *testing.T) // {"control_kind": null} clears it; {} leaves itfunc TestMoneyAndRatesAreStringsOnTheWire(t *testing.T) // GET /tax-codes: "rate" is a JSON string "8.0000000000"func TestAnUnknownFieldIsRefused(t *testing.T) // 400func 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, thenapi/openapi.yaml, thenmake generate. - Step 4: Compose (
app.go,routes_test.go, the fourcmd/*/main.go, the twowiring_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 anMYorg is a Task 14–15 defect: fix it there, not by loosening the test. -
Step 7: Commit
git add internal/modules/accounting constants.yaml internal/kernel/vocab packages/contracts api/openapi.yaml internal/gen/api \ internal/app cmdgit 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.gohelpers (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?(defaultclock.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)}]}. AlwaysSemantic: manual,PostedBy: actor(ctx)— a manual entry through the API always names its member; there is no way to postaccrual/payoutover HTTP (those come only fromposting.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_idis one extraSELECT id FROM journal_entry WHERE org_id = $1 AND reversal_of_id = $2inGet’s handler — add it toledger.Get’sEntryasReversedByID *uuid.UUIDin 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 HTTPfunc TestPostingNeedsJournalPost(t *testing.T) // PRESENCE with grant 201; ABSENCE with only ledger.read 403func TestReverseAnEntry(t *testing.T) // 201, reversal_of_id set; GET original shows reversed_by_id; second reversal 409func 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) toapi/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
git add internal/modules/accounting internal/platform/accounting/ledger api/openapi.yaml internal/gen/apigit 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: recordapperr.New(apperr.KindConflict, "the reconciliation set failed; the period stays open")and set the problem’s extension memberreconciliationto the result. Ifkernel/errors.Problemhas no extension field, return409with theReconciliationResultas a normal JSON body instead and document that in the OpenAPI response — do not editkernel/errors(outside ownership). -
GET /accounting/reconciliationis a GET because it changes nothing (R14: results are not stored); it runsreconcile.Default()in the request’s read transaction. -
mapErradditions: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 -> 409func 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 openfunc TestYearCloseOverHTTP(t *testing.T) // close periods 1..11 then the year -> closing_entry_id setfunc 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
git add internal/modules/accounting api/openapi.yaml internal/gen/apigit 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.Dispatchwhere 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 linePlan: [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 whenBNM_RATES_URLis set; nightly reconciliation is deferred to River (Ruling R14)”. Tick the Phase 0 carried item “The first task adding aNUMERICcolumn 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
git add internal/modules/accounting/e2e_test.go docs/ROADMAP.mdgit commit -m "Prove a Malaysian organization's first year end to end; tick Plan 4"Parallel groups
Section titled “Parallel groups”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.
Self-Review Notes
Section titled “Self-Review Notes”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’sFinding.Reftests. §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_chaincheck (Task 13). §20.2 idempotent, API-repairable, one code path → Task 14 steps (the readiness endpoint and/org/provisionalready exist). §6.5 money triple and frozen base → Tasks 1, 11. - Contract: migrations 00050–00053 ✓; steps 70–74 ✓; permissions
accounting.*✓; paths/accounting/…✓;postinguntouched,Posterregistered at boot (Task 18), tested with hand-built events (Task 16) ✓; shared files append-only ✓ (the one in-place edit,Money’sx-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).