Bank statements: manual lines and import preview 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: Let a user preview a bank statement before it is stored (adding manual lines there), and add manual lines to the latest statement afterwards, without ever editing a statement row.
Architecture: Manual lines are new rows in bank_statement_line flagged manual; the statement’s balance trigger ignores them and a SQL function bank_statement_effective_closing (stated closing + manual lines) becomes the one balance that reconciliation and the statement chain read. Import is split into a read-only checkImport shared with a new Preview, so the two cannot drift.
Tech Stack: Go (pgx, hand-written SQL, goose migrations), Postgres triggers, oapi-codegen, React/TanStack Query, openapi-typescript client, i18next (en/ms/zh).
Spec: docs/superpowers/specs/2026-10-06-bank-manual-lines-and-import-preview-design.md
Global Constraints
Section titled “Global Constraints”- The contract is
api/openapi.yaml; generated Go and TypeScript are regenerated (make generate), never hand-edited; the generated-code drift test must pass. - A float never touches money:
NUMERIC(19,4)in SQL,money.Amountin Go, a string on the wire and in the web. - The statement row (
closing_balance,line_count,file_bytes,file_sha256) is never written after import. Manual lines are append-only: noUPDATE, noDELETE. - Reconciliation and the statement chain read effective closing, never
closing_balancedirectly. - Manual lines go only on the account’s latest non-voided statement, after the latest locked reconciliation, with
booked_oninside the statement’s period. - Permission is the existing
banking.statement.import; no new permission key and no grant migration. - Lock order for every banking write:
bank_accountrowFOR UPDATEfirst (spec D14 of the reconciliation design). - Every user-facing string is a translation row in en, ms and zh; ms/zh are marked for native review, never claimed reviewed.
- Tests assert presence before absence (AGENTS.md); each new guard is watched failing once (break the thing, read the message, restore).
- DB tests: run only the packages you touched while iterating (
go test ./internal/platform/banking/...); use-p 1for any multi-package run; the full suite runs once before the final commit. Usetestdb.Start(the default) except the down-migration test, which usestestdb.StartAt. - Agents never run
git stash,git checkout -- <path>,git resetorgit cleanin a shared tree. Merges and pushes are the owner’s. - Do not add an event or webhook for this feature (see Task 1, spec correction 2).
File map
Section titled “File map”| File | Change |
|---|---|
db/migrations/01110_bank_statement_manual_lines.sql |
new: columns, CHECKs, bank_statement_effective_closing, revised line guard, balance check, statement chain guard, reconciliation guard |
internal/platform/banking/statement.go |
Statement.EffectiveClosing, StatementLine manual fields, checkImport split, Preview, AddLine, added lines in Import |
internal/platform/banking/manual.go |
new: ManualLineInput, validateManualLine, insertManualLines |
internal/platform/banking/reconciliation.go |
StatementBalance: stmt.EffectiveClosing |
internal/platform/banking/errors.go |
ErrAddLineNotAllowed |
internal/modules/banking/errors.go, routes.go, statement_handlers.go |
map the error, two routes, DTO fields, added_lines |
api/openapi.yaml |
two paths, schema changes |
web/src/features/banking/{api.ts,ImportStatementDialog.tsx,StatementsTab.tsx,AddLineDialog.tsx,MatchingTab.tsx} |
two-step import, add-line dialog, manual tag |
web/src/lib/i18n/locales/{en,ms,zh}/banking.json |
strings |
docs/ROADMAP.md, the spec |
corrections and ticks |
Task 1: Spec corrections and the migration
Section titled “Task 1: Spec corrections and the migration”Files:
- Modify:
docs/superpowers/specs/2026-10-06-bank-manual-lines-and-import-preview-design.md - Create:
db/migrations/01110_bank_statement_manual_lines.sql(confirm the number is free:ls db/migrations | tail; use the next free number above01101if another agent took it, and keep the file name suffix) - Test:
internal/platform/banking/migration01110_test.go
Interfaces:
-
Produces (SQL):
bank_statement_line.manual BOOLEAN,.reason TEXT,.added_by UUID,.added_at TIMESTAMPTZ; functionbank_statement_effective_closing(p_org UUID, p_statement UUID) RETURNS NUMERIC; constraint namesbank_statement_line_manual_allowed(raised by the line guard),bank_statement_chain(unchanged name, now effective-closing based),bank_reconciliation_statement_balance(unchanged name). -
Step 1: Correct the spec in place (three facts found while planning)
Edit the spec:
- In M1, add: “The
bank_reconciliation_guardbackstop (00354) comparesstatement_balancewith the statement’s closing; it compares with the effective closing instead.” - In M3, replace “and publish
banking.statement_line_added(webhook outbox, asEventStatementImported)” with “No event is published: a webhook event is a public-API contract (catalog, schema, counsel gate) and nobody asked for one.” Remove the same words from M3’s last sentence and from the Migration section if present. - In M4, replace the
chainandduplicate_ofbullets with: “previous_effective_closing(null for a first statement). A duplicate file is the existing 409duplicate_fileerror withstatement_id, and a chain mismatch is the existing 422opening_mismatch, so the preview needs no extra fields for them.” In M7, “Add line” goes on the Statements tab row of the latest non-voided statement (there is no statement detail screen); the “Manual” badge goes on the statement-line rows of the Matching tab.
- Step 2: Write the failing migration test
Create internal/platform/banking/migration01110_test.go. It uses the raw erp_app connection through the normal test env, so it exercises the real guards:
package banking_test
import ( "context" "testing"
"github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgconn" "github.com/stretchr/testify/require"
"github.com/mcom/erp/internal/platform/banking" "github.com/mcom/erp/internal/platform/banking/bankingtest")
// constraintOf runs fn and returns the violated constraint's name.func constraintOf(t *testing.T, e *bankingtest.Env, fn func(ctx context.Context, tx pgx.Tx) error) string { t.Helper() err := e.TxErr(fn) var pg *pgconn.PgError require.ErrorAs(t, err, &pg) return pg.ConstraintName}
func insertManualRaw(ctx context.Context, tx pgx.Tx, e *bankingtest.Env, st banking.Statement, bookedOn string) error { _, err := tx.Exec(ctx, ` INSERT INTO bank_statement_line (org_id, statement_id, bank_account_id, line_no, booked_on, amount, description, manual, reason, added_by, added_at) VALUES ($1,$2,$3,(SELECT max(line_no)+1 FROM bank_statement_line WHERE org_id=$1 AND statement_id=$2), $4::date, 10.0000, 'raw', true, 'missed by the file', $5, now())`, e.Org, st.ID, st.BankAccountID, bookedOn, e.Staff) return err}
func TestManualLineGuardAndEffectiveClosing(t *testing.T) { e := bankingtest.New(t) b := e.BankAccount(t, "1520", "514012345678") first := e.Import(t, b, bankingtest.MT940("514012345678", "MYR", "1000.00", "2026-09-01", []bankingtest.MTLine{{Date: "2026-09-02", Amount: "-250.00", Ref: "R1", Details: "First"}}, "2026-09-02"))
// PRESENCE: a manual line on the latest statement, inside its period, is accepted, // the stated closing is untouched and the effective closing moves by the line. require.NoError(t, e.TxErr(func(ctx context.Context, tx pgx.Tx) error { return insertManualRaw(ctx, tx, e, first.Statement, "2026-09-02") })) var closing, effective string require.NoError(t, e.DB.InTx(e.As(e.Staff), func(ctx context.Context, tx pgx.Tx) error { return tx.QueryRow(ctx, `SELECT closing_balance::text, bank_statement_effective_closing(org_id, id)::text FROM bank_statement WHERE org_id = $1 AND id = $2`, e.Org, first.Statement.ID).Scan(&closing, &effective) })) require.Equal(t, "750.0000", closing) require.Equal(t, "760.0000", effective)
// ABSENCE: outside the period is refused by the database itself. require.Equal(t, "bank_statement_line_manual_allowed", constraintOf(t, e, func(ctx context.Context, tx pgx.Tx) error { return insertManualRaw(ctx, tx, e, first.Statement, "2026-10-15") }))
// ABSENCE: once a later statement exists, the earlier one is frozen. e.Import(t, b, bankingtest.MT940("514012345678", "MYR", "760.00", "2026-09-03", []bankingtest.MTLine{{Date: "2026-09-04", Amount: "5.00", Ref: "R2", Details: "Second"}}, "2026-09-04")) require.Equal(t, "bank_statement_line_manual_allowed", constraintOf(t, e, func(ctx context.Context, tx pgx.Tx) error { return insertManualRaw(ctx, tx, e, first.Statement, "2026-09-02") }))}
func TestStatementChainUsesEffectiveClosingInSQL(t *testing.T) { e := bankingtest.New(t) b := e.BankAccount(t, "1520", "514012345678") first := e.Import(t, b, bankingtest.MT940("514012345678", "MYR", "1000.00", "2026-09-01", []bankingtest.MTLine{{Date: "2026-09-02", Amount: "-250.00", Ref: "R1", Details: "First"}}, "2026-09-02")) require.NoError(t, e.TxErr(func(ctx context.Context, tx pgx.Tx) error { return insertManualRaw(ctx, tx, e, first.Statement, "2026-09-02") }))
insertNext := func(opening string) string { return constraintOf(t, e, func(ctx context.Context, tx pgx.Tx) error { _, err := tx.Exec(ctx, ` INSERT INTO bank_statement (org_id, bank_account_id, format, starts_on, ends_on, currency, opening_balance, closing_balance, line_count, file_name, file_sha256, file_bytes, imported_by, imported_at) VALUES ($1,$2,'mt940','2026-09-03','2026-09-04','MYR',$3::numeric,$3::numeric,0,'x', encode(sha256('x'::bytea),'hex'),'x'::bytea,$4,now())`, e.Org, b.ID, opening, e.Staff) return err }) } // The stated closing (750.00) is no longer the chain: effective is 760.00. require.Equal(t, "bank_statement_chain", insertNext("750.00"))}If e.Org or e.Staff have other names in bankingtest.Env, use what statement_test.go already uses (e.Org and e.Staff appear in TestImportIsAudited and Env.Import). The second test’s final assertion is the presence check for the wrong opening; add before it the accepted case (opening 760.00 inserts without error in a TxErr that you roll back by returning a sentinel, or simply run it as the last statement of a separate subtest) so the test proves the 760.00 opening is accepted.
- Step 3: Run it to verify it fails
Run: go test ./internal/platform/banking/ -run 'TestManualLineGuardAndEffectiveClosing|TestStatementChainUsesEffectiveClosingInSQL' -count=1
Expected: FAIL (column "manual" ... does not exist).
- Step 4: Write the migration
Create db/migrations/01110_bank_statement_manual_lines.sql. The four functions below are CREATE OR REPLACE of the versions in 00351 (line guard, balance check), 00460 (statement guard, the latest definition) and 00354 (reconciliation guard); each is identical to its predecessor except for the marked lines.
-- +goose Up
-- Manual lines on a statement (spec 2026-10-06 M2): rows of bank_statement_line flagged manual.-- The statement row is never written; its balance check sums only the imported lines.ALTER TABLE bank_statement_line ADD COLUMN manual BOOLEAN NOT NULL DEFAULT false, ADD COLUMN reason TEXT, ADD COLUMN added_by UUID, ADD COLUMN added_at TIMESTAMPTZ, ADD CONSTRAINT bank_statement_line_manual_complete CHECK ( (manual AND num_nonnulls(reason, added_by, added_at) = 3) OR (NOT manual AND num_nonnulls(reason, added_by, added_at) = 0)), ADD CONSTRAINT bank_statement_line_reason_present CHECK ( reason IS NULL OR (btrim(reason) <> '' AND length(reason) <= 500)), ADD CONSTRAINT bank_statement_line_reason_language CHECK (reason !~ '[-ヿ㐀-䶿一-鿿가-]'), ADD CONSTRAINT bank_statement_line_manual_bank_text CHECK ( NOT manual OR (bank_reference = '' AND transaction_code = ''));
-- The one definition of a statement's balance for reconciliation and the chain.-- +goose StatementBeginCREATE FUNCTION bank_statement_effective_closing(p_org UUID, p_statement UUID) RETURNS NUMERIC LANGUAGE sql STABLE SET search_path = pg_catalog, public, pg_temp AS $$ SELECT s.closing_balance + COALESCE((SELECT sum(l.amount) FROM public.bank_statement_line l WHERE l.org_id = s.org_id AND l.statement_id = s.id AND l.manual), 0) FROM public.bank_statement s WHERE s.org_id = p_org AND s.id = p_statement$$;-- +goose StatementEnd
-- Line guard: imported lines keep 00351's rule; a manual line has its own.-- +goose StatementBeginCREATE OR REPLACE FUNCTION bank_statement_line_guard() RETURNS trigger LANGUAGE plpgsql SET search_path = pg_catalog, public, pg_temp AS $$DECLARE v public.bank_statement%ROWTYPE; v_locked DATE;BEGIN IF NOT NEW.manual THEN IF (SELECT s.imported_txid FROM public.bank_statement s WHERE s.org_id = NEW.org_id AND s.id = NEW.statement_id) IS DISTINCT FROM pg_current_xact_id() THEN RAISE EXCEPTION 'statement %: lines are written only by the import that wrote it', NEW.statement_id USING ERRCODE = '23514', CONSTRAINT = 'bank_statement_line_statement_open'; END IF; RETURN NEW; END IF; PERFORM 1 FROM public.bank_account a WHERE a.org_id = NEW.org_id AND a.id = NEW.bank_account_id FOR UPDATE; SELECT * INTO v FROM public.bank_statement s WHERE s.org_id = NEW.org_id AND s.id = NEW.statement_id; v_locked := public.bank_locked_through(NEW.org_id, NEW.bank_account_id); IF v.voided_at IS NOT NULL OR EXISTS (SELECT 1 FROM public.bank_statement s WHERE s.org_id = NEW.org_id AND s.bank_account_id = NEW.bank_account_id AND s.voided_at IS NULL AND s.ends_on > v.ends_on) OR (v_locked IS NOT NULL AND v.ends_on <= v_locked) OR NEW.booked_on < v.starts_on OR NEW.booked_on > v.ends_on THEN RAISE EXCEPTION 'statement %: a manual line goes only on the latest unvoided, unlocked statement, inside its period', NEW.statement_id USING ERRCODE = '23514', CONSTRAINT = 'bank_statement_line_manual_allowed'; END IF; RETURN NEW;END $$;-- +goose StatementEnd
-- Balance check: the stated figures prove the FILE, so manual lines are left out (changed: NOT l.manual).-- +goose StatementBeginCREATE OR REPLACE FUNCTION bank_statement_check(p_org UUID, p_statement UUID) RETURNS void LANGUAGE plpgsql SET search_path = pg_catalog, public, pg_temp AS $$DECLARE v public.bank_statement%ROWTYPE; v_n INT; v_sum NUMERIC;BEGIN IF p_org IS DISTINCT FROM current_setting('app.current_org', true)::uuid THEN RAISE EXCEPTION 'statement %: checked under another organization', p_statement USING ERRCODE = '23514', CONSTRAINT = 'bank_statement_balanced'; END IF; SELECT * INTO v FROM public.bank_statement s WHERE s.org_id = p_org AND s.id = p_statement; SELECT count(*), COALESCE(sum(l.amount), 0) INTO v_n, v_sum FROM public.bank_statement_line l WHERE l.org_id = p_org AND l.statement_id = p_statement AND NOT l.manual; IF v_n <> v.line_count OR v.opening_balance + v_sum <> v.closing_balance THEN RAISE EXCEPTION 'statement %: % lines summing % do not take % to %', p_statement, v_n, v_sum, v.opening_balance, v.closing_balance USING ERRCODE = '23514', CONSTRAINT = 'bank_statement_balanced'; END IF;END $$;-- +goose StatementEnd
-- Statement guard = 00460's body; the only change is the chain test reading the previous-- statement's EFFECTIVE closing (marked below).-- +goose StatementBeginCREATE OR REPLACE FUNCTION bank_statement_guard() RETURNS trigger LANGUAGE plpgsql SET search_path = pg_catalog, public, pg_temp AS $$DECLARE v_currency TEXT; v_prev public.bank_statement%ROWTYPE; v_prev_closing NUMERIC;BEGIN IF TG_OP = 'INSERT' THEN NEW.imported_txid := pg_current_xact_id(); SELECT a.currency INTO v_currency FROM public.bank_account a WHERE a.org_id = NEW.org_id AND a.id = NEW.bank_account_id FOR UPDATE; IF NEW.currency IS DISTINCT FROM v_currency THEN RAISE EXCEPTION 'statement %: currency % is not the bank account''s %', NEW.id, NEW.currency, v_currency USING ERRCODE = '23514', CONSTRAINT = 'bank_statement_currency'; END IF; SELECT * INTO v_prev FROM public.bank_statement s WHERE s.org_id = NEW.org_id AND s.bank_account_id = NEW.bank_account_id AND s.voided_at IS NULL ORDER BY s.ends_on DESC LIMIT 1; IF FOUND THEN v_prev_closing := public.bank_statement_effective_closing(v_prev.org_id, v_prev.id); -- changed END IF; IF FOUND AND (NEW.starts_on <= v_prev.ends_on OR NEW.ends_on <= v_prev.ends_on OR NEW.opening_balance <> v_prev_closing) THEN -- changed RAISE EXCEPTION 'statement %: must start after % and open at %', NEW.id, v_prev.ends_on, v_prev_closing -- changed USING ERRCODE = '23514', CONSTRAINT = 'bank_statement_chain'; END IF; RETURN NEW; END IF; IF OLD.voided_at IS NOT NULL OR NEW.voided_at IS NULL OR ROW(NEW.org_id, NEW.bank_account_id, NEW.format, NEW.starts_on, NEW.ends_on, NEW.currency, NEW.opening_balance, NEW.closing_balance, NEW.line_count, NEW.file_name, NEW.file_sha256, NEW.file_bytes, NEW.csv_mapping, NEW.imported_by, NEW.imported_at, NEW.imported_txid) IS DISTINCT FROM ROW(OLD.org_id, OLD.bank_account_id, OLD.format, OLD.starts_on, OLD.ends_on, OLD.currency, OLD.opening_balance, OLD.closing_balance, OLD.line_count, OLD.file_name, OLD.file_sha256, OLD.file_bytes, OLD.csv_mapping, OLD.imported_by, OLD.imported_at, OLD.imported_txid) THEN RAISE EXCEPTION 'statement %: only an unvoided statement can change, and only by being voided', OLD.id USING ERRCODE = '23514', CONSTRAINT = 'bank_statement_immutable'; END IF; PERFORM 1 FROM public.bank_account a WHERE a.org_id = OLD.org_id AND a.id = OLD.bank_account_id FOR UPDATE; IF EXISTS (SELECT 1 FROM public.bank_statement s WHERE s.org_id = OLD.org_id AND s.bank_account_id = OLD.bank_account_id AND s.voided_at IS NULL AND s.ends_on > OLD.ends_on) THEN RAISE EXCEPTION 'statement %: only the latest statement can be voided', OLD.id USING ERRCODE = '23514', CONSTRAINT = 'bank_statement_void_allowed'; END IF; IF EXISTS (SELECT 1 FROM public.bank_match_item i JOIN public.bank_statement_line l ON l.org_id = i.org_id AND l.id = i.statement_line_id WHERE i.org_id = OLD.org_id AND l.org_id = OLD.org_id AND l.statement_id = OLD.id AND public.bank_match_active(OLD.org_id, i.match_id)) OR OLD.ends_on <= public.bank_locked_through(OLD.org_id, OLD.bank_account_id) THEN RAISE EXCEPTION 'statement %: an actively matched or locked statement cannot be voided', OLD.id USING ERRCODE = '23514', CONSTRAINT = 'bank_statement_void_allowed'; END IF; RETURN NEW;END $$;-- +goose StatementEnd
-- Reconciliation guard = 00354's body; statement_balance is compared with the EFFECTIVE closing (changed).-- +goose StatementBeginCREATE OR REPLACE FUNCTION bank_reconciliation_guard() RETURNS trigger LANGUAGE plpgsql SET search_path = pg_catalog, public, pg_temp AS $$DECLARE v_locked DATE; v_closing NUMERIC;BEGIN NEW.created_txid := pg_current_xact_id(); PERFORM 1 FROM public.bank_account a WHERE a.org_id = NEW.org_id AND a.id = NEW.bank_account_id FOR UPDATE; SELECT public.bank_statement_effective_closing(s.org_id, s.id) INTO v_closing FROM public.bank_statement s -- changed WHERE s.org_id = NEW.org_id AND s.id = NEW.statement_id AND s.bank_account_id = NEW.bank_account_id AND s.voided_at IS NULL AND s.ends_on = NEW.as_of; IF NOT FOUND THEN RAISE EXCEPTION 'reconciliation %: statement % is not a non-voided statement ending %', NEW.id, NEW.statement_id, NEW.as_of USING ERRCODE = '23514', CONSTRAINT = 'bank_reconciliation_statement'; END IF; IF NEW.statement_balance <> v_closing THEN RAISE EXCEPTION 'reconciliation %: statement balance % is not the statement''s effective closing balance %', NEW.id, NEW.statement_balance, v_closing USING ERRCODE = '23514', CONSTRAINT = 'bank_reconciliation_statement_balance'; END IF; v_locked := public.bank_locked_through(NEW.org_id, NEW.bank_account_id); IF v_locked IS NOT NULL AND NEW.as_of <= v_locked THEN RAISE EXCEPTION 'reconciliation %: as_of % is not after the latest lock %', NEW.id, NEW.as_of, v_locked USING ERRCODE = '23514', CONSTRAINT = 'bank_reconciliation_in_order'; END IF; RETURN NEW;END $$;-- +goose StatementEnd
-- +goose Down-- Refuse while any manual row exists: dropping the column would silently erase them.-- +goose StatementBeginDO $$ BEGIN IF EXISTS (SELECT 1 FROM bank_statement_line WHERE manual) THEN RAISE EXCEPTION 'manual statement lines exist; they cannot be dropped'; END IF;END $$;-- +goose StatementEnd-- Restore the four functions to their previous bodies, mechanically. From the repo root, append the-- output of these commands to this Down section, in this order (each range is a whole-- StatementBegin..StatementEnd block; the two 00351 functions are `CREATE FUNCTION`, so the sed makes-- them `CREATE OR REPLACE FUNCTION`):-- sed -n 15,40p db/migrations/00354_bank_reconciliation_snapshot_backstops.sql # bank_reconciliation_guard-- sed -n 7,58p db/migrations/00460_bank_statement_chain_starts_after.sql # bank_statement_guard-- sed -n 123,134p db/migrations/00351_bank_statement.sql | sed 's/^CREATE FUNCTION/CREATE OR REPLACE FUNCTION/' # line guard-- (printf -- '-- +goose StatementBegin\n'; sed -n 141,157p db/migrations/00351_bank_statement.sql | sed 's/^CREATE FUNCTION/CREATE OR REPLACE FUNCTION/'; printf -- '-- +goose StatementEnd\n') # bank_statement_check-- Then delete these comment lines, so the file holds only SQL. The ALTER TABLE below follows them.ALTER TABLE bank_statement_line DROP CONSTRAINT bank_statement_line_manual_bank_text, DROP CONSTRAINT bank_statement_line_reason_language, DROP CONSTRAINT bank_statement_line_reason_present, DROP CONSTRAINT bank_statement_line_manual_complete, DROP COLUMN added_at, DROP COLUMN added_by, DROP COLUMN reason, DROP COLUMN manual;DROP FUNCTION bank_statement_effective_closing(UUID, UUID);The Down section’s restore is a mechanical copy (the sed ranges in its comment); run them, then delete the comment lines. After that the finished file holds only SQL. Verify the ranges still match before running them: sed -n 15p of 00354 must print -- +goose StatementBegin, and so on for each range’s first and last line (StatementEnd).
- Step 5: Run the tests
Run: go test ./internal/platform/banking/ -run 'TestManualLineGuardAndEffectiveClosing|TestStatementChainUsesEffectiveClosingInSQL' -count=1
Expected: PASS.
- Step 6: Run the existing schema and migration tests, and the drift tests
Run: go test ./internal/platform/banking/ -run 'Schema|Migration|X9|Unlock' -count=1 and go test ./internal/kernel/db/... -count=1 -p 1
Expected: PASS (every table tenant-isolated, SECURITY DEFINER allowlist untouched: nothing here is SECURITY DEFINER).
- Step 7: Add the down-migration test
In migration01110_test.go add a testdb.StartAt(t, <this migration's number>) test (pattern: migration00462_test.go) that applies Down with no manual rows and expects success, and with one manual row expects the manual statement lines exist error. Run it. Watch it fail first by temporarily deleting the DO block from the Down, then restore.
- Step 8: Watch the guards fail once
Temporarily change NEW.booked_on > v.ends_on to false in the migration’s line guard, re-run Step 5’s first test: the out-of-period assertion must fail with the constraint-name mismatch. Restore. Repeat for the chain: temporarily change v_prev_closing back to v_prev.closing_balance in the chain test; the second test must fail. Restore.
- Step 9: Commit
git add docs/superpowers/specs db/migrations/01110_bank_statement_manual_lines.sql internal/platform/banking/migration01110_test.gogit commit -m "Bank statements: manual lines and effective closing in the database
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>"Task 2: Effective closing in Go (read side)
Section titled “Task 2: Effective closing in Go (read side)”Files:
- Modify:
internal/platform/banking/statement.go(Statement,statementSelect,scanStatement,StatementLine,statementLineSelect,scanStatementLine, the chainswitchinImport) - Modify:
internal/platform/banking/reconciliation.go:189 - Test:
internal/platform/banking/statement_manual_test.go(new)
Interfaces:
-
Consumes: Task 1’s SQL function and columns.
-
Produces:
Statement.EffectiveClosing money.Amount;StatementLine.Manual bool,.Reason *string,.AddedBy *uuid.UUID,.AddedAt *time.Time. Every statement read now carriesEffectiveClosing. -
Step 1: Write the failing tests
Create internal/platform/banking/statement_manual_test.go:
package banking_test
import ( "context" "testing"
"github.com/jackc/pgx/v5" "github.com/stretchr/testify/require"
"github.com/mcom/erp/internal/platform/banking" "github.com/mcom/erp/internal/platform/banking/bankingtest")
// manualRaw inserts a manual line directly (Task 3 adds the real writer).func manualRaw(t *testing.T, e *bankingtest.Env, st banking.Statement, bookedOn, amount string) { t.Helper() require.NoError(t, e.TxErr(func(ctx context.Context, tx pgx.Tx) error { _, err := tx.Exec(ctx, ` INSERT INTO bank_statement_line (org_id, statement_id, bank_account_id, line_no, booked_on, amount, description, manual, reason, added_by, added_at) VALUES ($1,$2,$3,(SELECT max(line_no)+1 FROM bank_statement_line WHERE org_id=$1 AND statement_id=$2), $4::date, $5::numeric, 'raw', true, 'test', $6, now())`, e.Org, st.ID, st.BankAccountID, bookedOn, amount, e.Staff) return err }))}
func getStatement(t *testing.T, e *bankingtest.Env, id banking.Statement) (banking.Statement, []banking.StatementLine) { t.Helper() var s banking.Statement var lines []banking.StatementLine e.Tx(t, func(ctx context.Context, tx pgx.Tx) error { var err error s, lines, err = banking.GetStatement(ctx, tx, id.ID) return err }) return s, lines}
func TestStatementReadsCarryEffectiveClosing(t *testing.T) { e := bankingtest.New(t) b := e.BankAccount(t, "1520", "514012345678") first := e.Import(t, b, bankingtest.MT940("514012345678", "MYR", "1000.00", "2026-09-01", []bankingtest.MTLine{{Date: "2026-09-02", Amount: "-250.00", Ref: "R1", Details: "First"}}, "2026-09-02")) require.Equal(t, "750.0000", first.Statement.EffectiveClosing.String(), "PRESENCE: no manual line, effective = stated")
manualRaw(t, e, first.Statement, "2026-09-02", "10.00") s, lines := getStatement(t, e, first.Statement) require.Equal(t, "750.0000", s.Closing.String(), "the stated closing never moves") require.Equal(t, "760.0000", s.EffectiveClosing.String()) require.Len(t, lines, 2) require.False(t, lines[0].Manual) require.True(t, lines[1].Manual) require.NotNil(t, lines[1].Reason) require.NotNil(t, lines[1].AddedBy)}
func TestNextStatementChainsFromEffectiveClosing(t *testing.T) { e := bankingtest.New(t) b := e.BankAccount(t, "1520", "514012345678") first := e.Import(t, b, bankingtest.MT940("514012345678", "MYR", "1000.00", "2026-09-01", []bankingtest.MTLine{{Date: "2026-09-02", Amount: "-250.00", Ref: "R1", Details: "First"}}, "2026-09-02")) manualRaw(t, e, first.Statement, "2026-09-02", "10.00")
// ABSENCE: opening at the stated closing is now a chain break, and the error names the effective one. stale := bankingtest.MT940("514012345678", "MYR", "750.00", "2026-09-03", []bankingtest.MTLine{{Date: "2026-09-04", Amount: "5.00", Ref: "R2", Details: "Second"}}, "2026-09-04") _, err := doImport(e, banking.ImportInput{BankAccountID: b.ID, Format: "mt940", FileName: "s.mt940", File: stale, By: e.Staff}) var mismatch *banking.OpeningMismatchError require.ErrorAs(t, err, &mismatch) require.Equal(t, "760.0000", mismatch.Expected.String())
// PRESENCE: opening at the effective closing imports. good := bankingtest.MT940("514012345678", "MYR", "760.00", "2026-09-03", []bankingtest.MTLine{{Date: "2026-09-04", Amount: "5.00", Ref: "R2", Details: "Second"}}, "2026-09-04") _, err = doImport(e, banking.ImportInput{BankAccountID: b.ID, Format: "mt940", FileName: "s.mt940", File: good, By: e.Staff}) require.NoError(t, err)}If e.Tx(t, …) is not the exact helper, use the same one TestImportStoresTheFileAndItsLines uses (it calls e.Tx(t, func(ctx, tx) error {...})).
- Step 2: Run to verify they fail
Run: go test ./internal/platform/banking/ -run 'TestStatementReadsCarryEffectiveClosing|TestNextStatementChainsFromEffectiveClosing' -count=1
Expected: FAIL (compile error: EffectiveClosing, Manual undefined).
- Step 3: Implement
In statement.go:
StatementgainsEffectiveClosing money.AmountafterClosing.statementSelectbecomes (one added column, last):
const statementSelect = ` SELECT s.id, s.bank_account_id, s.format, s.starts_on, s.ends_on, s.currency, s.opening_balance, s.closing_balance, s.line_count, s.file_name, s.file_sha256, s.imported_by, s.imported_at, s.voided_at, s.voided_by, s.void_reason, bank_statement_effective_closing(s.org_id, s.id) FROM bank_statement s WHERE s.org_id = $1 `and scanStatement scans &s.EffectiveClosing last.
3. StatementLine gains Manual bool; Reason *string; AddedBy *uuid.UUID; AddedAt *time.Time; statementLineSelect adds , l.manual, l.reason, l.added_by, l.added_at; scanStatementLine scans &l.Manual, &l.Reason, &l.AddedBy, &l.AddedAt last.
4. In Import’s chain switch, replace the three uses of prev.Closing with prev.EffectiveClosing (o := prev.EffectiveClosing, opening.Cmp(prev.EffectiveClosing), OpeningMismatchError{Expected: prev.EffectiveClosing}).
In reconciliation.go:189 use StatementBalance: stmt.EffectiveClosing.
Grep for any other scanner of these columns: grep -rn "scanStatement\|statementLineSelect" internal/platform/banking --include=*.go and update them.
- Step 4: Run the new tests and the whole banking platform package
Run: go test ./internal/platform/banking/... -count=1 -p 1
Expected: PASS. Existing reconciliation and property tests pass unchanged because every existing statement has effective = stated.
- Step 5: Watch it fail once
Revert reconciliation.go to stmt.Closing temporarily and add to statement_manual_test.go a test that adds a raw manual line to the example of workedExample(t, e, true) (see reconciliation_test.go) and calls reconcile(e, x.acct.ID, "2026-09-30"), asserting StatementBalance equals stated + the line. It must fail on the reverted line. Restore.
- Step 6: Commit
git add internal/platform/bankinggit commit -m "Bank statements: reconciliation and the chain read effective closing
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>"Task 3: AddLine
Section titled “Task 3: AddLine”Files:
- Create:
internal/platform/banking/manual.go - Modify:
internal/platform/banking/errors.go,internal/platform/banking/statement.go(addAddLine) - Test:
internal/platform/banking/statement_manual_test.go
Interfaces:
- Consumes: Task 2’s
Statement.EffectiveClosing,StatementLinefields; existinglockBankAccount,latestStatement,lockedThrough,autoMatch,audit.Write,recordtext.Check,fieldErr,ErrVoided,ErrLocked. - Produces:
type ManualLineInput struct { BookedOn time.Time Amount money.Amount Description, Reference, Reason string}var ErrAddLineNotAllowed error // 409 not_latestfunc validateManualLine(l ManualLineInput, startsOn, endsOn time.Time, field string) errorfunc insertManualLines(ctx context.Context, tx pgx.Tx, st Statement, by uuid.UUID, now time.Time, lines []ManualLineInput) errorfunc AddLine(ctx context.Context, tx pgx.Tx, c clock.Clock, statementID uuid.UUID, in ManualLineInput, by uuid.UUID) (StatementLine, Statement, error)- Step 1: Write the failing tests
Append to statement_manual_test.go:
func addLine(e *bankingtest.Env, stmtID uuid.UUID, in banking.ManualLineInput) (banking.StatementLine, banking.Statement, error) { var l banking.StatementLine var s banking.Statement err := e.TxErr(func(ctx context.Context, tx pgx.Tx) error { var err error l, s, err = banking.AddLine(ctx, tx, e.Clock, stmtID, in, e.Staff) return err }) return l, s, err}
func manualIn(date, amount string) banking.ManualLineInput { return banking.ManualLineInput{BookedOn: day(date), Amount: money.MustParse(amount), Description: "Bank charge missed by the file", Reference: "MISS1", Reason: "the bank omitted it"}}
func TestAddLine(t *testing.T) { e := bankingtest.New(t) b := e.BankAccount(t, "1520", "514012345678") first := e.Import(t, b, bankingtest.MT940("514012345678", "MYR", "1000.00", "2026-09-01", []bankingtest.MTLine{{Date: "2026-09-02", Amount: "-250.00", Ref: "R1", Details: "First"}}, "2026-09-30"))
// PRESENCE: a valid line on the latest statement. l, s, err := addLine(e, first.Statement.ID, manualIn("2026-09-10", "-12.50")) require.NoError(t, err) require.True(t, l.Manual) require.Equal(t, 2, l.LineNo) require.Equal(t, "737.5000", s.EffectiveClosing.String()) require.Equal(t, "750.0000", s.Closing.String())
n := e.Count(t, `SELECT count(*) FROM audit_log WHERE org_id = $1 AND entity_kind = 'bank_statement' AND entity_id = $2 AND action = 'add_line'`, e.Org, first.Statement.ID) require.Equal(t, 1, n)
// ABSENCE: each refusal, one at a time. _, _, err = addLine(e, first.Statement.ID, manualIn("2026-10-10", "5.00")) require.ErrorContains(t, err, "rule=period") _, _, err = addLine(e, first.Statement.ID, manualIn("2026-09-10", "0")) require.ErrorContains(t, err, "field=amount") bad := manualIn("2026-09-10", "5.00") bad.Reason = " " _, _, err = addLine(e, first.Statement.ID, bad) require.ErrorContains(t, err, "field=reason") bad.Reason = "原因" // not English or Bahasa Malaysia _, _, err = addLine(e, first.Statement.ID, bad) require.ErrorContains(t, err, "record_language") require.Equal(t, 1, e.Count(t, `SELECT count(*) FROM bank_statement_line WHERE org_id = $1 AND statement_id = $2 AND manual`, e.Org, first.Statement.ID), "no refused line was stored")}
func TestAddLineOnlyOnTheLatestStatement(t *testing.T) { e := bankingtest.New(t) b := e.BankAccount(t, "1520", "514012345678") first := e.Import(t, b, bankingtest.MT940("514012345678", "MYR", "1000.00", "2026-09-01", []bankingtest.MTLine{{Date: "2026-09-02", Amount: "-250.00", Ref: "R1", Details: "First"}}, "2026-09-02")) second := e.Import(t, b, bankingtest.MT940("514012345678", "MYR", "750.00", "2026-09-03", []bankingtest.MTLine{{Date: "2026-09-04", Amount: "5.00", Ref: "R2", Details: "Second"}}, "2026-09-04"))
_, _, err := addLine(e, second.Statement.ID, manualIn("2026-09-04", "1.00")) require.NoError(t, err, "PRESENCE: the latest accepts") _, _, err = addLine(e, first.Statement.ID, manualIn("2026-09-02", "1.00")) require.ErrorIs(t, err, banking.ErrAddLineNotAllowed)}
func TestAddLineRefusedOnVoidedAndLocked(t *testing.T) { e := bankingtest.New(t) x := workedExample(t, e, true) _, _, err := addLine(e, x.stmt.ID, manualIn("2026-09-20", "1.00")) require.NoError(t, err, "PRESENCE: unlocked, accepted")
// The line made the reconciliation differ; lock needs difference zero, so use the twin fixture. locked := bankingtest.New(t) y := workedExample(t, locked, true) _, err = doLock(locked, y.acct.ID, "2026-09-30") require.NoError(t, err) _, _, err = addLine(locked, y.stmt.ID, manualIn("2026-09-20", "1.00")) require.ErrorIs(t, err, banking.ErrLocked)}Add imports github.com/google/uuid and github.com/mcom/erp/internal/kernel/money to the file. workedExample returns a struct with acct and stmt (see reconciliation_test.go); if x.stmt is a different field name use the one that holds the September banking.Statement. A voided-statement case: add a subtest that voids the latest (banking.VoidStatement) and expects banking.ErrVoided.
- Step 2: Run to verify they fail
Run: go test ./internal/platform/banking/ -run 'TestAddLine' -count=1
Expected: FAIL (compile: AddLine undefined).
- Step 3: Implement
Add to errors.go var block:
// ErrAddLineNotAllowed: a manual line goes only on the account's latest // non-voided statement (409 not_latest). ErrAddLineNotAllowed = errors.New("banking: a manual line goes only on the account's latest statement")Create manual.go:
package banking
import ( "context" "fmt" "strings" "time"
"github.com/google/uuid" "github.com/jackc/pgx/v5"
"github.com/mcom/erp/internal/kernel/money" "github.com/mcom/erp/internal/kernel/recordtext")
// MaxAddedLines is the most manual lines one import request may carry.const MaxAddedLines = 200
// ManualLineInput is one line a person adds to a statement (spec 2026-10-06 M3). The// description and reason are ours, not the bank's, so they are record-language checked.type ManualLineInput struct { BookedOn time.Time Amount money.Amount Description, Reference, Reason string}
// validateManualLine checks one line against the statement period. field prefixes the refusal// ("added_lines[2]" in an import, "" for a direct add).func validateManualLine(l ManualLineInput, startsOn, endsOn time.Time, prefix string) error { f := func(name string) string { if prefix == "" { return name } return prefix + "." + name } if l.BookedOn.Before(startsOn) || l.BookedOn.After(endsOn) { return fieldErr(f("booked_on"), "period", fmt.Sprintf("must be within the statement's period %s to %s", startsOn.Format(time.DateOnly), endsOn.Format(time.DateOnly))) } if l.Amount.IsZero() { return fieldErr(f("amount"), "nonzero", "the amount must not be zero") } desc := strings.TrimSpace(l.Description) if desc == "" || len(desc) > 2000 { return fieldErr(f("description"), "required", "a description of 1 to 2000 characters is required") } if len(l.Reference) > 140 { return fieldErr(f("reference"), "length", "a reference is at most 140 characters") } reason := strings.TrimSpace(l.Reason) if reason == "" || len(reason) > 500 { return fieldErr(f("reason"), "required", "the reason, up to 500 characters, in English or Bahasa Malaysia, is required") } for name, v := range map[string]string{"description": desc, "reason": reason} { if recordtext.Check(name, v) != nil { return fieldErr(f(name), "record_language", recordtext.Detail) } } return nil}
// insertManualLines appends lines to st, numbered after its highest line_no. The caller holds the// bank account lock and has validated every line.func insertManualLines(ctx context.Context, tx pgx.Tx, st Statement, by uuid.UUID, now time.Time, lines []ManualLineInput) error { org, err := orgOf(ctx) if err != nil { return err } for _, l := range lines { if _, err := tx.Exec(ctx, ` INSERT INTO bank_statement_line (org_id, statement_id, bank_account_id, line_no, booked_on, amount, description, reference, manual, reason, added_by, added_at) VALUES ($1, $2, $3, (SELECT COALESCE(max(line_no), 0) + 1 FROM bank_statement_line WHERE org_id = $1 AND statement_id = $2), $4, $5::numeric, $6, $7, true, $8, $9, $10)`, org, st.ID, st.BankAccountID, l.BookedOn, l.Amount.String(), strings.TrimSpace(l.Description), l.Reference, strings.TrimSpace(l.Reason), by, now); err != nil { return MapError(err) } } return nil}recordtext.Check(name, v) and recordtext.Detail are used the same way in VoidStatement; keep that call shape.
Add to statement.go:
// AddLine appends a manual line to the account's latest non-voided statement. The statement row// is not written: its effective closing is its stated closing plus its manual lines (spec M1).func AddLine(ctx context.Context, tx pgx.Tx, c clock.Clock, statementID uuid.UUID, in ManualLineInput, by uuid.UUID) (StatementLine, Statement, error) { org, err := orgOf(ctx) if err != nil { return StatementLine{}, Statement{}, err } target, err := scanStatement(tx.QueryRow(ctx, statementSelect+`AND s.id = $2`, org, statementID)) if err != nil { return StatementLine{}, Statement{}, err } if _, err := lockBankAccount(ctx, tx, target.BankAccountID); err != nil { return StatementLine{}, Statement{}, err } st, err := scanStatement(tx.QueryRow(ctx, statementSelect+`AND s.id = $2`, org, statementID)) if err != nil { return StatementLine{}, Statement{}, err } if st.VoidedAt != nil { return StatementLine{}, Statement{}, ErrVoided } latest, err := latestStatement(ctx, tx, st.BankAccountID) if err != nil { return StatementLine{}, Statement{}, err } if latest == nil || latest.ID != st.ID { return StatementLine{}, Statement{}, ErrAddLineNotAllowed } locked, err := lockedThrough(ctx, tx, st.BankAccountID) if err != nil { return StatementLine{}, Statement{}, err } if locked != nil && !st.EndsOn.After(*locked) { return StatementLine{}, Statement{}, ErrLocked } if err := validateManualLine(in, st.StartsOn, st.EndsOn, ""); err != nil { return StatementLine{}, Statement{}, err } if err := insertManualLines(ctx, tx, st, by, c.Now(), []ManualLineInput{in}); err != nil { return StatementLine{}, Statement{}, err } var line StatementLine if line, err = scanStatementLine(tx.QueryRow(ctx, ` SELECT l.id, l.statement_id, l.line_no, l.booked_on, l.value_on, l.amount, l.description, l.reference, l.bank_reference, l.transaction_code, l.manual, l.reason, l.added_by, l.added_at FROM bank_statement_line l WHERE l.org_id = $1 AND l.statement_id = $2 ORDER BY l.line_no DESC LIMIT 1`, org, statementID)); err != nil { return StatementLine{}, Statement{}, err } acct, err := GetBankAccount(ctx, tx, st.BankAccountID) if err != nil { return StatementLine{}, Statement{}, err } matched, err := autoMatch(ctx, tx, c, acct, by) if err != nil { return StatementLine{}, Statement{}, err } after, err := scanStatement(tx.QueryRow(ctx, statementSelect+`AND s.id = $2`, org, statementID)) if err != nil { return StatementLine{}, Statement{}, err } return line, after, audit.Write(ctx, tx, c, audit.Entry{ EntityKind: EntityBankStatement, EntityID: statementID, Action: "add_line", After: map[string]any{ "line_id": line.ID, "line_no": line.LineNo, "booked_on": line.BookedOn, "amount": line.Amount, "description": line.Description, "reference": line.Reference, "reason": line.Reason, "effective_closing": after.EffectiveClosing, "auto_matched": matched, }, })}Add ErrAddLineNotAllowed to MapError’s backstop switch: case "bank_statement_line_manual_allowed": return fmt.Errorf("%w: %w", ErrAddLineNotAllowed, err).
- Step 4: Run to verify they pass
Run: go test ./internal/platform/banking/ -run 'TestAddLine' -count=1
Expected: PASS.
- Step 5: Watch each guard fail once
(a) Delete the latest.ID != st.ID check: TestAddLineOnlyOnTheLatestStatement must still fail, with ErrAddLineNotAllowed now coming from the SQL backstop via MapError (this proves the backstop works independently); then also delete the Go lock check and confirm TestAddLineRefusedOnVoidedAndLocked still fails on the SQL guard. Restore both Go checks.
- Step 6: Commit
git add internal/platform/bankinggit commit -m "Bank statements: add a manual line to the latest statement
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>"Task 4: Preview and added lines on import
Section titled “Task 4: Preview and added lines on import”Files:
- Modify:
internal/platform/banking/statement.go(splitImport; addPreview;ImportInput.Added) - Test:
internal/platform/banking/statement_preview_test.go(new)
Interfaces:
- Consumes: Task 3’s
ManualLineInput,validateManualLine,insertManualLines,MaxAddedLines. - Produces:
// ImportInput gains: Added []ManualLineInputtype PreviewResult struct { Format string StartsOn, EndsOn time.Time Currency string Opening, Closing money.Amount Lines []ParsedLine PreviousEffectiveClosing *money.Amount}func Preview(ctx context.Context, tx pgx.Tx, in ImportInput) (PreviewResult, error)- Step 1: Write the failing tests
Create statement_preview_test.go:
package banking_test
import ( "fmt" "testing"
"github.com/jackc/pgx/v5" "github.com/stretchr/testify/require"
"github.com/mcom/erp/internal/platform/banking" "github.com/mcom/erp/internal/platform/banking/bankingtest" "context")
func previewOf(e *bankingtest.Env, in banking.ImportInput) (banking.PreviewResult, error) { var res banking.PreviewResult err := e.TxErr(func(ctx context.Context, tx pgx.Tx) error { var err error res, err = banking.Preview(ctx, tx, in) return err }) return res, err}
func TestPreviewWritesNothing(t *testing.T) { e := bankingtest.New(t) b := e.BankAccount(t, "1520", "514012345678") file := bankingtest.MT940("514012345678", "MYR", "1000.00", "2026-09-01", []bankingtest.MTLine{{Date: "2026-09-02", Amount: "-250.00", Ref: "R1", Details: "First"}}, "2026-09-02") in := banking.ImportInput{BankAccountID: b.ID, Format: "mt940", FileName: "s.mt940", File: file, By: e.Staff}
counts := func() [4]int { return [4]int{ e.Count(t, `SELECT count(*) FROM bank_statement WHERE org_id = $1`, e.Org), e.Count(t, `SELECT count(*) FROM bank_statement_line WHERE org_id = $1`, e.Org), e.Count(t, `SELECT count(*) FROM bank_match WHERE org_id = $1`, e.Org), e.Count(t, `SELECT count(*) FROM audit_log WHERE org_id = $1 AND entity_kind = 'bank_statement'`, e.Org), } } before := counts() p, err := previewOf(e, in) require.NoError(t, err) require.Len(t, p.Lines, 1) require.Equal(t, "750.0000", p.Closing.String()) require.Nil(t, p.PreviousEffectiveClosing) require.Equal(t, before, counts(), "a preview writes nothing")
// PRESENCE: the same input, imported, does write (so the equality above was not vacuous). _, err = doImport(e, in) require.NoError(t, err) after := counts() require.Equal(t, before[0]+1, after[0]) require.Equal(t, before[1]+1, after[1]) require.Equal(t, before[3]+1, after[3])}
// The preview and the import must refuse the same inputs with the same error.func TestPreviewAndImportAgree(t *testing.T) { e := bankingtest.New(t) b := e.BankAccount(t, "1520", "514012345678") first := bankingtest.MT940("514012345678", "MYR", "1000.00", "2026-09-01", []bankingtest.MTLine{{Date: "2026-09-02", Amount: "-250.00", Ref: "R1", Details: "First"}}, "2026-09-02") e.Import(t, b, first)
cases := map[string]banking.ImportInput{ "duplicate": {Format: "mt940", FileName: "s.mt940", File: first}, "wrong opening": {Format: "mt940", FileName: "s.mt940", File: bankingtest.MT940("514012345678", "MYR", "1.00", "2026-09-03", []bankingtest.MTLine{{Date: "2026-09-04", Amount: "1.00", Ref: "R", Details: "x"}}, "2026-09-04")}, "wrong account": {Format: "mt940", FileName: "s.mt940", File: bankingtest.MT940("999999999999", "MYR", "750.00", "2026-09-03", []bankingtest.MTLine{{Date: "2026-09-04", Amount: "1.00", Ref: "R", Details: "x"}}, "2026-09-04")}, "not mt940": {Format: "mt940", FileName: "s.mt940", File: []byte("garbage")}, "ok": {Format: "mt940", FileName: "s.mt940", File: bankingtest.MT940("514012345678", "MYR", "750.00", "2026-09-03", []bankingtest.MTLine{{Date: "2026-09-04", Amount: "1.00", Ref: "R", Details: "x"}}, "2026-09-04")}, } refused := 0 for name, in := range cases { in.BankAccountID, in.By = b.ID, e.Staff _, perr := previewOf(e, in) _, ierr := doImport(e, in) require.Equal(t, fmt.Sprint(ierr), fmt.Sprint(perr), name) if ierr != nil { refused++ } } require.Equal(t, 4, refused, "four cases refuse and one is accepted, so the agreement above is not vacuous")}
func TestImportWithAddedLines(t *testing.T) { e := bankingtest.New(t) b := e.BankAccount(t, "1520", "514012345678") file := bankingtest.MT940("514012345678", "MYR", "1000.00", "2026-09-01", []bankingtest.MTLine{{Date: "2026-09-02", Amount: "-250.00", Ref: "R1", Details: "First"}}, "2026-09-30") in := banking.ImportInput{BankAccountID: b.ID, Format: "mt940", FileName: "s.mt940", File: file, By: e.Staff, Added: []banking.ManualLineInput{manualIn("2026-09-10", "-12.50"), manualIn("2026-09-11", "3.00")}} res, err := doImport(e, in) require.NoError(t, err) require.Equal(t, "750.0000", res.Statement.Closing.String()) require.Equal(t, "740.5000", res.Statement.EffectiveClosing.String()) require.Equal(t, 1, res.Statement.LineCount, "line_count counts the file's lines only")
// ABSENCE: an added line outside the period refuses the whole import, and nothing is stored. e2 := bankingtest.New(t) b2 := e2.BankAccount(t, "1520", "514012345678") in2 := banking.ImportInput{BankAccountID: b2.ID, Format: "mt940", FileName: "s.mt940", File: file, By: e2.Staff, Added: []banking.ManualLineInput{manualIn("2026-10-30", "1.00")}} _, err = doImport(e2, in2) require.ErrorContains(t, err, "added_lines[0].booked_on") require.Equal(t, 0, e2.Count(t, `SELECT count(*) FROM bank_statement WHERE org_id = $1`, e2.Org))}Tidy the import block (put context with the other stdlib imports).
- Step 2: Run to verify they fail
Run: go test ./internal/platform/banking/ -run 'TestPreview|TestImportWithAddedLines' -count=1
Expected: FAIL (compile: Preview, Added undefined).
- Step 3: Implement the split
Restructure Import in statement.go so that its three phases are separate. The code between the account lock and the insert moves into checkImport unchanged except where marked.
// importPlan is a checked import: everything Import writes, resolved, with nothing written.type importPlan struct { acct BankAccount startsOn, endsOn time.Time opening, closing money.Amount lines []ParsedLine csvMapping []byte sha string prev *Statement}
// parseForImport is Import's unlocked first phase: validate the request shell, read the account,// parse the file. It writes nothing and takes no lock.func parseForImport(ctx context.Context, tx pgx.Tx, in ImportInput) (BankAccount, Parsed, error) { // the existing code from "if len(in.File) < 1" through the end of the format switch // (file size, file name, GetBankAccount, IsActive, ParseMT940 / ParseCSV / format error) // returning (acct, p, nil); every "return ImportResult{}, X" becomes "return BankAccount{}, Parsed{}, X".}
// checkImport is Import's read-only second phase: every refusal that needs the database, none// of the writes. Import calls it under the account lock; Preview calls it without one.func checkImport(ctx context.Context, tx pgx.Tx, in ImportInput, acct BankAccount, p Parsed) (importPlan, error) { // the existing code from "sum := sha256.Sum256(in.File)" through the "unbalanced" check, // with these changes only: // - returns (importPlan{}, err) instead of (ImportResult{}, err) // - `opening`/`closing` stay *money.Amount until the end, then are dereferenced into the plan // - after the period loop over `lines`, add: if len(in.Added) > MaxAddedLines { return importPlan{}, fieldErr("added_lines", "count", fmt.Sprintf("at most %d lines may be added", MaxAddedLines)) } for i, l := range in.Added { if err := validateManualLine(l, startsOn, endsOn, fmt.Sprintf("added_lines[%d]", i)); err != nil { return importPlan{}, err } } return importPlan{acct: acct, startsOn: startsOn, endsOn: endsOn, opening: *opening, closing: *closing, lines: lines, csvMapping: csvMapping, sha: sha, prev: prev}, nil}Import becomes: org := orgOf; acct, p, err := parseForImport(...); the existing importBeforeLock(), lockBankAccount, revision check, acct = locked; plan, err := checkImport(ctx, tx, in, acct, p); then the existing INSERT of the statement and lines using plan.* instead of the old locals; then, after the lines insert and before scanStatement, if err := insertManualLines(ctx, tx, stmtShell, in.By, now, in.Added) — insertManualLines needs a Statement with ID and BankAccountID, so build Statement{ID: id, BankAccountID: acct.ID}; then the existing scanStatement, autoMatch, audit (add "added_lines": len(in.Added) to the audit After map) and publish.
ImportInput gains Added []ManualLineInput.
// PreviewResult is what Import would store, without storing it (spec M4).type PreviewResult struct { Format string StartsOn, EndsOn time.Time Currency string Opening, Closing money.Amount Lines []ParsedLine PreviousEffectiveClosing *money.Amount}
// Preview runs every check Import runs and writes nothing: no row, no audit entry, no auto-match,// no lock. A file Import would refuse is refused here with the same error.func Preview(ctx context.Context, tx pgx.Tx, in ImportInput) (PreviewResult, error) { acct, p, err := parseForImport(ctx, tx, in) if err != nil { return PreviewResult{}, err } plan, err := checkImport(ctx, tx, in, acct, p) if err != nil { return PreviewResult{}, err } res := PreviewResult{Format: in.Format, StartsOn: plan.startsOn, EndsOn: plan.endsOn, Currency: acct.Currency, Opening: plan.opening, Closing: plan.closing, Lines: plan.lines} if plan.prev != nil { pc := plan.prev.EffectiveClosing res.PreviousEffectiveClosing = &pc } return res, nil}Preview also lets in.Added be validated (the web sends none, tests may).
- Step 4: Run to verify they pass
Run: go test ./internal/platform/banking/... -count=1 -p 1
Expected: PASS, including every pre-existing import test (the refactor is behaviour-preserving) and FuzzParseMT940/FuzzParseCSV seed corpora.
- Step 5: Watch the drift guard fail once
In checkImport, temporarily skip the duplicate-hash check; TestPreviewAndImportAgree still passes only if both agree, so instead temporarily skip it only in Preview by returning early before checkImport’s dup check using a debug flag; the test must go red on the “duplicate” case message. Restore.
- Step 6: Commit
git add internal/platform/bankinggit commit -m "Bank statements: import preview and added lines on import
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>"Task 5: API (openapi, handlers, routes, errors)
Section titled “Task 5: API (openapi, handlers, routes, errors)”Files:
- Modify:
api/openapi.yaml(schemasStatement,StatementLine,StatementImport; new schemas; two paths) - Modify:
internal/modules/banking/statement_handlers.go,routes.go,errors.go - Test:
internal/modules/banking/statement_handlers_test.go - Regenerate:
make generate
Interfaces:
-
Consumes: Tasks 3-4’s
AddLine,Preview,ImportInput.Added,ManualLineInput. -
Produces:
POST /banking/accounts/{id}/statements/preview(200StatementPreview);POST /banking/statements/{id}/lines(201StatementLineAdded);StatementImport.added_lines;Statement.effective_closing;StatementLine.manual/reason/added_by/added_at. -
Step 1: Edit
api/openapi.yaml
Statement: addeffective_closingtorequiredandproperties(effective_closing: { $ref: '#/components/schemas/Money' }).StatementLine: addmanual, reason, added_by, added_attorequired; propertiesmanual: { type: boolean },reason: { type: string, nullable: true },added_by: { type: string, format: uuid, nullable: true },added_at: { type: string, format: date-time, nullable: true }.- New schemas next to
StatementImport:
ManualLineInput: type: object additionalProperties: false required: [booked_on, amount, description, reason] properties: booked_on: { $ref: '#/components/schemas/Date' } amount: { $ref: '#/components/schemas/Money', description: Signed; a debit is negative. Never zero. } description: { type: string, minLength: 1, maxLength: 2000, description: English or Bahasa Malaysia. } reference: { type: string, maxLength: 140 } reason: { type: string, minLength: 1, maxLength: 500, description: Why the line is added. English or Bahasa Malaysia. } StatementPreviewLine: type: object required: [line_no, booked_on, value_on, amount, description, reference, bank_reference, transaction_code] properties: line_no: { type: integer } booked_on: { $ref: '#/components/schemas/Date' } value_on: { allOf: [{ $ref: '#/components/schemas/Date' }], nullable: true } amount: { $ref: '#/components/schemas/Money' } description: { type: string } reference: { type: string } bank_reference: { type: string } transaction_code: { type: string } StatementPreview: type: object required: [format, starts_on, ends_on, currency, opening_balance, closing_balance, previous_effective_closing, lines] properties: format: { type: string, enum: [mt940, csv] } starts_on: { $ref: '#/components/schemas/Date' } ends_on: { $ref: '#/components/schemas/Date' } currency: { type: string } opening_balance: { $ref: '#/components/schemas/Money' } closing_balance: { $ref: '#/components/schemas/Money' } previous_effective_closing: { allOf: [{ $ref: '#/components/schemas/Money' }], nullable: true } lines: { type: array, items: { $ref: '#/components/schemas/StatementPreviewLine' } } StatementLineAdded: type: object required: [line, statement] properties: line: { $ref: '#/components/schemas/StatementLine' } statement: { $ref: '#/components/schemas/Statement' }StatementImport.properties: addadded_lines: { type: array, maxItems: 200, items: { $ref: '#/components/schemas/ManualLineInput' } }.- Paths: after
/banking/accounts/{id}/statements, add/banking/accounts/{id}/statements/preview(operationId: previewBankStatement, POST, bodyStatementImport, responses200StatementPreview,400/401/403/404/409/422with the same$refs asimportBankStatement; description: “Parses and checks the file exactly as import does and stores nothing. Requires banking.statement.import.”). After/banking/statements/{id}/void, add/banking/statements/{id}/lines(operationId: addBankStatementLine, POST, bodyManualLineInput,201StatementLineAdded,400/401/403/404/409/422; description: “Only the account’s latest non-voided statement, after the latest locked reconciliation, and only inside its period. Requires banking.statement.import.”).
- Step 2: Regenerate and see the compile fail
Run: make generate then go build ./...
Expected: either clean (routes are registered by httpx.Protected, not by the generated interface) or a route-coverage test failure in Step 4 until the routes exist.
- Step 3: Write the failing handler tests
Append to internal/modules/banking/statement_handlers_test.go, following TestImportStatementOverHTTP for the env, token and e.do usage:
func TestPreviewStatementOverHTTP(t *testing.T) { e := newEnv(t) // as TestImportStatementOverHTTP does acct := /* the bank account and token set up exactly as TestImportStatementOverHTTP */ // PRESENCE: preview returns the parsed statement and stores nothing. rec := e.do(t, http.MethodPost, e.importPath(acct)+"/preview", tok, importBody(septemberFile())) got := decode[struct { ClosingBalance string `json:"closing_balance"` Lines []struct{ Amount string `json:"amount"` } `json:"lines"` }](t, rec, http.StatusOK) require.Equal(t, "787.5000", got.ClosingBalance) require.Len(t, got.Lines, 3) require.Equal(t, 0, e.bx.Count(t, `SELECT count(*) FROM bank_statement WHERE org_id = $1`, e.bx.Org))
// Import the same body; the preview of it is now the 409 duplicate_file import also gives. e.do(t, http.MethodPost, e.importPath(acct), tok, importBody(septemberFile())) rec = e.do(t, http.MethodPost, e.importPath(acct)+"/preview", tok, importBody(septemberFile())) require.Equal(t, "duplicate_file", decode[problemView](t, rec, http.StatusConflict).Code)}
func TestAddStatementLineOverHTTP(t *testing.T) { // set up and import as above, then: body := map[string]any{"booked_on": "2026-09-20", "amount": "-5.00", "description": "Bank fee the file omitted", "reason": "the bank omitted it"} rec := e.do(t, http.MethodPost, "/banking/statements/"+stmtID+"/lines", tok, body) got := decode[struct { Line struct{ Manual bool `json:"manual"` } `json:"line"` Statement struct { ClosingBalance, EffectiveClosing string } }](t, rec, http.StatusCreated) require.True(t, got.Line.Manual) // ABSENCE: a second statement makes the first frozen -> 409 not_latest; a missing reason -> 422. ...}Fill the elided set-up from TestImportStatementOverHTTP (lines 63-119): reuse its newEnv, grant (banking.statement.import), mint, account and importPath. Add assertions: effective_closing equals closing -5.00, a body with a bad date gives 422 containing rule=period, a user with only banking.read gets 403 on both new routes (presence: the same call by the importing user succeeded), and after importing a later statement the first returns 409 code == "not_latest".
- Step 4: Run to verify they fail
Run: go test ./internal/modules/banking/ -run 'TestPreviewStatementOverHTTP|TestAddStatementLineOverHTTP' -count=1
Expected: FAIL (404 route not found).
- Step 5: Implement the handlers
routes.go, next to the import and void routes:
httpx.Protected(g, pl, "POST", "/banking/accounts/:id/statements/preview", PermissionStatementImport, h.previewStatement) httpx.Protected(g, pl, "POST", "/banking/statements/:id/lines", PermissionStatementImport, h.addStatementLine)errors.go, next to ErrVoided:
case errors.Is(err, p.ErrAddLineNotAllowed): return conflict("not_latest", "a manual line goes only on the account's latest statement, after the latest locked reconciliation")statement_handlers.go:
statementDTOgainsEffectiveClosing money.Amount \json:“effective_closing”`set froms.EffectiveClosing`.statementLineDTOgainsManual bool \json:“manual”`,Reason *string `json:“reason”`,AddedBy *uuid.UUID `json:“added_by”`,AddedAt *time.Time `json:“added_at”`, set intoStatementLineDTO(AddedAt: utc(l.AddedAt)`).- Refactor the import body decode into a shared function used by both handlers (the preview and the import take the same body):
type importBody struct { Format string `json:"format"` FileName string `json:"file_name"` ContentBase64 string `json:"content_base64"` StartsOn *string `json:"starts_on"` EndsOn *string `json:"ends_on"` OpeningBalance *money.Amount `json:"opening_balance"` ClosingBalance *money.Amount `json:"closing_balance"` AddedLines []manualLineBody `json:"added_lines"`}type manualLineBody struct { BookedOn string `json:"booked_on"` Amount money.Amount `json:"amount"` Description string `json:"description"` Reference string `json:"reference"` Reason string `json:"reason"`}
func (b manualLineBody) input(field string) (p.ManualLineInput, error) { d, err := parseDate(field+".booked_on", b.BookedOn) if err != nil { return p.ManualLineInput{}, err } return p.ManualLineInput{BookedOn: d, Amount: b.Amount, Description: b.Description, Reference: b.Reference, Reason: b.Reason}, nil}
// readImportInput decodes and converts an import or preview request body.func readImportInput(c *gin.Context) (p.ImportInput, error) { c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxImportBody) id, err := parseID(c, "id") var body importBody if err := firstErr(err, decodeStrict(c, &body)); err != nil { return p.ImportInput{}, err } file, err := base64.StdEncoding.DecodeString(body.ContentBase64) if err != nil { return p.ImportInput{}, apperr.New(apperr.KindBadRequest, "content_base64 is not base64") } in := p.ImportInput{BankAccountID: id, Format: body.Format, FileName: body.FileName, File: file, Opening: body.OpeningBalance, Closing: body.ClosingBalance} for _, d := range []struct { field string src *string dst **time.Time }{{"starts_on", body.StartsOn, &in.StartsOn}, {"ends_on", body.EndsOn, &in.EndsOn}} { if d.src == nil { continue } t, err := parseDate(d.field, *d.src) if err != nil { return p.ImportInput{}, err } *d.dst = &t } for i, l := range body.AddedLines { li, err := l.input(fmt.Sprintf("added_lines[%d]", i)) if err != nil { return p.ImportInput{}, err } in.Added = append(in.Added, li) } who, err := mustActor(c.Request.Context()) if err != nil { return p.ImportInput{}, err } in.By = who return in, nil}importStatement becomes in, err := readImportInput(c) then the existing InTx + p.Import + response. Add:
func (h handlers) previewStatement(c *gin.Context) { in, err := readImportInput(c) if err != nil { _ = c.Error(err) return } var res p.PreviewResult if err := h.deps.DB.InTx(c.Request.Context(), func(ctx context.Context, tx pgx.Tx) error { var err error res, err = p.Preview(ctx, tx, in) return err }); err != nil { _ = c.Error(mapErr(p.MapError(err))) return } lines := make([]gin.H, 0, len(res.Lines)) for i, l := range res.Lines { var valueOn *string if l.ValueOn != nil { v := l.ValueOn.Format(dateLayout) valueOn = &v } lines = append(lines, gin.H{"line_no": i + 1, "booked_on": l.BookedOn.Format(dateLayout), "value_on": valueOn, "amount": l.Amount, "description": l.Description, "reference": l.Reference, "bank_reference": l.BankReference, "transaction_code": l.TransactionCode}) } c.JSON(http.StatusOK, gin.H{"format": res.Format, "starts_on": res.StartsOn.Format(dateLayout), "ends_on": res.EndsOn.Format(dateLayout), "currency": res.Currency, "opening_balance": res.Opening, "closing_balance": res.Closing, "previous_effective_closing": res.PreviousEffectiveClosing, "lines": lines})}
func (h handlers) addStatementLine(c *gin.Context) { id, err := parseID(c, "id") var body manualLineBody if err := firstErr(err, decodeStrict(c, &body)); err != nil { _ = c.Error(err) return } in, err := body.input("booked_on") if err != nil { _ = c.Error(err) return } who, err := mustActor(c.Request.Context()) if err != nil { _ = c.Error(err) return } var ( line p.StatementLine st p.Statement ) if err := h.deps.DB.InTx(c.Request.Context(), func(ctx context.Context, tx pgx.Tx) error { var err error line, st, err = p.AddLine(ctx, tx, h.deps.Clock, id, in, who) return err }); err != nil { _ = c.Error(mapErr(p.MapError(err))) return } c.JSON(http.StatusCreated, gin.H{"line": toStatementLineDTO(line, nil), "statement": toStatementDTO(st)})}parseDate(field, s) has the signature used in the existing handler (parseDate(d.field, *d.src)). Note: with decodeStrict, an unknown JSON field is rejected, which is what additionalProperties: false in the spec promises.
- Step 6: Run the handler tests, the route/drift tests and the whole module
Run: go test ./internal/modules/banking/... -count=1 -p 1 then go test ./internal/gen/... ./cmd/... -count=1 -p 1 (the drift tests: generated code matches openapi.yaml; every route is behind httpx.Authorize or in api/public_routes.txt; the OpenAPI/route parity test).
Expected: PASS. The two new routes are not public: do not add them to api/public_routes.txt.
- Step 7: Watch the permission guard fail once
Change a new route’s permission to PermissionRead, confirm the read-only-user 403 assertion goes red, restore.
- Step 8: Commit
git add api internal/gen internal/modules/banking web/src/lib/apigit commit -m "Banking API: statement preview and manual lines
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>"(Include the regenerated TypeScript types path that make generate writes; check git status and add whatever it changed.)
Task 6: Web
Section titled “Task 6: Web”Files:
- Modify:
web/src/features/banking/api.ts,ImportStatementDialog.tsx,StatementsTab.tsx,MatchingTab.tsx - Create:
web/src/features/banking/AddLineDialog.tsx - Modify:
web/src/lib/i18n/locales/{en,ms,zh}/banking.json - Test:
web/src/features/banking/__tests__/StatementsTab.test.tsx,MatchingTab.test.tsx
Interfaces:
-
Consumes: Task 5’s generated types (
Schemas['StatementPreview'],Schemas['ManualLineInput'],Schemas['StatementLineAdded']), routes/banking/accounts/{id}/statements/previewand/banking/statements/{id}/lines. -
Produces: hooks
usePreviewStatement(accountId),useAddStatementLine(statementId); componentAddLineDialog({ statement, currency, onClose }). -
Step 1: Strings first (all three locales)
Add under "statements" in en/banking.json:
"preview": "Preview", "previewTitle": "Check the statement before importing", "confirmImport": "Confirm import", "back": "Back", "statedClosing": "Stated closing balance", "effectiveClosing": "Closing balance with added lines", "previousClosing": "Previous statement closing", "addLine": "Add line", "addLineTitle": "Add a line the bank file omitted", "lineDate": "Date", "lineAmount": "Amount (negative for a debit)", "lineDescription": "Description", "lineReference": "Reference", "lineReason": "Why is it being added?", "addedLines": "Added lines", "removeLine": "Remove", "manual": "Manual", "addedBy": "Added by", "notLatest": "A line can only be added to the latest statement.", "locked": "This period is locked.", "importMemo": "Nothing is saved until you confirm."Add the same keys to ms and zh with real translations (Bahasa Malaysia / Simplified Chinese). Mark them unreviewed the way the repo does (see docs/review/ sheets): append the new keys to the ms and zh review sheets under docs/review/ using the format already there. Run the i18n key-parity test (pnpm -C web exec vitest run src/lib/i18n) to confirm en/ms/zh have identical keys.
- Step 2: Write the failing web tests
In StatementsTab.test.tsx (it already has mockApi, statement(), renderWithApp), add:
it('previews before importing and stores nothing until confirm', async () => { const previewCalls: { body: unknown }[] = []; const importCalls: { body: unknown }[] = []; mockApi({ importCalls }); server.use( http.post(apiPath('/banking/accounts/:id/statements/preview'), async ({ request }) => { previewCalls.push({ body: await request.json() }); return HttpResponse.json({ format: 'mt940', starts_on: '2026-10-01', ends_on: '2026-10-31', currency: 'MYR', opening_balance: '2000.0000', closing_balance: '2100.0000', previous_effective_closing: '2000.0000', lines: [{ line_no: 1, booked_on: '2026-10-02', value_on: null, amount: '100.0000', description: 'DUITNOW', reference: '', bank_reference: '', transaction_code: '' }], }); }), ); // render the Statements tab, choose a file, click "Preview" (open the dialog first, as the existing import test does) // PRESENCE: the preview call was made and the table shows the line and both balances expect(previewCalls).toHaveLength(1); expect(importCalls).toHaveLength(0); // nothing imported yet // click "Add line", fill date 2026-10-10, amount -5.00, description, reason; the effective closing reads 2,095.00 // click "Confirm import": importCalls[0].body.added_lines has 1 entry});
it('has no Remove on imported lines, only on added ones', async () => { /* preview with one imported line: queryByRole('button', {name: /remove/i}) is null (ABSENCE); after adding a line it is present (PRESENCE) */ });
it('adds a line to the latest statement only', async () => { // two statements in the mock: ends 2026-09-30 and 2026-10-31 (not voided): only the 10-31 row has an "Add line" button // PRESENCE first: the latest row's button exists; then ABSENCE: the older row's does not.});Write them out in full using the file’s existing style (userEvent, screen.getByRole, waitFor); the three comments above list each interaction and each assertion the test must make. Add one test to MatchingTab.test.tsx: a statement line with manual: true renders the Manual tag and a line with manual: false does not (presence first).
- Step 3: Run to verify they fail
Run: pnpm -C web exec vitest run src/features/banking
Expected: FAIL.
- Step 4: Implement
api.ts, next to useImportStatement:
export function usePreviewStatement(accountId: string) { return useMutation({ mutationFn: async (body: Schemas['StatementImport']) => unwrap( await api.POST('/banking/accounts/{id}/statements/preview', { params: { path: { id: accountId } }, body, }), ), });}
export function useAddStatementLine(statementId: string) { const after = useAfter(); return useMutation({ mutationFn: async (body: Schemas['ManualLineInput']) => unwrap(await api.POST('/banking/statements/{id}/lines', { params: { path: { id: statementId } }, body })), onSuccess: after, });}ImportStatementDialog.tsx: add state preview: Schemas['StatementPreview'] | null and added: Schemas['ManualLineInput'][]. submit() builds the same body but calls previewMutation.mutateAsync(body) and stores the result (step 2); the primary button label is t('statements.preview'). In step 2 render: the header (period, opening, statedClosing, and effectiveClosing = closing + Σ added computed with the repo’s money helpers, never floats — use whatever web/src/lib/money exposes for decimal-string addition, grep export function there; if there is no add helper use decimal.js/big.js only if already a dependency in web/package.json), the previous-closing line when non-null, a DataTable of preview.lines, an “Added lines” list with a Remove button per added row, an inline form (DatePicker, MoneyInput, Input description, Input reference, Textarea reason) with an Add line button that appends to added, and the footer buttons Back (clears preview, keeps the file) and Confirm import (calls doImport.mutateAsync({ ...body, added_lines: added }), then the existing toast and onDone). Keep the existing duplicate_file handling for both calls. Imported lines have no Remove control.
AddLineDialog.tsx: a Dialog with the same inline fields, calling useAddStatementLine(statement.id).mutateAsync, mapping ProblemError codes not_latest → t('statements.notLatest') and reconciliation_locked → t('statements.locked'), everything else via problemMessage(e, tc), as ImportStatementDialog does.
StatementsTab.tsx: compute latestId = the id of the non-voided statement with the greatest ends_on in list.data; in the actions cell add, when canImport && !row.original.voided_at && row.original.id === latestId, a Button size="sm" variant="outline" labelled t('statements.addLine') that sets adding state; render <AddLineDialog> when set. Show effective_closing in the closing column when it differs from closing_balance (both values, stated first).
MatchingTab.tsx lines 219-221 (the statement-line description cell): render <>{row.original.description}{row.original.manual && <Tag tone="blue">{t('statements.manual')}</Tag>}</> (use an existing TagTone; check Tag.tsx for the valid names).
- Step 5: Run the tests
Run: pnpm -C web exec vitest run src/features/banking src/lib/i18n then pnpm -C web run typecheck and pnpm -C web run lint
Expected: PASS.
- Step 6: Watch it fail once
Make the preview step call the import mutation instead of preview; the “stores nothing until confirm” test must go red (importCalls length 1). Restore.
- Step 7: Run in the browser
Use preview_start (the repo’s .claude/launch.json dev server, plus the API stack per agent-stack-isolation rules: unique ERP_STACK / ERP_PG_PORT), import a real MT940 from internal/platform/banking/testdata, and look at the preview, the added line and the confirm. Report what was seen. If the stack cannot be run, say that this step was not done; do not claim it was.
- Step 8: Commit
git add web docs/reviewgit commit -m "Bank statements: import preview dialog and add-line dialog
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>"Task 7: Property test, roadmap, full verification
Section titled “Task 7: Property test, roadmap, full verification”Files:
-
Modify:
internal/platform/banking/reconciliation_property_test.go,reconciliation_model_test.go(extend) -
Modify:
docs/ROADMAP.md, the plan (execution record) -
Step 1: Extend the D11 property test
In reconciliation_property_test.go, add one operation to the generated sequence: add a manual line to the latest statement (via banking.AddLine) with a random amount inside its period. The existing property is “difference = first opening - opening-cleared book lines, independent of matching”; it must keep holding with manual lines present, and the model test’s U_s/S expectations must count manual lines (they are lines of the statement). Read both files first and follow the generator’s existing operation shape.
- Step 2: Run, then watch it fail once
Run: go test ./internal/platform/banking/ -run 'Property|Model' -count=1
Expected: PASS. Then temporarily revert StatementBalance: stmt.EffectiveClosing to stmt.Closing in reconciliation.go: the property test must go red with a non-zero difference equal to a manual line’s amount. Restore.
- Step 3: Full verification, once
Run: go build ./... && go vet ./..., then go test ./... -count=1 -p 1 (serial; slow), then pnpm -C web run typecheck && pnpm -C web run lint && pnpm -C web exec vitest run, then git status (clean apart from intended files). Report any failure with its output; do not claim a pass that was not run.
- Step 4: Roadmap and execution record
Tick the roadmap item “Bank statements: import preview and manual lines” and replace “plan not yet written” with the plan link and the migration number actually used. Add an execution record at the end of this plan: deviations, and which guards were watched failing (Tasks 1, 2, 3, 4, 5, 6, 7 each name one).
- Step 5: Commit
git add docs internalgit commit -m "Bank statements: manual lines and import preview, roadmap ticked
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>"Self-review
Section titled “Self-review”Spec coverage: M1 effective closing (Tasks 1, 2); M2 storage, guard, balance check, append-only (Task 1); M3 add line (Task 3, API Task 5); M4 preview (Task 4, API Task 5); M5 added lines on confirm (Tasks 4, 5); M6 API (Task 5); M7 web (Task 6); M8 tests: property (Task 7), frozen earlier statement (Tasks 1, 3), locked period (Task 3, SQL in Task 1), chain Go and SQL (Tasks 1, 2), balanced check (Task 1 presence, existing import tests), append-only (revoked grants unchanged; the existing schema test covers UPDATE/DELETE, see Task 1 Step 6), preview writes nothing (Task 4), preview/import drift (Task 4), route/permission drift (Task 5), web (Task 6); M9 out of scope (nothing built). Corrections to the spec that the code forced (reconciliation guard, no event, duplicate and chain surfaced as existing errors, no statement-detail screen) are Task 1 Step 1.
Placeholders: the migration Up is complete; its Down restore is a mechanical sed copy from named line ranges (Task 1 Step 4). Task 5 Step 3 and Task 6 Step 2 name the existing tests whose set-up to reuse (TestImportStatementOverHTTP, the StatementsTab import test) and list every interaction and assertion each new test must make; the executor writes them in that file’s style.
Type consistency: ManualLineInput (Go) fields BookedOn, Amount, Description, Reference, Reason match the wire body manualLineBody; AddLine returns (StatementLine, Statement, error) in Tasks 3 and 5; Preview/PreviewResult fields in Task 4 match the handler in Task 5; EffectiveClosing is the one name for the balance in Go, SQL (bank_statement_effective_closing), JSON (effective_closing) and TS.
Execution record
Section titled “Execution record”Migration 01110. Deviations from the plan, and the guard each task watched fail.
Task 1 (migration). Deviations: the follow-on statement in the chain test is inserted by raw SQL, not bankingtest.Import, because the Go chain check still demanded the stated closing until Task 2 (it stays raw deliberately, to hit the DB guard directly); the raw INSERT adds id gen_random_uuid(); the accepted chain case is rolled back via a sentinel error; the Down test plants its manual row with session_replication_role = replica; manual_complete’s second arm is unreachable through the app role (the BEFORE INSERT guard fires first), so only the first arm is covered. Watched failing: the line guard (booked_on > v.ends_on made false), the SQL chain (v_prev.closing_balance), the Down refusal (IF false).
Task 2 (reconciliation and chain read the effective closing). Deviation: a reconciliation-guard end-to-end test (TestReconciliationAndLockUseEffectiveClosing) was added by ruling. Watched failing: reconciliation.go reverted to stmt.Closing.
Task 3 (AddLine). No deviation of substance. Watched failing: the Go lock check removed (the SQL guard then surfaced a different error and the test went red); the Go latest-statement check removed (the test still passed, proving the SQL backstop is independent).
Task 4 (Preview, added lines on import). Deviations: checkImport returns (importPlan, *Statement, error) rather than carrying prev in the plan; acct is not stored in the plan; the preview/import agreement cases are an ordered slice so “ok” (which imports) runs last. Watched failing: Preview made to ignore DuplicateFileError.
Task 5 (API). Deviation: routes_test.go’s want-map needed the two new routes (the brief did not list that file). The new tests did not go through a 404-red phase because routes and handlers were added in one step. Watched failing: routes registered with PermissionRead (both new tests went red), and the route/permission drift test listing the missing routes.
Task 6 (web). Deviations: the amount field is a plain Input with a signed-decimal regex because MoneyInput rejects -; the browser check (Step 7) was skipped. Watched failing: the preview step also calling the import mutation; the latest-statement condition removed from the Add line button.
Task 7 (property test, polish). Brief Step 3 (the full-suite run) was left to the controller. The property test gained an add-manual-line operation, and the model test adds two manual lines to the latest statement and re-adds them after its void and re-import step (a void takes the manual lines with it). Polish from the reviews: preview/import agreement gained not-after-previous and CSV cases; the preview-writes-nothing test now makes the bank_match count meaningful (the import auto-matches), and webhook_event is not counted because no recorder runs in that harness (observed 0 before and after, so it would be vacuous); added-line numbering, manual and the audit added_lines are asserted; HTTP tests cover added_lines on import and preview, rule=period, previous_effective_closing, and 404 on an unknown statement; web: date bounds, Back and a new file drop the added lines, the chosen file name stays visible, the unused currency prop and the unused statements.addedBy key were removed (statements.selectedFile took its place), and the shared line helpers moved to ManualLineFields.tsx. Watched failing: StatementBalance: stmt.Closing in reconciliation.go turned both the property test (step 1, difference 488.63 against 400) and the model test (step 0) red; removing the setAdded([]) on Back turned the new Back test red.