# Phase 2 exit evidence: the accounting engine

Brief section 13, Phase 2 exit criteria: **unbalanced entries impossible; closed periods reject postings;
the ledger is append-only.** Brief section 14 adds the accounting invariants listed below. Each line says
how the rule is enforced and which automated test proves it. All tests run on SQLite (every commit) and
on MariaDB (CI job `tests-mariadb`, and locally on 18 Oct 2026).

## Exit criteria

| Criterion | Enforced by | Proved by |
|---|---|---|
| Unbalanced entries impossible | Posting service (`LedgerService::validateLines`: total and per fund) **and** database trigger `journals_post_checks` | `LedgerIntegrityTest::test_unbalanced_journal_is_rejected`, `…each_fund_must_balance_within_a_journal`; `LedgerInvariantsTest::test_database_refuses_to_post_an_unbalanced_or_incomplete_journal` |
| Closed periods reject postings | `PeriodService::assertOpen` on draft and post; database trigger (`the period is closed`) | `LedgerIntegrityTest::test_closed_period_blocks_posting_including_backdated_entries`; `LedgerInvariantsTest::test_database_refuses_postings_into_a_closed_month_and_orphan_reversals` |
| Ledger is append-only | `Journal` / `JournalLine` model guards; database triggers `journals_posted_guard`, `journals_posted_no_delete`, `journal_lines_posted_no_update`, `journal_lines_posted_no_delete`, `journal_lines_no_insert_when_posted` | `LedgerIntegrityTest::test_posted_journals_and_lines_are_immutable`; `LedgerInvariantsTest::test_posted_journals_and_lines_cannot_be_changed_even_directly` |

## Section 14 accounting invariants

| Invariant | Proved by |
|---|---|
| Every journal balances | The three tests above, and `WholeLedgerProofTest` (every journal from every module: giving, online giving, expenses, bills, payroll, remittances, assets, projects, business units, welfare, transfers, opening balances) |
| Posted journals are immutable | Append-only row above |
| Reversals reference originals | `LedgerIntegrityTest::test_reversal_nets_to_zero_and_cannot_repeat`; database trigger refuses an orphan reversal; `WholeLedgerProofTest` checks every reversal is linked both ways |
| Closed periods reject entries | Closed-period row above; reopening only by HQ Finance with a reason: `LedgerInvariantsTest::test_only_headquarters_finance_reopens_and_the_reason_stays_with_the_period` |
| Trial balance always equals zero difference | `LedgerIntegrityTest::test_trial_balance_always_balances_and_audit_trail_is_written`; `WholeLedgerProofTest`; nightly `ledger:verify` (`test_ledger_verify_passes_clean_books_and_reports_tampering`) |
| Gap-free numbering, per branch and type | `LedgerIntegrityTest::test_journal_numbers_are_sequential_and_gap_free`; `LedgerInvariantsTest::test_each_branch_numbers_its_own_documents_without_gaps`; `WholeLedgerProofTest` (every series 1..n) |

## Chart of accounts and dimensions (brief 7.1, section 5)

| Rule | Proved by |
|---|---|
| Hierarchical 5-digit chart; children share their parent's type; only accounts post | `ChartOfAccountsTest::test_new_chart_is_a_consistent_five_digit_hierarchy` |
| Branches add sub-accounts only under accounts HQ opened; see only their own; post only in their branch; roll up across branches | `ChartOfAccountsTest::test_branches_add_sub_accounts_only_under_open_accounts_and_see_only_their_own`, `…sub_accounts_post_only_in_their_branch_and_roll_up_across_branches` |
| Locked accounts keep their place; system accounts stay on | `ChartOfAccountsTest::test_locked_and_open_accounts_keep_their_rules` |
| Older charts renumbered without touching postings | `ChartOfAccountsTest::test_an_old_four_digit_chart_is_renumbered_without_touching_postings` (and the four local churches, 81 to 107 accounts each) |
| Every line can carry fund, cost centre / outlet, ministry | `ChartOfAccountsTest::test_cost_centres_and_ministries_follow_branches_and_filter_reports` |

## Traceability and evidence (section 5)

| Rule | Proved by |
|---|---|
| Every posting records user, time, branch, device, IP and source module | `LedgerInvariantsTest::test_journals_record_where_they_came_from` (plus the hash-chained audit log, `RolesAndAuditTest`) |
| Attachments on any entry, type and size checked, kept once posted | `LedgerInvariantsTest::test_supporting_documents_are_private_and_stay_once_posted` |
| Branch isolation of every new table | `BranchIsolationTest` (accounts, cost centres, attachments, journal lines…) |

## Checked by hand on the local MariaDB (18 Oct 2026)

Inside a transaction that was rolled back afterwards, against the demo church's real data: editing a
posted journal's memo, deleting a posted journal, changing, deleting or adding a line on a posted journal,
posting an unbalanced journal, and a two-sided line were all refused by the database. `ledger:verify` on
the four local churches: all checks passed (62 posted journals in the demo church).

## Full suite on MariaDB (18 Oct 2026)

All 181 tests pass on MariaDB 10.4 (XAMPP), as well as on SQLite. Running them there found and fixed a
real fault: MariaDB silently rewrote the first timestamp of a table on every update, which made a
member-portal code expire after one wrong attempt (D-038, migration `2026_10_22_000003`). The 2A and 2B
migrations were rolled back and applied again on MariaDB. CI repeats all of this on MariaDB 10.11 on
every pull request, then seeds the demo church and runs `ledger:verify` and `audit:verify` on it.

## Deliberately not in Phase 2

Approval step for every manual journal beyond maker-checker, recurring journals and the reversal wizard
(Phase 3, General Journal); cash book and imprest, voucher workflow and bank reconciliation upgrades
(Phase 3); year-end wizard upgrades (Phase 4); chart template versions and rollout (Phase 5); offline
provisional numbers (Phase 6).
