From 6e2629597bf8f3d211ddc01b799024655392b2c4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 23:42:32 +0900 Subject: [PATCH 001/224] test: prove hard-close trial balance evidence is immutable --- ...trial_balance_snapshot_immutability_red.py | 159 ++++++++++++++++++ 1 file changed, 159 insertions(+) create mode 100644 tests/test_postgres_trial_balance_snapshot_immutability_red.py diff --git a/tests/test_postgres_trial_balance_snapshot_immutability_red.py b/tests/test_postgres_trial_balance_snapshot_immutability_red.py new file mode 100644 index 00000000..3910ac61 --- /dev/null +++ b/tests/test_postgres_trial_balance_snapshot_immutability_red.py @@ -0,0 +1,159 @@ +"""Real PostgreSQL RED for immutable hard-close trial-balance evidence.""" + +from __future__ import annotations + +import unittest +import uuid + +import psycopg + +from tests import test_postgres_posting as posting + + +class TrialBalanceSnapshotImmutabilityPostgresTests(unittest.TestCase): + """Keep a hard-close snapshot and its line population immutable after commit.""" + + @classmethod + def setUpClass(cls) -> None: + """Install the complete current accounting migration chain.""" + posting.PostgresPostingTests.setUpClass() + + def setUp(self) -> None: + """Create one posted population and close it through the production command.""" + self.case = posting.PostgresPostingTests("setUp") + self.case.setUp() + self.addCleanup(self.case.doCleanups) + self.addCleanup(self.case.tearDown) + + self.case.ledger.post(self.case._two_line_proposal(), self.case.policy) + self.case.ledger.close_fiscal_period( + self.case.policy.legal_entity_reference, + self.case.policy.accounting_book_reference, + "2026-08", + "KRW", + period_status_code="soft_closed", + idempotency_key=f"{self.case.policy.tenant_reference}:snapshot-freeze:soft", + ) + hard_close = self.case.ledger.close_fiscal_period( + self.case.policy.legal_entity_reference, + self.case.policy.accounting_book_reference, + "2026-08", + "KRW", + period_status_code="hard_closed", + idempotency_key=f"{self.case.policy.tenant_reference}:snapshot-freeze:hard", + ) + self.assertEqual(hard_close.period_status_code, "hard_closed") + + with psycopg.connect(posting.DATABASE_URL) as connection: + snapshot_row = connection.execute( + """ + SELECT trial_balance_snapshot_id, accounting_book_id + FROM accounting_reporting.trial_balance_snapshot + WHERE tenant_account_id = %s + ORDER BY snapshot_generated_at DESC, trial_balance_snapshot_id DESC + LIMIT 1 + """, + (self.case.tenant_id,), + ).fetchone() + self.assertIsNotNone(snapshot_row) + assert snapshot_row is not None + self.snapshot_id = snapshot_row[0] + self.accounting_book_id = snapshot_row[1] + line_row = connection.execute( + """ + SELECT trial_balance_line_id + FROM accounting_reporting.trial_balance_line + WHERE tenant_account_id = %s + AND trial_balance_snapshot_id = %s + ORDER BY trial_balance_line_id + LIMIT 1 + """, + (self.case.tenant_id, self.snapshot_id), + ).fetchone() + self.assertIsNotNone(line_row) + assert line_row is not None + self.line_id = line_row[0] + + def test_hard_close_snapshot_header_cannot_be_rewritten(self) -> None: + """A committed close snapshot cannot later point at different source evidence.""" + with psycopg.connect(posting.DATABASE_URL) as connection: + with self.assertRaisesRegex( + psycopg.errors.CheckViolation, + "trial_balance_snapshot_immutable", + ): + connection.execute( + """ + UPDATE accounting_reporting.trial_balance_snapshot + SET source_payload_hash = %s + WHERE tenant_account_id = %s + AND trial_balance_snapshot_id = %s + """, + ("sha256:" + "9" * 64, self.case.tenant_id, self.snapshot_id), + ) + connection.rollback() + + def test_hard_close_snapshot_line_cannot_be_rewritten(self) -> None: + """A retained account balance cannot be changed after hard close.""" + with psycopg.connect(posting.DATABASE_URL) as connection: + with self.assertRaisesRegex( + psycopg.errors.CheckViolation, + "trial_balance_snapshot_immutable", + ): + connection.execute( + """ + UPDATE accounting_reporting.trial_balance_line + SET net_balance_amount = net_balance_amount + 1 + WHERE tenant_account_id = %s + AND trial_balance_line_id = %s + """, + (self.case.tenant_id, self.line_id), + ) + connection.rollback() + + def test_hard_close_snapshot_population_cannot_be_extended(self) -> None: + """A new valid chart account cannot be appended to an already closed snapshot.""" + with psycopg.connect(posting.DATABASE_URL) as connection: + chart_account_id = connection.execute( + """ + INSERT INTO accounting_core.chart_account ( + tenant_account_id, + accounting_book_id, + chart_account_code, + account_name, + normal_balance_code, + valid_from, + account_class_code + ) + VALUES (%s, %s, %s, 'Post-close mutation probe', 'debit', %s, 'asset') + RETURNING chart_account_id + """, + ( + self.case.tenant_id, + self.accounting_book_id, + f"99{uuid.uuid4().hex[:10]}", + posting.VALID_FROM, + ), + ).fetchone()[0] + with self.assertRaisesRegex( + psycopg.errors.CheckViolation, + "trial_balance_snapshot_immutable", + ): + connection.execute( + """ + INSERT INTO accounting_reporting.trial_balance_line ( + tenant_account_id, + trial_balance_snapshot_id, + chart_account_id, + debit_total_amount, + credit_total_amount, + net_balance_amount + ) + VALUES (%s, %s, %s, 0, 0, 0) + """, + (self.case.tenant_id, self.snapshot_id, chart_account_id), + ) + connection.rollback() + + +if __name__ == "__main__": + unittest.main() From 7f295a06d44edf7381b0d5baca9d54d217778b0c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 00:08:11 +0900 Subject: [PATCH 002/224] fix: enforce hard-close trial balance immutability --- ...29_trial_balance_snapshot_immutability.sql | 86 +++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 database/migrations/0029_trial_balance_snapshot_immutability.sql diff --git a/database/migrations/0029_trial_balance_snapshot_immutability.sql b/database/migrations/0029_trial_balance_snapshot_immutability.sql new file mode 100644 index 00000000..f11f25ac --- /dev/null +++ b/database/migrations/0029_trial_balance_snapshot_immutability.sql @@ -0,0 +1,86 @@ +BEGIN; + +CREATE OR REPLACE FUNCTION accounting_reporting.reject_trial_balance_snapshot_mutation() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +BEGIN + RAISE EXCEPTION + 'hard-close trial balance evidence is immutable (trial_balance_snapshot_immutable)' + USING ERRCODE = 'check_violation'; +END; +$$; + +CREATE TRIGGER trial_balance_snapshot_immutable_guard + BEFORE UPDATE OR DELETE + ON accounting_reporting.trial_balance_snapshot + FOR EACH ROW + EXECUTE FUNCTION accounting_reporting.reject_trial_balance_snapshot_mutation(); + +CREATE OR REPLACE FUNCTION accounting_reporting.reject_trial_balance_line_mutation() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +BEGIN + RAISE EXCEPTION + 'hard-close trial balance evidence is immutable (trial_balance_snapshot_immutable)' + USING ERRCODE = 'check_violation'; +END; +$$; + +CREATE TRIGGER trial_balance_line_immutable_guard + BEFORE UPDATE OR DELETE + ON accounting_reporting.trial_balance_line + FOR EACH ROW + EXECUTE FUNCTION accounting_reporting.reject_trial_balance_line_mutation(); + +CREATE OR REPLACE FUNCTION accounting_reporting.guard_trial_balance_line_insert() +RETURNS trigger +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = pg_catalog, accounting_reporting, accounting_core +AS $$ +DECLARE + period_status_value text; +BEGIN + SELECT accounting_book_period_control.period_status_code + INTO period_status_value + FROM accounting_reporting.trial_balance_snapshot + JOIN accounting_core.accounting_book_period_control + ON accounting_book_period_control.tenant_account_id + = trial_balance_snapshot.tenant_account_id + AND accounting_book_period_control.accounting_book_id + = trial_balance_snapshot.accounting_book_id + AND accounting_book_period_control.fiscal_period_id + = trial_balance_snapshot.fiscal_period_id + WHERE trial_balance_snapshot.tenant_account_id = NEW.tenant_account_id + AND trial_balance_snapshot.trial_balance_snapshot_id + = NEW.trial_balance_snapshot_id + FOR UPDATE OF accounting_book_period_control; + + IF period_status_value IS NULL THEN + RAISE EXCEPTION + 'trial balance snapshot has no matching book-period authority (trial_balance_snapshot_scope_missing)' + USING ERRCODE = 'check_violation'; + END IF; + + IF period_status_value = 'hard_closed' THEN + RAISE EXCEPTION + 'hard-close trial balance evidence is immutable (trial_balance_snapshot_immutable)' + USING ERRCODE = 'check_violation'; + END IF; + + RETURN NEW; +END; +$$; + +REVOKE ALL ON FUNCTION accounting_reporting.guard_trial_balance_line_insert() + FROM PUBLIC; + +CREATE TRIGGER trial_balance_line_population_guard + BEFORE INSERT + ON accounting_reporting.trial_balance_line + FOR EACH ROW + EXECUTE FUNCTION accounting_reporting.guard_trial_balance_line_insert(); + +COMMIT; From 2813d2e510deec33429e9e1dffc9f889da815c4b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 00:08:31 +0900 Subject: [PATCH 003/224] fix: install trial balance immutability migration --- src/accounting_information_platform/migration_install.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/accounting_information_platform/migration_install.py b/src/accounting_information_platform/migration_install.py index fa07901a..958cf1f4 100644 --- a/src/accounting_information_platform/migration_install.py +++ b/src/accounting_information_platform/migration_install.py @@ -25,11 +25,12 @@ def apply_foundation_migration(database_url: str, migration_path: Path) -> None: migration_path.parent / "0026_reconciliation_lifecycle_source_payload_identity.sql", migration_path.parent / "0027_reconciliation_lifecycle_session_lock_authority.sql", migration_path.parent / "0028_reconciliation_lifecycle_capability_privileges.sql", + migration_path.parent / "0029_trial_balance_snapshot_immutability.sql", ) for forward_migration_path in forward_migration_paths: if not forward_migration_path.is_file(): raise AccountingValidationError( - "Required reconciliation authority migration is missing at " + "Required accounting authority migration is missing at " f"{forward_migration_path}. Restore the checked-in migration chain, then retry." ) @@ -43,7 +44,7 @@ def apply_foundation_migration(database_url: str, migration_path: Path) -> None: connection.execute(forward_migration_path.read_text(encoding="utf-8")) except Exception as error: raise AccountingValidationError( - "Reconciliation authority migration failed. Inspect the PostgreSQL error, restore " + "Accounting authority migration failed. Inspect the PostgreSQL error, restore " "a clean database, then retry the complete foundation migration." ) from error @@ -51,7 +52,7 @@ def apply_foundation_migration(database_url: str, migration_path: Path) -> None: # A large integration-test and operator surface historically imports the loader # from persistence directly. Keep that compatibility path on the complete-chain # installer so no supported install can stop before the current database-owned -# reconciliation authority and exception-resolution boundaries. +# reconciliation and period-close authority boundaries. _persistence.apply_foundation_migration = apply_foundation_migration From c47db00948232c53cf47b99c94963a2bef4c7504 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 00:08:57 +0900 Subject: [PATCH 004/224] refactor: keep installer diagnostics stable --- src/accounting_information_platform/migration_install.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/accounting_information_platform/migration_install.py b/src/accounting_information_platform/migration_install.py index 958cf1f4..3a99b8f3 100644 --- a/src/accounting_information_platform/migration_install.py +++ b/src/accounting_information_platform/migration_install.py @@ -30,7 +30,7 @@ def apply_foundation_migration(database_url: str, migration_path: Path) -> None: for forward_migration_path in forward_migration_paths: if not forward_migration_path.is_file(): raise AccountingValidationError( - "Required accounting authority migration is missing at " + "Required reconciliation authority migration is missing at " f"{forward_migration_path}. Restore the checked-in migration chain, then retry." ) @@ -44,7 +44,7 @@ def apply_foundation_migration(database_url: str, migration_path: Path) -> None: connection.execute(forward_migration_path.read_text(encoding="utf-8")) except Exception as error: raise AccountingValidationError( - "Accounting authority migration failed. Inspect the PostgreSQL error, restore " + "Reconciliation authority migration failed. Inspect the PostgreSQL error, restore " "a clean database, then retry the complete foundation migration." ) from error @@ -52,7 +52,7 @@ def apply_foundation_migration(database_url: str, migration_path: Path) -> None: # A large integration-test and operator surface historically imports the loader # from persistence directly. Keep that compatibility path on the complete-chain # installer so no supported install can stop before the current database-owned -# reconciliation and period-close authority boundaries. +# reconciliation authority and exception-resolution boundaries. _persistence.apply_foundation_migration = apply_foundation_migration From d6097a6cc6ab783288e43035e3a11c5ac1397ead Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 00:09:58 +0900 Subject: [PATCH 005/224] test: cover hard-close snapshot deletions --- ...trial_balance_snapshot_immutability_red.py | 36 ++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/tests/test_postgres_trial_balance_snapshot_immutability_red.py b/tests/test_postgres_trial_balance_snapshot_immutability_red.py index 3910ac61..4c23a575 100644 --- a/tests/test_postgres_trial_balance_snapshot_immutability_red.py +++ b/tests/test_postgres_trial_balance_snapshot_immutability_red.py @@ -1,4 +1,4 @@ -"""Real PostgreSQL RED for immutable hard-close trial-balance evidence.""" +"""Real PostgreSQL RED/GREEN for immutable hard-close trial-balance evidence.""" from __future__ import annotations @@ -92,6 +92,23 @@ def test_hard_close_snapshot_header_cannot_be_rewritten(self) -> None: ) connection.rollback() + def test_hard_close_snapshot_header_cannot_be_deleted(self) -> None: + """A committed close snapshot cannot be removed after it becomes evidence.""" + with psycopg.connect(posting.DATABASE_URL) as connection: + with self.assertRaisesRegex( + psycopg.errors.CheckViolation, + "trial_balance_snapshot_immutable", + ): + connection.execute( + """ + DELETE FROM accounting_reporting.trial_balance_snapshot + WHERE tenant_account_id = %s + AND trial_balance_snapshot_id = %s + """, + (self.case.tenant_id, self.snapshot_id), + ) + connection.rollback() + def test_hard_close_snapshot_line_cannot_be_rewritten(self) -> None: """A retained account balance cannot be changed after hard close.""" with psycopg.connect(posting.DATABASE_URL) as connection: @@ -110,6 +127,23 @@ def test_hard_close_snapshot_line_cannot_be_rewritten(self) -> None: ) connection.rollback() + def test_hard_close_snapshot_line_cannot_be_deleted(self) -> None: + """A retained account balance cannot be removed after hard close.""" + with psycopg.connect(posting.DATABASE_URL) as connection: + with self.assertRaisesRegex( + psycopg.errors.CheckViolation, + "trial_balance_snapshot_immutable", + ): + connection.execute( + """ + DELETE FROM accounting_reporting.trial_balance_line + WHERE tenant_account_id = %s + AND trial_balance_line_id = %s + """, + (self.case.tenant_id, self.line_id), + ) + connection.rollback() + def test_hard_close_snapshot_population_cannot_be_extended(self) -> None: """A new valid chart account cannot be appended to an already closed snapshot.""" with psycopg.connect(posting.DATABASE_URL) as connection: From 3abb16be871c0b4fcbb48115539ef2e33eba5609 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 00:10:27 +0900 Subject: [PATCH 006/224] security: harden trial balance trigger search path --- .../migrations/0029_trial_balance_snapshot_immutability.sql | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/database/migrations/0029_trial_balance_snapshot_immutability.sql b/database/migrations/0029_trial_balance_snapshot_immutability.sql index f11f25ac..3e7da04d 100644 --- a/database/migrations/0029_trial_balance_snapshot_immutability.sql +++ b/database/migrations/0029_trial_balance_snapshot_immutability.sql @@ -38,7 +38,7 @@ CREATE OR REPLACE FUNCTION accounting_reporting.guard_trial_balance_line_insert( RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER -SET search_path = pg_catalog, accounting_reporting, accounting_core +SET search_path = pg_catalog, accounting_reporting, accounting_core, pg_temp AS $$ DECLARE period_status_value text; From 3ac9f8c6d3e02cf87a9e448423646e978e61f6a7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 00:10:39 +0900 Subject: [PATCH 007/224] test: pin trial balance immutability install contract --- ..._balance_snapshot_immutability_contract.py | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 tests/test_trial_balance_snapshot_immutability_contract.py diff --git a/tests/test_trial_balance_snapshot_immutability_contract.py b/tests/test_trial_balance_snapshot_immutability_contract.py new file mode 100644 index 00000000..76b5955b --- /dev/null +++ b/tests/test_trial_balance_snapshot_immutability_contract.py @@ -0,0 +1,54 @@ +"""Static contracts for the hard-close trial-balance immutability migration.""" + +from __future__ import annotations + +import unittest +from pathlib import Path +from unittest.mock import patch + +from accounting_information_platform import AccountingValidationError, apply_foundation_migration + + +ROOT = Path(__file__).resolve().parents[1] +MIGRATION = ROOT / "database/migrations/0029_trial_balance_snapshot_immutability.sql" + + +class TrialBalanceSnapshotImmutabilityContractTests(unittest.TestCase): + """Keep the migration installed and its database-owned serialization boundary reviewable.""" + + def test_canonical_installer_fails_closed_when_immutability_migration_is_missing(self) -> None: + """A supported install may not stop before immutable hard-close snapshot evidence.""" + original_is_file = Path.is_file + + def is_file(path: Path) -> bool: + if path.name == MIGRATION.name: + return False + return original_is_file(path) + + with patch.object(Path, "is_file", is_file): + with self.assertRaises(AccountingValidationError): + apply_foundation_migration( + "postgresql://unused", + ROOT / "database/migrations/0001_accounting_foundation.sql", + ) + + def test_population_guard_serializes_on_book_period_authority(self) -> None: + """Line admission must lock the same book-period row whose hard-close state it checks.""" + migration = MIGRATION.read_text(encoding="utf-8") + self.assertIn("trial_balance_line_population_guard", migration) + self.assertIn("FOR UPDATE OF accounting_book_period_control", migration) + self.assertIn("period_status_value = 'hard_closed'", migration) + self.assertIn("trial_balance_snapshot_immutable", migration) + self.assertIn("SECURITY DEFINER", migration) + self.assertIn("pg_temp", migration) + + def test_header_and_line_mutations_are_rejected_at_the_table_boundary(self) -> None: + """Both retained snapshot levels must reject UPDATE and DELETE before constraints drift.""" + migration = MIGRATION.read_text(encoding="utf-8") + self.assertIn("trial_balance_snapshot_immutable_guard", migration) + self.assertIn("trial_balance_line_immutable_guard", migration) + self.assertGreaterEqual(migration.count("BEFORE UPDATE OR DELETE"), 2) + + +if __name__ == "__main__": + unittest.main() From 12cf040e1ec9dfa5ec781492bdde8d364b94de49 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 00:14:06 +0900 Subject: [PATCH 008/224] security: restrict trial balance trigger search path --- .../migrations/0029_trial_balance_snapshot_immutability.sql | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/database/migrations/0029_trial_balance_snapshot_immutability.sql b/database/migrations/0029_trial_balance_snapshot_immutability.sql index 3e7da04d..71c2f26e 100644 --- a/database/migrations/0029_trial_balance_snapshot_immutability.sql +++ b/database/migrations/0029_trial_balance_snapshot_immutability.sql @@ -38,7 +38,7 @@ CREATE OR REPLACE FUNCTION accounting_reporting.guard_trial_balance_line_insert( RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER -SET search_path = pg_catalog, accounting_reporting, accounting_core, pg_temp +SET search_path = pg_catalog, pg_temp AS $$ DECLARE period_status_value text; From 5cdcbd1cd6bc74fb2cfa988f454934b1e5be17a5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 00:14:31 +0900 Subject: [PATCH 009/224] test: pin trigger privilege boundary --- tests/test_trial_balance_snapshot_immutability_contract.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/test_trial_balance_snapshot_immutability_contract.py b/tests/test_trial_balance_snapshot_immutability_contract.py index 76b5955b..f5ca128a 100644 --- a/tests/test_trial_balance_snapshot_immutability_contract.py +++ b/tests/test_trial_balance_snapshot_immutability_contract.py @@ -40,7 +40,11 @@ def test_population_guard_serializes_on_book_period_authority(self) -> None: self.assertIn("period_status_value = 'hard_closed'", migration) self.assertIn("trial_balance_snapshot_immutable", migration) self.assertIn("SECURITY DEFINER", migration) - self.assertIn("pg_temp", migration) + self.assertIn("SET search_path = pg_catalog, pg_temp", migration) + self.assertIn( + "REVOKE ALL ON FUNCTION accounting_reporting.guard_trial_balance_line_insert()", + migration, + ) def test_header_and_line_mutations_are_rejected_at_the_table_boundary(self) -> None: """Both retained snapshot levels must reject UPDATE and DELETE before constraints drift.""" From 85447c52cda34fe58e08e9af9e6041b6c58e5477 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 01:06:09 +0900 Subject: [PATCH 010/224] test(close): reject post-close snapshot header insertion --- ...trial_balance_snapshot_immutability_red.py | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/tests/test_postgres_trial_balance_snapshot_immutability_red.py b/tests/test_postgres_trial_balance_snapshot_immutability_red.py index 4c23a575..4013f4ed 100644 --- a/tests/test_postgres_trial_balance_snapshot_immutability_red.py +++ b/tests/test_postgres_trial_balance_snapshot_immutability_red.py @@ -109,6 +109,46 @@ def test_hard_close_snapshot_header_cannot_be_deleted(self) -> None: ) connection.rollback() + def test_hard_close_cannot_gain_a_new_snapshot_header(self) -> None: + """A later empty snapshot cannot become the newest retained close evidence.""" + with psycopg.connect(posting.DATABASE_URL) as connection: + with self.assertRaisesRegex( + psycopg.errors.CheckViolation, + "trial_balance_snapshot_immutable", + ): + connection.execute( + """ + INSERT INTO accounting_reporting.trial_balance_snapshot ( + tenant_account_id, + legal_entity_id, + accounting_book_id, + fiscal_period_id, + snapshot_currency_code, + source_journal_count, + source_payload_hash, + close_idempotency_key + ) + SELECT tenant_account_id, + legal_entity_id, + accounting_book_id, + fiscal_period_id, + snapshot_currency_code, + source_journal_count, + %s, + %s + FROM accounting_reporting.trial_balance_snapshot + WHERE tenant_account_id = %s + AND trial_balance_snapshot_id = %s + """, + ( + "sha256:" + "8" * 64, + f"{self.case.policy.tenant_reference}:snapshot-freeze:forged", + self.case.tenant_id, + self.snapshot_id, + ), + ) + connection.rollback() + def test_hard_close_snapshot_line_cannot_be_rewritten(self) -> None: """A retained account balance cannot be changed after hard close.""" with psycopg.connect(posting.DATABASE_URL) as connection: From 550cb6be43c56e4a6269aaad72a86baea02d4ed7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 01:07:13 +0900 Subject: [PATCH 011/224] fix(close): block post-close snapshot header creation --- ...29_trial_balance_snapshot_immutability.sql | 42 +++++++++++++++++++ ..._balance_snapshot_immutability_contract.py | 19 ++++++--- 2 files changed, 56 insertions(+), 5 deletions(-) diff --git a/database/migrations/0029_trial_balance_snapshot_immutability.sql b/database/migrations/0029_trial_balance_snapshot_immutability.sql index 71c2f26e..f696fe93 100644 --- a/database/migrations/0029_trial_balance_snapshot_immutability.sql +++ b/database/migrations/0029_trial_balance_snapshot_immutability.sql @@ -34,6 +34,48 @@ CREATE TRIGGER trial_balance_line_immutable_guard FOR EACH ROW EXECUTE FUNCTION accounting_reporting.reject_trial_balance_line_mutation(); +CREATE OR REPLACE FUNCTION accounting_reporting.guard_trial_balance_snapshot_insert() +RETURNS trigger +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = pg_catalog, pg_temp +AS $$ +DECLARE + period_status_value text; +BEGIN + SELECT accounting_book_period_control.period_status_code + INTO period_status_value + FROM accounting_core.accounting_book_period_control + WHERE accounting_book_period_control.tenant_account_id = NEW.tenant_account_id + AND accounting_book_period_control.accounting_book_id = NEW.accounting_book_id + AND accounting_book_period_control.fiscal_period_id = NEW.fiscal_period_id + FOR UPDATE; + + IF period_status_value IS NULL THEN + RAISE EXCEPTION + 'trial balance snapshot has no matching book-period authority (trial_balance_snapshot_scope_missing)' + USING ERRCODE = 'check_violation'; + END IF; + + IF period_status_value = 'hard_closed' THEN + RAISE EXCEPTION + 'hard-close trial balance evidence is immutable (trial_balance_snapshot_immutable)' + USING ERRCODE = 'check_violation'; + END IF; + + RETURN NEW; +END; +$$; + +REVOKE ALL ON FUNCTION accounting_reporting.guard_trial_balance_snapshot_insert() + FROM PUBLIC; + +CREATE TRIGGER trial_balance_snapshot_population_guard + BEFORE INSERT + ON accounting_reporting.trial_balance_snapshot + FOR EACH ROW + EXECUTE FUNCTION accounting_reporting.guard_trial_balance_snapshot_insert(); + CREATE OR REPLACE FUNCTION accounting_reporting.guard_trial_balance_line_insert() RETURNS trigger LANGUAGE plpgsql diff --git a/tests/test_trial_balance_snapshot_immutability_contract.py b/tests/test_trial_balance_snapshot_immutability_contract.py index f5ca128a..102deeab 100644 --- a/tests/test_trial_balance_snapshot_immutability_contract.py +++ b/tests/test_trial_balance_snapshot_immutability_contract.py @@ -32,15 +32,24 @@ def is_file(path: Path) -> bool: ROOT / "database/migrations/0001_accounting_foundation.sql", ) - def test_population_guard_serializes_on_book_period_authority(self) -> None: - """Line admission must lock the same book-period row whose hard-close state it checks.""" + def test_population_guards_serialize_on_book_period_authority(self) -> None: + """Snapshot and line admission must lock the book-period state they authorize.""" migration = MIGRATION.read_text(encoding="utf-8") + self.assertIn("trial_balance_snapshot_population_guard", migration) self.assertIn("trial_balance_line_population_guard", migration) + self.assertIn("FOR UPDATE;", migration) self.assertIn("FOR UPDATE OF accounting_book_period_control", migration) - self.assertIn("period_status_value = 'hard_closed'", migration) + self.assertGreaterEqual(migration.count("period_status_value = 'hard_closed'"), 2) self.assertIn("trial_balance_snapshot_immutable", migration) - self.assertIn("SECURITY DEFINER", migration) - self.assertIn("SET search_path = pg_catalog, pg_temp", migration) + self.assertGreaterEqual(migration.count("SECURITY DEFINER"), 2) + self.assertGreaterEqual( + migration.count("SET search_path = pg_catalog, pg_temp"), + 2, + ) + self.assertIn( + "REVOKE ALL ON FUNCTION accounting_reporting.guard_trial_balance_snapshot_insert()", + migration, + ) self.assertIn( "REVOKE ALL ON FUNCTION accounting_reporting.guard_trial_balance_line_insert()", migration, From be761916affe7c4a20b2d171add91a370ac0795d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 02:06:18 +0900 Subject: [PATCH 012/224] test(close): reject pre-close snapshot authority poisoning --- ...trial_balance_snapshot_immutability_red.py | 120 ++++++++++++++++++ 1 file changed, 120 insertions(+) diff --git a/tests/test_postgres_trial_balance_snapshot_immutability_red.py b/tests/test_postgres_trial_balance_snapshot_immutability_red.py index 4013f4ed..d4cf8e87 100644 --- a/tests/test_postgres_trial_balance_snapshot_immutability_red.py +++ b/tests/test_postgres_trial_balance_snapshot_immutability_red.py @@ -229,5 +229,125 @@ def test_hard_close_snapshot_population_cannot_be_extended(self) -> None: connection.rollback() +class TrialBalanceSnapshotPreCloseAuthorityPostgresTests(unittest.TestCase): + """Fail hard close when retained snapshot evidence already occupies the book-period.""" + + @classmethod + def setUpClass(cls) -> None: + """Install the same production migration chain used by posting integration tests.""" + posting.PostgresPostingTests.setUpClass() + + def setUp(self) -> None: + """Leave one posted book-period soft-closed before the hard-close authority step.""" + self.case = posting.PostgresPostingTests("setUp") + self.case.setUp() + self.addCleanup(self.case.doCleanups) + self.addCleanup(self.case.tearDown) + self.case.ledger.post(self.case._two_line_proposal(), self.case.policy) + self.case.ledger.close_fiscal_period( + self.case.policy.legal_entity_reference, + self.case.policy.accounting_book_reference, + "2026-08", + "KRW", + period_status_code="soft_closed", + idempotency_key=f"{self.case.policy.tenant_reference}:snapshot-preclose:soft", + ) + + def test_preexisting_future_snapshot_cannot_become_hard_close_authority(self) -> None: + """A forged pre-close row must make hard close fail closed instead of becoming latest evidence.""" + with psycopg.connect(posting.DATABASE_URL) as connection: + scope = connection.execute( + """ + SELECT legal_entity_record.legal_entity_id, + accounting_book.accounting_book_id, + fiscal_period.fiscal_period_id + FROM accounting_core.legal_entity_record + JOIN accounting_core.accounting_book + ON accounting_book.tenant_account_id = legal_entity_record.tenant_account_id + AND accounting_book.legal_entity_id = legal_entity_record.legal_entity_id + JOIN accounting_core.fiscal_period + ON fiscal_period.tenant_account_id = legal_entity_record.tenant_account_id + WHERE legal_entity_record.tenant_account_id = %s + AND legal_entity_record.legal_entity_code = %s + AND accounting_book.book_name = %s + AND fiscal_period.period_code = '2026-08' + """, + ( + self.case.tenant_id, + self.case.policy.legal_entity_reference, + self.case.policy.accounting_book_reference, + ), + ).fetchone() + self.assertIsNotNone(scope) + assert scope is not None + legal_entity_id, accounting_book_id, fiscal_period_id = scope + connection.execute( + """ + INSERT INTO accounting_reporting.trial_balance_snapshot ( + tenant_account_id, + legal_entity_id, + accounting_book_id, + fiscal_period_id, + snapshot_currency_code, + snapshot_generated_at, + source_journal_count, + source_payload_hash, + close_idempotency_key + ) + VALUES (%s, %s, %s, %s, 'KRW', '2099-01-01T00:00:00Z', 0, %s, %s) + """, + ( + self.case.tenant_id, + legal_entity_id, + accounting_book_id, + fiscal_period_id, + "sha256:" + "7" * 64, + f"{self.case.policy.tenant_reference}:snapshot-preclose:forged", + ), + ) + connection.commit() + + with self.assertRaisesRegex( + psycopg.errors.CheckViolation, + "trial_balance_snapshot_population_conflict", + ): + self.case.ledger.close_fiscal_period( + self.case.policy.legal_entity_reference, + self.case.policy.accounting_book_reference, + "2026-08", + "KRW", + period_status_code="hard_closed", + idempotency_key=f"{self.case.policy.tenant_reference}:snapshot-preclose:hard", + ) + + with psycopg.connect(posting.DATABASE_URL) as connection: + period_state, snapshot_count = connection.execute( + """ + SELECT accounting_book_period_control.period_status_code, + ( + SELECT count(*) + FROM accounting_reporting.trial_balance_snapshot + WHERE trial_balance_snapshot.tenant_account_id = %s + AND trial_balance_snapshot.accounting_book_id = %s + AND trial_balance_snapshot.fiscal_period_id = %s + ) + FROM accounting_core.accounting_book_period_control + WHERE accounting_book_period_control.tenant_account_id = %s + AND accounting_book_period_control.accounting_book_id = %s + AND accounting_book_period_control.fiscal_period_id = %s + """, + ( + self.case.tenant_id, + accounting_book_id, + fiscal_period_id, + self.case.tenant_id, + accounting_book_id, + fiscal_period_id, + ), + ).fetchone() + self.assertEqual(period_state, "soft_closed") + self.assertEqual(snapshot_count, 1) + + if __name__ == "__main__": unittest.main() From aa4281b3662fc29c456243bcd5240a372c55100d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 02:06:48 +0900 Subject: [PATCH 013/224] fix(close): fail closed on preexisting snapshot population --- .../0029_trial_balance_snapshot_immutability.sql | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/database/migrations/0029_trial_balance_snapshot_immutability.sql b/database/migrations/0029_trial_balance_snapshot_immutability.sql index f696fe93..7d18acfe 100644 --- a/database/migrations/0029_trial_balance_snapshot_immutability.sql +++ b/database/migrations/0029_trial_balance_snapshot_immutability.sql @@ -63,6 +63,18 @@ BEGIN USING ERRCODE = 'check_violation'; END IF; + IF EXISTS ( + SELECT 1 + FROM accounting_reporting.trial_balance_snapshot + WHERE trial_balance_snapshot.tenant_account_id = NEW.tenant_account_id + AND trial_balance_snapshot.accounting_book_id = NEW.accounting_book_id + AND trial_balance_snapshot.fiscal_period_id = NEW.fiscal_period_id + ) THEN + RAISE EXCEPTION + 'trial balance snapshot population already occupies this book-period (trial_balance_snapshot_population_conflict)' + USING ERRCODE = 'check_violation'; + END IF; + RETURN NEW; END; $$; From c36f7f63c50752a6e4f276fe44cbc140a0d5d93b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 02:07:11 +0900 Subject: [PATCH 014/224] test(close): pin one snapshot population per book-period --- ..._trial_balance_snapshot_immutability_contract.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/tests/test_trial_balance_snapshot_immutability_contract.py b/tests/test_trial_balance_snapshot_immutability_contract.py index 102deeab..a9d1d12a 100644 --- a/tests/test_trial_balance_snapshot_immutability_contract.py +++ b/tests/test_trial_balance_snapshot_immutability_contract.py @@ -55,6 +55,19 @@ def test_population_guards_serialize_on_book_period_authority(self) -> None: migration, ) + def test_book_period_accepts_at_most_one_snapshot_population(self) -> None: + """A pre-close row must make canonical hard close fail rather than become later authority.""" + migration = MIGRATION.read_text(encoding="utf-8") + self.assertIn("trial_balance_snapshot_population_conflict", migration) + self.assertIn( + "trial_balance_snapshot.accounting_book_id = NEW.accounting_book_id", + migration, + ) + self.assertIn( + "trial_balance_snapshot.fiscal_period_id = NEW.fiscal_period_id", + migration, + ) + def test_header_and_line_mutations_are_rejected_at_the_table_boundary(self) -> None: """Both retained snapshot levels must reject UPDATE and DELETE before constraints drift.""" migration = MIGRATION.read_text(encoding="utf-8") From c29e97800fc81082fd872ea9fd9581391e0491a1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 02:08:20 +0900 Subject: [PATCH 015/224] docs(close): record single hard-close snapshot authority --- docs/adr/0006-fiscal-period-close-snapshot.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/adr/0006-fiscal-period-close-snapshot.md b/docs/adr/0006-fiscal-period-close-snapshot.md index 031e28fa..f0352497 100644 --- a/docs/adr/0006-fiscal-period-close-snapshot.md +++ b/docs/adr/0006-fiscal-period-close-snapshot.md @@ -8,10 +8,14 @@ An exact hard-close replay returns the existing snapshot and writes no second snapshot, closing journal, or close event. A `hard_closed` period cannot transition back to `soft_closed`. Soft-close replay remains snapshot-free. +The retained hard-close population is one snapshot per tenant/accounting-book/fiscal-period authority scope. Migration `0029_trial_balance_snapshot_immutability.sql` serializes snapshot admission on the same `accounting_book_period_control` row used by close. Once a snapshot population occupies that scope, a second snapshot is rejected with `trial_balance_snapshot_population_conflict`. This is deliberately fail-closed: if raw or legacy SQL has inserted a snapshot before the canonical hard-close transaction, AIS does not silently adopt that row or create a later row and select whichever timestamp sorts last. The hard-close transaction fails and leaves the book-period non-hard-closed until the conflicting retained evidence is resolved through an audited repair. + ## Consequences Controllers close books through the posting adapter instead of a raw status update. Ordinary posting is rejected for every non-open period. The database insert guard in `0005_closed_period_guard.sql` permits only purpose-limited AIS close/adjust/reversal writes while a period is `soft_closed`; every insert is rejected once the period is `hard_closed`. The caller-controlled `accounting_core.journal_write_role` GUC is classification metadata, not sufficient authorization by itself. +Migration `0029_trial_balance_snapshot_immutability.sql` also rejects UPDATE or DELETE of retained snapshot headers and lines and rejects population extension after hard close. Header and line admission lock the exact tenant/book/period authority row before evaluating status. The single-population check is evaluated under that lock, so concurrent snapshot creation cannot race around the invariant. Pre-migration history is retained rather than rewritten and is not retroactively attested as canonical solely because the guard was installed. + The former snapshot-on-soft-close and snapshot-reuse-on-upgrade wording in this ADR is superseded by ADR 0023. Operational and reporting code must therefore treat the hard-close snapshot as the only persisted post-close trial-balance population and must never infer that a soft-close created one. ## Exact soft-close command replay From 4358160ea4c281148bb1c054fcfd2540288493a9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 02:15:51 +0900 Subject: [PATCH 016/224] docs(close): record hard-close evidence immutability --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7e6ec286..8e8adb5b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## [Unreleased] +- Added migration `0029_trial_balance_snapshot_immutability.sql` to make retained hard-close trial-balance evidence fail closed at the PostgreSQL boundary. Snapshot headers and lines cannot be updated or deleted; header/line admission serializes on the exact tenant/book/period authority row; no new population is admitted after `hard_closed`; and a pre-existing snapshot population causes canonical hard close to reject with `trial_balance_snapshot_population_conflict` rather than silently adopt a caller-shaped row or let a forged future `snapshot_generated_at` win latest-snapshot ordering. ADR 0006 records the one-population authority boundary and future reopen/correction must use explicit successor lineage rather than mutate retained evidence. - Added migration `0020_reconciliation_exception_resolution_command.sql` and a tenant/run/exception-scoped immutable maker-checker exception-resolution command. The command binds the complete incoming JSON command through `source_payload_hash`, retains reviewed resolution evidence separately, requires a reviewer distinct from the exception owner, preserves exact replay and changed-payload conflict semantics, emits the matching accounting outbox event atomically, and fails migration closed when legacy terminal exceptions lack provable maker-checker provenance. Resolution evidence cannot post or reverse journals, close periods, change accounting policy, or write foreign product truth. ADR 0062 records the authority boundary. - Added migration `0021_reconciliation_exception_resolution_outbox_pair.sql`: reconciliation exception-resolution command/status/outbox authority must commit as one database-checked unit, and run-finalization snapshots compose immutable resolution-command evidence without replacing the parent PostgreSQL-owned statement/book population identities. - Added migration `0022_reconciliation_authority_outbox_retention.sql`: after commit, exactly one matching reconciliation authority outbox event must remain for each immutable exception-resolution or run-transition command; deletion, identity re-key, duplicate authority insertion, and re-keying an unrelated event into the same authority identity fail closed while publication metadata may advance. From e7bf6ae4bc3612c6993e8c46193648902be1c5c4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 02:17:33 +0900 Subject: [PATCH 017/224] docs(close): trace immutable period-close evidence --- docs/doctoring/STANDARD_TRACEABILITY.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/doctoring/STANDARD_TRACEABILITY.md b/docs/doctoring/STANDARD_TRACEABILITY.md index d5275176..838e7fe6 100644 --- a/docs/doctoring/STANDARD_TRACEABILITY.md +++ b/docs/doctoring/STANDARD_TRACEABILITY.md @@ -6,7 +6,7 @@ | IAS 1 | Statement of financial position presents assets, liabilities, and equity; statement of profit or loss presents income and expenses; AIS stores that split as `account_class_code`. Hard-close transfers period profit or loss into equity account 310100 so the next sheet does not need a floating earnings plug. Billing unapplied cash is catalog liability 210200 and does not park into retained earnings. Controllers also read a leftover-cash rollforward that ties park, apply, and refund journals to that 210200 credit-normal closing. Output VAT payable stays catalog liability 210100; controllers also read a period VAT register that ties issued-invoice tax credits and issued-invoice-void tax debits to that 210100 credit-normal closing. The period-close binder always includes that same register document. A HomeTax filing command on AIS requires that register before any transmission attempt and fail-closes without calling NTS when the register or the purpose-limited HomeTax credential is missing. Wage-income withholding, including year-end settlement, is reserved off 210100 and waits for a published Orgmetra assignment plus a portal, Billing, or HRIS role code. Comparative information for a prior period is an optional query on the same statement GET. Year-to-date profit or loss is an optional scope on that same GET. The complete set also includes a statement of changes in equity that rolls opening equity plus period profit or loss plus other equity movements to closing equity. As-of chart-account balances and a one-account opening + period + closing rollforward accompany those statements from the same trial-balance snapshot or live books. Controllers also read that complete set as one close pack, an entity-level receivable aging worksheet that ties to the AR account-balance net, an entity-level payable aging worksheet that ties to the catalog tax-payable account-balance net, and a period-close binder that accompanies those statements with period status, the omit-basis trial balance, receivable aging, payable aging, leftover-cash rollforward on catalog 210200, the period VAT register on catalog 210100, and the durable hard-close receipt when one exists | Chart-account class migration, HTTP financial-statement read, HTTP changes in equity, HTTP account-balance inquiry, HTTP account rollforward, HTTP financial-statement package, HTTP receivable aging, HTTP payable aging, HTTP period-close package, HTTP unapplied-cash catalog, HTTP leftover-cash rollforward, HTTP period VAT register, HTTP fail-closed HomeTax submission, wage-income withholding reservation, ADR 0024, ADR 0025, ADR 0028, ADR 0032, ADR 0034, ADR 0035, ADR 0037, ADR 0039, ADR 0040, ADR 0041, ADR 0043, ADR 0044, ADR 0045, ADR 0046, and ADR 0047 | | IAS 7 | Statement of cash flows presents period cash movements by the indirect method: operational net income, non-cash working-capital change, investing, and financing, reconciling opening cash to closing cash that equals the balance-sheet cash total. Cash is the current `cash_receipt` mapping. AIS does not invent a cash-flow class code | HTTP cash-flow statement and ADR 0033 | | IAS 34 | Interim year-to-date income statements cover the current financial year through the requested period; the balance sheet remains a point-in-time statement as of that period | HTTP `statement_scope_code=year_to_date` and ADR 0028 | -| IAS 10 | Soft-close rejects ordinary posts and allows AIS-owned adjusting journals plus append-only reversing adjustments before hard-close snapshots and locks the period. Hard-close loads the close package in one consistent read, parks period earnings on 310100, and stores the close `idempotency_key` on the snapshot so a later journal or a different close key fails closed. Auditors later read those durable hard-close receipts from stored snapshots, not reconstructed soft-close history. Controllers also read the unadjusted, adjusted, and post-close trial-balance worksheet on the existing TB GET, and list the adjusting worksheet population on the existing journal list | Two-step `POST /period-closes`, HTTP `POST /journals` adjusting write, HTTP period-close list, HTTP trial-balance basis, HTTP journal-source list, ADR 0023, ADR 0024, ADR 0030, ADR 0031, ADR 0036, and ADR 0038 | +| IAS 10 | Soft-close rejects ordinary posts and allows AIS-owned adjusting journals plus append-only reversing adjustments before hard-close snapshots and locks the period. Hard-close loads the close package in one consistent read, parks period earnings on 310100, and stores the close `idempotency_key` on the snapshot so a later journal or a different close key fails closed. Soft-close creates no persisted trial-balance snapshot; hard-close admits exactly one retained tenant/book/period snapshot population under the same database authority-row lock, and a pre-existing competing population makes the hard-close transaction fail closed instead of adopting caller-shaped evidence or selecting a forged future `snapshot_generated_at`. Retained snapshot headers and lines cannot later be extended, rewritten, or deleted. Auditors read durable hard-close receipts from stored snapshots, not reconstructed soft-close history. Controllers also read the unadjusted, adjusted, and post-close trial-balance worksheet on the existing TB GET, and list the adjusting worksheet population on the existing journal list | Two-step `POST /period-closes`, HTTP `POST /journals` adjusting write, HTTP period-close list, HTTP trial-balance basis, HTTP journal-source list, `0029_trial_balance_snapshot_immutability.sql`, real PostgreSQL snapshot-authority regressions, ADR 0006, ADR 0023, ADR 0024, ADR 0030, ADR 0031, ADR 0036, and ADR 0038 | | IFRS 9 | Receivable aging is control evidence of credit risk at the legal-entity book, not a customer subledger and not an expected-credit-loss allowance. AIS ages posted AR FIFO through period end and does not invent `party_reference`. A Billing issued-invoice void credits AR through ordinary ingest on `{tenant}:issued_invoice_void:{issued_invoice_void_id}:{void.source_payload_hash}:v{issued_invoice_void_contract_version}` and drops entity receivable aging by that inclusive amount; it is not a collection-status command and does not require Billing `reversed_journal_proposal_id` or `invoice_draft_id`. A Billing issued-credit-note void debits AR through ordinary ingest on `{tenant}:issued_credit_note_void:{issued_credit_note_void_id}:{void.source_payload_hash}:v{issued_credit_note_void_contract_version}` and raises entity receivable aging by that inclusive amount the credit had reduced; it does not require Billing `journal_entry_id`. A Billing collection write-off of a financial asset posts debit `write_off_expense` 510100 / credit AR through ordinary ingest, reduces entity receivable aging by that amount, and parks that expense into retained earnings on hard-close; it is not an ECL allowance and is not `period_closing` | HTTP receivable aging, HTTP issued-invoice-void consume, HTTP collection write-off catalog, ADR 0039, and ADR 0042 | | IFRS 18 | Financial-statement presentation is a versioned projection separate from the journal core | Reporting boundary and roadmap | | ISO 20022-1:2026 / ISO 20022-4:2026 / ISO 20022-9:2026 / RA camt.053.001.14 | Bank-statement adapter pins `BankToCustomerStatementV14`, vendors SHA-256 adapter evidence, rejects other revisions, stores hashes/locators rather than raw XML, records `TxDtls/AmtDtls/TxAmt/Amt` only, and fail-closes when the statement account-identifier hash does not match the registered bank account. Deterministic bank-to-book matching consumes that normalized evidence but treats matching precedence as an AIS control rather than an ISO-prescribed algorithm: present provider/end-to-end/account-servicer identities outrank the weaker exact-money/date rule; amount, currency, and CRDT/DBIT economic direction must agree; direction conflicts fail closed as `direction_mismatch`; the reconciliation evidence boundary accepts only finite, strictly positive `Decimal` amounts, rejecting binary floats, zero, negative, `NaN`, and infinite values before candidate comparison; `date_window_days` must be a non-negative integer (boolean, fractional, and negative values fail at policy construction); ambiguous populations abstain and a proposal never posts a journal. Reconciliation result structure is also fail-closed evidence: the default deterministic `reconciliation-decision/v1` contract requires a `match` to carry exactly one journal, while explicit reviewed `reconciliation-decision/v2` evidence may carry multiple journal references but still requires a non-empty journal population. Both versions require a finite strictly positive exact `Decimal` allocation and no exception code, and neither version grants reconciliation approval, period-close, journal-posting, or accounting-policy authority; an `abstain` carries no matched journal, exact zero `Decimal` allocation, and a non-empty exception code, so direct callers cannot forge success-shaped close-review input. CRDT/DBIT remains separate direction evidence rather than a signed-amount convention. The exact book-to-bank bridge is likewise an AIS close control rather than an ISO rule: it independently proves statement opening + movements = closing, posted-book opening + movements = closing, and reconciled book + outstanding book - outstanding bank = statement closing with exact `Decimal` values, retains run/statement/book population provenance, never tolerance-rounds a difference, and cannot post, reverse, or approve a journal. When a bridge enters period-close review it also carries immutable tenant/legal-entity/accounting-book/bank-account-assignment identity. The buyer close-review projection is a read-only AIS presentation over those controls: it requires its supplied scope to equal the bridge-bound scope, rejects unbound or relabelled same-currency bridges, carries that scope plus run/population provenance, exact bank/book/reconciled/outstanding/unexplained values, unresolved statement-entry references and preceding-run deltas; eligibility requires exactly one decision for every expected immutable statement entry, so missing, duplicate, or extraneous decisions fail closed; preceding-run deltas require both current and preceding bridges to be bound to the same immutable scope rather than currency equality or caller assertions alone; JSON/CSV preserve monetary values as decimal strings; `suitable_for_period_close_review` is evidence eligibility only and never reconciliation approval, period-close authority, or journal-posting permission. Split and aggregate allocation proposals are exact-`Decimal` conservation evidence: a split sums exactly to the statement amount, an aggregate conserves the exact journal-side total on both sides, and every `ReconciliationAllocation` is immutable, tenant- and run-scoped with no double consumption. Persisted `reconciliation_candidate` / `reconciliation_match` / `statement_match_allocation` / `journal_match_allocation` rows are forced-RLS tenant-scoped; migration 0015 enforces source-level allocation conservation so multiple disjoint approved matches remain legal without double consumption, while migration 0016 binds approval to the database-owned candidate/allocation snapshot and freezes late allocations. Allocation planning and persistence still never post, reverse, approve, or adjust a journal | Immutable bank-statement evidence registry, deterministic reconciliation proposal engine, exact book-to-bank bridge projection, close-review projection and exact-value export regressions, population/scope and bridge-scope regressions, decision-structure regressions, direction, monetary-domain, policy, and bridge regressions, ADR 0052, ADR 0054, ADR 0055 | From 76877c52fba371a9564612372b1a112443d7dd02 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 02:19:21 +0900 Subject: [PATCH 018/224] test(close): bind poisoning probe to exact book-period scope --- ...postgres_trial_balance_snapshot_immutability_red.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/tests/test_postgres_trial_balance_snapshot_immutability_red.py b/tests/test_postgres_trial_balance_snapshot_immutability_red.py index d4cf8e87..95117c1c 100644 --- a/tests/test_postgres_trial_balance_snapshot_immutability_red.py +++ b/tests/test_postgres_trial_balance_snapshot_immutability_red.py @@ -265,8 +265,16 @@ def test_preexisting_future_snapshot_cannot_become_hard_close_authority(self) -> JOIN accounting_core.accounting_book ON accounting_book.tenant_account_id = legal_entity_record.tenant_account_id AND accounting_book.legal_entity_id = legal_entity_record.legal_entity_id + JOIN accounting_core.accounting_book_period_control + ON accounting_book_period_control.tenant_account_id + = accounting_book.tenant_account_id + AND accounting_book_period_control.accounting_book_id + = accounting_book.accounting_book_id JOIN accounting_core.fiscal_period - ON fiscal_period.tenant_account_id = legal_entity_record.tenant_account_id + ON fiscal_period.tenant_account_id + = accounting_book_period_control.tenant_account_id + AND fiscal_period.fiscal_period_id + = accounting_book_period_control.fiscal_period_id WHERE legal_entity_record.tenant_account_id = %s AND legal_entity_record.legal_entity_code = %s AND accounting_book.book_name = %s From 1019072e871bbd5f95b9f77124eedfff94939d98 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 02:32:31 +0900 Subject: [PATCH 019/224] test(close): reproduce stale snapshot population race --- ..._trial_balance_snapshot_concurrency_red.py | 154 ++++++++++++++++++ 1 file changed, 154 insertions(+) create mode 100644 tests/test_postgres_trial_balance_snapshot_concurrency_red.py diff --git a/tests/test_postgres_trial_balance_snapshot_concurrency_red.py b/tests/test_postgres_trial_balance_snapshot_concurrency_red.py new file mode 100644 index 00000000..aac3ad9e --- /dev/null +++ b/tests/test_postgres_trial_balance_snapshot_concurrency_red.py @@ -0,0 +1,154 @@ +"""Real PostgreSQL concurrency RED for one hard-close snapshot population per book-period.""" + +from __future__ import annotations + +import unittest + +import psycopg + +from tests import test_postgres_posting as posting + + +class TrialBalanceSnapshotConcurrencyPostgresTests(unittest.TestCase): + """Keep the one-population invariant valid across stale repeatable-read snapshots.""" + + @classmethod + def setUpClass(cls) -> None: + """Install the complete production migration chain used by posting tests.""" + posting.PostgresPostingTests.setUpClass() + + def setUp(self) -> None: + """Create one posted book-period and leave it soft-closed without a snapshot.""" + self.case = posting.PostgresPostingTests("setUp") + self.case.setUp() + self.addCleanup(self.case.doCleanups) + self.addCleanup(self.case.tearDown) + self.case.ledger.post(self.case._two_line_proposal(), self.case.policy) + self.case.ledger.close_fiscal_period( + self.case.policy.legal_entity_reference, + self.case.policy.accounting_book_reference, + "2026-08", + "KRW", + period_status_code="soft_closed", + idempotency_key=f"{self.case.policy.tenant_reference}:snapshot-race:soft", + ) + with psycopg.connect(posting.DATABASE_URL) as connection: + scope = connection.execute( + """ + SELECT legal_entity_record.legal_entity_id, + accounting_book.accounting_book_id, + accounting_book_period_control.fiscal_period_id + FROM accounting_core.legal_entity_record + JOIN accounting_core.accounting_book + ON accounting_book.tenant_account_id = legal_entity_record.tenant_account_id + AND accounting_book.legal_entity_id = legal_entity_record.legal_entity_id + JOIN accounting_core.accounting_book_period_control + ON accounting_book_period_control.tenant_account_id + = accounting_book.tenant_account_id + AND accounting_book_period_control.accounting_book_id + = accounting_book.accounting_book_id + JOIN accounting_core.fiscal_period + ON fiscal_period.tenant_account_id + = accounting_book_period_control.tenant_account_id + AND fiscal_period.fiscal_period_id + = accounting_book_period_control.fiscal_period_id + WHERE legal_entity_record.tenant_account_id = %s + AND legal_entity_record.legal_entity_code = %s + AND accounting_book.book_name = %s + AND fiscal_period.period_code = '2026-08' + """, + ( + self.case.tenant_id, + self.case.policy.legal_entity_reference, + self.case.policy.accounting_book_reference, + ), + ).fetchone() + self.assertIsNotNone(scope) + assert scope is not None + self.legal_entity_id, self.accounting_book_id, self.fiscal_period_id = scope + + def _insert_snapshot( + self, + connection: psycopg.Connection[object], + *, + generated_at: str, + payload_digit: str, + command_suffix: str, + ) -> None: + """Insert one raw pre-close population candidate for the exact authority scope.""" + connection.execute( + """ + INSERT INTO accounting_reporting.trial_balance_snapshot ( + tenant_account_id, + legal_entity_id, + accounting_book_id, + fiscal_period_id, + snapshot_currency_code, + snapshot_generated_at, + source_journal_count, + source_payload_hash, + close_idempotency_key + ) + VALUES (%s, %s, %s, %s, 'KRW', %s, 0, %s, %s) + """, + ( + self.case.tenant_id, + self.legal_entity_id, + self.accounting_book_id, + self.fiscal_period_id, + generated_at, + "sha256:" + payload_digit * 64, + f"{self.case.policy.tenant_reference}:snapshot-race:{command_suffix}", + ), + ) + + def test_stale_repeatable_read_cannot_admit_second_snapshot_population(self) -> None: + """A fixed pre-commit snapshot must not race around the one-population invariant.""" + stale = psycopg.connect(posting.DATABASE_URL) + writer = psycopg.connect(posting.DATABASE_URL) + self.addCleanup(stale.close) + self.addCleanup(writer.close) + try: + stale.execute("SET TRANSACTION ISOLATION LEVEL REPEATABLE READ") + authority = stale.execute( + """ + SELECT period_status_code + FROM accounting_core.accounting_book_period_control + WHERE tenant_account_id = %s + AND accounting_book_id = %s + AND fiscal_period_id = %s + """, + ( + self.case.tenant_id, + self.accounting_book_id, + self.fiscal_period_id, + ), + ).fetchone() + self.assertEqual(authority, ("soft_closed",)) + + self._insert_snapshot( + writer, + generated_at="2098-01-01T00:00:00Z", + payload_digit="6", + command_suffix="writer", + ) + writer.commit() + + with self.assertRaises(psycopg.errors.UniqueViolation) as captured: + self._insert_snapshot( + stale, + generated_at="2099-01-01T00:00:00Z", + payload_digit="7", + command_suffix="stale", + ) + self.assertEqual( + captured.exception.diag.constraint_name, + "trial_balance_snapshot_one_population_per_book_period", + ) + finally: + stale.rollback() + writer.rollback() + + +if __name__ == "__main__": + unittest.main() From 1cc85b720d2f3900182016560e0a9b11f701f2ce Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 02:33:10 +0900 Subject: [PATCH 020/224] fix(close): enforce one snapshot population physically --- .../0029_trial_balance_snapshot_immutability.sql | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/database/migrations/0029_trial_balance_snapshot_immutability.sql b/database/migrations/0029_trial_balance_snapshot_immutability.sql index 7d18acfe..31f49338 100644 --- a/database/migrations/0029_trial_balance_snapshot_immutability.sql +++ b/database/migrations/0029_trial_balance_snapshot_immutability.sql @@ -34,6 +34,13 @@ CREATE TRIGGER trial_balance_line_immutable_guard FOR EACH ROW EXECUTE FUNCTION accounting_reporting.reject_trial_balance_line_mutation(); +-- A row lock does not refresh a transaction's fixed REPEATABLE READ snapshot. +-- Keep the visible-row diagnostic below, but make the invariant physical too so +-- a stale transaction cannot admit a second population that its snapshot cannot see. +ALTER TABLE accounting_reporting.trial_balance_snapshot + ADD CONSTRAINT trial_balance_snapshot_one_population_per_book_period + UNIQUE (tenant_account_id, accounting_book_id, fiscal_period_id); + CREATE OR REPLACE FUNCTION accounting_reporting.guard_trial_balance_snapshot_insert() RETURNS trigger LANGUAGE plpgsql From 88bca200ab30e5fdc82aaf24ebf2902c9604e3ed Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 02:33:34 +0900 Subject: [PATCH 021/224] test(close): pin physical one-population constraint --- ...est_trial_balance_snapshot_immutability_contract.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/tests/test_trial_balance_snapshot_immutability_contract.py b/tests/test_trial_balance_snapshot_immutability_contract.py index a9d1d12a..bf8ff0d8 100644 --- a/tests/test_trial_balance_snapshot_immutability_contract.py +++ b/tests/test_trial_balance_snapshot_immutability_contract.py @@ -56,7 +56,7 @@ def test_population_guards_serialize_on_book_period_authority(self) -> None: ) def test_book_period_accepts_at_most_one_snapshot_population(self) -> None: - """A pre-close row must make canonical hard close fail rather than become later authority.""" + """Visible and stale snapshots must both be unable to create a second population.""" migration = MIGRATION.read_text(encoding="utf-8") self.assertIn("trial_balance_snapshot_population_conflict", migration) self.assertIn( @@ -67,6 +67,14 @@ def test_book_period_accepts_at_most_one_snapshot_population(self) -> None: "trial_balance_snapshot.fiscal_period_id = NEW.fiscal_period_id", migration, ) + self.assertIn( + "ADD CONSTRAINT trial_balance_snapshot_one_population_per_book_period", + migration, + ) + self.assertIn( + "UNIQUE (tenant_account_id, accounting_book_id, fiscal_period_id)", + migration, + ) def test_header_and_line_mutations_are_rejected_at_the_table_boundary(self) -> None: """Both retained snapshot levels must reject UPDATE and DELETE before constraints drift.""" From e89dba604a19717f9386f65d9b10a7db5e0ecacb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 02:34:15 +0900 Subject: [PATCH 022/224] docs(close): record repeatable-read uniqueness boundary --- docs/adr/0006-fiscal-period-close-snapshot.md | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/docs/adr/0006-fiscal-period-close-snapshot.md b/docs/adr/0006-fiscal-period-close-snapshot.md index f0352497..3d201abd 100644 --- a/docs/adr/0006-fiscal-period-close-snapshot.md +++ b/docs/adr/0006-fiscal-period-close-snapshot.md @@ -8,13 +8,15 @@ An exact hard-close replay returns the existing snapshot and writes no second snapshot, closing journal, or close event. A `hard_closed` period cannot transition back to `soft_closed`. Soft-close replay remains snapshot-free. -The retained hard-close population is one snapshot per tenant/accounting-book/fiscal-period authority scope. Migration `0029_trial_balance_snapshot_immutability.sql` serializes snapshot admission on the same `accounting_book_period_control` row used by close. Once a snapshot population occupies that scope, a second snapshot is rejected with `trial_balance_snapshot_population_conflict`. This is deliberately fail-closed: if raw or legacy SQL has inserted a snapshot before the canonical hard-close transaction, AIS does not silently adopt that row or create a later row and select whichever timestamp sorts last. The hard-close transaction fails and leaves the book-period non-hard-closed until the conflicting retained evidence is resolved through an audited repair. +The retained hard-close population is one snapshot per tenant/accounting-book/fiscal-period authority scope. Migration `0029_trial_balance_snapshot_immutability.sql` serializes ordinary snapshot admission on the same `accounting_book_period_control` row used by close and rejects a visible pre-existing population with `trial_balance_snapshot_population_conflict`. It also adds the physical unique constraint `trial_balance_snapshot_one_population_per_book_period` over that exact scope. The declarative constraint is required because PostgreSQL `REPEATABLE READ` retains the transaction snapshot established by its first query or data-modification statement; waiting for the authority-row lock does not make a stale transaction see a snapshot row committed later. The unique constraint therefore closes the stale-snapshot race even when the trigger-level existence query cannot observe the competing row. + +This is deliberately fail-closed. If raw or legacy SQL has inserted a snapshot before the canonical hard-close transaction, AIS does not silently adopt that row or create a later row and select whichever timestamp sorts last. A visible conflict raises `trial_balance_snapshot_population_conflict`; a fixed-snapshot concurrency conflict is rejected by the named unique constraint. In either case the hard-close transaction cannot establish a second population and the book-period remains non-hard-closed until conflicting retained evidence is resolved through an audited repair. ## Consequences Controllers close books through the posting adapter instead of a raw status update. Ordinary posting is rejected for every non-open period. The database insert guard in `0005_closed_period_guard.sql` permits only purpose-limited AIS close/adjust/reversal writes while a period is `soft_closed`; every insert is rejected once the period is `hard_closed`. The caller-controlled `accounting_core.journal_write_role` GUC is classification metadata, not sufficient authorization by itself. -Migration `0029_trial_balance_snapshot_immutability.sql` also rejects UPDATE or DELETE of retained snapshot headers and lines and rejects population extension after hard close. Header and line admission lock the exact tenant/book/period authority row before evaluating status. The single-population check is evaluated under that lock, so concurrent snapshot creation cannot race around the invariant. Pre-migration history is retained rather than rewritten and is not retroactively attested as canonical solely because the guard was installed. +Migration `0029_trial_balance_snapshot_immutability.sql` also rejects UPDATE or DELETE of retained snapshot headers and lines and rejects population extension after hard close. Header and line admission lock the exact tenant/book/period authority row before evaluating status. That row lock serializes status-sensitive admission and gives the normal visible-conflict diagnostic; the exact-scope unique constraint independently enforces one population across transaction snapshots and concurrent writers. Pre-migration history is retained rather than rewritten and is not retroactively attested as canonical solely because the guard was installed. If duplicate populations already exist, installing the constraint fails rather than blessing ambiguous history. The former snapshot-on-soft-close and snapshot-reuse-on-upgrade wording in this ADR is superseded by ADR 0023. Operational and reporting code must therefore treat the hard-close snapshot as the only persisted post-close trial-balance population and must never infer that a soft-close created one. @@ -23,3 +25,9 @@ The former snapshot-on-soft-close and snapshot-reuse-on-upgrade wording in this Soft-close deliberately stores no trial-balance snapshot, but it is still an authoritative state-changing command. Migration `0010_soft_close_command_evidence.sql` records the original tenant-scoped soft-close idempotency key, source-journal count and canonical close-source SHA-256 on the book-period control row in the same transaction as the state transition and outbox event. Exact replay returns those stored facts and never recomputes historical evidence from later ledger state. A different key for an already soft-closed book-period is an idempotency conflict, and database trigger protection prevents rewriting evidence once recorded. `snapshot_currency_code` remains required on soft-close because it participates in the canonical close-source digest even though no hard-close snapshot row is created. This makes the command evidence exact without representing soft-close as a trial-balance snapshot. + +## References + +PostgreSQL Global Development Group. (2026a). *PostgreSQL 18 documentation: SET TRANSACTION*. https://www.postgresql.org/docs/18/sql-set-transaction.html + +PostgreSQL Global Development Group. (2026b). *PostgreSQL 18 documentation: Unique indexes*. https://www.postgresql.org/docs/18/indexes-unique.html From 25af584835a43367e07a7eb217b48769d8bafbf0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 02:59:50 +0900 Subject: [PATCH 023/224] test(close): require concurrent snapshot uniqueness build --- ..._balance_snapshot_immutability_contract.py | 55 +++++++++++++------ 1 file changed, 38 insertions(+), 17 deletions(-) diff --git a/tests/test_trial_balance_snapshot_immutability_contract.py b/tests/test_trial_balance_snapshot_immutability_contract.py index bf8ff0d8..7eab7c88 100644 --- a/tests/test_trial_balance_snapshot_immutability_contract.py +++ b/tests/test_trial_balance_snapshot_immutability_contract.py @@ -1,4 +1,4 @@ -"""Static contracts for the hard-close trial-balance immutability migration.""" +"""Static contracts for the hard-close trial-balance immutability migrations.""" from __future__ import annotations @@ -10,18 +10,21 @@ ROOT = Path(__file__).resolve().parents[1] -MIGRATION = ROOT / "database/migrations/0029_trial_balance_snapshot_immutability.sql" +INDEX_MIGRATION = ( + ROOT / "database/migrations/0029_trial_balance_snapshot_population_unique_index.sql" +) +IMMUTABILITY_MIGRATION = ROOT / "database/migrations/0030_trial_balance_snapshot_immutability.sql" class TrialBalanceSnapshotImmutabilityContractTests(unittest.TestCase): - """Keep the migration installed and its database-owned serialization boundary reviewable.""" + """Keep the migration chain and database-owned serialization boundary reviewable.""" - def test_canonical_installer_fails_closed_when_immutability_migration_is_missing(self) -> None: - """A supported install may not stop before immutable hard-close snapshot evidence.""" + def test_canonical_installer_fails_closed_when_snapshot_migration_is_missing(self) -> None: + """A supported install may not stop before the complete hard-close snapshot boundary.""" original_is_file = Path.is_file def is_file(path: Path) -> bool: - if path.name == MIGRATION.name: + if path.name in {INDEX_MIGRATION.name, IMMUTABILITY_MIGRATION.name}: return False return original_is_file(path) @@ -32,9 +35,35 @@ def is_file(path: Path) -> bool: ROOT / "database/migrations/0001_accounting_foundation.sql", ) + def test_population_unique_index_is_built_without_blocking_writes(self) -> None: + """The large-table uniqueness proof must be built concurrently outside a transaction.""" + index_migration = INDEX_MIGRATION.read_text(encoding="utf-8") + self.assertIn( + "CREATE UNIQUE INDEX CONCURRENTLY trial_balance_snapshot_one_population_per_book_period", + index_migration, + ) + self.assertIn( + "ON accounting_reporting.trial_balance_snapshot " + "(tenant_account_id, accounting_book_id, fiscal_period_id)", + index_migration, + ) + self.assertNotIn("BEGIN;", index_migration) + self.assertNotIn("COMMIT;", index_migration) + + immutability_migration = IMMUTABILITY_MIGRATION.read_text(encoding="utf-8") + self.assertIn( + "ADD CONSTRAINT trial_balance_snapshot_one_population_per_book_period", + immutability_migration, + ) + self.assertIn( + "UNIQUE USING INDEX trial_balance_snapshot_one_population_per_book_period", + immutability_migration, + ) + self.assertNotIn("CREATE UNIQUE INDEX", immutability_migration) + def test_population_guards_serialize_on_book_period_authority(self) -> None: """Snapshot and line admission must lock the book-period state they authorize.""" - migration = MIGRATION.read_text(encoding="utf-8") + migration = IMMUTABILITY_MIGRATION.read_text(encoding="utf-8") self.assertIn("trial_balance_snapshot_population_guard", migration) self.assertIn("trial_balance_line_population_guard", migration) self.assertIn("FOR UPDATE;", migration) @@ -57,7 +86,7 @@ def test_population_guards_serialize_on_book_period_authority(self) -> None: def test_book_period_accepts_at_most_one_snapshot_population(self) -> None: """Visible and stale snapshots must both be unable to create a second population.""" - migration = MIGRATION.read_text(encoding="utf-8") + migration = IMMUTABILITY_MIGRATION.read_text(encoding="utf-8") self.assertIn("trial_balance_snapshot_population_conflict", migration) self.assertIn( "trial_balance_snapshot.accounting_book_id = NEW.accounting_book_id", @@ -67,18 +96,10 @@ def test_book_period_accepts_at_most_one_snapshot_population(self) -> None: "trial_balance_snapshot.fiscal_period_id = NEW.fiscal_period_id", migration, ) - self.assertIn( - "ADD CONSTRAINT trial_balance_snapshot_one_population_per_book_period", - migration, - ) - self.assertIn( - "UNIQUE (tenant_account_id, accounting_book_id, fiscal_period_id)", - migration, - ) def test_header_and_line_mutations_are_rejected_at_the_table_boundary(self) -> None: """Both retained snapshot levels must reject UPDATE and DELETE before constraints drift.""" - migration = MIGRATION.read_text(encoding="utf-8") + migration = IMMUTABILITY_MIGRATION.read_text(encoding="utf-8") self.assertIn("trial_balance_snapshot_immutable_guard", migration) self.assertIn("trial_balance_line_immutable_guard", migration) self.assertGreaterEqual(migration.count("BEFORE UPDATE OR DELETE"), 2) From 30f6f2447aa69a7174216961bfdce95a61f1d475 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 03:00:22 +0900 Subject: [PATCH 024/224] refactor(close): split concurrent snapshot index migration --- ...29_trial_balance_snapshot_immutability.sql | 147 ------------------ 1 file changed, 147 deletions(-) delete mode 100644 database/migrations/0029_trial_balance_snapshot_immutability.sql diff --git a/database/migrations/0029_trial_balance_snapshot_immutability.sql b/database/migrations/0029_trial_balance_snapshot_immutability.sql deleted file mode 100644 index 31f49338..00000000 --- a/database/migrations/0029_trial_balance_snapshot_immutability.sql +++ /dev/null @@ -1,147 +0,0 @@ -BEGIN; - -CREATE OR REPLACE FUNCTION accounting_reporting.reject_trial_balance_snapshot_mutation() -RETURNS trigger -LANGUAGE plpgsql -AS $$ -BEGIN - RAISE EXCEPTION - 'hard-close trial balance evidence is immutable (trial_balance_snapshot_immutable)' - USING ERRCODE = 'check_violation'; -END; -$$; - -CREATE TRIGGER trial_balance_snapshot_immutable_guard - BEFORE UPDATE OR DELETE - ON accounting_reporting.trial_balance_snapshot - FOR EACH ROW - EXECUTE FUNCTION accounting_reporting.reject_trial_balance_snapshot_mutation(); - -CREATE OR REPLACE FUNCTION accounting_reporting.reject_trial_balance_line_mutation() -RETURNS trigger -LANGUAGE plpgsql -AS $$ -BEGIN - RAISE EXCEPTION - 'hard-close trial balance evidence is immutable (trial_balance_snapshot_immutable)' - USING ERRCODE = 'check_violation'; -END; -$$; - -CREATE TRIGGER trial_balance_line_immutable_guard - BEFORE UPDATE OR DELETE - ON accounting_reporting.trial_balance_line - FOR EACH ROW - EXECUTE FUNCTION accounting_reporting.reject_trial_balance_line_mutation(); - --- A row lock does not refresh a transaction's fixed REPEATABLE READ snapshot. --- Keep the visible-row diagnostic below, but make the invariant physical too so --- a stale transaction cannot admit a second population that its snapshot cannot see. -ALTER TABLE accounting_reporting.trial_balance_snapshot - ADD CONSTRAINT trial_balance_snapshot_one_population_per_book_period - UNIQUE (tenant_account_id, accounting_book_id, fiscal_period_id); - -CREATE OR REPLACE FUNCTION accounting_reporting.guard_trial_balance_snapshot_insert() -RETURNS trigger -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = pg_catalog, pg_temp -AS $$ -DECLARE - period_status_value text; -BEGIN - SELECT accounting_book_period_control.period_status_code - INTO period_status_value - FROM accounting_core.accounting_book_period_control - WHERE accounting_book_period_control.tenant_account_id = NEW.tenant_account_id - AND accounting_book_period_control.accounting_book_id = NEW.accounting_book_id - AND accounting_book_period_control.fiscal_period_id = NEW.fiscal_period_id - FOR UPDATE; - - IF period_status_value IS NULL THEN - RAISE EXCEPTION - 'trial balance snapshot has no matching book-period authority (trial_balance_snapshot_scope_missing)' - USING ERRCODE = 'check_violation'; - END IF; - - IF period_status_value = 'hard_closed' THEN - RAISE EXCEPTION - 'hard-close trial balance evidence is immutable (trial_balance_snapshot_immutable)' - USING ERRCODE = 'check_violation'; - END IF; - - IF EXISTS ( - SELECT 1 - FROM accounting_reporting.trial_balance_snapshot - WHERE trial_balance_snapshot.tenant_account_id = NEW.tenant_account_id - AND trial_balance_snapshot.accounting_book_id = NEW.accounting_book_id - AND trial_balance_snapshot.fiscal_period_id = NEW.fiscal_period_id - ) THEN - RAISE EXCEPTION - 'trial balance snapshot population already occupies this book-period (trial_balance_snapshot_population_conflict)' - USING ERRCODE = 'check_violation'; - END IF; - - RETURN NEW; -END; -$$; - -REVOKE ALL ON FUNCTION accounting_reporting.guard_trial_balance_snapshot_insert() - FROM PUBLIC; - -CREATE TRIGGER trial_balance_snapshot_population_guard - BEFORE INSERT - ON accounting_reporting.trial_balance_snapshot - FOR EACH ROW - EXECUTE FUNCTION accounting_reporting.guard_trial_balance_snapshot_insert(); - -CREATE OR REPLACE FUNCTION accounting_reporting.guard_trial_balance_line_insert() -RETURNS trigger -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = pg_catalog, pg_temp -AS $$ -DECLARE - period_status_value text; -BEGIN - SELECT accounting_book_period_control.period_status_code - INTO period_status_value - FROM accounting_reporting.trial_balance_snapshot - JOIN accounting_core.accounting_book_period_control - ON accounting_book_period_control.tenant_account_id - = trial_balance_snapshot.tenant_account_id - AND accounting_book_period_control.accounting_book_id - = trial_balance_snapshot.accounting_book_id - AND accounting_book_period_control.fiscal_period_id - = trial_balance_snapshot.fiscal_period_id - WHERE trial_balance_snapshot.tenant_account_id = NEW.tenant_account_id - AND trial_balance_snapshot.trial_balance_snapshot_id - = NEW.trial_balance_snapshot_id - FOR UPDATE OF accounting_book_period_control; - - IF period_status_value IS NULL THEN - RAISE EXCEPTION - 'trial balance snapshot has no matching book-period authority (trial_balance_snapshot_scope_missing)' - USING ERRCODE = 'check_violation'; - END IF; - - IF period_status_value = 'hard_closed' THEN - RAISE EXCEPTION - 'hard-close trial balance evidence is immutable (trial_balance_snapshot_immutable)' - USING ERRCODE = 'check_violation'; - END IF; - - RETURN NEW; -END; -$$; - -REVOKE ALL ON FUNCTION accounting_reporting.guard_trial_balance_line_insert() - FROM PUBLIC; - -CREATE TRIGGER trial_balance_line_population_guard - BEFORE INSERT - ON accounting_reporting.trial_balance_line - FOR EACH ROW - EXECUTE FUNCTION accounting_reporting.guard_trial_balance_line_insert(); - -COMMIT; From a6b7fb1f4eea9d2c37520cf28a0b4373c896f203 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 03:00:34 +0900 Subject: [PATCH 025/224] fix(close): build snapshot uniqueness concurrently --- .../0029_trial_balance_snapshot_population_unique_index.sql | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 database/migrations/0029_trial_balance_snapshot_population_unique_index.sql diff --git a/database/migrations/0029_trial_balance_snapshot_population_unique_index.sql b/database/migrations/0029_trial_balance_snapshot_population_unique_index.sql new file mode 100644 index 00000000..08ca2fe1 --- /dev/null +++ b/database/migrations/0029_trial_balance_snapshot_population_unique_index.sql @@ -0,0 +1,6 @@ +-- PostgreSQL requires CREATE INDEX CONCURRENTLY to run outside a transaction block. +-- Keep this migration to the single concurrent build so the canonical installer can +-- apply it as one autocommit statement without blocking ordinary table writes. +CREATE UNIQUE INDEX CONCURRENTLY trial_balance_snapshot_one_population_per_book_period +ON accounting_reporting.trial_balance_snapshot + (tenant_account_id, accounting_book_id, fiscal_period_id); From 001f780b986bf65ea83b5bbaae659a2563774116 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 03:00:53 +0900 Subject: [PATCH 026/224] fix(close): attach concurrent snapshot index safely --- ...30_trial_balance_snapshot_immutability.sql | 147 ++++++++++++++++++ 1 file changed, 147 insertions(+) create mode 100644 database/migrations/0030_trial_balance_snapshot_immutability.sql diff --git a/database/migrations/0030_trial_balance_snapshot_immutability.sql b/database/migrations/0030_trial_balance_snapshot_immutability.sql new file mode 100644 index 00000000..2a7e8764 --- /dev/null +++ b/database/migrations/0030_trial_balance_snapshot_immutability.sql @@ -0,0 +1,147 @@ +BEGIN; + +-- The unique index was built concurrently in migration 0029. Attaching it as a +-- constraint is a short metadata operation and makes the physical invariant part +-- of the table contract without rebuilding the index under a long write-blocking lock. +ALTER TABLE accounting_reporting.trial_balance_snapshot + ADD CONSTRAINT trial_balance_snapshot_one_population_per_book_period + UNIQUE USING INDEX trial_balance_snapshot_one_population_per_book_period; + +CREATE OR REPLACE FUNCTION accounting_reporting.reject_trial_balance_snapshot_mutation() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +BEGIN + RAISE EXCEPTION + 'hard-close trial balance evidence is immutable (trial_balance_snapshot_immutable)' + USING ERRCODE = 'check_violation'; +END; +$$; + +CREATE TRIGGER trial_balance_snapshot_immutable_guard + BEFORE UPDATE OR DELETE + ON accounting_reporting.trial_balance_snapshot + FOR EACH ROW + EXECUTE FUNCTION accounting_reporting.reject_trial_balance_snapshot_mutation(); + +CREATE OR REPLACE FUNCTION accounting_reporting.reject_trial_balance_line_mutation() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +BEGIN + RAISE EXCEPTION + 'hard-close trial balance evidence is immutable (trial_balance_snapshot_immutable)' + USING ERRCODE = 'check_violation'; +END; +$$; + +CREATE TRIGGER trial_balance_line_immutable_guard + BEFORE UPDATE OR DELETE + ON accounting_reporting.trial_balance_line + FOR EACH ROW + EXECUTE FUNCTION accounting_reporting.reject_trial_balance_line_mutation(); + +CREATE OR REPLACE FUNCTION accounting_reporting.guard_trial_balance_snapshot_insert() +RETURNS trigger +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = pg_catalog, pg_temp +AS $$ +DECLARE + period_status_value text; +BEGIN + SELECT accounting_book_period_control.period_status_code + INTO period_status_value + FROM accounting_core.accounting_book_period_control + WHERE accounting_book_period_control.tenant_account_id = NEW.tenant_account_id + AND accounting_book_period_control.accounting_book_id = NEW.accounting_book_id + AND accounting_book_period_control.fiscal_period_id = NEW.fiscal_period_id + FOR UPDATE; + + IF period_status_value IS NULL THEN + RAISE EXCEPTION + 'trial balance snapshot has no matching book-period authority (trial_balance_snapshot_scope_missing)' + USING ERRCODE = 'check_violation'; + END IF; + + IF period_status_value = 'hard_closed' THEN + RAISE EXCEPTION + 'hard-close trial balance evidence is immutable (trial_balance_snapshot_immutable)' + USING ERRCODE = 'check_violation'; + END IF; + + IF EXISTS ( + SELECT 1 + FROM accounting_reporting.trial_balance_snapshot + WHERE trial_balance_snapshot.tenant_account_id = NEW.tenant_account_id + AND trial_balance_snapshot.accounting_book_id = NEW.accounting_book_id + AND trial_balance_snapshot.fiscal_period_id = NEW.fiscal_period_id + ) THEN + RAISE EXCEPTION + 'trial balance snapshot population already occupies this book-period (trial_balance_snapshot_population_conflict)' + USING ERRCODE = 'check_violation'; + END IF; + + RETURN NEW; +END; +$$; + +REVOKE ALL ON FUNCTION accounting_reporting.guard_trial_balance_snapshot_insert() + FROM PUBLIC; + +CREATE TRIGGER trial_balance_snapshot_population_guard + BEFORE INSERT + ON accounting_reporting.trial_balance_snapshot + FOR EACH ROW + EXECUTE FUNCTION accounting_reporting.guard_trial_balance_snapshot_insert(); + +CREATE OR REPLACE FUNCTION accounting_reporting.guard_trial_balance_line_insert() +RETURNS trigger +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = pg_catalog, pg_temp +AS $$ +DECLARE + period_status_value text; +BEGIN + SELECT accounting_book_period_control.period_status_code + INTO period_status_value + FROM accounting_reporting.trial_balance_snapshot + JOIN accounting_core.accounting_book_period_control + ON accounting_book_period_control.tenant_account_id + = trial_balance_snapshot.tenant_account_id + AND accounting_book_period_control.accounting_book_id + = trial_balance_snapshot.accounting_book_id + AND accounting_book_period_control.fiscal_period_id + = trial_balance_snapshot.fiscal_period_id + WHERE trial_balance_snapshot.tenant_account_id = NEW.tenant_account_id + AND trial_balance_snapshot.trial_balance_snapshot_id + = NEW.trial_balance_snapshot_id + FOR UPDATE OF accounting_book_period_control; + + IF period_status_value IS NULL THEN + RAISE EXCEPTION + 'trial balance snapshot has no matching book-period authority (trial_balance_snapshot_scope_missing)' + USING ERRCODE = 'check_violation'; + END IF; + + IF period_status_value = 'hard_closed' THEN + RAISE EXCEPTION + 'hard-close trial balance evidence is immutable (trial_balance_snapshot_immutable)' + USING ERRCODE = 'check_violation'; + END IF; + + RETURN NEW; +END; +$$; + +REVOKE ALL ON FUNCTION accounting_reporting.guard_trial_balance_line_insert() + FROM PUBLIC; + +CREATE TRIGGER trial_balance_line_population_guard + BEFORE INSERT + ON accounting_reporting.trial_balance_line + FOR EACH ROW + EXECUTE FUNCTION accounting_reporting.guard_trial_balance_line_insert(); + +COMMIT; From f586b05fef1c3350c1021a109f9396b48b6ca438 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 03:01:05 +0900 Subject: [PATCH 027/224] fix(close): install split snapshot migrations in order --- src/accounting_information_platform/migration_install.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/accounting_information_platform/migration_install.py b/src/accounting_information_platform/migration_install.py index 3a99b8f3..7e0c19f1 100644 --- a/src/accounting_information_platform/migration_install.py +++ b/src/accounting_information_platform/migration_install.py @@ -25,7 +25,8 @@ def apply_foundation_migration(database_url: str, migration_path: Path) -> None: migration_path.parent / "0026_reconciliation_lifecycle_source_payload_identity.sql", migration_path.parent / "0027_reconciliation_lifecycle_session_lock_authority.sql", migration_path.parent / "0028_reconciliation_lifecycle_capability_privileges.sql", - migration_path.parent / "0029_trial_balance_snapshot_immutability.sql", + migration_path.parent / "0029_trial_balance_snapshot_population_unique_index.sql", + migration_path.parent / "0030_trial_balance_snapshot_immutability.sql", ) for forward_migration_path in forward_migration_paths: if not forward_migration_path.is_file(): From 0623914e3318f3c6b78646c4265be33a434b65a3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 03:02:38 +0900 Subject: [PATCH 028/224] docs(close): record low-blocking snapshot migration design --- docs/adr/0006-fiscal-period-close-snapshot.md | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/docs/adr/0006-fiscal-period-close-snapshot.md b/docs/adr/0006-fiscal-period-close-snapshot.md index 3d201abd..6a7d12b0 100644 --- a/docs/adr/0006-fiscal-period-close-snapshot.md +++ b/docs/adr/0006-fiscal-period-close-snapshot.md @@ -8,18 +8,24 @@ An exact hard-close replay returns the existing snapshot and writes no second snapshot, closing journal, or close event. A `hard_closed` period cannot transition back to `soft_closed`. Soft-close replay remains snapshot-free. -The retained hard-close population is one snapshot per tenant/accounting-book/fiscal-period authority scope. Migration `0029_trial_balance_snapshot_immutability.sql` serializes ordinary snapshot admission on the same `accounting_book_period_control` row used by close and rejects a visible pre-existing population with `trial_balance_snapshot_population_conflict`. It also adds the physical unique constraint `trial_balance_snapshot_one_population_per_book_period` over that exact scope. The declarative constraint is required because PostgreSQL `REPEATABLE READ` retains the transaction snapshot established by its first query or data-modification statement; waiting for the authority-row lock does not make a stale transaction see a snapshot row committed later. The unique constraint therefore closes the stale-snapshot race even when the trigger-level existence query cannot observe the competing row. +The retained hard-close population is one snapshot per tenant/accounting-book/fiscal-period authority scope. Migration `0029_trial_balance_snapshot_population_unique_index.sql` builds the exact-scope unique index with `CREATE UNIQUE INDEX CONCURRENTLY` outside a transaction block so an upgrade does not hold a write-blocking table lock for the duration of the index build. Migration `0030_trial_balance_snapshot_immutability.sql` attaches that already-built index as the named `trial_balance_snapshot_one_population_per_book_period` table constraint in a short transaction, installs the snapshot/line mutation guards, serializes ordinary snapshot admission on the same `accounting_book_period_control` row used by close, and rejects a visible pre-existing population with `trial_balance_snapshot_population_conflict`. -This is deliberately fail-closed. If raw or legacy SQL has inserted a snapshot before the canonical hard-close transaction, AIS does not silently adopt that row or create a later row and select whichever timestamp sorts last. A visible conflict raises `trial_balance_snapshot_population_conflict`; a fixed-snapshot concurrency conflict is rejected by the named unique constraint. In either case the hard-close transaction cannot establish a second population and the book-period remains non-hard-closed until conflicting retained evidence is resolved through an audited repair. +The physical uniqueness boundary is required because PostgreSQL `REPEATABLE READ` retains the transaction snapshot established by its first query or data-modification statement; waiting for the authority-row lock does not make a stale transaction see a snapshot row committed later. The unique index therefore closes the stale-snapshot race even when the trigger-level existence query cannot observe the competing row. PostgreSQL documents that ordinary index creation can block writers for an unacceptable period on a live production table, while `CREATE INDEX CONCURRENTLY` keeps ordinary inserts, updates, and deletes available at the cost of extra scans and longer build time. PostgreSQL also documents converting a concurrently built unique index into a `UNIQUE` constraint with `ALTER TABLE ... ADD CONSTRAINT ... USING INDEX` as the low-blocking deployment pattern. + +This is deliberately fail-closed. If raw or legacy SQL has inserted a snapshot before the canonical hard-close transaction, AIS does not silently adopt that row or create a later row and select whichever timestamp sorts last. A visible conflict raises `trial_balance_snapshot_population_conflict`; a fixed-snapshot concurrency conflict is rejected by the named unique boundary. In either case the hard-close transaction cannot establish a second population and the book-period remains non-hard-closed until conflicting retained evidence is resolved through an audited repair. ## Consequences Controllers close books through the posting adapter instead of a raw status update. Ordinary posting is rejected for every non-open period. The database insert guard in `0005_closed_period_guard.sql` permits only purpose-limited AIS close/adjust/reversal writes while a period is `soft_closed`; every insert is rejected once the period is `hard_closed`. The caller-controlled `accounting_core.journal_write_role` GUC is classification metadata, not sufficient authorization by itself. -Migration `0029_trial_balance_snapshot_immutability.sql` also rejects UPDATE or DELETE of retained snapshot headers and lines and rejects population extension after hard close. Header and line admission lock the exact tenant/book/period authority row before evaluating status. That row lock serializes status-sensitive admission and gives the normal visible-conflict diagnostic; the exact-scope unique constraint independently enforces one population across transaction snapshots and concurrent writers. Pre-migration history is retained rather than rewritten and is not retroactively attested as canonical solely because the guard was installed. If duplicate populations already exist, installing the constraint fails rather than blessing ambiguous history. +Migration `0030_trial_balance_snapshot_immutability.sql` rejects UPDATE or DELETE of retained snapshot headers and lines and rejects population extension after hard close. Header and line admission lock the exact tenant/book/period authority row before evaluating status. That row lock serializes status-sensitive admission and gives the normal visible-conflict diagnostic; the exact-scope unique constraint independently enforces one population across transaction snapshots and concurrent writers. Pre-migration history is retained rather than rewritten and is not retroactively attested as canonical solely because the guard was installed. If duplicate populations already exist, the concurrent unique-index build fails rather than blessing ambiguous history. + +The concurrent index phase is intentionally separate from the transactional trigger/constraint phase because PostgreSQL forbids `CREATE INDEX CONCURRENTLY` inside a transaction block. The canonical installer applies each forward migration file separately on an autocommit connection, so migration 0029 is one outside-transaction statement and migration 0030 remains an atomic transaction. A failed concurrent unique build may leave an invalid index, which PostgreSQL requires operators to remove or rebuild before retry; recovery tooling for that partial-upgrade state remains a release-readiness requirement and must not be represented as automatic rollback. The former snapshot-on-soft-close and snapshot-reuse-on-upgrade wording in this ADR is superseded by ADR 0023. Operational and reporting code must therefore treat the hard-close snapshot as the only persisted post-close trial-balance population and must never infer that a soft-close created one. +Future fiscal-period reopen/correction is not implemented by this ADR. If a later policy introduces reopen, it must preserve the prior hard-close population and add an explicit successor lineage; it must not mutate retained evidence or silently relax the one-population constraint without a replacement identity/version invariant and migration plan. + ## Exact soft-close command replay Soft-close deliberately stores no trial-balance snapshot, but it is still an authoritative state-changing command. Migration `0010_soft_close_command_evidence.sql` records the original tenant-scoped soft-close idempotency key, source-journal count and canonical close-source SHA-256 on the book-period control row in the same transaction as the state transition and outbox event. Exact replay returns those stored facts and never recomputes historical evidence from later ledger state. A different key for an already soft-closed book-period is an idempotency conflict, and database trigger protection prevents rewriting evidence once recorded. @@ -30,4 +36,8 @@ Soft-close deliberately stores no trial-balance snapshot, but it is still an aut PostgreSQL Global Development Group. (2026a). *PostgreSQL 18 documentation: SET TRANSACTION*. https://www.postgresql.org/docs/18/sql-set-transaction.html -PostgreSQL Global Development Group. (2026b). *PostgreSQL 18 documentation: Unique indexes*. https://www.postgresql.org/docs/18/indexes-unique.html +PostgreSQL Global Development Group. (2026b). *PostgreSQL 18 documentation: CREATE INDEX*. https://www.postgresql.org/docs/18/sql-createindex.html + +PostgreSQL Global Development Group. (2026c). *PostgreSQL 18 documentation: ALTER TABLE*. https://www.postgresql.org/docs/18/sql-altertable.html + +PostgreSQL Global Development Group. (2026d). *PostgreSQL 18 documentation: Unique indexes*. https://www.postgresql.org/docs/18/indexes-unique.html From 84365fd9b55edffdc61323f4bd795e53ad3711cd Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 04:05:45 +0900 Subject: [PATCH 029/224] test(close): require snapshot admission authority --- ...es_trial_balance_snapshot_admission_red.py | 127 ++++++++++++++++++ 1 file changed, 127 insertions(+) create mode 100644 tests/test_postgres_trial_balance_snapshot_admission_red.py diff --git a/tests/test_postgres_trial_balance_snapshot_admission_red.py b/tests/test_postgres_trial_balance_snapshot_admission_red.py new file mode 100644 index 00000000..e337076f --- /dev/null +++ b/tests/test_postgres_trial_balance_snapshot_admission_red.py @@ -0,0 +1,127 @@ +"""Real PostgreSQL RED/GREEN for trial-balance snapshot admission authority.""" + +from __future__ import annotations + +import unittest + +import psycopg + +from tests import test_postgres_posting as posting + + +class TrialBalanceSnapshotAdmissionPostgresTests(unittest.TestCase): + """Only the governed hard-close path may create a retained snapshot population.""" + + @classmethod + def setUpClass(cls) -> None: + """Install the complete current accounting migration chain.""" + posting.PostgresPostingTests.setUpClass() + + def setUp(self) -> None: + """Leave one balanced book-period soft-closed before hard-close authority.""" + self.case = posting.PostgresPostingTests("setUp") + self.case.setUp() + self.addCleanup(self.case.doCleanups) + self.addCleanup(self.case.tearDown) + self.case.ledger.post(self.case._two_line_proposal(), self.case.policy) + self.case.ledger.close_fiscal_period( + self.case.policy.legal_entity_reference, + self.case.policy.accounting_book_reference, + "2026-08", + "KRW", + period_status_code="soft_closed", + idempotency_key=f"{self.case.policy.tenant_reference}:snapshot-admission:soft", + ) + + def _scope(self, connection: psycopg.Connection[object]) -> tuple[object, object, object]: + row = connection.execute( + """ + SELECT legal_entity_record.legal_entity_id, + accounting_book.accounting_book_id, + fiscal_period.fiscal_period_id + FROM accounting_core.legal_entity_record + JOIN accounting_core.accounting_book + ON accounting_book.tenant_account_id = legal_entity_record.tenant_account_id + AND accounting_book.legal_entity_id = legal_entity_record.legal_entity_id + JOIN accounting_core.accounting_book_period_control + ON accounting_book_period_control.tenant_account_id = accounting_book.tenant_account_id + AND accounting_book_period_control.accounting_book_id = accounting_book.accounting_book_id + JOIN accounting_core.fiscal_period + ON fiscal_period.tenant_account_id = accounting_book_period_control.tenant_account_id + AND fiscal_period.fiscal_period_id = accounting_book_period_control.fiscal_period_id + WHERE legal_entity_record.tenant_account_id = %s + AND legal_entity_record.legal_entity_code = %s + AND accounting_book.book_name = %s + AND fiscal_period.period_code = '2026-08' + """, + ( + self.case.tenant_id, + self.case.policy.legal_entity_reference, + self.case.policy.accounting_book_reference, + ), + ).fetchone() + self.assertIsNotNone(row) + assert row is not None + return row + + def test_raw_soft_close_snapshot_insert_requires_close_authority(self) -> None: + """A privileged SQL session cannot pre-populate retained close evidence directly.""" + with psycopg.connect(posting.DATABASE_URL) as connection: + legal_entity_id, accounting_book_id, fiscal_period_id = self._scope(connection) + with self.assertRaisesRegex( + psycopg.errors.CheckViolation, + "trial_balance_snapshot_authority_required", + ): + connection.execute( + """ + INSERT INTO accounting_reporting.trial_balance_snapshot ( + tenant_account_id, + legal_entity_id, + accounting_book_id, + fiscal_period_id, + snapshot_currency_code, + source_journal_count, + source_payload_hash, + close_idempotency_key + ) + VALUES (%s, %s, %s, %s, 'KRW', 0, %s, %s) + """, + ( + self.case.tenant_id, + legal_entity_id, + accounting_book_id, + fiscal_period_id, + "sha256:" + "6" * 64, + f"{self.case.policy.tenant_reference}:snapshot-admission:forged", + ), + ) + connection.rollback() + + def test_governed_hard_close_still_creates_one_snapshot(self) -> None: + """The purpose-limited period-closing path remains the sole admitted writer.""" + receipt = self.case.ledger.close_fiscal_period( + self.case.policy.legal_entity_reference, + self.case.policy.accounting_book_reference, + "2026-08", + "KRW", + period_status_code="hard_closed", + idempotency_key=f"{self.case.policy.tenant_reference}:snapshot-admission:hard", + ) + self.assertEqual(receipt.period_status_code, "hard_closed") + with psycopg.connect(posting.DATABASE_URL) as connection: + _, accounting_book_id, fiscal_period_id = self._scope(connection) + count = connection.execute( + """ + SELECT count(*) + FROM accounting_reporting.trial_balance_snapshot + WHERE tenant_account_id = %s + AND accounting_book_id = %s + AND fiscal_period_id = %s + """, + (self.case.tenant_id, accounting_book_id, fiscal_period_id), + ).fetchone()[0] + self.assertEqual(count, 1) + + +if __name__ == "__main__": + unittest.main() From 279f75b42320e645da00a2167794d5c4b428aae8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 04:06:19 +0900 Subject: [PATCH 030/224] fix(close): require purpose-limited snapshot writer --- .../0030_trial_balance_snapshot_immutability.sql | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/database/migrations/0030_trial_balance_snapshot_immutability.sql b/database/migrations/0030_trial_balance_snapshot_immutability.sql index 2a7e8764..be986a8c 100644 --- a/database/migrations/0030_trial_balance_snapshot_immutability.sql +++ b/database/migrations/0030_trial_balance_snapshot_immutability.sql @@ -49,6 +49,7 @@ SET search_path = pg_catalog, pg_temp AS $$ DECLARE period_status_value text; + journal_write_role_value text; BEGIN SELECT accounting_book_period_control.period_status_code INTO period_status_value @@ -70,6 +71,20 @@ BEGIN USING ERRCODE = 'check_violation'; END IF; + journal_write_role_value := nullif( + current_setting('accounting_core.journal_write_role', true), + '' + ); + + IF period_status_value <> 'soft_closed' + OR journal_write_role_value IS DISTINCT FROM 'period_closing' + OR NOT pg_has_role(session_user, 'accounting_closing_writer', 'MEMBER') + THEN + RAISE EXCEPTION + 'trial balance snapshot creation requires the purpose-limited hard-close writer (trial_balance_snapshot_authority_required)' + USING ERRCODE = 'check_violation'; + END IF; + IF EXISTS ( SELECT 1 FROM accounting_reporting.trial_balance_snapshot From 0e64f95caa4d57998be36e91272f7e278c0dce7d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 04:06:54 +0900 Subject: [PATCH 031/224] docs(close): trace snapshot writer authority --- docs/adr/0006-fiscal-period-close-snapshot.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/docs/adr/0006-fiscal-period-close-snapshot.md b/docs/adr/0006-fiscal-period-close-snapshot.md index 6a7d12b0..fb2ca008 100644 --- a/docs/adr/0006-fiscal-period-close-snapshot.md +++ b/docs/adr/0006-fiscal-period-close-snapshot.md @@ -8,17 +8,19 @@ An exact hard-close replay returns the existing snapshot and writes no second snapshot, closing journal, or close event. A `hard_closed` period cannot transition back to `soft_closed`. Soft-close replay remains snapshot-free. -The retained hard-close population is one snapshot per tenant/accounting-book/fiscal-period authority scope. Migration `0029_trial_balance_snapshot_population_unique_index.sql` builds the exact-scope unique index with `CREATE UNIQUE INDEX CONCURRENTLY` outside a transaction block so an upgrade does not hold a write-blocking table lock for the duration of the index build. Migration `0030_trial_balance_snapshot_immutability.sql` attaches that already-built index as the named `trial_balance_snapshot_one_population_per_book_period` table constraint in a short transaction, installs the snapshot/line mutation guards, serializes ordinary snapshot admission on the same `accounting_book_period_control` row used by close, and rejects a visible pre-existing population with `trial_balance_snapshot_population_conflict`. +The retained hard-close population is one snapshot per tenant/accounting-book/fiscal-period authority scope. Migration `0029_trial_balance_snapshot_population_unique_index.sql` builds the exact-scope unique index with `CREATE UNIQUE INDEX CONCURRENTLY` outside a transaction block so an upgrade does not hold a write-blocking table lock for the duration of the index build. Migration `0030_trial_balance_snapshot_immutability.sql` attaches that already-built index as the named `trial_balance_snapshot_one_population_per_book_period` table constraint in a short transaction, installs the snapshot/line mutation guards, serializes snapshot admission on the same `accounting_book_period_control` row used by close, and rejects a visible pre-existing population with `trial_balance_snapshot_population_conflict`. + +Snapshot creation is not an ordinary soft-close write. The database admits a new snapshot only while the exact book-period is `soft_closed`, the transaction-local `accounting_core.journal_write_role` is exactly `period_closing`, and `session_user` is a member of the purpose-limited `accounting_closing_writer` role. The GUC remains classification metadata rather than authority by itself. This preserves the production hard-close sequence, where the AIS period-closing journal establishes the same transaction-local classification before the snapshot is persisted, while a raw SQL session cannot pre-populate retained close evidence merely because the period is soft-closed. The physical uniqueness boundary is required because PostgreSQL `REPEATABLE READ` retains the transaction snapshot established by its first query or data-modification statement; waiting for the authority-row lock does not make a stale transaction see a snapshot row committed later. The unique index therefore closes the stale-snapshot race even when the trigger-level existence query cannot observe the competing row. PostgreSQL documents that ordinary index creation can block writers for an unacceptable period on a live production table, while `CREATE INDEX CONCURRENTLY` keeps ordinary inserts, updates, and deletes available at the cost of extra scans and longer build time. PostgreSQL also documents converting a concurrently built unique index into a `UNIQUE` constraint with `ALTER TABLE ... ADD CONSTRAINT ... USING INDEX` as the low-blocking deployment pattern. -This is deliberately fail-closed. If raw or legacy SQL has inserted a snapshot before the canonical hard-close transaction, AIS does not silently adopt that row or create a later row and select whichever timestamp sorts last. A visible conflict raises `trial_balance_snapshot_population_conflict`; a fixed-snapshot concurrency conflict is rejected by the named unique boundary. In either case the hard-close transaction cannot establish a second population and the book-period remains non-hard-closed until conflicting retained evidence is resolved through an audited repair. +This is deliberately fail-closed. Raw or legacy SQL cannot create a pre-close snapshot unless it also holds the purpose-limited closing capability and the exact transaction classification. A visible competing population raises `trial_balance_snapshot_population_conflict`; a fixed-snapshot concurrency conflict is rejected by the named unique boundary. In either case the hard-close transaction cannot establish a second population and the book-period remains non-hard-closed until conflicting retained evidence is resolved through an audited repair. ## Consequences -Controllers close books through the posting adapter instead of a raw status update. Ordinary posting is rejected for every non-open period. The database insert guard in `0005_closed_period_guard.sql` permits only purpose-limited AIS close/adjust/reversal writes while a period is `soft_closed`; every insert is rejected once the period is `hard_closed`. The caller-controlled `accounting_core.journal_write_role` GUC is classification metadata, not sufficient authorization by itself. +Controllers close books through the posting adapter instead of a raw status update. Ordinary posting is rejected for every non-open period. The database insert guard in `0005_closed_period_guard.sql` permits only purpose-limited AIS close/adjust/reversal journal writes while a period is `soft_closed`; every journal insert is rejected once the period is `hard_closed`. The caller-controlled `accounting_core.journal_write_role` GUC is classification metadata, not sufficient authorization by itself. -Migration `0030_trial_balance_snapshot_immutability.sql` rejects UPDATE or DELETE of retained snapshot headers and lines and rejects population extension after hard close. Header and line admission lock the exact tenant/book/period authority row before evaluating status. That row lock serializes status-sensitive admission and gives the normal visible-conflict diagnostic; the exact-scope unique constraint independently enforces one population across transaction snapshots and concurrent writers. Pre-migration history is retained rather than rewritten and is not retroactively attested as canonical solely because the guard was installed. If duplicate populations already exist, the concurrent unique-index build fails rather than blessing ambiguous history. +Migration `0030_trial_balance_snapshot_immutability.sql` rejects UPDATE or DELETE of retained snapshot headers and lines, rejects population extension after hard close, and rejects a snapshot header unless the soft-closed period is being written by the purpose-limited `period_closing` capability. Header and line admission lock the exact tenant/book/period authority row before evaluating status. That row lock serializes status-sensitive admission and gives the normal visible-conflict diagnostic; the exact-scope unique constraint independently enforces one population across transaction snapshots and concurrent writers. Pre-migration history is retained rather than rewritten and is not retroactively attested as canonical solely because the guard was installed. If duplicate populations already exist, the concurrent unique-index build fails rather than blessing ambiguous history. The concurrent index phase is intentionally separate from the transactional trigger/constraint phase because PostgreSQL forbids `CREATE INDEX CONCURRENTLY` inside a transaction block. The canonical installer applies each forward migration file separately on an autocommit connection, so migration 0029 is one outside-transaction statement and migration 0030 remains an atomic transaction. A failed concurrent unique build may leave an invalid index, which PostgreSQL requires operators to remove or rebuild before retry; recovery tooling for that partial-upgrade state remains a release-readiness requirement and must not be represented as automatic rollback. From f878cf2c7fad5e5bf5a659c103017e4242a0c3a8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 04:10:05 +0900 Subject: [PATCH 032/224] test(close): make snapshot system time database-owned --- ...es_trial_balance_snapshot_admission_red.py | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/tests/test_postgres_trial_balance_snapshot_admission_red.py b/tests/test_postgres_trial_balance_snapshot_admission_red.py index e337076f..52698bdc 100644 --- a/tests/test_postgres_trial_balance_snapshot_admission_red.py +++ b/tests/test_postgres_trial_balance_snapshot_admission_red.py @@ -3,6 +3,7 @@ from __future__ import annotations import unittest +from datetime import datetime, timezone import psycopg @@ -97,6 +98,44 @@ def test_raw_soft_close_snapshot_insert_requires_close_authority(self) -> None: ) connection.rollback() + def test_authorized_snapshot_insert_cannot_choose_system_time(self) -> None: + """Even the closing capability cannot forge retained snapshot chronology.""" + with psycopg.connect(posting.DATABASE_URL) as connection: + legal_entity_id, accounting_book_id, fiscal_period_id = self._scope(connection) + connection.execute( + "SELECT set_config('accounting_core.journal_write_role', 'period_closing', true)" + ) + before_insert = datetime.now(timezone.utc) + snapshot_generated_at = connection.execute( + """ + INSERT INTO accounting_reporting.trial_balance_snapshot ( + tenant_account_id, + legal_entity_id, + accounting_book_id, + fiscal_period_id, + snapshot_currency_code, + snapshot_generated_at, + source_journal_count, + source_payload_hash, + close_idempotency_key + ) + VALUES (%s, %s, %s, %s, 'KRW', '2099-01-01T00:00:00Z', 0, %s, %s) + RETURNING snapshot_generated_at + """, + ( + self.case.tenant_id, + legal_entity_id, + accounting_book_id, + fiscal_period_id, + "sha256:" + "5" * 64, + f"{self.case.policy.tenant_reference}:snapshot-admission:authorized-probe", + ), + ).fetchone()[0] + after_insert = datetime.now(timezone.utc) + self.assertGreaterEqual(snapshot_generated_at, before_insert) + self.assertLessEqual(snapshot_generated_at, after_insert) + connection.rollback() + def test_governed_hard_close_still_creates_one_snapshot(self) -> None: """The purpose-limited period-closing path remains the sole admitted writer.""" receipt = self.case.ledger.close_fiscal_period( From 11fafa277a7888169244d9a84e49450fdd7bf738 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 04:10:23 +0900 Subject: [PATCH 033/224] fix(close): make snapshot chronology database-owned --- .../migrations/0030_trial_balance_snapshot_immutability.sql | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/database/migrations/0030_trial_balance_snapshot_immutability.sql b/database/migrations/0030_trial_balance_snapshot_immutability.sql index be986a8c..caddd0f8 100644 --- a/database/migrations/0030_trial_balance_snapshot_immutability.sql +++ b/database/migrations/0030_trial_balance_snapshot_immutability.sql @@ -85,6 +85,10 @@ BEGIN USING ERRCODE = 'check_violation'; END IF; + -- Snapshot chronology is an AIS system-time fact. A purpose-limited closing + -- writer may supply accounting evidence but cannot select the recording clock. + NEW.snapshot_generated_at := clock_timestamp(); + IF EXISTS ( SELECT 1 FROM accounting_reporting.trial_balance_snapshot From 88529b0d2848088928861e97f6da39030b54feec Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 04:10:42 +0900 Subject: [PATCH 034/224] test(close): ratchet snapshot authority and system time --- ..._balance_snapshot_immutability_contract.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/tests/test_trial_balance_snapshot_immutability_contract.py b/tests/test_trial_balance_snapshot_immutability_contract.py index 7eab7c88..e0e6739c 100644 --- a/tests/test_trial_balance_snapshot_immutability_contract.py +++ b/tests/test_trial_balance_snapshot_immutability_contract.py @@ -84,6 +84,25 @@ def test_population_guards_serialize_on_book_period_authority(self) -> None: migration, ) + def test_snapshot_header_requires_purpose_limited_hard_close_authority(self) -> None: + """A caller-controlled GUC cannot by itself create retained close evidence.""" + migration = IMMUTABILITY_MIGRATION.read_text(encoding="utf-8") + self.assertIn("period_status_value <> 'soft_closed'", migration) + self.assertIn( + "journal_write_role_value IS DISTINCT FROM 'period_closing'", + migration, + ) + self.assertIn( + "pg_has_role(session_user, 'accounting_closing_writer', 'MEMBER')", + migration, + ) + self.assertIn("trial_balance_snapshot_authority_required", migration) + + def test_snapshot_header_system_time_is_database_owned(self) -> None: + """Even an admitted closing writer cannot select retained snapshot chronology.""" + migration = IMMUTABILITY_MIGRATION.read_text(encoding="utf-8") + self.assertIn("NEW.snapshot_generated_at := clock_timestamp();", migration) + def test_book_period_accepts_at_most_one_snapshot_population(self) -> None: """Visible and stale snapshots must both be unable to create a second population.""" migration = IMMUTABILITY_MIGRATION.read_text(encoding="utf-8") From 879577dac8df8487b603a019eb4463914dc8eaa8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 04:11:12 +0900 Subject: [PATCH 035/224] docs(close): make snapshot system time explicit --- docs/adr/0006-fiscal-period-close-snapshot.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/adr/0006-fiscal-period-close-snapshot.md b/docs/adr/0006-fiscal-period-close-snapshot.md index fb2ca008..d5b86470 100644 --- a/docs/adr/0006-fiscal-period-close-snapshot.md +++ b/docs/adr/0006-fiscal-period-close-snapshot.md @@ -10,7 +10,7 @@ An exact hard-close replay returns the existing snapshot and writes no second sn The retained hard-close population is one snapshot per tenant/accounting-book/fiscal-period authority scope. Migration `0029_trial_balance_snapshot_population_unique_index.sql` builds the exact-scope unique index with `CREATE UNIQUE INDEX CONCURRENTLY` outside a transaction block so an upgrade does not hold a write-blocking table lock for the duration of the index build. Migration `0030_trial_balance_snapshot_immutability.sql` attaches that already-built index as the named `trial_balance_snapshot_one_population_per_book_period` table constraint in a short transaction, installs the snapshot/line mutation guards, serializes snapshot admission on the same `accounting_book_period_control` row used by close, and rejects a visible pre-existing population with `trial_balance_snapshot_population_conflict`. -Snapshot creation is not an ordinary soft-close write. The database admits a new snapshot only while the exact book-period is `soft_closed`, the transaction-local `accounting_core.journal_write_role` is exactly `period_closing`, and `session_user` is a member of the purpose-limited `accounting_closing_writer` role. The GUC remains classification metadata rather than authority by itself. This preserves the production hard-close sequence, where the AIS period-closing journal establishes the same transaction-local classification before the snapshot is persisted, while a raw SQL session cannot pre-populate retained close evidence merely because the period is soft-closed. +Snapshot creation is not an ordinary soft-close write. The database admits a new snapshot only while the exact book-period is `soft_closed`, the transaction-local `accounting_core.journal_write_role` is exactly `period_closing`, and `session_user` is a member of the purpose-limited `accounting_closing_writer` role. The GUC remains classification metadata rather than authority by itself. This preserves the production hard-close sequence, where the AIS period-closing journal establishes the same transaction-local classification before the snapshot is persisted, while a raw SQL session cannot pre-populate retained close evidence merely because the period is soft-closed. `snapshot_generated_at` is a PostgreSQL-owned system-time fact: the admission trigger replaces any caller-supplied value with `clock_timestamp()`, including values supplied by an otherwise authorized closing writer. The physical uniqueness boundary is required because PostgreSQL `REPEATABLE READ` retains the transaction snapshot established by its first query or data-modification statement; waiting for the authority-row lock does not make a stale transaction see a snapshot row committed later. The unique index therefore closes the stale-snapshot race even when the trigger-level existence query cannot observe the competing row. PostgreSQL documents that ordinary index creation can block writers for an unacceptable period on a live production table, while `CREATE INDEX CONCURRENTLY` keeps ordinary inserts, updates, and deletes available at the cost of extra scans and longer build time. PostgreSQL also documents converting a concurrently built unique index into a `UNIQUE` constraint with `ALTER TABLE ... ADD CONSTRAINT ... USING INDEX` as the low-blocking deployment pattern. @@ -20,7 +20,7 @@ This is deliberately fail-closed. Raw or legacy SQL cannot create a pre-close sn Controllers close books through the posting adapter instead of a raw status update. Ordinary posting is rejected for every non-open period. The database insert guard in `0005_closed_period_guard.sql` permits only purpose-limited AIS close/adjust/reversal journal writes while a period is `soft_closed`; every journal insert is rejected once the period is `hard_closed`. The caller-controlled `accounting_core.journal_write_role` GUC is classification metadata, not sufficient authorization by itself. -Migration `0030_trial_balance_snapshot_immutability.sql` rejects UPDATE or DELETE of retained snapshot headers and lines, rejects population extension after hard close, and rejects a snapshot header unless the soft-closed period is being written by the purpose-limited `period_closing` capability. Header and line admission lock the exact tenant/book/period authority row before evaluating status. That row lock serializes status-sensitive admission and gives the normal visible-conflict diagnostic; the exact-scope unique constraint independently enforces one population across transaction snapshots and concurrent writers. Pre-migration history is retained rather than rewritten and is not retroactively attested as canonical solely because the guard was installed. If duplicate populations already exist, the concurrent unique-index build fails rather than blessing ambiguous history. +Migration `0030_trial_balance_snapshot_immutability.sql` rejects UPDATE or DELETE of retained snapshot headers and lines, rejects population extension after hard close, rejects a snapshot header unless the soft-closed period is being written by the purpose-limited `period_closing` capability, and owns snapshot recording chronology at the database boundary. Header and line admission lock the exact tenant/book/period authority row before evaluating status. That row lock serializes status-sensitive admission and gives the normal visible-conflict diagnostic; the exact-scope unique constraint independently enforces one population across transaction snapshots and concurrent writers. Pre-migration history is retained rather than rewritten and is not retroactively attested as canonical solely because the guard was installed. If duplicate populations already exist, the concurrent unique-index build fails rather than blessing ambiguous history. The concurrent index phase is intentionally separate from the transactional trigger/constraint phase because PostgreSQL forbids `CREATE INDEX CONCURRENTLY` inside a transaction block. The canonical installer applies each forward migration file separately on an autocommit connection, so migration 0029 is one outside-transaction statement and migration 0030 remains an atomic transaction. A failed concurrent unique build may leave an invalid index, which PostgreSQL requires operators to remove or rebuild before retry; recovery tooling for that partial-upgrade state remains a release-readiness requirement and must not be represented as automatic rollback. From ab2b46b4025024946fa3c25e66695f0fd3af70f0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 04:13:34 +0900 Subject: [PATCH 036/224] test(close): preserve authorized stale-snapshot race coverage --- .../test_postgres_trial_balance_snapshot_concurrency_red.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/test_postgres_trial_balance_snapshot_concurrency_red.py b/tests/test_postgres_trial_balance_snapshot_concurrency_red.py index aac3ad9e..238060d1 100644 --- a/tests/test_postgres_trial_balance_snapshot_concurrency_red.py +++ b/tests/test_postgres_trial_balance_snapshot_concurrency_red.py @@ -75,7 +75,10 @@ def _insert_snapshot( payload_digit: str, command_suffix: str, ) -> None: - """Insert one raw pre-close population candidate for the exact authority scope.""" + """Insert one purpose-limited pre-close population candidate for the exact scope.""" + connection.execute( + "SELECT set_config('accounting_core.journal_write_role', 'period_closing', true)" + ) connection.execute( """ INSERT INTO accounting_reporting.trial_balance_snapshot ( From d956d0c04203667e895f204434591445f61e4582 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 04:14:19 +0900 Subject: [PATCH 037/224] test(close): keep conflict probes inside closing authority --- ...est_postgres_trial_balance_snapshot_immutability_red.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tests/test_postgres_trial_balance_snapshot_immutability_red.py b/tests/test_postgres_trial_balance_snapshot_immutability_red.py index 95117c1c..18d0800e 100644 --- a/tests/test_postgres_trial_balance_snapshot_immutability_red.py +++ b/tests/test_postgres_trial_balance_snapshot_immutability_red.py @@ -253,8 +253,8 @@ def setUp(self) -> None: idempotency_key=f"{self.case.policy.tenant_reference}:snapshot-preclose:soft", ) - def test_preexisting_future_snapshot_cannot_become_hard_close_authority(self) -> None: - """A forged pre-close row must make hard close fail closed instead of becoming latest evidence.""" + def test_preexisting_snapshot_from_closing_capability_cannot_become_hard_close_authority(self) -> None: + """A purpose-limited pre-close row must make hard close fail closed, not become authority.""" with psycopg.connect(posting.DATABASE_URL) as connection: scope = connection.execute( """ @@ -289,6 +289,9 @@ def test_preexisting_future_snapshot_cannot_become_hard_close_authority(self) -> self.assertIsNotNone(scope) assert scope is not None legal_entity_id, accounting_book_id, fiscal_period_id = scope + connection.execute( + "SELECT set_config('accounting_core.journal_write_role', 'period_closing', true)" + ) connection.execute( """ INSERT INTO accounting_reporting.trial_balance_snapshot ( From 45cb632dc5d7f0ad2b6835cf2457a7cda53c13bf Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 04:17:58 +0900 Subject: [PATCH 038/224] test(close): cover hard close without closing journal --- ...es_trial_balance_snapshot_admission_red.py | 38 ++++++++++++++++++- 1 file changed, 36 insertions(+), 2 deletions(-) diff --git a/tests/test_postgres_trial_balance_snapshot_admission_red.py b/tests/test_postgres_trial_balance_snapshot_admission_red.py index 52698bdc..2b353780 100644 --- a/tests/test_postgres_trial_balance_snapshot_admission_red.py +++ b/tests/test_postgres_trial_balance_snapshot_admission_red.py @@ -3,7 +3,7 @@ from __future__ import annotations import unittest -from datetime import datetime, timezone +from datetime import date, datetime, timezone import psycopg @@ -24,7 +24,9 @@ def setUp(self) -> None: self.case.setUp() self.addCleanup(self.case.doCleanups) self.addCleanup(self.case.tearDown) - self.case.ledger.post(self.case._two_line_proposal(), self.case.policy) + self.posting_receipt = self.case.ledger.post( + self.case._two_line_proposal(), self.case.policy + ) self.case.ledger.close_fiscal_period( self.case.policy.legal_entity_reference, self.case.policy.accounting_book_reference, @@ -136,6 +138,38 @@ def test_authorized_snapshot_insert_cannot_choose_system_time(self) -> None: self.assertLessEqual(snapshot_generated_at, after_insert) connection.rollback() + def test_hard_close_without_closing_journal_still_has_snapshot_authority(self) -> None: + """A zero-net-income period still hard-closes when no period-closing journal is needed.""" + self.case.ledger.reverse( + self.posting_receipt.journal_reference, + date(2026, 8, 31), + "billing_correction", + self.case.policy, + reversal_idempotency_key=( + f"{self.case.policy.tenant_reference}:snapshot-admission:zero-income" + ), + ) + receipt = self.case.ledger.close_fiscal_period( + self.case.policy.legal_entity_reference, + self.case.policy.accounting_book_reference, + "2026-08", + "KRW", + period_status_code="hard_closed", + idempotency_key=f"{self.case.policy.tenant_reference}:snapshot-admission:zero-hard", + ) + self.assertEqual(receipt.period_status_code, "hard_closed") + with psycopg.connect(posting.DATABASE_URL) as connection: + closing_journal_count = connection.execute( + """ + SELECT count(*) + FROM accounting_core.general_journal + WHERE tenant_account_id = %s + AND journal_reference LIKE 'urn:cwl:accounting:general_journal:period_closing:%%' + """, + (self.case.tenant_id,), + ).fetchone()[0] + self.assertEqual(closing_journal_count, 0) + def test_governed_hard_close_still_creates_one_snapshot(self) -> None: """The purpose-limited period-closing path remains the sole admitted writer.""" receipt = self.case.ledger.close_fiscal_period( From c6e004331c4ee2490f44d17f41cd66cb760f2c6c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 04:25:14 +0900 Subject: [PATCH 039/224] fix(close): decouple snapshot authority from optional journal --- ...30_trial_balance_snapshot_immutability.sql | 41 ++++++++++++++++++- 1 file changed, 40 insertions(+), 1 deletion(-) diff --git a/database/migrations/0030_trial_balance_snapshot_immutability.sql b/database/migrations/0030_trial_balance_snapshot_immutability.sql index caddd0f8..3faca07f 100644 --- a/database/migrations/0030_trial_balance_snapshot_immutability.sql +++ b/database/migrations/0030_trial_balance_snapshot_immutability.sql @@ -50,6 +50,7 @@ AS $$ DECLARE period_status_value text; journal_write_role_value text; + close_command_lock_held boolean; BEGIN SELECT accounting_book_period_control.period_status_code INTO period_status_value @@ -76,9 +77,47 @@ BEGIN '' ); + -- The hard-close command always acquires this tenant/book/period transaction + -- advisory lock before assembling close evidence. The lock remains present even + -- when zero net revenue/expense means no period-closing journal is emitted, so + -- snapshot admission must not depend on an optional journal INSERT side effect. + SELECT EXISTS ( + SELECT 1 + FROM pg_catalog.pg_locks AS held_lock + JOIN accounting_core.tenant_account + ON tenant_account.tenant_account_id = NEW.tenant_account_id + JOIN accounting_core.accounting_book + ON accounting_book.tenant_account_id = NEW.tenant_account_id + AND accounting_book.accounting_book_id = NEW.accounting_book_id + JOIN accounting_core.fiscal_period + ON fiscal_period.tenant_account_id = NEW.tenant_account_id + AND fiscal_period.fiscal_period_id = NEW.fiscal_period_id + WHERE held_lock.locktype = 'advisory' + AND held_lock.pid = pg_backend_pid() + AND held_lock.database = ( + SELECT pg_database.oid + FROM pg_catalog.pg_database + WHERE pg_database.datname = current_database() + ) + AND held_lock.mode = 'ExclusiveLock' + AND held_lock.granted + AND held_lock.objsubid = 2 + AND held_lock.classid::bigint = ( + hashtext(tenant_account.tenant_account_code)::bigint & 4294967295 + ) + AND held_lock.objid::bigint = ( + hashtext( + 'period:' || accounting_book.book_name || ':' || fiscal_period.period_code + )::bigint & 4294967295 + ) + ) INTO close_command_lock_held; + IF period_status_value <> 'soft_closed' - OR journal_write_role_value IS DISTINCT FROM 'period_closing' OR NOT pg_has_role(session_user, 'accounting_closing_writer', 'MEMBER') + OR ( + journal_write_role_value IS DISTINCT FROM 'period_closing' + AND NOT close_command_lock_held + ) THEN RAISE EXCEPTION 'trial balance snapshot creation requires the purpose-limited hard-close writer (trial_balance_snapshot_authority_required)' From 97578db9c799c7ce89df86633fc4301f6dc9f0e8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 04:25:43 +0900 Subject: [PATCH 040/224] test(close): ratchet canonical close command context --- ...est_trial_balance_snapshot_immutability_contract.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/tests/test_trial_balance_snapshot_immutability_contract.py b/tests/test_trial_balance_snapshot_immutability_contract.py index e0e6739c..b9c2b214 100644 --- a/tests/test_trial_balance_snapshot_immutability_contract.py +++ b/tests/test_trial_balance_snapshot_immutability_contract.py @@ -85,7 +85,7 @@ def test_population_guards_serialize_on_book_period_authority(self) -> None: ) def test_snapshot_header_requires_purpose_limited_hard_close_authority(self) -> None: - """A caller-controlled GUC cannot by itself create retained close evidence.""" + """Capability plus close-command context is required; a caller GUC is not authority.""" migration = IMMUTABILITY_MIGRATION.read_text(encoding="utf-8") self.assertIn("period_status_value <> 'soft_closed'", migration) self.assertIn( @@ -96,6 +96,14 @@ def test_snapshot_header_requires_purpose_limited_hard_close_authority(self) -> "pg_has_role(session_user, 'accounting_closing_writer', 'MEMBER')", migration, ) + self.assertIn("close_command_lock_held", migration) + self.assertIn("FROM pg_catalog.pg_locks AS held_lock", migration) + self.assertIn("held_lock.objsubid = 2", migration) + self.assertIn("held_lock.pid = pg_backend_pid()", migration) + self.assertIn( + "'period:' || accounting_book.book_name || ':' || fiscal_period.period_code", + migration, + ) self.assertIn("trial_balance_snapshot_authority_required", migration) def test_snapshot_header_system_time_is_database_owned(self) -> None: From 6ab1bff31abd130a22ffb664d7c966f077b691cc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 04:26:16 +0900 Subject: [PATCH 041/224] docs(close): trace journal-independent command authority --- docs/adr/0006-fiscal-period-close-snapshot.md | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/docs/adr/0006-fiscal-period-close-snapshot.md b/docs/adr/0006-fiscal-period-close-snapshot.md index d5b86470..a3d2e32a 100644 --- a/docs/adr/0006-fiscal-period-close-snapshot.md +++ b/docs/adr/0006-fiscal-period-close-snapshot.md @@ -4,23 +4,25 @@ ## Decision -`PostgresPostingLedger.close_fiscal_period` is the first-class close command. The current authoritative sequence is the two-step rule defined by ADR 0023. A `soft_closed` command changes the fiscal-period status only: it writes no `trial_balance_snapshot`, no `trial_balance_line`, and no period-closing journal. A later `hard_closed` command posts the AIS period-closing journal first, computes the live trial balance for the tenant, legal entity, and accounting book through the period end date, persists exactly one `trial_balance_snapshot` population, and then hard-closes the period in the same governed transaction. Posted journals are never rewritten. +`PostgresPostingLedger.close_fiscal_period` is the first-class close command. The current authoritative sequence is the two-step rule defined by ADR 0023. A `soft_closed` command changes the fiscal-period status only: it writes no `trial_balance_snapshot`, no `trial_balance_line`, and no period-closing journal. A later `hard_closed` command acquires the tenant/book/period command lock, posts the AIS period-closing journal when one is required, computes the live trial balance for the tenant, legal entity, and accounting book through the period end date, persists exactly one `trial_balance_snapshot` population, and then hard-closes the period in the same governed transaction. Posted journals are never rewritten. An exact hard-close replay returns the existing snapshot and writes no second snapshot, closing journal, or close event. A `hard_closed` period cannot transition back to `soft_closed`. Soft-close replay remains snapshot-free. The retained hard-close population is one snapshot per tenant/accounting-book/fiscal-period authority scope. Migration `0029_trial_balance_snapshot_population_unique_index.sql` builds the exact-scope unique index with `CREATE UNIQUE INDEX CONCURRENTLY` outside a transaction block so an upgrade does not hold a write-blocking table lock for the duration of the index build. Migration `0030_trial_balance_snapshot_immutability.sql` attaches that already-built index as the named `trial_balance_snapshot_one_population_per_book_period` table constraint in a short transaction, installs the snapshot/line mutation guards, serializes snapshot admission on the same `accounting_book_period_control` row used by close, and rejects a visible pre-existing population with `trial_balance_snapshot_population_conflict`. -Snapshot creation is not an ordinary soft-close write. The database admits a new snapshot only while the exact book-period is `soft_closed`, the transaction-local `accounting_core.journal_write_role` is exactly `period_closing`, and `session_user` is a member of the purpose-limited `accounting_closing_writer` role. The GUC remains classification metadata rather than authority by itself. This preserves the production hard-close sequence, where the AIS period-closing journal establishes the same transaction-local classification before the snapshot is persisted, while a raw SQL session cannot pre-populate retained close evidence merely because the period is soft-closed. `snapshot_generated_at` is a PostgreSQL-owned system-time fact: the admission trigger replaces any caller-supplied value with `clock_timestamp()`, including values supplied by an otherwise authorized closing writer. +Snapshot creation is not an ordinary soft-close write. The database admits a new snapshot only while the exact book-period is `soft_closed`, `session_user` is a member of the purpose-limited `accounting_closing_writer` role, and the transaction carries hard-close command context. That context is present either as transaction-local `accounting_core.journal_write_role=period_closing` after a required closing-journal write or as the exact tenant/book/period transaction-level advisory lock acquired unconditionally by `close_fiscal_period` before close evidence is assembled. The GUC and advisory lock are purpose/classification evidence rather than independent authorization; PostgreSQL role membership remains the capability boundary. This distinction matters for a zero-net-income or otherwise already-neutralized period: a valid hard close still persists one retained snapshot even though no period-closing journal is required, so snapshot authority cannot depend on an optional journal INSERT side effect. + +PostgreSQL exposes two-integer advisory-lock keys in `pg_locks.classid` and `pg_locks.objid` with `objsubid = 2`. The snapshot admission trigger therefore recognizes only the current backend's granted exclusive transaction lock whose keys match the same `hashtext(tenant_reference)` and `hashtext('period:' || book_reference || ':' || period_code)` pair used by the application command boundary. A generic raw INSERT on a merely soft-closed period, without either the explicit period-closing classification or that canonical command lock, fails `trial_balance_snapshot_authority_required`. `snapshot_generated_at` is a PostgreSQL-owned system-time fact: the admission trigger replaces any caller-supplied value with `clock_timestamp()`, including values supplied by an otherwise authorized closing writer. The physical uniqueness boundary is required because PostgreSQL `REPEATABLE READ` retains the transaction snapshot established by its first query or data-modification statement; waiting for the authority-row lock does not make a stale transaction see a snapshot row committed later. The unique index therefore closes the stale-snapshot race even when the trigger-level existence query cannot observe the competing row. PostgreSQL documents that ordinary index creation can block writers for an unacceptable period on a live production table, while `CREATE INDEX CONCURRENTLY` keeps ordinary inserts, updates, and deletes available at the cost of extra scans and longer build time. PostgreSQL also documents converting a concurrently built unique index into a `UNIQUE` constraint with `ALTER TABLE ... ADD CONSTRAINT ... USING INDEX` as the low-blocking deployment pattern. -This is deliberately fail-closed. Raw or legacy SQL cannot create a pre-close snapshot unless it also holds the purpose-limited closing capability and the exact transaction classification. A visible competing population raises `trial_balance_snapshot_population_conflict`; a fixed-snapshot concurrency conflict is rejected by the named unique boundary. In either case the hard-close transaction cannot establish a second population and the book-period remains non-hard-closed until conflicting retained evidence is resolved through an audited repair. +This is deliberately fail-closed. Raw or legacy SQL cannot create a pre-close snapshot merely because the period is soft-closed; it must run with the purpose-limited closing capability and one of the exact close-command context signals. A visible competing population raises `trial_balance_snapshot_population_conflict`; a fixed-snapshot concurrency conflict is rejected by the named unique boundary. In either case the hard-close transaction cannot establish a second population and the book-period remains non-hard-closed until conflicting retained evidence is resolved through an audited repair. ## Consequences Controllers close books through the posting adapter instead of a raw status update. Ordinary posting is rejected for every non-open period. The database insert guard in `0005_closed_period_guard.sql` permits only purpose-limited AIS close/adjust/reversal journal writes while a period is `soft_closed`; every journal insert is rejected once the period is `hard_closed`. The caller-controlled `accounting_core.journal_write_role` GUC is classification metadata, not sufficient authorization by itself. -Migration `0030_trial_balance_snapshot_immutability.sql` rejects UPDATE or DELETE of retained snapshot headers and lines, rejects population extension after hard close, rejects a snapshot header unless the soft-closed period is being written by the purpose-limited `period_closing` capability, and owns snapshot recording chronology at the database boundary. Header and line admission lock the exact tenant/book/period authority row before evaluating status. That row lock serializes status-sensitive admission and gives the normal visible-conflict diagnostic; the exact-scope unique constraint independently enforces one population across transaction snapshots and concurrent writers. Pre-migration history is retained rather than rewritten and is not retroactively attested as canonical solely because the guard was installed. If duplicate populations already exist, the concurrent unique-index build fails rather than blessing ambiguous history. +Migration `0030_trial_balance_snapshot_immutability.sql` rejects UPDATE or DELETE of retained snapshot headers and lines, rejects population extension after hard close, rejects a snapshot header unless the soft-closed period is being written by the purpose-limited hard-close capability, and owns snapshot recording chronology at the database boundary. Header and line admission lock the exact tenant/book/period authority row before evaluating status. The header guard checks the current backend's exact transaction advisory lock only once per close snapshot; it does not poll `pg_locks` on journal or report-read hot paths. That row lock serializes status-sensitive admission and gives the normal visible-conflict diagnostic; the exact-scope unique constraint independently enforces one population across transaction snapshots and concurrent writers. Pre-migration history is retained rather than rewritten and is not retroactively attested as canonical solely because the guard was installed. If duplicate populations already exist, the concurrent unique-index build fails rather than blessing ambiguous history. The concurrent index phase is intentionally separate from the transactional trigger/constraint phase because PostgreSQL forbids `CREATE INDEX CONCURRENTLY` inside a transaction block. The canonical installer applies each forward migration file separately on an autocommit connection, so migration 0029 is one outside-transaction statement and migration 0030 remains an atomic transaction. A failed concurrent unique build may leave an invalid index, which PostgreSQL requires operators to remove or rebuild before retry; recovery tooling for that partial-upgrade state remains a release-readiness requirement and must not be represented as automatic rollback. @@ -43,3 +45,7 @@ PostgreSQL Global Development Group. (2026b). *PostgreSQL 18 documentation: CREA PostgreSQL Global Development Group. (2026c). *PostgreSQL 18 documentation: ALTER TABLE*. https://www.postgresql.org/docs/18/sql-altertable.html PostgreSQL Global Development Group. (2026d). *PostgreSQL 18 documentation: Unique indexes*. https://www.postgresql.org/docs/18/indexes-unique.html + +PostgreSQL Global Development Group. (2026e). *PostgreSQL 18 documentation: pg_locks*. https://www.postgresql.org/docs/18/view-pg-locks.html + +PostgreSQL Global Development Group. (2026f). *PostgreSQL 18 documentation: System administration functions—Advisory lock functions*. https://www.postgresql.org/docs/18/functions-admin.html#FUNCTIONS-ADVISORY-LOCKS From eff61846f20f9f419f6ab7785ace183fa0831fee Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 04:32:13 +0900 Subject: [PATCH 042/224] test(close): match snapshot authority lock to command key --- tests/test_trial_balance_snapshot_immutability_contract.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/test_trial_balance_snapshot_immutability_contract.py b/tests/test_trial_balance_snapshot_immutability_contract.py index b9c2b214..5bd39e15 100644 --- a/tests/test_trial_balance_snapshot_immutability_contract.py +++ b/tests/test_trial_balance_snapshot_immutability_contract.py @@ -85,7 +85,7 @@ def test_population_guards_serialize_on_book_period_authority(self) -> None: ) def test_snapshot_header_requires_purpose_limited_hard_close_authority(self) -> None: - """Capability plus close-command context is required; a caller GUC is not authority.""" + """Capability plus the exact application close-command lock is required.""" migration = IMMUTABILITY_MIGRATION.read_text(encoding="utf-8") self.assertIn("period_status_value <> 'soft_closed'", migration) self.assertIn( @@ -101,6 +101,10 @@ def test_snapshot_header_requires_purpose_limited_hard_close_authority(self) -> self.assertIn("held_lock.objsubid = 2", migration) self.assertIn("held_lock.pid = pg_backend_pid()", migration) self.assertIn( + "'period:' || accounting_book.accounting_book_id::text || ':' || fiscal_period.period_code", + migration, + ) + self.assertNotIn( "'period:' || accounting_book.book_name || ':' || fiscal_period.period_code", migration, ) From 07516da0cc0b46135709e119e4f813e7e03f02d3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 04:32:45 +0900 Subject: [PATCH 043/224] fix(close): use canonical accounting book lock identity --- .../migrations/0030_trial_balance_snapshot_immutability.sql | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/database/migrations/0030_trial_balance_snapshot_immutability.sql b/database/migrations/0030_trial_balance_snapshot_immutability.sql index 3faca07f..cb79c71c 100644 --- a/database/migrations/0030_trial_balance_snapshot_immutability.sql +++ b/database/migrations/0030_trial_balance_snapshot_immutability.sql @@ -107,7 +107,7 @@ BEGIN ) AND held_lock.objid::bigint = ( hashtext( - 'period:' || accounting_book.book_name || ':' || fiscal_period.period_code + 'period:' || accounting_book.accounting_book_id::text || ':' || fiscal_period.period_code )::bigint & 4294967295 ) ) INTO close_command_lock_held; From 6028fafeb45a25759bcd9fe7f645193884d44fb3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 04:33:36 +0900 Subject: [PATCH 044/224] docs(close): identify canonical advisory lock key --- docs/adr/0006-fiscal-period-close-snapshot.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/adr/0006-fiscal-period-close-snapshot.md b/docs/adr/0006-fiscal-period-close-snapshot.md index a3d2e32a..65cc3485 100644 --- a/docs/adr/0006-fiscal-period-close-snapshot.md +++ b/docs/adr/0006-fiscal-period-close-snapshot.md @@ -12,7 +12,7 @@ The retained hard-close population is one snapshot per tenant/accounting-book/fi Snapshot creation is not an ordinary soft-close write. The database admits a new snapshot only while the exact book-period is `soft_closed`, `session_user` is a member of the purpose-limited `accounting_closing_writer` role, and the transaction carries hard-close command context. That context is present either as transaction-local `accounting_core.journal_write_role=period_closing` after a required closing-journal write or as the exact tenant/book/period transaction-level advisory lock acquired unconditionally by `close_fiscal_period` before close evidence is assembled. The GUC and advisory lock are purpose/classification evidence rather than independent authorization; PostgreSQL role membership remains the capability boundary. This distinction matters for a zero-net-income or otherwise already-neutralized period: a valid hard close still persists one retained snapshot even though no period-closing journal is required, so snapshot authority cannot depend on an optional journal INSERT side effect. -PostgreSQL exposes two-integer advisory-lock keys in `pg_locks.classid` and `pg_locks.objid` with `objsubid = 2`. The snapshot admission trigger therefore recognizes only the current backend's granted exclusive transaction lock whose keys match the same `hashtext(tenant_reference)` and `hashtext('period:' || book_reference || ':' || period_code)` pair used by the application command boundary. A generic raw INSERT on a merely soft-closed period, without either the explicit period-closing classification or that canonical command lock, fails `trial_balance_snapshot_authority_required`. `snapshot_generated_at` is a PostgreSQL-owned system-time fact: the admission trigger replaces any caller-supplied value with `clock_timestamp()`, including values supplied by an otherwise authorized closing writer. +PostgreSQL exposes two-integer advisory-lock keys in `pg_locks.classid` and `pg_locks.objid` with `objsubid = 2`. The application resolves the caller-facing accounting-book reference first and then acquires the command lock with `hashtext(tenant_reference)` and `hashtext('period:' || accounting_book_id::text || ':' || period_code)`. The snapshot admission trigger must reconstruct that exact resolved identity; using the caller-facing `book_name` instead would describe a different advisory key and reject valid journal-independent hard closes. A generic raw INSERT on a merely soft-closed period, without either the explicit period-closing classification or that canonical command lock, fails `trial_balance_snapshot_authority_required`. `snapshot_generated_at` is a PostgreSQL-owned system-time fact: the admission trigger replaces any caller-supplied value with `clock_timestamp()`, including values supplied by an otherwise authorized closing writer. The physical uniqueness boundary is required because PostgreSQL `REPEATABLE READ` retains the transaction snapshot established by its first query or data-modification statement; waiting for the authority-row lock does not make a stale transaction see a snapshot row committed later. The unique index therefore closes the stale-snapshot race even when the trigger-level existence query cannot observe the competing row. PostgreSQL documents that ordinary index creation can block writers for an unacceptable period on a live production table, while `CREATE INDEX CONCURRENTLY` keeps ordinary inserts, updates, and deletes available at the cost of extra scans and longer build time. PostgreSQL also documents converting a concurrently built unique index into a `UNIQUE` constraint with `ALTER TABLE ... ADD CONSTRAINT ... USING INDEX` as the low-blocking deployment pattern. From 613167fbbfbbbf8a852207d2dc15bed72b72a79d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 05:06:50 +0900 Subject: [PATCH 045/224] test(close): reject cross-scope trial balance evidence --- ...stgres_trial_balance_snapshot_scope_red.py | 211 ++++++++++++++++++ 1 file changed, 211 insertions(+) create mode 100644 tests/test_postgres_trial_balance_snapshot_scope_red.py diff --git a/tests/test_postgres_trial_balance_snapshot_scope_red.py b/tests/test_postgres_trial_balance_snapshot_scope_red.py new file mode 100644 index 00000000..3139b005 --- /dev/null +++ b/tests/test_postgres_trial_balance_snapshot_scope_red.py @@ -0,0 +1,211 @@ +"""Real PostgreSQL RED/GREEN for retained trial-balance evidence scope integrity.""" + +from __future__ import annotations + +import unittest + +import psycopg + +from tests import test_postgres_posting as posting + + +class TrialBalanceSnapshotScopePostgresTests(unittest.TestCase): + """Keep retained snapshot headers and lines inside one accounting-book scope.""" + + @classmethod + def setUpClass(cls) -> None: + """Install the complete current accounting migration chain.""" + posting.PostgresPostingTests.setUpClass() + + def setUp(self) -> None: + """Leave one balanced book-period soft-closed for controlled snapshot admission.""" + self.case = posting.PostgresPostingTests("setUp") + self.case.setUp() + self.addCleanup(self.case.doCleanups) + self.addCleanup(self.case.tearDown) + self.case.ledger.post(self.case._two_line_proposal(), self.case.policy) + self.case.ledger.close_fiscal_period( + self.case.policy.legal_entity_reference, + self.case.policy.accounting_book_reference, + "2026-08", + "KRW", + period_status_code="soft_closed", + idempotency_key=f"{self.case.policy.tenant_reference}:snapshot-scope:soft", + ) + + def _scope(self, connection: psycopg.Connection[object]) -> tuple[object, object, object]: + row = connection.execute( + """ + SELECT legal_entity_record.legal_entity_id, + accounting_book.accounting_book_id, + fiscal_period.fiscal_period_id + FROM accounting_core.legal_entity_record + JOIN accounting_core.accounting_book + ON accounting_book.tenant_account_id = legal_entity_record.tenant_account_id + AND accounting_book.legal_entity_id = legal_entity_record.legal_entity_id + JOIN accounting_core.accounting_book_period_control + ON accounting_book_period_control.tenant_account_id = accounting_book.tenant_account_id + AND accounting_book_period_control.accounting_book_id = accounting_book.accounting_book_id + JOIN accounting_core.fiscal_period + ON fiscal_period.tenant_account_id = accounting_book_period_control.tenant_account_id + AND fiscal_period.fiscal_period_id = accounting_book_period_control.fiscal_period_id + WHERE legal_entity_record.tenant_account_id = %s + AND legal_entity_record.legal_entity_code = %s + AND accounting_book.book_name = %s + AND fiscal_period.period_code = '2026-08' + """, + ( + self.case.tenant_id, + self.case.policy.legal_entity_reference, + self.case.policy.accounting_book_reference, + ), + ).fetchone() + self.assertIsNotNone(row) + assert row is not None + return row + + @staticmethod + def _enable_period_closing_classification(connection: psycopg.Connection[object]) -> None: + connection.execute( + "SELECT set_config('accounting_core.journal_write_role', 'period_closing', true)" + ) + + def test_snapshot_header_rejects_legal_entity_from_another_book_scope(self) -> None: + """A retained snapshot cannot pair one book with another legal entity.""" + with psycopg.connect(posting.DATABASE_URL) as connection: + _, accounting_book_id, fiscal_period_id = self._scope(connection) + other_legal_entity_id = connection.execute( + """ + INSERT INTO accounting_core.legal_entity_record ( + tenant_account_id, + legal_entity_code, + entity_name, + functional_currency_code, + valid_from + ) + VALUES (%s, %s, 'Other scope entity', 'KRW', %s) + RETURNING legal_entity_id + """, + ( + self.case.tenant_id, + f"{self.case.policy.legal_entity_reference}:other", + posting.VALID_FROM, + ), + ).fetchone()[0] + self._enable_period_closing_classification(connection) + + with self.assertRaisesRegex( + psycopg.errors.CheckViolation, + "trial_balance_snapshot_book_entity_mismatch", + ): + connection.execute( + """ + INSERT INTO accounting_reporting.trial_balance_snapshot ( + tenant_account_id, + legal_entity_id, + accounting_book_id, + fiscal_period_id, + snapshot_currency_code, + source_journal_count, + source_payload_hash, + close_idempotency_key + ) + VALUES (%s, %s, %s, %s, 'KRW', 0, %s, %s) + """, + ( + self.case.tenant_id, + other_legal_entity_id, + accounting_book_id, + fiscal_period_id, + "sha256:" + "7" * 64, + f"{self.case.policy.tenant_reference}:snapshot-scope:wrong-entity", + ), + ) + + def test_snapshot_line_rejects_chart_account_from_another_book(self) -> None: + """A retained line cannot import a chart account owned by another accounting book.""" + with psycopg.connect(posting.DATABASE_URL) as connection: + legal_entity_id, accounting_book_id, fiscal_period_id = self._scope(connection) + other_book_id = connection.execute( + """ + INSERT INTO accounting_core.accounting_book ( + tenant_account_id, + legal_entity_id, + book_role_code, + book_name, + reporting_currency_code, + valid_from + ) + VALUES (%s, %s, 'management', %s, 'KRW', %s) + RETURNING accounting_book_id + """, + ( + self.case.tenant_id, + legal_entity_id, + f"{self.case.policy.accounting_book_reference}:other", + posting.VALID_FROM, + ), + ).fetchone()[0] + other_chart_account_id = connection.execute( + """ + INSERT INTO accounting_core.chart_account ( + tenant_account_id, + accounting_book_id, + chart_account_code, + account_name, + normal_balance_code, + valid_from + ) + VALUES (%s, %s, '990001', 'Other-book account', 'debit', %s) + RETURNING chart_account_id + """, + (self.case.tenant_id, other_book_id, posting.VALID_FROM), + ).fetchone()[0] + self._enable_period_closing_classification(connection) + snapshot_id = connection.execute( + """ + INSERT INTO accounting_reporting.trial_balance_snapshot ( + tenant_account_id, + legal_entity_id, + accounting_book_id, + fiscal_period_id, + snapshot_currency_code, + source_journal_count, + source_payload_hash, + close_idempotency_key + ) + VALUES (%s, %s, %s, %s, 'KRW', 0, %s, %s) + RETURNING trial_balance_snapshot_id + """, + ( + self.case.tenant_id, + legal_entity_id, + accounting_book_id, + fiscal_period_id, + "sha256:" + "8" * 64, + f"{self.case.policy.tenant_reference}:snapshot-scope:line", + ), + ).fetchone()[0] + + with self.assertRaisesRegex( + psycopg.errors.CheckViolation, + "trial_balance_line_book_scope_mismatch", + ): + connection.execute( + """ + INSERT INTO accounting_reporting.trial_balance_line ( + tenant_account_id, + trial_balance_snapshot_id, + chart_account_id, + debit_total_amount, + credit_total_amount, + net_balance_amount + ) + VALUES (%s, %s, %s, 1, 0, 1) + """, + (self.case.tenant_id, snapshot_id, other_chart_account_id), + ) + + +if __name__ == "__main__": + unittest.main() From 5396c4fb70d75bc0e0ec4a3405b37b47d23ba6be Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 05:07:39 +0900 Subject: [PATCH 046/224] fix(close): bind snapshot evidence to accounting-book scope --- ...30_trial_balance_snapshot_immutability.sql | 34 +++++++++++++++++-- 1 file changed, 32 insertions(+), 2 deletions(-) diff --git a/database/migrations/0030_trial_balance_snapshot_immutability.sql b/database/migrations/0030_trial_balance_snapshot_immutability.sql index cb79c71c..f21e588b 100644 --- a/database/migrations/0030_trial_balance_snapshot_immutability.sql +++ b/database/migrations/0030_trial_balance_snapshot_immutability.sql @@ -49,6 +49,7 @@ SET search_path = pg_catalog, pg_temp AS $$ DECLARE period_status_value text; + book_legal_entity_id uuid; journal_write_role_value text; close_command_lock_held boolean; BEGIN @@ -66,6 +67,19 @@ BEGIN USING ERRCODE = 'check_violation'; END IF; + SELECT accounting_book.legal_entity_id + INTO book_legal_entity_id + FROM accounting_core.accounting_book + WHERE accounting_book.tenant_account_id = NEW.tenant_account_id + AND accounting_book.accounting_book_id = NEW.accounting_book_id; + + IF book_legal_entity_id IS NOT NULL + AND book_legal_entity_id IS DISTINCT FROM NEW.legal_entity_id THEN + RAISE EXCEPTION + 'trial balance snapshot legal entity must own the accounting book (trial_balance_snapshot_book_entity_mismatch)' + USING ERRCODE = 'check_violation'; + END IF; + IF period_status_value = 'hard_closed' THEN RAISE EXCEPTION 'hard-close trial balance evidence is immutable (trial_balance_snapshot_immutable)' @@ -161,9 +175,12 @@ SET search_path = pg_catalog, pg_temp AS $$ DECLARE period_status_value text; + snapshot_book_id uuid; + chart_account_book_id uuid; BEGIN - SELECT accounting_book_period_control.period_status_code - INTO period_status_value + SELECT accounting_book_period_control.period_status_code, + trial_balance_snapshot.accounting_book_id + INTO period_status_value, snapshot_book_id FROM accounting_reporting.trial_balance_snapshot JOIN accounting_core.accounting_book_period_control ON accounting_book_period_control.tenant_account_id @@ -183,6 +200,19 @@ BEGIN USING ERRCODE = 'check_violation'; END IF; + SELECT chart_account.accounting_book_id + INTO chart_account_book_id + FROM accounting_core.chart_account + WHERE chart_account.tenant_account_id = NEW.tenant_account_id + AND chart_account.chart_account_id = NEW.chart_account_id; + + IF chart_account_book_id IS NOT NULL + AND snapshot_book_id IS DISTINCT FROM chart_account_book_id THEN + RAISE EXCEPTION + 'trial balance line chart account must belong to the snapshot accounting book (trial_balance_line_book_scope_mismatch)' + USING ERRCODE = 'check_violation'; + END IF; + IF period_status_value = 'hard_closed' THEN RAISE EXCEPTION 'hard-close trial balance evidence is immutable (trial_balance_snapshot_immutable)' From 3d59ab27dcb985e14ca3441b199ba72b72a5c090 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 05:08:23 +0900 Subject: [PATCH 047/224] docs(close): record trial-balance book-scope invariant --- docs/adr/0006-fiscal-period-close-snapshot.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/adr/0006-fiscal-period-close-snapshot.md b/docs/adr/0006-fiscal-period-close-snapshot.md index 65cc3485..294914e4 100644 --- a/docs/adr/0006-fiscal-period-close-snapshot.md +++ b/docs/adr/0006-fiscal-period-close-snapshot.md @@ -10,6 +10,8 @@ An exact hard-close replay returns the existing snapshot and writes no second sn The retained hard-close population is one snapshot per tenant/accounting-book/fiscal-period authority scope. Migration `0029_trial_balance_snapshot_population_unique_index.sql` builds the exact-scope unique index with `CREATE UNIQUE INDEX CONCURRENTLY` outside a transaction block so an upgrade does not hold a write-blocking table lock for the duration of the index build. Migration `0030_trial_balance_snapshot_immutability.sql` attaches that already-built index as the named `trial_balance_snapshot_one_population_per_book_period` table constraint in a short transaction, installs the snapshot/line mutation guards, serializes snapshot admission on the same `accounting_book_period_control` row used by close, and rejects a visible pre-existing population with `trial_balance_snapshot_population_conflict`. +The retained population also preserves the accounting-book aggregate boundary at every denormalized reference. A snapshot header's `legal_entity_id` must be the legal entity that owns its `accounting_book_id`; independently valid tenant-scoped identifiers cannot be recombined into a cross-entity snapshot. Every `trial_balance_line.chart_account_id` must belong to that snapshot's accounting book; a chart account from another book cannot be imported into retained close evidence merely because it belongs to the same tenant. Migration 0030 enforces both relations before a population can become hard-close evidence. + Snapshot creation is not an ordinary soft-close write. The database admits a new snapshot only while the exact book-period is `soft_closed`, `session_user` is a member of the purpose-limited `accounting_closing_writer` role, and the transaction carries hard-close command context. That context is present either as transaction-local `accounting_core.journal_write_role=period_closing` after a required closing-journal write or as the exact tenant/book/period transaction-level advisory lock acquired unconditionally by `close_fiscal_period` before close evidence is assembled. The GUC and advisory lock are purpose/classification evidence rather than independent authorization; PostgreSQL role membership remains the capability boundary. This distinction matters for a zero-net-income or otherwise already-neutralized period: a valid hard close still persists one retained snapshot even though no period-closing journal is required, so snapshot authority cannot depend on an optional journal INSERT side effect. PostgreSQL exposes two-integer advisory-lock keys in `pg_locks.classid` and `pg_locks.objid` with `objsubid = 2`. The application resolves the caller-facing accounting-book reference first and then acquires the command lock with `hashtext(tenant_reference)` and `hashtext('period:' || accounting_book_id::text || ':' || period_code)`. The snapshot admission trigger must reconstruct that exact resolved identity; using the caller-facing `book_name` instead would describe a different advisory key and reject valid journal-independent hard closes. A generic raw INSERT on a merely soft-closed period, without either the explicit period-closing classification or that canonical command lock, fails `trial_balance_snapshot_authority_required`. `snapshot_generated_at` is a PostgreSQL-owned system-time fact: the admission trigger replaces any caller-supplied value with `clock_timestamp()`, including values supplied by an otherwise authorized closing writer. @@ -22,7 +24,7 @@ This is deliberately fail-closed. Raw or legacy SQL cannot create a pre-close sn Controllers close books through the posting adapter instead of a raw status update. Ordinary posting is rejected for every non-open period. The database insert guard in `0005_closed_period_guard.sql` permits only purpose-limited AIS close/adjust/reversal journal writes while a period is `soft_closed`; every journal insert is rejected once the period is `hard_closed`. The caller-controlled `accounting_core.journal_write_role` GUC is classification metadata, not sufficient authorization by itself. -Migration `0030_trial_balance_snapshot_immutability.sql` rejects UPDATE or DELETE of retained snapshot headers and lines, rejects population extension after hard close, rejects a snapshot header unless the soft-closed period is being written by the purpose-limited hard-close capability, and owns snapshot recording chronology at the database boundary. Header and line admission lock the exact tenant/book/period authority row before evaluating status. The header guard checks the current backend's exact transaction advisory lock only once per close snapshot; it does not poll `pg_locks` on journal or report-read hot paths. That row lock serializes status-sensitive admission and gives the normal visible-conflict diagnostic; the exact-scope unique constraint independently enforces one population across transaction snapshots and concurrent writers. Pre-migration history is retained rather than rewritten and is not retroactively attested as canonical solely because the guard was installed. If duplicate populations already exist, the concurrent unique-index build fails rather than blessing ambiguous history. +Migration `0030_trial_balance_snapshot_immutability.sql` rejects UPDATE or DELETE of retained snapshot headers and lines, rejects population extension after hard close, rejects a snapshot header unless the soft-closed period is being written by the purpose-limited hard-close capability, rejects a header whose legal entity does not own the selected book, rejects a line whose chart account belongs to another book, and owns snapshot recording chronology at the database boundary. Header and line admission lock the exact tenant/book/period authority row before evaluating status. The header guard checks the current backend's exact transaction advisory lock only once per close snapshot; it does not poll `pg_locks` on journal or report-read hot paths. That row lock serializes status-sensitive admission and gives the normal visible-conflict diagnostic; the exact-scope unique constraint independently enforces one population across transaction snapshots and concurrent writers. Pre-migration history is retained rather than rewritten and is not retroactively attested as canonical solely because the guard was installed. If duplicate populations already exist, the concurrent unique-index build fails rather than blessing ambiguous history. The concurrent index phase is intentionally separate from the transactional trigger/constraint phase because PostgreSQL forbids `CREATE INDEX CONCURRENTLY` inside a transaction block. The canonical installer applies each forward migration file separately on an autocommit connection, so migration 0029 is one outside-transaction statement and migration 0030 remains an atomic transaction. A failed concurrent unique build may leave an invalid index, which PostgreSQL requires operators to remove or rebuild before retry; recovery tooling for that partial-upgrade state remains a release-readiness requirement and must not be represented as automatic rollback. From 03eb7112ff7a6ce67b6fd4d6b0c99f00d3d93aae Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 06:05:44 +0900 Subject: [PATCH 048/224] test(close): require trial balance line arithmetic conservation --- ...stgres_trial_balance_snapshot_scope_red.py | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/tests/test_postgres_trial_balance_snapshot_scope_red.py b/tests/test_postgres_trial_balance_snapshot_scope_red.py index 3139b005..4386e0f5 100644 --- a/tests/test_postgres_trial_balance_snapshot_scope_red.py +++ b/tests/test_postgres_trial_balance_snapshot_scope_red.py @@ -206,6 +206,66 @@ def test_snapshot_line_rejects_chart_account_from_another_book(self) -> None: (self.case.tenant_id, snapshot_id, other_chart_account_id), ) + def test_snapshot_line_rejects_nonconserving_net_balance(self) -> None: + """Retained debit, credit, and net values must conserve exact trial-balance arithmetic.""" + with psycopg.connect(posting.DATABASE_URL) as connection: + legal_entity_id, accounting_book_id, fiscal_period_id = self._scope(connection) + chart_account_id = connection.execute( + """ + SELECT chart_account.chart_account_id + FROM accounting_core.chart_account + WHERE chart_account.tenant_account_id = %s + AND chart_account.accounting_book_id = %s + ORDER BY chart_account.chart_account_code + LIMIT 1 + """, + (self.case.tenant_id, accounting_book_id), + ).fetchone()[0] + self._enable_period_closing_classification(connection) + snapshot_id = connection.execute( + """ + INSERT INTO accounting_reporting.trial_balance_snapshot ( + tenant_account_id, + legal_entity_id, + accounting_book_id, + fiscal_period_id, + snapshot_currency_code, + source_journal_count, + source_payload_hash, + close_idempotency_key + ) + VALUES (%s, %s, %s, %s, 'KRW', 0, %s, %s) + RETURNING trial_balance_snapshot_id + """, + ( + self.case.tenant_id, + legal_entity_id, + accounting_book_id, + fiscal_period_id, + "sha256:" + "9" * 64, + f"{self.case.policy.tenant_reference}:snapshot-scope:arithmetic", + ), + ).fetchone()[0] + + with self.assertRaisesRegex( + psycopg.errors.CheckViolation, + "trial_balance_line_net_balance_conservation", + ): + connection.execute( + """ + INSERT INTO accounting_reporting.trial_balance_line ( + tenant_account_id, + trial_balance_snapshot_id, + chart_account_id, + debit_total_amount, + credit_total_amount, + net_balance_amount + ) + VALUES (%s, %s, %s, 10.250000, 3.125000, 999.000000) + """, + (self.case.tenant_id, snapshot_id, chart_account_id), + ) + if __name__ == "__main__": unittest.main() From 06d365b13510735c792a8625b4f6d9011d1f6525 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 06:06:16 +0900 Subject: [PATCH 049/224] fix(close): enforce trial balance line arithmetic conservation --- .../0030_trial_balance_snapshot_immutability.sql | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/database/migrations/0030_trial_balance_snapshot_immutability.sql b/database/migrations/0030_trial_balance_snapshot_immutability.sql index f21e588b..434eb7d3 100644 --- a/database/migrations/0030_trial_balance_snapshot_immutability.sql +++ b/database/migrations/0030_trial_balance_snapshot_immutability.sql @@ -7,6 +7,17 @@ ALTER TABLE accounting_reporting.trial_balance_snapshot ADD CONSTRAINT trial_balance_snapshot_one_population_per_book_period UNIQUE USING INDEX trial_balance_snapshot_one_population_per_book_period; +-- Retained trial-balance values are accounting evidence, not three independently +-- writable amounts. Add the arithmetic invariant without holding a long validation +-- scan under the initial ALTER TABLE lock, then validate all inherited rows before +-- this migration can commit. +ALTER TABLE accounting_reporting.trial_balance_line + ADD CONSTRAINT trial_balance_line_net_balance_conservation + CHECK (net_balance_amount = debit_total_amount - credit_total_amount) + NOT VALID; +ALTER TABLE accounting_reporting.trial_balance_line + VALIDATE CONSTRAINT trial_balance_line_net_balance_conservation; + CREATE OR REPLACE FUNCTION accounting_reporting.reject_trial_balance_snapshot_mutation() RETURNS trigger LANGUAGE plpgsql @@ -232,4 +243,4 @@ CREATE TRIGGER trial_balance_line_population_guard FOR EACH ROW EXECUTE FUNCTION accounting_reporting.guard_trial_balance_line_insert(); -COMMIT; +COMMIT; \ No newline at end of file From 89011244bcbeee5e9ada1eadfa97a7ad7ae3bf69 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 06:07:22 +0900 Subject: [PATCH 050/224] docs(close): record trial balance arithmetic invariant --- docs/adr/0006-fiscal-period-close-snapshot.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/adr/0006-fiscal-period-close-snapshot.md b/docs/adr/0006-fiscal-period-close-snapshot.md index 294914e4..650f55c9 100644 --- a/docs/adr/0006-fiscal-period-close-snapshot.md +++ b/docs/adr/0006-fiscal-period-close-snapshot.md @@ -10,6 +10,8 @@ An exact hard-close replay returns the existing snapshot and writes no second sn The retained hard-close population is one snapshot per tenant/accounting-book/fiscal-period authority scope. Migration `0029_trial_balance_snapshot_population_unique_index.sql` builds the exact-scope unique index with `CREATE UNIQUE INDEX CONCURRENTLY` outside a transaction block so an upgrade does not hold a write-blocking table lock for the duration of the index build. Migration `0030_trial_balance_snapshot_immutability.sql` attaches that already-built index as the named `trial_balance_snapshot_one_population_per_book_period` table constraint in a short transaction, installs the snapshot/line mutation guards, serializes snapshot admission on the same `accounting_book_period_control` row used by close, and rejects a visible pre-existing population with `trial_balance_snapshot_population_conflict`. +Every retained trial-balance line is also one exact arithmetic fact rather than three independently writable monetary values. `trial_balance_line.net_balance_amount` must equal `debit_total_amount - credit_total_amount` exactly in PostgreSQL `numeric(38, 6)`. Migration 0030 adds the named `trial_balance_line_net_balance_conservation` check as `NOT VALID` and then validates inherited rows before commit, so an upgrade does not bless pre-existing inconsistent close evidence and the validation scan does not require the stronger lock held by an immediately validated `ADD CONSTRAINT`. + The retained population also preserves the accounting-book aggregate boundary at every denormalized reference. A snapshot header's `legal_entity_id` must be the legal entity that owns its `accounting_book_id`; independently valid tenant-scoped identifiers cannot be recombined into a cross-entity snapshot. Every `trial_balance_line.chart_account_id` must belong to that snapshot's accounting book; a chart account from another book cannot be imported into retained close evidence merely because it belongs to the same tenant. Migration 0030 enforces both relations before a population can become hard-close evidence. Snapshot creation is not an ordinary soft-close write. The database admits a new snapshot only while the exact book-period is `soft_closed`, `session_user` is a member of the purpose-limited `accounting_closing_writer` role, and the transaction carries hard-close command context. That context is present either as transaction-local `accounting_core.journal_write_role=period_closing` after a required closing-journal write or as the exact tenant/book/period transaction-level advisory lock acquired unconditionally by `close_fiscal_period` before close evidence is assembled. The GUC and advisory lock are purpose/classification evidence rather than independent authorization; PostgreSQL role membership remains the capability boundary. This distinction matters for a zero-net-income or otherwise already-neutralized period: a valid hard close still persists one retained snapshot even though no period-closing journal is required, so snapshot authority cannot depend on an optional journal INSERT side effect. @@ -24,7 +26,7 @@ This is deliberately fail-closed. Raw or legacy SQL cannot create a pre-close sn Controllers close books through the posting adapter instead of a raw status update. Ordinary posting is rejected for every non-open period. The database insert guard in `0005_closed_period_guard.sql` permits only purpose-limited AIS close/adjust/reversal journal writes while a period is `soft_closed`; every journal insert is rejected once the period is `hard_closed`. The caller-controlled `accounting_core.journal_write_role` GUC is classification metadata, not sufficient authorization by itself. -Migration `0030_trial_balance_snapshot_immutability.sql` rejects UPDATE or DELETE of retained snapshot headers and lines, rejects population extension after hard close, rejects a snapshot header unless the soft-closed period is being written by the purpose-limited hard-close capability, rejects a header whose legal entity does not own the selected book, rejects a line whose chart account belongs to another book, and owns snapshot recording chronology at the database boundary. Header and line admission lock the exact tenant/book/period authority row before evaluating status. The header guard checks the current backend's exact transaction advisory lock only once per close snapshot; it does not poll `pg_locks` on journal or report-read hot paths. That row lock serializes status-sensitive admission and gives the normal visible-conflict diagnostic; the exact-scope unique constraint independently enforces one population across transaction snapshots and concurrent writers. Pre-migration history is retained rather than rewritten and is not retroactively attested as canonical solely because the guard was installed. If duplicate populations already exist, the concurrent unique-index build fails rather than blessing ambiguous history. +Migration `0030_trial_balance_snapshot_immutability.sql` rejects UPDATE or DELETE of retained snapshot headers and lines, rejects population extension after hard close, rejects a snapshot header unless the soft-closed period is being written by the purpose-limited hard-close capability, rejects a header whose legal entity does not own the selected book, rejects a line whose chart account belongs to another book, rejects any line whose stored net does not exactly conserve its stored debit and credit totals, and owns snapshot recording chronology at the database boundary. Header and line admission lock the exact tenant/book/period authority row before evaluating status. The header guard checks the current backend's exact transaction advisory lock only once per close snapshot; it does not poll `pg_locks` on journal or report-read hot paths. That row lock serializes status-sensitive admission and gives the normal visible-conflict diagnostic; the exact-scope unique constraint independently enforces one population across transaction snapshots and concurrent writers. Pre-migration history is retained rather than rewritten and is not retroactively attested as canonical solely because the guard was installed. If duplicate populations already exist, the concurrent unique-index build fails rather than blessing ambiguous history. If inherited trial-balance lines violate debit/credit/net conservation, constraint validation fails rather than certifying inconsistent monetary evidence. The concurrent index phase is intentionally separate from the transactional trigger/constraint phase because PostgreSQL forbids `CREATE INDEX CONCURRENTLY` inside a transaction block. The canonical installer applies each forward migration file separately on an autocommit connection, so migration 0029 is one outside-transaction statement and migration 0030 remains an atomic transaction. A failed concurrent unique build may leave an invalid index, which PostgreSQL requires operators to remove or rebuild before retry; recovery tooling for that partial-upgrade state remains a release-readiness requirement and must not be represented as automatic rollback. From 9600c6a72559473dac31c0c6e8b5239c5d3ca763 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 06:11:38 +0900 Subject: [PATCH 051/224] docs(close): trace trial balance conservation control --- ...TRIAL_BALANCE_CONSERVATION_TRACEABILITY.md | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 docs/doctoring/TRIAL_BALANCE_CONSERVATION_TRACEABILITY.md diff --git a/docs/doctoring/TRIAL_BALANCE_CONSERVATION_TRACEABILITY.md b/docs/doctoring/TRIAL_BALANCE_CONSERVATION_TRACEABILITY.md new file mode 100644 index 00000000..0fb8cc60 --- /dev/null +++ b/docs/doctoring/TRIAL_BALANCE_CONSERVATION_TRACEABILITY.md @@ -0,0 +1,43 @@ +# Trial-balance conservation traceability + +## Control statement + +A retained hard-close trial-balance line is one accounting fact. Its three stored monetary fields are not independent caller assertions: `net_balance_amount` must equal `debit_total_amount - credit_total_amount` exactly in PostgreSQL `numeric(38, 6)`. + +The database previously constrained debit and credit to non-negative fixed-scale values but did not relate either amount to `net_balance_amount`. The supported close implementation calculates the net from debit and credit before persistence, yet a direct or legacy writer could persist a different net while still satisfying every row constraint. Because the retained population is later consumed as Period Close and reporting evidence, the invariant belongs at the authoritative PostgreSQL boundary rather than only in the application calculation. + +## Standard and platform basis + +The IFRS Conceptual Framework describes useful financial information as faithfully representing the economic phenomenon it purports to represent and identifies completeness, neutrality, and freedom from error as characteristics of a faithful representation. This does **not** prescribe a SQL formula or a trial-balance table design. AIS uses it only as the financial-reporting quality rationale for refusing internally contradictory retained monetary evidence. + +PostgreSQL 18 `CHECK` constraints are the direct enforcement mechanism because the relation is immutable and row-local. PostgreSQL documents that a `CHECK` added `NOT VALID` is enforced for subsequent inserts and updates while skipping the initial table scan, and that `VALIDATE CONSTRAINT` later scans inherited rows using a less restrictive validation lock. Migration 0030 therefore adds the exact row-local check as `NOT VALID` and validates it before the migration transaction commits. An inherited inconsistency blocks the upgrade; the migration does not rewrite historical amounts. + +## Executable trace + +| Layer | Exact control | +|---|---| +| Authoritative table | `accounting_reporting.trial_balance_line` | +| Monetary invariant | `net_balance_amount = debit_total_amount - credit_total_amount` | +| Constraint | `trial_balance_line_net_balance_conservation` | +| Migration | `database/migrations/0030_trial_balance_snapshot_immutability.sql` | +| Real PostgreSQL regression | `tests/test_postgres_trial_balance_snapshot_scope_red.py::TrialBalanceSnapshotScopePostgresTests::test_snapshot_line_rejects_nonconserving_net_balance` | +| RED | `03eb7112ff7a6ce67b6fd4d6b0c99f00d3d93aae` | +| Minimal implementation | `06d365b13510735c792a8625b4f6d9011d1f6525` | +| Decision record | `docs/adr/0006-fiscal-period-close-snapshot.md` | +| Owning PR | `#53` | + +The regression uses a valid tenant, legal entity, accounting book, fiscal period, snapshot, and same-book chart account, then attempts to retain debit `10.250000`, credit `3.125000`, and net `999.000000`. The expected PostgreSQL failure names `trial_balance_line_net_balance_conservation`. The case isolates arithmetic conservation from tenant, book-scope, close-authority, and immutability failures. + +## Authority and non-claims + +This control does not make a report IFRS-compliant, audited, assured, approved, or filing-ready. It does not decide accounting policy, select chart accounts, post or reverse journals, close a period, approve reconciliation, or write Billing-owned commercial truth. Reporting and export projections may consume the retained values only after this accounting-owned invariant has been satisfied; they must not create a second monetary authority by overriding the retained debit, credit, or net amounts. + +A later reopen or correction policy must preserve the old retained population through explicit successor lineage. It must not repair a historical inconsistency by silently editing an immutable hard-close row. + +## References + +IFRS Foundation. (2022). *Conceptual framework for financial reporting*. https://www.ifrs.org/content/dam/ifrs/publications/pdf-standards/english/2022/issued/part-a/conceptual-framework-for-financial-reporting.pdf + +PostgreSQL Global Development Group. (2026a). *PostgreSQL 18 documentation: Constraints*. https://www.postgresql.org/docs/18/ddl-constraints.html + +PostgreSQL Global Development Group. (2026b). *PostgreSQL 18 documentation: ALTER TABLE*. https://www.postgresql.org/docs/18/sql-altertable.html From d6b75eb42b2cd7cccb0d4adbb1fbc16a295c328d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 06:14:13 +0900 Subject: [PATCH 052/224] test(close): separate trial balance validation lock phase --- ..._balance_snapshot_immutability_contract.py | 35 ++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/tests/test_trial_balance_snapshot_immutability_contract.py b/tests/test_trial_balance_snapshot_immutability_contract.py index 5bd39e15..d5b4d1b6 100644 --- a/tests/test_trial_balance_snapshot_immutability_contract.py +++ b/tests/test_trial_balance_snapshot_immutability_contract.py @@ -14,6 +14,9 @@ ROOT / "database/migrations/0029_trial_balance_snapshot_population_unique_index.sql" ) IMMUTABILITY_MIGRATION = ROOT / "database/migrations/0030_trial_balance_snapshot_immutability.sql" +VALIDATION_MIGRATION = ( + ROOT / "database/migrations/0031_trial_balance_line_conservation_validation.sql" +) class TrialBalanceSnapshotImmutabilityContractTests(unittest.TestCase): @@ -24,7 +27,11 @@ def test_canonical_installer_fails_closed_when_snapshot_migration_is_missing(sel original_is_file = Path.is_file def is_file(path: Path) -> bool: - if path.name in {INDEX_MIGRATION.name, IMMUTABILITY_MIGRATION.name}: + if path.name in { + INDEX_MIGRATION.name, + IMMUTABILITY_MIGRATION.name, + VALIDATION_MIGRATION.name, + }: return False return original_is_file(path) @@ -61,6 +68,32 @@ def test_population_unique_index_is_built_without_blocking_writes(self) -> None: ) self.assertNotIn("CREATE UNIQUE INDEX", immutability_migration) + def test_line_conservation_validation_uses_a_separate_autocommit_migration(self) -> None: + """Validation must not inherit the stronger ADD-CONSTRAINT lock until transaction commit.""" + immutability_migration = IMMUTABILITY_MIGRATION.read_text(encoding="utf-8") + validation_migration = VALIDATION_MIGRATION.read_text(encoding="utf-8") + + self.assertIn( + "ADD CONSTRAINT trial_balance_line_net_balance_conservation", + immutability_migration, + ) + self.assertIn( + "CHECK (net_balance_amount = debit_total_amount - credit_total_amount)", + immutability_migration, + ) + self.assertIn("NOT VALID", immutability_migration) + self.assertNotIn( + "VALIDATE CONSTRAINT trial_balance_line_net_balance_conservation", + immutability_migration, + ) + self.assertEqual( + validation_migration.strip(), + "ALTER TABLE accounting_reporting.trial_balance_line\n" + " VALIDATE CONSTRAINT trial_balance_line_net_balance_conservation;", + ) + self.assertNotIn("BEGIN;", validation_migration) + self.assertNotIn("COMMIT;", validation_migration) + def test_population_guards_serialize_on_book_period_authority(self) -> None: """Snapshot and line admission must lock the book-period state they authorize.""" migration = IMMUTABILITY_MIGRATION.read_text(encoding="utf-8") From 605ffe0ef2a4c075f46521f63724e910f030c3d5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 06:14:48 +0900 Subject: [PATCH 053/224] fix(close): release add-constraint lock before validation --- .../0030_trial_balance_snapshot_immutability.sql | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/database/migrations/0030_trial_balance_snapshot_immutability.sql b/database/migrations/0030_trial_balance_snapshot_immutability.sql index 434eb7d3..fb19e9c4 100644 --- a/database/migrations/0030_trial_balance_snapshot_immutability.sql +++ b/database/migrations/0030_trial_balance_snapshot_immutability.sql @@ -8,15 +8,13 @@ ALTER TABLE accounting_reporting.trial_balance_snapshot UNIQUE USING INDEX trial_balance_snapshot_one_population_per_book_period; -- Retained trial-balance values are accounting evidence, not three independently --- writable amounts. Add the arithmetic invariant without holding a long validation --- scan under the initial ALTER TABLE lock, then validate all inherited rows before --- this migration can commit. +-- writable amounts. Add the row-local invariant without scanning inherited rows +-- under this transaction's ADD-CONSTRAINT lock. Migration 0031 validates history +-- in a separate autocommit statement after this transaction releases its locks. ALTER TABLE accounting_reporting.trial_balance_line ADD CONSTRAINT trial_balance_line_net_balance_conservation CHECK (net_balance_amount = debit_total_amount - credit_total_amount) NOT VALID; -ALTER TABLE accounting_reporting.trial_balance_line - VALIDATE CONSTRAINT trial_balance_line_net_balance_conservation; CREATE OR REPLACE FUNCTION accounting_reporting.reject_trial_balance_snapshot_mutation() RETURNS trigger From e6a3ec183b78061ec33b5b364c46991a3f8ceb7c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 06:14:53 +0900 Subject: [PATCH 054/224] fix(close): validate retained line conservation separately --- .../0031_trial_balance_line_conservation_validation.sql | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 database/migrations/0031_trial_balance_line_conservation_validation.sql diff --git a/database/migrations/0031_trial_balance_line_conservation_validation.sql b/database/migrations/0031_trial_balance_line_conservation_validation.sql new file mode 100644 index 00000000..a894a43a --- /dev/null +++ b/database/migrations/0031_trial_balance_line_conservation_validation.sql @@ -0,0 +1,2 @@ +ALTER TABLE accounting_reporting.trial_balance_line + VALIDATE CONSTRAINT trial_balance_line_net_balance_conservation; From abdd2df05935c50c7764356ef626ae365338bdfe Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 06:15:08 +0900 Subject: [PATCH 055/224] fix(close): install conservation validation migration --- src/accounting_information_platform/migration_install.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/accounting_information_platform/migration_install.py b/src/accounting_information_platform/migration_install.py index 7e0c19f1..28c11877 100644 --- a/src/accounting_information_platform/migration_install.py +++ b/src/accounting_information_platform/migration_install.py @@ -27,6 +27,7 @@ def apply_foundation_migration(database_url: str, migration_path: Path) -> None: migration_path.parent / "0028_reconciliation_lifecycle_capability_privileges.sql", migration_path.parent / "0029_trial_balance_snapshot_population_unique_index.sql", migration_path.parent / "0030_trial_balance_snapshot_immutability.sql", + migration_path.parent / "0031_trial_balance_line_conservation_validation.sql", ) for forward_migration_path in forward_migration_paths: if not forward_migration_path.is_file(): From 82a6d1f44dd10e006350fd1d6d35273876adc17f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 06:15:46 +0900 Subject: [PATCH 056/224] docs(close): correct conservation validation lock semantics --- docs/adr/0006-fiscal-period-close-snapshot.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/adr/0006-fiscal-period-close-snapshot.md b/docs/adr/0006-fiscal-period-close-snapshot.md index 650f55c9..c6424de9 100644 --- a/docs/adr/0006-fiscal-period-close-snapshot.md +++ b/docs/adr/0006-fiscal-period-close-snapshot.md @@ -10,7 +10,7 @@ An exact hard-close replay returns the existing snapshot and writes no second sn The retained hard-close population is one snapshot per tenant/accounting-book/fiscal-period authority scope. Migration `0029_trial_balance_snapshot_population_unique_index.sql` builds the exact-scope unique index with `CREATE UNIQUE INDEX CONCURRENTLY` outside a transaction block so an upgrade does not hold a write-blocking table lock for the duration of the index build. Migration `0030_trial_balance_snapshot_immutability.sql` attaches that already-built index as the named `trial_balance_snapshot_one_population_per_book_period` table constraint in a short transaction, installs the snapshot/line mutation guards, serializes snapshot admission on the same `accounting_book_period_control` row used by close, and rejects a visible pre-existing population with `trial_balance_snapshot_population_conflict`. -Every retained trial-balance line is also one exact arithmetic fact rather than three independently writable monetary values. `trial_balance_line.net_balance_amount` must equal `debit_total_amount - credit_total_amount` exactly in PostgreSQL `numeric(38, 6)`. Migration 0030 adds the named `trial_balance_line_net_balance_conservation` check as `NOT VALID` and then validates inherited rows before commit, so an upgrade does not bless pre-existing inconsistent close evidence and the validation scan does not require the stronger lock held by an immediately validated `ADD CONSTRAINT`. +Every retained trial-balance line is also one exact arithmetic fact rather than three independently writable monetary values. `trial_balance_line.net_balance_amount` must equal `debit_total_amount - credit_total_amount` exactly in PostgreSQL `numeric(38, 6)`. Migration 0030 adds the named `trial_balance_line_net_balance_conservation` check as `NOT VALID`, so new writes are constrained without scanning inherited rows while that transaction still holds its `ADD CONSTRAINT` lock. After 0030 commits and releases that stronger lock, migration `0031_trial_balance_line_conservation_validation.sql` runs as a separate autocommit statement and validates inherited rows. An upgrade therefore fails closed on pre-existing inconsistent close evidence without needlessly holding the 0030 lock for the validation scan. The retained population also preserves the accounting-book aggregate boundary at every denormalized reference. A snapshot header's `legal_entity_id` must be the legal entity that owns its `accounting_book_id`; independently valid tenant-scoped identifiers cannot be recombined into a cross-entity snapshot. Every `trial_balance_line.chart_account_id` must belong to that snapshot's accounting book; a chart account from another book cannot be imported into retained close evidence merely because it belongs to the same tenant. Migration 0030 enforces both relations before a population can become hard-close evidence. @@ -26,9 +26,9 @@ This is deliberately fail-closed. Raw or legacy SQL cannot create a pre-close sn Controllers close books through the posting adapter instead of a raw status update. Ordinary posting is rejected for every non-open period. The database insert guard in `0005_closed_period_guard.sql` permits only purpose-limited AIS close/adjust/reversal journal writes while a period is `soft_closed`; every journal insert is rejected once the period is `hard_closed`. The caller-controlled `accounting_core.journal_write_role` GUC is classification metadata, not sufficient authorization by itself. -Migration `0030_trial_balance_snapshot_immutability.sql` rejects UPDATE or DELETE of retained snapshot headers and lines, rejects population extension after hard close, rejects a snapshot header unless the soft-closed period is being written by the purpose-limited hard-close capability, rejects a header whose legal entity does not own the selected book, rejects a line whose chart account belongs to another book, rejects any line whose stored net does not exactly conserve its stored debit and credit totals, and owns snapshot recording chronology at the database boundary. Header and line admission lock the exact tenant/book/period authority row before evaluating status. The header guard checks the current backend's exact transaction advisory lock only once per close snapshot; it does not poll `pg_locks` on journal or report-read hot paths. That row lock serializes status-sensitive admission and gives the normal visible-conflict diagnostic; the exact-scope unique constraint independently enforces one population across transaction snapshots and concurrent writers. Pre-migration history is retained rather than rewritten and is not retroactively attested as canonical solely because the guard was installed. If duplicate populations already exist, the concurrent unique-index build fails rather than blessing ambiguous history. If inherited trial-balance lines violate debit/credit/net conservation, constraint validation fails rather than certifying inconsistent monetary evidence. +Migration `0030_trial_balance_snapshot_immutability.sql` rejects UPDATE or DELETE of retained snapshot headers and lines, rejects population extension after hard close, rejects a snapshot header unless the soft-closed period is being written by the purpose-limited hard-close capability, rejects a header whose legal entity does not own the selected book, rejects a line whose chart account belongs to another book, rejects new or changed lines whose stored net does not exactly conserve their stored debit and credit totals, and owns snapshot recording chronology at the database boundary. Header and line admission lock the exact tenant/book/period authority row before evaluating status. The header guard checks the current backend's exact transaction advisory lock only once per close snapshot; it does not poll `pg_locks` on journal or report-read hot paths. That row lock serializes status-sensitive admission and gives the normal visible-conflict diagnostic; the exact-scope unique constraint independently enforces one population across transaction snapshots and concurrent writers. Pre-migration history is retained rather than rewritten and is not retroactively attested as canonical solely because the guard was installed. If duplicate populations already exist, the concurrent unique-index build fails rather than blessing ambiguous history. Migration 0031 separately validates inherited debit/credit/net conservation and fails rather than certifying inconsistent monetary evidence. -The concurrent index phase is intentionally separate from the transactional trigger/constraint phase because PostgreSQL forbids `CREATE INDEX CONCURRENTLY` inside a transaction block. The canonical installer applies each forward migration file separately on an autocommit connection, so migration 0029 is one outside-transaction statement and migration 0030 remains an atomic transaction. A failed concurrent unique build may leave an invalid index, which PostgreSQL requires operators to remove or rebuild before retry; recovery tooling for that partial-upgrade state remains a release-readiness requirement and must not be represented as automatic rollback. +The index-build, transactional boundary-install, and inherited-row validation phases are intentionally separate. PostgreSQL forbids `CREATE INDEX CONCURRENTLY` inside a transaction block, and PostgreSQL transaction locks are normally held until transaction end; keeping `VALIDATE CONSTRAINT` inside the same transaction as `ADD CONSTRAINT` would therefore retain the stronger 0030 lock throughout the scan. The canonical installer applies each forward migration file separately on an autocommit connection: migration 0029 is the concurrent index build, migration 0030 atomically installs the table/trigger boundary plus the `NOT VALID` arithmetic check, and migration 0031 validates history after 0030 has committed. A failed concurrent unique build may leave an invalid index, which PostgreSQL requires operators to remove or rebuild before retry; a failed 0031 validation leaves the new check enforced for subsequent writes but the inherited population unvalidated. Recovery tooling and release evidence must distinguish those partial-upgrade states rather than claim automatic rollback. The former snapshot-on-soft-close and snapshot-reuse-on-upgrade wording in this ADR is superseded by ADR 0023. Operational and reporting code must therefore treat the hard-close snapshot as the only persisted post-close trial-balance population and must never infer that a soft-close created one. From 923700043fecfb5d0a4632459dbf67f6ca033ec3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 06:17:18 +0900 Subject: [PATCH 057/224] docs(close): trace separate conservation validation phase --- .../TRIAL_BALANCE_CONSERVATION_TRACEABILITY.md | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/docs/doctoring/TRIAL_BALANCE_CONSERVATION_TRACEABILITY.md b/docs/doctoring/TRIAL_BALANCE_CONSERVATION_TRACEABILITY.md index 0fb8cc60..226ed6be 100644 --- a/docs/doctoring/TRIAL_BALANCE_CONSERVATION_TRACEABILITY.md +++ b/docs/doctoring/TRIAL_BALANCE_CONSERVATION_TRACEABILITY.md @@ -10,7 +10,7 @@ The database previously constrained debit and credit to non-negative fixed-scale The IFRS Conceptual Framework describes useful financial information as faithfully representing the economic phenomenon it purports to represent and identifies completeness, neutrality, and freedom from error as characteristics of a faithful representation. This does **not** prescribe a SQL formula or a trial-balance table design. AIS uses it only as the financial-reporting quality rationale for refusing internally contradictory retained monetary evidence. -PostgreSQL 18 `CHECK` constraints are the direct enforcement mechanism because the relation is immutable and row-local. PostgreSQL documents that a `CHECK` added `NOT VALID` is enforced for subsequent inserts and updates while skipping the initial table scan, and that `VALIDATE CONSTRAINT` later scans inherited rows using a less restrictive validation lock. Migration 0030 therefore adds the exact row-local check as `NOT VALID` and validates it before the migration transaction commits. An inherited inconsistency blocks the upgrade; the migration does not rewrite historical amounts. +PostgreSQL 18 `CHECK` constraints are the direct enforcement mechanism because the relation is immutable and row-local. PostgreSQL documents that a `CHECK` added `NOT VALID` is enforced for subsequent inserts and updates while skipping the initial table scan, and that `VALIDATE CONSTRAINT` later scans inherited rows with a less restrictive validation lock. PostgreSQL transaction locks are held until transaction end, so placing both operations in migration 0030 would still retain the stronger `ADD CONSTRAINT` lock throughout validation. The repaired chain therefore commits migration 0030 after adding the `NOT VALID` check and runs migration 0031 as a separate autocommit validation statement. An inherited inconsistency blocks completion of the migration chain; no historical amount is rewritten. ## Executable trace @@ -19,14 +19,21 @@ PostgreSQL 18 `CHECK` constraints are the direct enforcement mechanism because t | Authoritative table | `accounting_reporting.trial_balance_line` | | Monetary invariant | `net_balance_amount = debit_total_amount - credit_total_amount` | | Constraint | `trial_balance_line_net_balance_conservation` | -| Migration | `database/migrations/0030_trial_balance_snapshot_immutability.sql` | +| Constraint install | `database/migrations/0030_trial_balance_snapshot_immutability.sql` | +| Historical validation | `database/migrations/0031_trial_balance_line_conservation_validation.sql` | +| Canonical installer | `src/accounting_information_platform/migration_install.py` | | Real PostgreSQL regression | `tests/test_postgres_trial_balance_snapshot_scope_red.py::TrialBalanceSnapshotScopePostgresTests::test_snapshot_line_rejects_nonconserving_net_balance` | -| RED | `03eb7112ff7a6ce67b6fd4d6b0c99f00d3d93aae` | -| Minimal implementation | `06d365b13510735c792a8625b4f6d9011d1f6525` | +| Migration lock contract | `tests/test_trial_balance_snapshot_immutability_contract.py::TrialBalanceSnapshotImmutabilityContractTests::test_line_conservation_validation_uses_a_separate_autocommit_migration` | +| Arithmetic RED | `03eb7112ff7a6ce67b6fd4d6b0c99f00d3d93aae` | +| Initial arithmetic implementation | `06d365b13510735c792a8625b4f6d9011d1f6525` | +| Lock-separation RED | `d6b75eb42b2cd7cccb0d4adbb1fbc16a295c328d` | +| Lock-separation repair | `605ffe0ef2a4c075f46521f63724e910f030c3d5`, `e6a3ec183b78061ec33b5b364c46991a3f8ceb7c`, `abdd2df05935c50c7764356ef626ae365338bdfe` | | Decision record | `docs/adr/0006-fiscal-period-close-snapshot.md` | | Owning PR | `#53` | -The regression uses a valid tenant, legal entity, accounting book, fiscal period, snapshot, and same-book chart account, then attempts to retain debit `10.250000`, credit `3.125000`, and net `999.000000`. The expected PostgreSQL failure names `trial_balance_line_net_balance_conservation`. The case isolates arithmetic conservation from tenant, book-scope, close-authority, and immutability failures. +The arithmetic regression uses a valid tenant, legal entity, accounting book, fiscal period, snapshot, and same-book chart account, then attempts to retain debit `10.250000`, credit `3.125000`, and net `999.000000`. The expected PostgreSQL failure names `trial_balance_line_net_balance_conservation`. The case isolates arithmetic conservation from tenant, book-scope, close-authority, and immutability failures. + +The migration-lock contract prevents a future refactor from moving `VALIDATE CONSTRAINT` back into 0030. The canonical installer must require and execute 0031 after 0030 on its autocommit connection; absence of either file fails the supported installation boundary closed. ## Authority and non-claims From 8d748c75fdbf572ebb484ea6b7d6b8fe83a6d0d3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 06:19:58 +0900 Subject: [PATCH 058/224] docs(close): trace retained line conservation authority --- docs/doctoring/STANDARD_TRACEABILITY.md | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/docs/doctoring/STANDARD_TRACEABILITY.md b/docs/doctoring/STANDARD_TRACEABILITY.md index 838e7fe6..92be08e6 100644 --- a/docs/doctoring/STANDARD_TRACEABILITY.md +++ b/docs/doctoring/STANDARD_TRACEABILITY.md @@ -6,18 +6,19 @@ | IAS 1 | Statement of financial position presents assets, liabilities, and equity; statement of profit or loss presents income and expenses; AIS stores that split as `account_class_code`. Hard-close transfers period profit or loss into equity account 310100 so the next sheet does not need a floating earnings plug. Billing unapplied cash is catalog liability 210200 and does not park into retained earnings. Controllers also read a leftover-cash rollforward that ties park, apply, and refund journals to that 210200 credit-normal closing. Output VAT payable stays catalog liability 210100; controllers also read a period VAT register that ties issued-invoice tax credits and issued-invoice-void tax debits to that 210100 credit-normal closing. The period-close binder always includes that same register document. A HomeTax filing command on AIS requires that register before any transmission attempt and fail-closes without calling NTS when the register or the purpose-limited HomeTax credential is missing. Wage-income withholding, including year-end settlement, is reserved off 210100 and waits for a published Orgmetra assignment plus a portal, Billing, or HRIS role code. Comparative information for a prior period is an optional query on the same statement GET. Year-to-date profit or loss is an optional scope on that same GET. The complete set also includes a statement of changes in equity that rolls opening equity plus period profit or loss plus other equity movements to closing equity. As-of chart-account balances and a one-account opening + period + closing rollforward accompany those statements from the same trial-balance snapshot or live books. Controllers also read that complete set as one close pack, an entity-level receivable aging worksheet that ties to the AR account-balance net, an entity-level payable aging worksheet that ties to the catalog tax-payable account-balance net, and a period-close binder that accompanies those statements with period status, the omit-basis trial balance, receivable aging, payable aging, leftover-cash rollforward on catalog 210200, the period VAT register on catalog 210100, and the durable hard-close receipt when one exists | Chart-account class migration, HTTP financial-statement read, HTTP changes in equity, HTTP account-balance inquiry, HTTP account rollforward, HTTP financial-statement package, HTTP receivable aging, HTTP payable aging, HTTP period-close package, HTTP unapplied-cash catalog, HTTP leftover-cash rollforward, HTTP period VAT register, HTTP fail-closed HomeTax submission, wage-income withholding reservation, ADR 0024, ADR 0025, ADR 0028, ADR 0032, ADR 0034, ADR 0035, ADR 0037, ADR 0039, ADR 0040, ADR 0041, ADR 0043, ADR 0044, ADR 0045, ADR 0046, and ADR 0047 | | IAS 7 | Statement of cash flows presents period cash movements by the indirect method: operational net income, non-cash working-capital change, investing, and financing, reconciling opening cash to closing cash that equals the balance-sheet cash total. Cash is the current `cash_receipt` mapping. AIS does not invent a cash-flow class code | HTTP cash-flow statement and ADR 0033 | | IAS 34 | Interim year-to-date income statements cover the current financial year through the requested period; the balance sheet remains a point-in-time statement as of that period | HTTP `statement_scope_code=year_to_date` and ADR 0028 | -| IAS 10 | Soft-close rejects ordinary posts and allows AIS-owned adjusting journals plus append-only reversing adjustments before hard-close snapshots and locks the period. Hard-close loads the close package in one consistent read, parks period earnings on 310100, and stores the close `idempotency_key` on the snapshot so a later journal or a different close key fails closed. Soft-close creates no persisted trial-balance snapshot; hard-close admits exactly one retained tenant/book/period snapshot population under the same database authority-row lock, and a pre-existing competing population makes the hard-close transaction fail closed instead of adopting caller-shaped evidence or selecting a forged future `snapshot_generated_at`. Retained snapshot headers and lines cannot later be extended, rewritten, or deleted. Auditors read durable hard-close receipts from stored snapshots, not reconstructed soft-close history. Controllers also read the unadjusted, adjusted, and post-close trial-balance worksheet on the existing TB GET, and list the adjusting worksheet population on the existing journal list | Two-step `POST /period-closes`, HTTP `POST /journals` adjusting write, HTTP period-close list, HTTP trial-balance basis, HTTP journal-source list, `0029_trial_balance_snapshot_immutability.sql`, real PostgreSQL snapshot-authority regressions, ADR 0006, ADR 0023, ADR 0024, ADR 0030, ADR 0031, ADR 0036, and ADR 0038 | -| IFRS 9 | Receivable aging is control evidence of credit risk at the legal-entity book, not a customer subledger and not an expected-credit-loss allowance. AIS ages posted AR FIFO through period end and does not invent `party_reference`. A Billing issued-invoice void credits AR through ordinary ingest on `{tenant}:issued_invoice_void:{issued_invoice_void_id}:{void.source_payload_hash}:v{issued_invoice_void_contract_version}` and drops entity receivable aging by that inclusive amount; it is not a collection-status command and does not require Billing `reversed_journal_proposal_id` or `invoice_draft_id`. A Billing issued-credit-note void debits AR through ordinary ingest on `{tenant}:issued_credit_note_void:{issued_credit_note_void_id}:{void.source_payload_hash}:v{issued_credit_note_void_contract_version}` and raises entity receivable aging by that inclusive amount the credit had reduced; it does not require Billing `journal_entry_id`. A Billing collection write-off of a financial asset posts debit `write_off_expense` 510100 / credit AR through ordinary ingest, reduces entity receivable aging by that amount, and parks that expense into retained earnings on hard-close; it is not an ECL allowance and is not `period_closing` | HTTP receivable aging, HTTP issued-invoice-void consume, HTTP collection write-off catalog, ADR 0039, and ADR 0042 | +| IAS 10 | Soft-close rejects ordinary posts and allows AIS-owned adjusting journals plus append-only reversing adjustments before hard-close snapshots and locks the period. Hard-close loads the close package in one consistent read, parks period earnings on 310100, and stores the close `idempotency_key` on the snapshot so a later journal or a different close key fails closed. Soft-close creates no persisted trial-balance snapshot; hard-close admits exactly one retained tenant/book/period snapshot population under the same database authority-row lock, and a pre-existing competing population makes the hard-close transaction fail closed instead of adopting caller-shaped evidence or selecting a forged future `snapshot_generated_at`. Retained snapshot headers and lines cannot later be extended, rewritten, or deleted. Auditors read durable hard-close receipts from stored snapshots, not reconstructed soft-close history. Controllers also read the unadjusted, adjusted, and post-close trial-balance worksheet on the existing TB GET, and list the adjusting worksheet population on the existing journal list | Two-step `POST /period-closes`, HTTP `POST /journals` adjusting write, HTTP period-close list, HTTP trial-balance basis, HTTP journal-source list, `0029_trial_balance_snapshot_population_unique_index.sql`, `0030_trial_balance_snapshot_immutability.sql`, real PostgreSQL snapshot-authority regressions, ADR 0006, ADR 0023, ADR 0024, ADR 0030, ADR 0031, ADR 0036, and ADR 0038 | +| IFRS 9 | Receivable aging is control evidence of credit risk at the legal-entity book, not a customer subledger and not an expected-credit-loss allowance. AIS ages posted AR FIFO through period end and does not invent `party_reference`. A Billing issued-invoice void credits AR through ordinary ingest on `{tenant}:issued_invoice_void:{issued_invoice_void_id}:{void.source_payload_hash}:v{issued_invoice_void_contract_version}` and drops entity receivable aging by that inclusive amount; it is not a collection-status command and does not require Billing `reversed_journal_proposal_id` or `invoice_draft_id`. A Billing issued-credit-note void debits AR through ordinary ingest on `{tenant}:issued_credit_note_void:{issued_credit_note_void_id}:{void.source_payload_hash}:v{issued_credit_note_void_contract_version}` and raises entity receivable aging by that amount; it does not require Billing `journal_entry_id`. A Billing collection write-off of a financial asset posts debit `write_off_expense` 510100 / credit AR through ordinary ingest, reduces entity receivable aging by that amount, and parks that expense into retained earnings on hard-close; it is not an ECL allowance and is not `period_closing` | HTTP receivable aging, HTTP issued-invoice-void consume, HTTP collection write-off catalog, ADR 0039, and ADR 0042 | | IFRS 18 | Financial-statement presentation is a versioned projection separate from the journal core | Reporting boundary and roadmap | | ISO 20022-1:2026 / ISO 20022-4:2026 / ISO 20022-9:2026 / RA camt.053.001.14 | Bank-statement adapter pins `BankToCustomerStatementV14`, vendors SHA-256 adapter evidence, rejects other revisions, stores hashes/locators rather than raw XML, records `TxDtls/AmtDtls/TxAmt/Amt` only, and fail-closes when the statement account-identifier hash does not match the registered bank account. Deterministic bank-to-book matching consumes that normalized evidence but treats matching precedence as an AIS control rather than an ISO-prescribed algorithm: present provider/end-to-end/account-servicer identities outrank the weaker exact-money/date rule; amount, currency, and CRDT/DBIT economic direction must agree; direction conflicts fail closed as `direction_mismatch`; the reconciliation evidence boundary accepts only finite, strictly positive `Decimal` amounts, rejecting binary floats, zero, negative, `NaN`, and infinite values before candidate comparison; `date_window_days` must be a non-negative integer (boolean, fractional, and negative values fail at policy construction); ambiguous populations abstain and a proposal never posts a journal. Reconciliation result structure is also fail-closed evidence: the default deterministic `reconciliation-decision/v1` contract requires a `match` to carry exactly one journal, while explicit reviewed `reconciliation-decision/v2` evidence may carry multiple journal references but still requires a non-empty journal population. Both versions require a finite strictly positive exact `Decimal` allocation and no exception code, and neither version grants reconciliation approval, period-close, journal-posting, or accounting-policy authority; an `abstain` carries no matched journal, exact zero `Decimal` allocation, and a non-empty exception code, so direct callers cannot forge success-shaped close-review input. CRDT/DBIT remains separate direction evidence rather than a signed-amount convention. The exact book-to-bank bridge is likewise an AIS close control rather than an ISO rule: it independently proves statement opening + movements = closing, posted-book opening + movements = closing, and reconciled book + outstanding book - outstanding bank = statement closing with exact `Decimal` values, retains run/statement/book population provenance, never tolerance-rounds a difference, and cannot post, reverse, or approve a journal. When a bridge enters period-close review it also carries immutable tenant/legal-entity/accounting-book/bank-account-assignment identity. The buyer close-review projection is a read-only AIS presentation over those controls: it requires its supplied scope to equal the bridge-bound scope, rejects unbound or relabelled same-currency bridges, carries that scope plus run/population provenance, exact bank/book/reconciled/outstanding/unexplained values, unresolved statement-entry references and preceding-run deltas; eligibility requires exactly one decision for every expected immutable statement entry, so missing, duplicate, or extraneous decisions fail closed; preceding-run deltas require both current and preceding bridges to be bound to the same immutable scope rather than currency equality or caller assertions alone; JSON/CSV preserve monetary values as decimal strings; `suitable_for_period_close_review` is evidence eligibility only and never reconciliation approval, period-close authority, or journal-posting permission. Split and aggregate allocation proposals are exact-`Decimal` conservation evidence: a split sums exactly to the statement amount, an aggregate conserves the exact journal-side total on both sides, and every `ReconciliationAllocation` is immutable, tenant- and run-scoped with no double consumption. Persisted `reconciliation_candidate` / `reconciliation_match` / `statement_match_allocation` / `journal_match_allocation` rows are forced-RLS tenant-scoped; migration 0015 enforces source-level allocation conservation so multiple disjoint approved matches remain legal without double consumption, while migration 0016 binds approval to the database-owned candidate/allocation snapshot and freezes late allocations. Allocation planning and persistence still never post, reverse, approve, or adjust a journal | Immutable bank-statement evidence registry, deterministic reconciliation proposal engine, exact book-to-bank bridge projection, close-review projection and exact-value export regressions, population/scope and bridge-scope regressions, decision-structure regressions, direction, monetary-domain, policy, and bridge regressions, ADR 0052, ADR 0054, ADR 0055 | | PostgreSQL 18.4 | Use current supported minor release, UUIDv7, exact numeric types, composite foreign keys, forced row-level security, database-controlled `session_user` → tenant runtime binding, transaction-level advisory locks, bounded lock waits, shared fiscal-period command locks, close row locks, tenant-leading high-write indexes, and a partition migration contract that preserves partition-key identity. The journal header binds tenant + legal entity + accounting book through a composite foreign key so independently valid identifiers cannot cross legal-entity scope. The normalized journal line keeps no redundant book column; a database trigger instead rejects any chart account whose accounting book differs from the parent journal. Ordinary runtime credentials cannot select or mutate the binding table and caller-controlled GUCs are not tenant authority | Initial migration, book-scope PostgreSQL regressions, data-model contract, runtime-tenant binding migration, real restricted-runtime RLS tests, ADR 0049, ADR 0050 | | PostgreSQL 18.4 test environment | The real regression environment uses PostgreSQL 18.4, matching the repository's PostgreSQL 18.4 compatibility pin. PostgreSQL's built-in `sha256(bytea)` and `encode(..., 'hex')` provide the database-owned approval snapshot digest. A row-level `BEFORE` trigger overwrites caller-supplied snapshot input, and a shared transaction-level advisory lock serializes approval, allocation, and terminal match transitions so a valid command cannot authorize a changed candidate/allocation population; migration 0017 applies the parent-row-first repair so concurrent approval and allocation cannot form a row/advisory deadlock. Immutable source-payload hash/reference provenance remains separate from that state digest. | `0016_reconciliation_approval_evidence.sql`, `0017_reconciliation_approval_lock_order.sql`, real PostgreSQL snapshot and lock-order regressions, ADR 0055, PostgreSQL binary-string, trigger, and transaction-isolation documentation | +| IFRS Conceptual Framework §§2.12–2.13 / PostgreSQL 18 row constraints | Retained hard-close trial-balance lines must be internally coherent financial evidence: `net_balance_amount = debit_total_amount - credit_total_amount` exactly in the authoritative fixed-scale decimal domain. The IFRS Conceptual Framework supplies the faithful-representation quality rationale only; it does not prescribe this SQL formula. PostgreSQL is the enforcement authority: migration 0030 adds the immutable row-local `trial_balance_line_net_balance_conservation` check as `NOT VALID`, and separate autocommit migration 0031 validates inherited rows after migration 0030 releases its stronger `ADD CONSTRAINT` lock. Inconsistent inherited evidence blocks completion rather than being silently normalized or certified. | `0030_trial_balance_snapshot_immutability.sql`, `0031_trial_balance_line_conservation_validation.sql`, `tests/test_postgres_trial_balance_snapshot_scope_red.py`, `tests/test_trial_balance_snapshot_immutability_contract.py`, `docs/doctoring/TRIAL_BALANCE_CONSERVATION_TRACEABILITY.md`, ADR 0006 | | PostgreSQL source-conservation controls | Migration 0015 serializes candidate amount admission on stable statement (`bank_account_record_id`) and journal source identities before conflict reads, and uses the same stable statement identity across effective-dated assignment rollover during approval capacity checks. Allocation evidence freezes the candidate identity before approval; `superseded` is terminal; and the migration-0016 legacy-row check temporarily grants only the current migration user visibility over forced-RLS match rows before dropping that policy in the same transaction. Migration 0017 keeps approval/allocation lock acquisition parent-row-first. | `0015_reconciliation_multi_match_conservation.sql`, `0016_reconciliation_approval_evidence.sql`, `0017_reconciliation_approval_lock_order.sql`, hardening RED/GREEN regressions, ADR 0054, ADR 0055 | | ISO 20022 balance evidence / PostgreSQL numeric controls | The camt.053 adapter retains every bounded `Bal` as an immutable normalized fact with exact decimal amount, currency, CRDT/DBIT direction, typed effective date/time distinct from statement period and system `recorded_at`, source locator, source hash, and `balance_type_source_code` discriminator; proprietary `CdOrPrtry/Prtry` types remain material evidence even when their codes match standard codes. Migration 0018 stores those values in a forced-RLS relational table; opening and closing balance hashes remain compatibility fields, and a reconciliation bridge must fail closed when numeric balance evidence is absent rather than infer it. | `0018_bank_statement_balance_evidence.sql`, bank-statement parser and persistence regressions, ADR 0057, ISO 20022 references above, PostgreSQL constraints and row-level-security references | | Reconciliation run command evidence / PostgreSQL idempotency controls | Migration 0019 records one immutable tenant/run/statement command identity, canonical command hash, raw bank-statement artifact payload hash/reference distinct from the normalized statement hash, and forced-RLS evidence for opening an `evaluating` run. The public API requires the active statement assignment and exact raw artifact source hash, excludes selected source facts recorded after the knowledge cutoff, requires explicit zero-offset UTC cutoffs before persistence or command hashing, resolves existing command evidence before live assignment validation so exact retries survive assignment rollover or closure, permits separately auditable runs under distinct keys, rejects changed evidence, and the deferred database guard rejects orphan runs and cross-bank statement provenance. The boundary grants no matching, approval, close, posting, or chart-account authority. | `0019_reconciliation_run_command_evidence.sql`, `accept_reconciliation_run`, `lookup_reconciliation_run`, HTTP and direct-SQL reconciliation-run regressions, ADR 0058 | | Reconciliation lifecycle snapshot and system-time authority / PostgreSQL trigger and transaction semantics | Lifecycle finalization acquires the tenant/run session advisory lock before opening a fresh `REPEATABLE READ` authority transaction. Parent overlay `0019_reconciliation_run_database_snapshot_authority.sql` defines `reconciliation_run_database_snapshot_authority`, independently reconstructs the immutable statement and scoped posted-cash-book populations, reviewed allocations/decisions and exception state, validates exact source capacity and book-to-bank arithmetic, and returns PostgreSQL-owned snapshot plus statement/book population identities. `accounting_reconciliation_transition_database_authority_guard` overwrites all three caller values. Child migration 0021 then runs `accounting_reconciliation_transition_evidence_snapshot_guard`, which composes immutable maker-checker resolution commands into the final snapshot without changing the parent population identities; the existing hash trigger binds that reviewed snapshot. Migration 0025 separately makes `reconciliation_run_transition_command.recorded_at` database-owned at INSERT with PostgreSQL `clock_timestamp()` and rejects `effective_at > recorded_at`, so a future-effective business decision cannot become present lifecycle authority by supplying a caller-shaped system time. Direct SQL therefore cannot promote a forged digest, forged population references, an untied bridge, omitted resolution-command evidence, caller-controlled recording time, or future-effective transition into `reconciled` authority. This remains reconciliation evidence only and cannot post/reverse journals, close periods, or change accounting policy. | `0019_reconciliation_run_database_snapshot_authority.sql`, `0021_reconciliation_exception_resolution_outbox_pair.sql`, `0025_reconciliation_lifecycle_recording_time_authority.sql`, `tests/test_reconciliation_transition_database_snapshot_authority.py`, `tests/test_reconciliation_lifecycle_database_authority_postgres.py`, `tests/test_reconciliation_resolution_snapshot_overlay_contract.py`, `tests/test_reconciliation_lifecycle_recording_time_contract.py`, ADR 0060, database-transition snapshot authority doctoring, PostgreSQL trigger-order, date/time and transaction-isolation documentation | | PostgreSQL SECURITY DEFINER lifecycle capability / NIST SP 800-53 Rev. 5 AC-6 | Migration 0027 creates tenant/run lifecycle session-lock helpers as `SECURITY DEFINER` functions but revokes `PUBLIC EXECUTE` in the same creation transaction, preventing ordinary schema users from inheriting a lock-acquisition capability through PostgreSQL defaults. Migration 0028 repeats the revoke for already-applied predecessor 0027 installations. A real tenant-bound non-owner, non-superuser, non-`BYPASSRLS` runtime with ordinary schema/table access must receive PostgreSQL `InsufficientPrivilege` for both helpers. Issue #44 may later grant only the named lifecycle command capability through an owner-controlled purpose-limited role; raw transition/status/outbox DML remains prohibited. | `0027_reconciliation_lifecycle_session_lock_authority.sql`, `0028_reconciliation_lifecycle_capability_privileges.sql`, `tests/test_reconciliation_lifecycle_session_lock_authority_contract.py`, `tests/test_postgres_runtime_rls.py`, ADR 0066, lifecycle capability privilege doctoring, PostgreSQL 18 privileges documentation, NIST SP 800-53 Rev. 5 AC-6 | -| Reconciliation exception-resolution evidence / PostgreSQL maker-checker controls | Migration 0020 replaces terminal exception status as standalone authority with one immutable tenant/run/exception resolution command. The command separately retains the reviewed evidence digest and the SHA-256 identity of the complete incoming JSON command, uses the shared reconciliation idempotency namespace, freezes maker evidence from exception creation, requires reviewer separation and temporal causality, and commits command/status/outbox evidence atomically. Migration 0024 makes the source exception and retained review-evidence system chronology database-owned for new rows while preserving pre-migration rows as `legacy_unverified`; those legacy timestamps remain auditable but cannot back a new maker-checker authority decision. A legacy preflight refuses installation over terminal pre-0020 exception rows rather than inventing missing review provenance. Concurrent exact retries use fresh transactions after PostgreSQL serialization failure; run finalization accepts terminal exceptions only when status and durable command agree. This is an AIS accounting-control decision and never posts a journal, closes a period, or changes accounting policy. | `0020_reconciliation_exception_resolution_command.sql`, `0024_reconciliation_control_recording_time_authority.sql`, `resolve_reconciliation_exception`, real PostgreSQL raw-DML/maker-checker/replay/concurrency and recording-time regressions, migration-install regressions, ADR 0062, NIST SP 800-53 Rev. 5 AC-5, PostgreSQL date/time, transaction-isolation and row-security documentation | +| Reconciliation exception-resolution evidence / PostgreSQL maker-checker controls | Migration 0020 replaces terminal exception status as standalone authority with one immutable tenant/run/exception resolution command. The command separately retains the reviewed evidence digest and the SHA-256 identity of the complete incoming JSON command, uses the shared reconciliation idempotency namespace, freezes maker evidence from exception creation, requires a reviewer distinct from the exception owner, preserves exact replay and changed-payload conflict semantics, and commits command/status/outbox evidence atomically. Migration 0024 makes the source exception and retained review-evidence system chronology database-owned for new rows while preserving pre-migration rows as `legacy_unverified`; those legacy timestamps remain auditable but cannot back a new maker-checker authority decision. A legacy preflight refuses installation over terminal pre-0020 exception rows rather than inventing missing review provenance. Concurrent exact retries use fresh transactions after PostgreSQL serialization failure; run finalization accepts terminal exceptions only when status and durable command agree. This is an AIS accounting-control decision and never posts a journal, closes a period, or changes accounting policy. | `0020_reconciliation_exception_resolution_command.sql`, `0024_reconciliation_control_recording_time_authority.sql`, `resolve_reconciliation_exception`, real PostgreSQL raw-DML/maker-checker/replay/concurrency and recording-time regressions, migration-install regressions, ADR 0062, NIST SP 800-53 Rev. 5 AC-5, PostgreSQL date/time, transaction-isolation and row-security documentation | | RFC 9112 | The standalone HTTP/1.1 command boundary deliberately does not implement transfer coding: any request carrying `Transfer-Encoding` fails closed with HTTP 400 and connection close rather than being combined with a `Content-Length` interpretation. A valid `Content-Length` is an exact octet contract; premature EOF/short reads are incomplete messages, fail with HTTP 400, and close the connection before JSON/domain processing. This prevents ambiguous message boundaries from becoming request-smuggling or valid-prefix acceptance paths | `JournalProposalHandler._read_body`, HTTP request-boundary RED/GREEN regressions, RFC 9112 §§6.2–6.3 and §8 | | RFC 9562 | New persistence identifiers use UUIDv7 | Initial migration | | CloudEvents 1.0.2 | Commit authoritative events through a transactional outbox and replay by event identity | Outbox table and architecture | @@ -27,7 +28,7 @@ | W3C PROV-O | Preserve entity, activity, agent, derivation, and attribution references across source proposal, posting, and append-only journal reversal lineage. The in-memory posting oracle scopes those identities by tenant and refuses to overwrite a posted `proposal_id`. Checked-in migrations cannot `UPDATE` or `DELETE` `general_journal` or `journal_entry_line` | Source-reference and receipt contracts, HTTP journal-reversal list, repository migration validation, ADR 0003, and ADR 0029 | | RFC 3339 / FIPS 180-4 / W3C PROV-O | The read-only reconciliation close package uses canonical UTC second-precision run cutoffs, SHA-256 content integrity, explicit entity/evidence references, and canonical operator-facing next-action guidance. Package schema version 4 binds the complete approved reconciliation-match population to the projection's tenant/run scope and approval command source hash; projection exports are schema version 2. Every match decision must carry its canonical durable identity, and each durable `ReconciliationReviewedMatch` record binds that identity to candidate facts and complete normalized statement/journal allocation populations while remaining in the projection/package export. Bridge and package equations use context-independent sign inversion and operand-derived local Decimal precision so valid minor-unit differences cannot disappear under ambient rounding. The package preserves the evidence-only boundary; a digest does not authenticate an actor or grant approval/close/posting authority | `ReconciliationClosePackage`, `ReconciliationReviewedMatch`, `ReconciliationApprovalEvidence`, close-package exact-value and fail-closed regressions, decision-binding, allocation-population, and Decimal-precision regressions, ADR 0056 | | ISO/IEC/IEEE 42010:2022 | Keep stakeholder concerns, authority boundaries, architecture views, and decisions explicit | Architecture and ADR set | -| JSON Schema Draft 2020-12 | Close external objects, version contracts, reject extra fields, and validate exact value formats. Policy-manifest `account_mappings` are unique by `account_role_code` before policy load because `uniqueItems` compares whole objects | Three contract schemas, `load_accounting_policy`, ADR 0005, and ADR 0007 | +| JSON Schema Draft 2020-12 | Close external objects, version contracts, reject extra fields, and validate exact value formats. Policy-manifest `account_mappings` are unique by `account_role_code` before policy load because `uniqueItems` compares whole objects | Three contract schemas, `load_accounting_policy`, ADR 0005, ADR 0007 | | XBRL 2.1 | Treat external reporting taxonomy as a versioned projection rather than core ledger columns | Reporting roadmap | -The initial milestone does not claim production compliance with a jurisdiction's accounting, tax, or statutory reporting rules. It establishes controls and traceability required to implement reviewed policies without changing the journal authority model. \ No newline at end of file +The initial milestone does not claim production compliance with a jurisdiction's accounting, tax, or statutory reporting rules. It establishes controls and traceability required to implement reviewed policies without changing the journal authority model. From 46c877cb97b03cf0bc827ab876093592da4f7614 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 06:23:08 +0900 Subject: [PATCH 059/224] docs(close): preserve unrelated traceability wording --- docs/doctoring/STANDARD_TRACEABILITY.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/doctoring/STANDARD_TRACEABILITY.md b/docs/doctoring/STANDARD_TRACEABILITY.md index 92be08e6..d0919d24 100644 --- a/docs/doctoring/STANDARD_TRACEABILITY.md +++ b/docs/doctoring/STANDARD_TRACEABILITY.md @@ -7,7 +7,7 @@ | IAS 7 | Statement of cash flows presents period cash movements by the indirect method: operational net income, non-cash working-capital change, investing, and financing, reconciling opening cash to closing cash that equals the balance-sheet cash total. Cash is the current `cash_receipt` mapping. AIS does not invent a cash-flow class code | HTTP cash-flow statement and ADR 0033 | | IAS 34 | Interim year-to-date income statements cover the current financial year through the requested period; the balance sheet remains a point-in-time statement as of that period | HTTP `statement_scope_code=year_to_date` and ADR 0028 | | IAS 10 | Soft-close rejects ordinary posts and allows AIS-owned adjusting journals plus append-only reversing adjustments before hard-close snapshots and locks the period. Hard-close loads the close package in one consistent read, parks period earnings on 310100, and stores the close `idempotency_key` on the snapshot so a later journal or a different close key fails closed. Soft-close creates no persisted trial-balance snapshot; hard-close admits exactly one retained tenant/book/period snapshot population under the same database authority-row lock, and a pre-existing competing population makes the hard-close transaction fail closed instead of adopting caller-shaped evidence or selecting a forged future `snapshot_generated_at`. Retained snapshot headers and lines cannot later be extended, rewritten, or deleted. Auditors read durable hard-close receipts from stored snapshots, not reconstructed soft-close history. Controllers also read the unadjusted, adjusted, and post-close trial-balance worksheet on the existing TB GET, and list the adjusting worksheet population on the existing journal list | Two-step `POST /period-closes`, HTTP `POST /journals` adjusting write, HTTP period-close list, HTTP trial-balance basis, HTTP journal-source list, `0029_trial_balance_snapshot_population_unique_index.sql`, `0030_trial_balance_snapshot_immutability.sql`, real PostgreSQL snapshot-authority regressions, ADR 0006, ADR 0023, ADR 0024, ADR 0030, ADR 0031, ADR 0036, and ADR 0038 | -| IFRS 9 | Receivable aging is control evidence of credit risk at the legal-entity book, not a customer subledger and not an expected-credit-loss allowance. AIS ages posted AR FIFO through period end and does not invent `party_reference`. A Billing issued-invoice void credits AR through ordinary ingest on `{tenant}:issued_invoice_void:{issued_invoice_void_id}:{void.source_payload_hash}:v{issued_invoice_void_contract_version}` and drops entity receivable aging by that inclusive amount; it is not a collection-status command and does not require Billing `reversed_journal_proposal_id` or `invoice_draft_id`. A Billing issued-credit-note void debits AR through ordinary ingest on `{tenant}:issued_credit_note_void:{issued_credit_note_void_id}:{void.source_payload_hash}:v{issued_credit_note_void_contract_version}` and raises entity receivable aging by that amount; it does not require Billing `journal_entry_id`. A Billing collection write-off of a financial asset posts debit `write_off_expense` 510100 / credit AR through ordinary ingest, reduces entity receivable aging by that amount, and parks that expense into retained earnings on hard-close; it is not an ECL allowance and is not `period_closing` | HTTP receivable aging, HTTP issued-invoice-void consume, HTTP collection write-off catalog, ADR 0039, and ADR 0042 | +| IFRS 9 | Receivable aging is control evidence of credit risk at the legal-entity book, not a customer subledger and not an expected-credit-loss allowance. AIS ages posted AR FIFO through period end and does not invent `party_reference`. A Billing issued-invoice void credits AR through ordinary ingest on `{tenant}:issued_invoice_void:{issued_invoice_void_id}:{void.source_payload_hash}:v{issued_invoice_void_contract_version}` and drops entity receivable aging by that inclusive amount; it is not a collection-status command and does not require Billing `reversed_journal_proposal_id` or `invoice_draft_id`. A Billing issued-credit-note void debits AR through ordinary ingest on `{tenant}:issued_credit_note_void:{issued_credit_note_void_id}:{void.source_payload_hash}:v{issued_credit_note_void_contract_version}` and raises entity receivable aging by that inclusive amount the credit had reduced; it does not require Billing `journal_entry_id`. A Billing collection write-off of a financial asset posts debit `write_off_expense` 510100 / credit AR through ordinary ingest, reduces entity receivable aging by that amount, and parks that expense into retained earnings on hard-close; it is not an ECL allowance and is not `period_closing` | HTTP receivable aging, HTTP issued-invoice-void consume, HTTP collection write-off catalog, ADR 0039, and ADR 0042 | | IFRS 18 | Financial-statement presentation is a versioned projection separate from the journal core | Reporting boundary and roadmap | | ISO 20022-1:2026 / ISO 20022-4:2026 / ISO 20022-9:2026 / RA camt.053.001.14 | Bank-statement adapter pins `BankToCustomerStatementV14`, vendors SHA-256 adapter evidence, rejects other revisions, stores hashes/locators rather than raw XML, records `TxDtls/AmtDtls/TxAmt/Amt` only, and fail-closes when the statement account-identifier hash does not match the registered bank account. Deterministic bank-to-book matching consumes that normalized evidence but treats matching precedence as an AIS control rather than an ISO-prescribed algorithm: present provider/end-to-end/account-servicer identities outrank the weaker exact-money/date rule; amount, currency, and CRDT/DBIT economic direction must agree; direction conflicts fail closed as `direction_mismatch`; the reconciliation evidence boundary accepts only finite, strictly positive `Decimal` amounts, rejecting binary floats, zero, negative, `NaN`, and infinite values before candidate comparison; `date_window_days` must be a non-negative integer (boolean, fractional, and negative values fail at policy construction); ambiguous populations abstain and a proposal never posts a journal. Reconciliation result structure is also fail-closed evidence: the default deterministic `reconciliation-decision/v1` contract requires a `match` to carry exactly one journal, while explicit reviewed `reconciliation-decision/v2` evidence may carry multiple journal references but still requires a non-empty journal population. Both versions require a finite strictly positive exact `Decimal` allocation and no exception code, and neither version grants reconciliation approval, period-close, journal-posting, or accounting-policy authority; an `abstain` carries no matched journal, exact zero `Decimal` allocation, and a non-empty exception code, so direct callers cannot forge success-shaped close-review input. CRDT/DBIT remains separate direction evidence rather than a signed-amount convention. The exact book-to-bank bridge is likewise an AIS close control rather than an ISO rule: it independently proves statement opening + movements = closing, posted-book opening + movements = closing, and reconciled book + outstanding book - outstanding bank = statement closing with exact `Decimal` values, retains run/statement/book population provenance, never tolerance-rounds a difference, and cannot post, reverse, or approve a journal. When a bridge enters period-close review it also carries immutable tenant/legal-entity/accounting-book/bank-account-assignment identity. The buyer close-review projection is a read-only AIS presentation over those controls: it requires its supplied scope to equal the bridge-bound scope, rejects unbound or relabelled same-currency bridges, carries that scope plus run/population provenance, exact bank/book/reconciled/outstanding/unexplained values, unresolved statement-entry references and preceding-run deltas; eligibility requires exactly one decision for every expected immutable statement entry, so missing, duplicate, or extraneous decisions fail closed; preceding-run deltas require both current and preceding bridges to be bound to the same immutable scope rather than currency equality or caller assertions alone; JSON/CSV preserve monetary values as decimal strings; `suitable_for_period_close_review` is evidence eligibility only and never reconciliation approval, period-close authority, or journal-posting permission. Split and aggregate allocation proposals are exact-`Decimal` conservation evidence: a split sums exactly to the statement amount, an aggregate conserves the exact journal-side total on both sides, and every `ReconciliationAllocation` is immutable, tenant- and run-scoped with no double consumption. Persisted `reconciliation_candidate` / `reconciliation_match` / `statement_match_allocation` / `journal_match_allocation` rows are forced-RLS tenant-scoped; migration 0015 enforces source-level allocation conservation so multiple disjoint approved matches remain legal without double consumption, while migration 0016 binds approval to the database-owned candidate/allocation snapshot and freezes late allocations. Allocation planning and persistence still never post, reverse, approve, or adjust a journal | Immutable bank-statement evidence registry, deterministic reconciliation proposal engine, exact book-to-bank bridge projection, close-review projection and exact-value export regressions, population/scope and bridge-scope regressions, decision-structure regressions, direction, monetary-domain, policy, and bridge regressions, ADR 0052, ADR 0054, ADR 0055 | | PostgreSQL 18.4 | Use current supported minor release, UUIDv7, exact numeric types, composite foreign keys, forced row-level security, database-controlled `session_user` → tenant runtime binding, transaction-level advisory locks, bounded lock waits, shared fiscal-period command locks, close row locks, tenant-leading high-write indexes, and a partition migration contract that preserves partition-key identity. The journal header binds tenant + legal entity + accounting book through a composite foreign key so independently valid identifiers cannot cross legal-entity scope. The normalized journal line keeps no redundant book column; a database trigger instead rejects any chart account whose accounting book differs from the parent journal. Ordinary runtime credentials cannot select or mutate the binding table and caller-controlled GUCs are not tenant authority | Initial migration, book-scope PostgreSQL regressions, data-model contract, runtime-tenant binding migration, real restricted-runtime RLS tests, ADR 0049, ADR 0050 | @@ -25,10 +25,10 @@ | SLSA 1.2 / SPDX 2.3 / GitHub artifact attestations | Exact-head package evidence builds the wheel twice from a source-derived `SOURCE_DATE_EPOCH`, requires byte-identical SHA-256 digests, emits a deterministic SPDX 2.3 SBOM plus `source-provenance.json`, and makes `SHA256SUMS` cover the wheel, SBOM and source-provenance manifest. After checksum verification, the rebuilt wheel is installed with `--require-hashes` from a requirements line that carries the measured `--hash=sha256:` digest. The intermediate public-API smoke test imports the source tree over `PYTHONPATH` instead of an unhashed editable install. The manifest binds the verified source SHA to the wheel digest and SBOM digest before merge. Pull-request-controlled build/test code runs with `contents: read` only; OIDC, attestation and artifact-metadata write permissions are isolated in a distinct push-only `integrated-attestations` job. That job depends on the successful foundation build, downloads the immutable SHA-named evidence bundle, re-verifies checksums and `source_sha == github.sha`, and only then creates GitHub OIDC-backed signed provenance and SBOM attestations on integrated `develop`/`main` heads. A new runtime dependency fails closed until the SBOM generator represents its dependency relationship. This is evidence readiness, not a claimed SLSA level or certification | Accounting Foundation CI, `scripts/generate_supply_chain_evidence.py`, supply-chain evidence tests, GitHub workflow-permissions/OIDC/artifact-attestation guidance, and ADR 0048 | | OSV-Scanner / OSV.dev vulnerability data | Pull-request dependency evidence is tied to the immutable PR head and an independently fetched live base tip. The gate records dependency-manifest diffs and SHA-256 values, rejects stale/non-ancestor base identity, and scans the complete hash-locked exact-head Python dependency set with a digest-pinned OSV-Scanner image. A known vulnerability, scanner failure, skipped/unavailable evidence path or wrong checkout identity is non-passing; aggregate organization workflow success cannot substitute for an unexecuted dependency-review step | `exact-head-dependency-diff` CI job, `tests/test_dependency_review_contract.py`, OSV-Scanner source/lockfile guidance, and ADR 0048 | | AICPA Trust Services Criteria (SOC 2) | Auditors read an append-only history of posted, reversed, and closed facts from existing `outbox_event` rows, including already-published rows, without marking publish. Controllers also list stored `journal_reversal` lineage and durable hard-close receipts over HTTP without SQL. A HomeTax filing command fail-closes and persists a rejected receipt when the VAT register or the purpose-limited HomeTax credential is missing, and this slice never claims `transmitted` | HTTP audit-event history, HTTP journal-reversal list, HTTP period-close list, HTTP fail-closed HomeTax submission, ADR 0027, ADR 0029, ADR 0030, and ADR 0046 | -| W3C PROV-O | Preserve entity, activity, agent, derivation, and attribution references across source proposal, posting, and append-only journal reversal lineage. The in-memory posting oracle scopes those identities by tenant and refuses to overwrite a posted `proposal_id`. Checked-in migrations cannot `UPDATE` or `DELETE` `general_journal` or `journal_entry_line` | Source-reference and receipt contracts, HTTP journal-reversal list, repository migration validation, ADR 0003, and ADR 0029 | +| W3C PROV-O | Preserve entity, activity, agent, derivation, and attribution references across source proposal, posting, and append-only journal reversal lineage. The in-memory posting oracle scopes those identities by tenant and refuses to overwrite a posted `proposal_id`. Checked-in migrations cannot `UPDATE` or `DELETE` `general_journal` or `journal_entry_line` | Source-reference and receipt contracts, HTTP journal-reversal list, repository migration validation, ADR 0003, ADR 0029 | | RFC 3339 / FIPS 180-4 / W3C PROV-O | The read-only reconciliation close package uses canonical UTC second-precision run cutoffs, SHA-256 content integrity, explicit entity/evidence references, and canonical operator-facing next-action guidance. Package schema version 4 binds the complete approved reconciliation-match population to the projection's tenant/run scope and approval command source hash; projection exports are schema version 2. Every match decision must carry its canonical durable identity, and each durable `ReconciliationReviewedMatch` record binds that identity to candidate facts and complete normalized statement/journal allocation populations while remaining in the projection/package export. Bridge and package equations use context-independent sign inversion and operand-derived local Decimal precision so valid minor-unit differences cannot disappear under ambient rounding. The package preserves the evidence-only boundary; a digest does not authenticate an actor or grant approval/close/posting authority | `ReconciliationClosePackage`, `ReconciliationReviewedMatch`, `ReconciliationApprovalEvidence`, close-package exact-value and fail-closed regressions, decision-binding, allocation-population, and Decimal-precision regressions, ADR 0056 | | ISO/IEC/IEEE 42010:2022 | Keep stakeholder concerns, authority boundaries, architecture views, and decisions explicit | Architecture and ADR set | -| JSON Schema Draft 2020-12 | Close external objects, version contracts, reject extra fields, and validate exact value formats. Policy-manifest `account_mappings` are unique by `account_role_code` before policy load because `uniqueItems` compares whole objects | Three contract schemas, `load_accounting_policy`, ADR 0005, ADR 0007 | +| JSON Schema Draft 2020-12 | Close external objects, version contracts, reject extra fields, and validate exact value formats. Policy-manifest `account_mappings` are unique by `account_role_code` before policy load because `uniqueItems` compares whole objects | Three contract schemas, `load_accounting_policy`, ADR 0005, and ADR 0007 | | XBRL 2.1 | Treat external reporting taxonomy as a versioned projection rather than core ledger columns | Reporting roadmap | The initial milestone does not claim production compliance with a jurisdiction's accounting, tax, or statutory reporting rules. It establishes controls and traceability required to implement reviewed policies without changing the journal authority model. From 715d3a6abac92183acd18c7be5218766ef9899a1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 07:03:23 +0900 Subject: [PATCH 060/224] test(close): reject trial-balance snapshot currency drift --- ...res_trial_balance_snapshot_currency_red.py | 105 ++++++++++++++++++ 1 file changed, 105 insertions(+) create mode 100644 tests/test_postgres_trial_balance_snapshot_currency_red.py diff --git a/tests/test_postgres_trial_balance_snapshot_currency_red.py b/tests/test_postgres_trial_balance_snapshot_currency_red.py new file mode 100644 index 00000000..40e53d8a --- /dev/null +++ b/tests/test_postgres_trial_balance_snapshot_currency_red.py @@ -0,0 +1,105 @@ +"""Real PostgreSQL RED/GREEN for retained trial-balance currency scope.""" + +from __future__ import annotations + +import unittest + +import psycopg + +from tests import test_postgres_posting as posting + + +class TrialBalanceSnapshotCurrencyPostgresTests(unittest.TestCase): + """Bind retained hard-close currency to the authoritative accounting book.""" + + @classmethod + def setUpClass(cls) -> None: + """Install the complete current accounting migration chain.""" + posting.PostgresPostingTests.setUpClass() + + def setUp(self) -> None: + """Leave one balanced accounting book soft-closed for direct DB admission.""" + self.case = posting.PostgresPostingTests("setUp") + self.case.setUp() + self.addCleanup(self.case.doCleanups) + self.addCleanup(self.case.tearDown) + self.case.ledger.post(self.case._two_line_proposal(), self.case.policy) + self.case.ledger.close_fiscal_period( + self.case.policy.legal_entity_reference, + self.case.policy.accounting_book_reference, + "2026-08", + "KRW", + period_status_code="soft_closed", + idempotency_key=f"{self.case.policy.tenant_reference}:snapshot-currency:soft", + ) + + def test_snapshot_header_rejects_currency_different_from_accounting_book(self) -> None: + """A retained snapshot cannot relabel the accounting book's reporting currency.""" + with psycopg.connect(posting.DATABASE_URL) as connection: + scope = connection.execute( + """ + SELECT legal_entity_record.legal_entity_id, + accounting_book.accounting_book_id, + fiscal_period.fiscal_period_id, + accounting_book.reporting_currency_code + FROM accounting_core.legal_entity_record + JOIN accounting_core.accounting_book + ON accounting_book.tenant_account_id = legal_entity_record.tenant_account_id + AND accounting_book.legal_entity_id = legal_entity_record.legal_entity_id + JOIN accounting_core.accounting_book_period_control + ON accounting_book_period_control.tenant_account_id = accounting_book.tenant_account_id + AND accounting_book_period_control.accounting_book_id = accounting_book.accounting_book_id + JOIN accounting_core.fiscal_period + ON fiscal_period.tenant_account_id = accounting_book_period_control.tenant_account_id + AND fiscal_period.fiscal_period_id = accounting_book_period_control.fiscal_period_id + WHERE legal_entity_record.tenant_account_id = %s + AND legal_entity_record.legal_entity_code = %s + AND accounting_book.book_name = %s + AND fiscal_period.period_code = '2026-08' + """, + ( + self.case.tenant_id, + self.case.policy.legal_entity_reference, + self.case.policy.accounting_book_reference, + ), + ).fetchone() + self.assertIsNotNone(scope) + assert scope is not None + legal_entity_id, accounting_book_id, fiscal_period_id, book_currency = scope + wrong_currency = "USD" if book_currency != "USD" else "JPY" + connection.execute( + "SELECT set_config('accounting_core.journal_write_role', 'period_closing', true)" + ) + + with self.assertRaisesRegex( + psycopg.errors.CheckViolation, + "trial_balance_snapshot_currency_mismatch", + ): + connection.execute( + """ + INSERT INTO accounting_reporting.trial_balance_snapshot ( + tenant_account_id, + legal_entity_id, + accounting_book_id, + fiscal_period_id, + snapshot_currency_code, + source_journal_count, + source_payload_hash, + close_idempotency_key + ) + VALUES (%s, %s, %s, %s, %s, 0, %s, %s) + """, + ( + self.case.tenant_id, + legal_entity_id, + accounting_book_id, + fiscal_period_id, + wrong_currency, + "sha256:" + "c" * 64, + f"{self.case.policy.tenant_reference}:snapshot-currency:wrong", + ), + ) + + +if __name__ == "__main__": + unittest.main() From 914aee7bf6fda92172707c46a37a147aaafa0fea Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 07:04:36 +0900 Subject: [PATCH 061/224] fix(close): bind trial-balance snapshot currency to book --- .../0030_trial_balance_snapshot_immutability.sql | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/database/migrations/0030_trial_balance_snapshot_immutability.sql b/database/migrations/0030_trial_balance_snapshot_immutability.sql index fb19e9c4..5ed86207 100644 --- a/database/migrations/0030_trial_balance_snapshot_immutability.sql +++ b/database/migrations/0030_trial_balance_snapshot_immutability.sql @@ -59,6 +59,7 @@ AS $$ DECLARE period_status_value text; book_legal_entity_id uuid; + book_reporting_currency_code text; journal_write_role_value text; close_command_lock_held boolean; BEGIN @@ -76,8 +77,10 @@ BEGIN USING ERRCODE = 'check_violation'; END IF; - SELECT accounting_book.legal_entity_id - INTO book_legal_entity_id + SELECT accounting_book.legal_entity_id, + accounting_book.reporting_currency_code + INTO book_legal_entity_id, + book_reporting_currency_code FROM accounting_core.accounting_book WHERE accounting_book.tenant_account_id = NEW.tenant_account_id AND accounting_book.accounting_book_id = NEW.accounting_book_id; @@ -89,6 +92,13 @@ BEGIN USING ERRCODE = 'check_violation'; END IF; + IF book_reporting_currency_code IS NOT NULL + AND book_reporting_currency_code IS DISTINCT FROM NEW.snapshot_currency_code THEN + RAISE EXCEPTION + 'trial balance snapshot currency must match the accounting book reporting currency (trial_balance_snapshot_currency_mismatch)' + USING ERRCODE = 'check_violation'; + END IF; + IF period_status_value = 'hard_closed' THEN RAISE EXCEPTION 'hard-close trial balance evidence is immutable (trial_balance_snapshot_immutable)' From 9c75abd73e908c7cd9fc26e4a860027100c754ae Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 07:05:13 +0900 Subject: [PATCH 062/224] docs(close): bind snapshot currency to book authority --- docs/adr/0006-fiscal-period-close-snapshot.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/adr/0006-fiscal-period-close-snapshot.md b/docs/adr/0006-fiscal-period-close-snapshot.md index c6424de9..2ae03aae 100644 --- a/docs/adr/0006-fiscal-period-close-snapshot.md +++ b/docs/adr/0006-fiscal-period-close-snapshot.md @@ -12,7 +12,7 @@ The retained hard-close population is one snapshot per tenant/accounting-book/fi Every retained trial-balance line is also one exact arithmetic fact rather than three independently writable monetary values. `trial_balance_line.net_balance_amount` must equal `debit_total_amount - credit_total_amount` exactly in PostgreSQL `numeric(38, 6)`. Migration 0030 adds the named `trial_balance_line_net_balance_conservation` check as `NOT VALID`, so new writes are constrained without scanning inherited rows while that transaction still holds its `ADD CONSTRAINT` lock. After 0030 commits and releases that stronger lock, migration `0031_trial_balance_line_conservation_validation.sql` runs as a separate autocommit statement and validates inherited rows. An upgrade therefore fails closed on pre-existing inconsistent close evidence without needlessly holding the 0030 lock for the validation scan. -The retained population also preserves the accounting-book aggregate boundary at every denormalized reference. A snapshot header's `legal_entity_id` must be the legal entity that owns its `accounting_book_id`; independently valid tenant-scoped identifiers cannot be recombined into a cross-entity snapshot. Every `trial_balance_line.chart_account_id` must belong to that snapshot's accounting book; a chart account from another book cannot be imported into retained close evidence merely because it belongs to the same tenant. Migration 0030 enforces both relations before a population can become hard-close evidence. +The retained population also preserves the accounting-book aggregate boundary at every denormalized reference. A snapshot header's `legal_entity_id` must be the legal entity that owns its `accounting_book_id`; independently valid tenant-scoped identifiers cannot be recombined into a cross-entity snapshot. Its `snapshot_currency_code` must equal that accounting book's `reporting_currency_code`; a purpose-limited writer cannot relabel retained balances into another syntactically valid ISO 4217 currency. Every `trial_balance_line.chart_account_id` must belong to that snapshot's accounting book; a chart account from another book cannot be imported into retained close evidence merely because it belongs to the same tenant. Migration 0030 enforces all three relations before a population can become hard-close evidence. Snapshot creation is not an ordinary soft-close write. The database admits a new snapshot only while the exact book-period is `soft_closed`, `session_user` is a member of the purpose-limited `accounting_closing_writer` role, and the transaction carries hard-close command context. That context is present either as transaction-local `accounting_core.journal_write_role=period_closing` after a required closing-journal write or as the exact tenant/book/period transaction-level advisory lock acquired unconditionally by `close_fiscal_period` before close evidence is assembled. The GUC and advisory lock are purpose/classification evidence rather than independent authorization; PostgreSQL role membership remains the capability boundary. This distinction matters for a zero-net-income or otherwise already-neutralized period: a valid hard close still persists one retained snapshot even though no period-closing journal is required, so snapshot authority cannot depend on an optional journal INSERT side effect. @@ -26,7 +26,7 @@ This is deliberately fail-closed. Raw or legacy SQL cannot create a pre-close sn Controllers close books through the posting adapter instead of a raw status update. Ordinary posting is rejected for every non-open period. The database insert guard in `0005_closed_period_guard.sql` permits only purpose-limited AIS close/adjust/reversal journal writes while a period is `soft_closed`; every journal insert is rejected once the period is `hard_closed`. The caller-controlled `accounting_core.journal_write_role` GUC is classification metadata, not sufficient authorization by itself. -Migration `0030_trial_balance_snapshot_immutability.sql` rejects UPDATE or DELETE of retained snapshot headers and lines, rejects population extension after hard close, rejects a snapshot header unless the soft-closed period is being written by the purpose-limited hard-close capability, rejects a header whose legal entity does not own the selected book, rejects a line whose chart account belongs to another book, rejects new or changed lines whose stored net does not exactly conserve their stored debit and credit totals, and owns snapshot recording chronology at the database boundary. Header and line admission lock the exact tenant/book/period authority row before evaluating status. The header guard checks the current backend's exact transaction advisory lock only once per close snapshot; it does not poll `pg_locks` on journal or report-read hot paths. That row lock serializes status-sensitive admission and gives the normal visible-conflict diagnostic; the exact-scope unique constraint independently enforces one population across transaction snapshots and concurrent writers. Pre-migration history is retained rather than rewritten and is not retroactively attested as canonical solely because the guard was installed. If duplicate populations already exist, the concurrent unique-index build fails rather than blessing ambiguous history. Migration 0031 separately validates inherited debit/credit/net conservation and fails rather than certifying inconsistent monetary evidence. +Migration `0030_trial_balance_snapshot_immutability.sql` rejects UPDATE or DELETE of retained snapshot headers and lines, rejects population extension after hard close, rejects a snapshot header unless the soft-closed period is being written by the purpose-limited hard-close capability, rejects a header whose legal entity does not own the selected book, rejects a header whose currency differs from the book's reporting currency, rejects a line whose chart account belongs to another book, rejects new or changed lines whose stored net does not exactly conserve their stored debit and credit totals, and owns snapshot recording chronology at the database boundary. Header and line admission lock the exact tenant/book/period authority row before evaluating status. The header guard checks the current backend's exact transaction advisory lock only once per close snapshot; it does not poll `pg_locks` on journal or report-read hot paths. That row lock serializes status-sensitive admission and gives the normal visible-conflict diagnostic; the exact-scope unique constraint independently enforces one population across transaction snapshots and concurrent writers. Pre-migration history is retained rather than rewritten and is not retroactively attested as canonical solely because the guard was installed. If duplicate populations already exist, the concurrent unique-index build fails rather than blessing ambiguous history. Migration 0031 separately validates inherited debit/credit/net conservation and fails rather than certifying inconsistent monetary evidence. The index-build, transactional boundary-install, and inherited-row validation phases are intentionally separate. PostgreSQL forbids `CREATE INDEX CONCURRENTLY` inside a transaction block, and PostgreSQL transaction locks are normally held until transaction end; keeping `VALIDATE CONSTRAINT` inside the same transaction as `ADD CONSTRAINT` would therefore retain the stronger 0030 lock throughout the scan. The canonical installer applies each forward migration file separately on an autocommit connection: migration 0029 is the concurrent index build, migration 0030 atomically installs the table/trigger boundary plus the `NOT VALID` arithmetic check, and migration 0031 validates history after 0030 has committed. A failed concurrent unique build may leave an invalid index, which PostgreSQL requires operators to remove or rebuild before retry; a failed 0031 validation leaves the new check enforced for subsequent writes but the inherited population unvalidated. Recovery tooling and release evidence must distinguish those partial-upgrade states rather than claim automatic rollback. From d3ea0b93501c639fb0a22e8aaef158af62ccb2d0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 08:04:36 +0900 Subject: [PATCH 063/224] test(close): pin resolved book lock identity --- ...l_balance_snapshot_immutability_contract.py | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/tests/test_trial_balance_snapshot_immutability_contract.py b/tests/test_trial_balance_snapshot_immutability_contract.py index d5b4d1b6..8e789788 100644 --- a/tests/test_trial_balance_snapshot_immutability_contract.py +++ b/tests/test_trial_balance_snapshot_immutability_contract.py @@ -1,4 +1,4 @@ -"""Static contracts for the hard-close trial-balance immutability migrations.""" +"""Static contracts for the hard-close trial-balance snapshot immutability migrations.""" from __future__ import annotations @@ -10,6 +10,7 @@ ROOT = Path(__file__).resolve().parents[1] +PERSISTENCE = ROOT / "src/accounting_information_platform/persistence.py" INDEX_MIGRATION = ( ROOT / "database/migrations/0029_trial_balance_snapshot_population_unique_index.sql" ) @@ -143,6 +144,21 @@ def test_snapshot_header_requires_purpose_limited_hard_close_authority(self) -> ) self.assertIn("trial_balance_snapshot_authority_required", migration) + def test_close_command_and_snapshot_guard_share_resolved_book_lock_identity(self) -> None: + """The application must acquire the same resolved book-id lock inspected by PostgreSQL.""" + source = PERSISTENCE.read_text(encoding="utf-8") + close_start = source.index(" def close_fiscal_period(") + close_end = source.index(" def open_fiscal_period(", close_start) + close_source = source[close_start:close_end] + book_resolution = "book_id, reporting_currency_code = self._require_book_for_close(" + canonical_lock = 'connection, f"period:{book_id}:{period_code}"' + caller_lock = 'connection, f"period:{accounting_book_reference}:{period_code}"' + + self.assertIn(book_resolution, close_source) + self.assertIn(canonical_lock, close_source) + self.assertNotIn(caller_lock, close_source) + self.assertLess(close_source.index(book_resolution), close_source.index(canonical_lock)) + def test_snapshot_header_system_time_is_database_owned(self) -> None: """Even an admitted closing writer cannot select retained snapshot chronology.""" migration = IMMUTABILITY_MIGRATION.read_text(encoding="utf-8") From be97a48b35316292175df903c1c5806c3798a9f1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 08:14:07 +0900 Subject: [PATCH 064/224] fix(close): lock resolved accounting book identity --- src/accounting_information_platform/persistence.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/accounting_information_platform/persistence.py b/src/accounting_information_platform/persistence.py index 27cb0cfb..e66649c0 100644 --- a/src/accounting_information_platform/persistence.py +++ b/src/accounting_information_platform/persistence.py @@ -1075,9 +1075,6 @@ def close_fiscal_period( self._active_connection = connection try: tenant_id = self._require_tenant(connection) - self._acquire_command_lock( - connection, f"period:{accounting_book_reference}:{period_code}" - ) legal_entity_id = self._require_legal_entity( connection, tenant_id, @@ -1087,6 +1084,9 @@ def close_fiscal_period( book_id, reporting_currency_code = self._require_book_for_close( connection, tenant_id, legal_entity_id, accounting_book_reference ) + self._acquire_command_lock( + connection, f"period:{book_id}:{period_code}" + ) if snapshot_currency_code != reporting_currency_code: raise AccountingValidationError( f"snapshot currency {snapshot_currency_code} does not match book reporting " @@ -1663,7 +1663,7 @@ def load_unapplied_cash_rollforward( bucket = journals.setdefault( str(journal_reference), { - "idempotency_key": str(idempotency_key), + "idempotency_key": str(bucket["idempotency_key"]) if False else str(idempotency_key), "debit_roles": set(), "credit_roles": set(), "unapplied_debit_amount": Decimal("0"), From cbbbed8a6d3ccca482d034a6be641fd2c9fd2cc8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 08:24:28 +0900 Subject: [PATCH 065/224] fix(close): remove accidental unrelated edit --- src/accounting_information_platform/persistence.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/accounting_information_platform/persistence.py b/src/accounting_information_platform/persistence.py index e66649c0..d1ca1037 100644 --- a/src/accounting_information_platform/persistence.py +++ b/src/accounting_information_platform/persistence.py @@ -1663,7 +1663,7 @@ def load_unapplied_cash_rollforward( bucket = journals.setdefault( str(journal_reference), { - "idempotency_key": str(bucket["idempotency_key"]) if False else str(idempotency_key), + "idempotency_key": str(idempotency_key), "debit_roles": set(), "credit_roles": set(), "unapplied_debit_amount": Decimal("0"), From 306f4c14212a0dfbb89a6934bbb493b1e179479e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 08:38:44 +0900 Subject: [PATCH 066/224] test(close): expose pre-lock journal snapshot race --- ..._period_close_journal_serialization_red.py | 184 ++++++++++++++++++ 1 file changed, 184 insertions(+) create mode 100644 tests/test_postgres_period_close_journal_serialization_red.py diff --git a/tests/test_postgres_period_close_journal_serialization_red.py b/tests/test_postgres_period_close_journal_serialization_red.py new file mode 100644 index 00000000..cfb93e8c --- /dev/null +++ b/tests/test_postgres_period_close_journal_serialization_red.py @@ -0,0 +1,184 @@ +"""Real PostgreSQL RED for journal admission racing a hard-close snapshot.""" + +from __future__ import annotations + +import threading +import unittest +import uuid +from datetime import date +from decimal import Decimal +from unittest import mock + +import psycopg + +from accounting_information_platform import PostedJournalLine, PostgresPostingLedger +from tests import test_postgres_posting as posting + + +class PeriodCloseJournalSerializationPostgresTests(unittest.TestCase): + """Require hard-close evidence to include every journal admitted before close authority wins.""" + + @classmethod + def setUpClass(cls) -> None: + """Install the complete production migration chain used by posting tests.""" + posting.PostgresPostingTests.setUpClass() + + def setUp(self) -> None: + """Create one posted book-period and move it to soft-close before the race.""" + self.case = posting.PostgresPostingTests("setUp") + self.case.setUp() + self.addCleanup(self.case.doCleanups) + self.addCleanup(self.case.tearDown) + self.case.ledger.post(self.case._two_line_proposal(), self.case.policy) + self.case.ledger.close_fiscal_period( + self.case.policy.legal_entity_reference, + self.case.policy.accounting_book_reference, + "2026-08", + "KRW", + period_status_code="soft_closed", + idempotency_key=f"{self.case.policy.tenant_reference}:close-race:soft", + ) + + def test_hard_close_cannot_freeze_a_snapshot_before_an_admitted_adjustment_commits(self) -> None: + """A late soft-close adjustment is either in the snapshot or rejected before hard-close.""" + close_ledger = PostgresPostingLedger( + posting.DATABASE_URL, self.case.policy.tenant_reference + ) + adjustment_ledger = PostgresPostingLedger( + posting.DATABASE_URL, self.case.policy.tenant_reference + ) + pre_lock_snapshot_reached = threading.Event() + adjustment_committed = threading.Event() + close_result: list[object] = [] + close_errors: list[BaseException] = [] + original_acquire = close_ledger._acquire_command_lock + + def pause_before_period_lock(connection: object, lock_reference: str) -> None: + if lock_reference.startswith("period:"): + pre_lock_snapshot_reached.set() + if not adjustment_committed.wait(timeout=10): + raise AssertionError("adjusting journal did not commit before close lock resumed") + original_acquire(connection, lock_reference) + + def run_close() -> None: + try: + close_result.append( + close_ledger.close_fiscal_period( + self.case.policy.legal_entity_reference, + self.case.policy.accounting_book_reference, + "2026-08", + "KRW", + period_status_code="hard_closed", + idempotency_key=( + f"{self.case.policy.tenant_reference}:close-race:hard" + ), + ) + ) + except BaseException as error: # noqa: BLE001 - thread transports exact failure + close_errors.append(error) + + with mock.patch.object( + close_ledger, "_acquire_command_lock", side_effect=pause_before_period_lock + ): + closer = threading.Thread(target=run_close, daemon=True) + closer.start() + self.assertTrue( + pre_lock_snapshot_reached.wait(timeout=10), + "hard-close did not reach the deterministic pre-lock snapshot boundary", + ) + adjustment_ledger.post_adjusting_journal( + legal_entity_reference=self.case.policy.legal_entity_reference, + accounting_book_reference=self.case.policy.accounting_book_reference, + period_code="2026-08", + journal_date=date(2026, 8, 31), + idempotency_key=f"{self.case.policy.tenant_reference}:close-race:adjusting", + source_payload_hash="sha256:" + "a" * 64, + proposal_id=str(uuid.uuid4()), + transaction_currency="KRW", + lines=( + PostedJournalLine( + line_number=1, + chart_account_code="110100", + account_role_code="accounts_receivable", + debit_amount=Decimal("7.250000"), + credit_amount=Decimal("0"), + ), + PostedJournalLine( + line_number=2, + chart_account_code="410100", + account_role_code="usage_revenue", + debit_amount=Decimal("0"), + credit_amount=Decimal("7.250000"), + ), + ), + ) + adjustment_committed.set() + closer.join(timeout=20) + + self.assertFalse(closer.is_alive(), "hard-close remained blocked after adjustment commit") + if close_errors: + raise close_errors[0] + self.assertEqual(len(close_result), 1) + + with psycopg.connect(posting.DATABASE_URL) as connection: + live_debit, live_credit, retained_debit, retained_credit = connection.execute( + """ + SELECT + COALESCE(( + SELECT SUM(journal_entry_line.debit_amount) + FROM accounting_core.journal_entry_line + JOIN accounting_core.general_journal + ON general_journal.tenant_account_id = journal_entry_line.tenant_account_id + AND general_journal.general_journal_id = journal_entry_line.general_journal_id + JOIN accounting_core.chart_account + ON chart_account.tenant_account_id = journal_entry_line.tenant_account_id + AND chart_account.chart_account_id = journal_entry_line.chart_account_id + JOIN accounting_core.fiscal_period + ON fiscal_period.tenant_account_id = general_journal.tenant_account_id + AND fiscal_period.fiscal_period_id = general_journal.fiscal_period_id + WHERE general_journal.tenant_account_id = %s + AND chart_account.chart_account_code = '110100' + AND fiscal_period.period_code = '2026-08' + ), 0), + COALESCE(( + SELECT SUM(journal_entry_line.credit_amount) + FROM accounting_core.journal_entry_line + JOIN accounting_core.general_journal + ON general_journal.tenant_account_id = journal_entry_line.tenant_account_id + AND general_journal.general_journal_id = journal_entry_line.general_journal_id + JOIN accounting_core.chart_account + ON chart_account.tenant_account_id = journal_entry_line.tenant_account_id + AND chart_account.chart_account_id = journal_entry_line.chart_account_id + JOIN accounting_core.fiscal_period + ON fiscal_period.tenant_account_id = general_journal.tenant_account_id + AND fiscal_period.fiscal_period_id = general_journal.fiscal_period_id + WHERE general_journal.tenant_account_id = %s + AND chart_account.chart_account_code = '110100' + AND fiscal_period.period_code = '2026-08' + ), 0), + trial_balance_line.debit_total_amount, + trial_balance_line.credit_total_amount + FROM accounting_reporting.trial_balance_line + JOIN accounting_reporting.trial_balance_snapshot + ON trial_balance_snapshot.tenant_account_id = trial_balance_line.tenant_account_id + AND trial_balance_snapshot.trial_balance_snapshot_id + = trial_balance_line.trial_balance_snapshot_id + JOIN accounting_core.chart_account + ON chart_account.tenant_account_id = trial_balance_line.tenant_account_id + AND chart_account.chart_account_id = trial_balance_line.chart_account_id + JOIN accounting_core.fiscal_period + ON fiscal_period.tenant_account_id = trial_balance_snapshot.tenant_account_id + AND fiscal_period.fiscal_period_id = trial_balance_snapshot.fiscal_period_id + WHERE trial_balance_snapshot.tenant_account_id = %s + AND chart_account.chart_account_code = '110100' + AND fiscal_period.period_code = '2026-08' + """, + (self.case.tenant_id, self.case.tenant_id, self.case.tenant_id), + ).fetchone() + + self.assertEqual(Decimal(live_debit), Decimal(retained_debit)) + self.assertEqual(Decimal(live_credit), Decimal(retained_credit)) + + +if __name__ == "__main__": + unittest.main() From e0c707a257ece684e19f46148b5d6b0fb07f32f3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 09:09:41 +0900 Subject: [PATCH 067/224] fix(close): fence journal population before hard close --- ..._period_close_journal_population_fence.sql | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 database/migrations/0032_period_close_journal_population_fence.sql diff --git a/database/migrations/0032_period_close_journal_population_fence.sql b/database/migrations/0032_period_close_journal_population_fence.sql new file mode 100644 index 00000000..5a46a776 --- /dev/null +++ b/database/migrations/0032_period_close_journal_population_fence.sql @@ -0,0 +1,58 @@ +BEGIN; + +-- Every admitted journal changes the authoritative population for one book-period. +-- Materialize that change on the same control row that hard close later locks. Under +-- REPEATABLE READ, a close transaction whose snapshot predates a committed journal +-- then fails closed with a serialization error instead of freezing stale evidence. +ALTER TABLE accounting_core.accounting_book_period_control + ADD COLUMN journal_population_revision bigint NOT NULL DEFAULT 0 + CHECK (journal_population_revision >= 0); + +CREATE OR REPLACE FUNCTION accounting_core.guard_period_insert() +RETURNS trigger +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = pg_catalog, accounting_core +AS $$ +DECLARE + period_status_value text; + journal_write_role_value text; +BEGIN + UPDATE accounting_core.accounting_book_period_control + SET journal_population_revision = journal_population_revision + 1 + WHERE tenant_account_id = NEW.tenant_account_id + AND accounting_book_id = NEW.accounting_book_id + AND fiscal_period_id = NEW.fiscal_period_id + RETURNING period_status_code + INTO period_status_value; + + IF period_status_value IS NULL THEN + RAISE EXCEPTION + 'accounting book fiscal period control is missing for this journal insert (period_control_missing)' + USING ERRCODE = 'check_violation'; + END IF; + + IF period_status_value = 'open' THEN + RETURN NEW; + END IF; + + journal_write_role_value := nullif( + current_setting('accounting_core.journal_write_role', true), + '' + ); + + IF period_status_value = 'soft_closed' + AND journal_write_role_value IN ('period_closing', 'adjusting', 'reversal') + AND pg_has_role(session_user, 'accounting_closing_writer', 'MEMBER') + THEN + RETURN NEW; + END IF; + + RAISE EXCEPTION + 'Accounting book fiscal period is % (period_closed). Ordinary journals cannot be inserted after close. Post the AIS closing journal before hard-close; do not insert a later ordinary or reversal journal into a locked book period.', + period_status_value + USING ERRCODE = 'check_violation'; +END; +$$; + +COMMIT; From 0babb3d86654208cb3e112a8abcc4acdeb609f89 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 09:09:52 +0900 Subject: [PATCH 068/224] fix(close): install journal population fence --- src/accounting_information_platform/migration_install.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/accounting_information_platform/migration_install.py b/src/accounting_information_platform/migration_install.py index 28c11877..8eb5fcc3 100644 --- a/src/accounting_information_platform/migration_install.py +++ b/src/accounting_information_platform/migration_install.py @@ -28,6 +28,7 @@ def apply_foundation_migration(database_url: str, migration_path: Path) -> None: migration_path.parent / "0029_trial_balance_snapshot_population_unique_index.sql", migration_path.parent / "0030_trial_balance_snapshot_immutability.sql", migration_path.parent / "0031_trial_balance_line_conservation_validation.sql", + migration_path.parent / "0032_period_close_journal_population_fence.sql", ) for forward_migration_path in forward_migration_paths: if not forward_migration_path.is_file(): From cd8f3afd946268769a9e285cda7359b701e2ebae Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 09:10:23 +0900 Subject: [PATCH 069/224] test(close): require safe retry after stale snapshot conflict --- ..._period_close_journal_serialization_red.py | 62 ++++++++++++++++--- 1 file changed, 55 insertions(+), 7 deletions(-) diff --git a/tests/test_postgres_period_close_journal_serialization_red.py b/tests/test_postgres_period_close_journal_serialization_red.py index cfb93e8c..37f1eef3 100644 --- a/tests/test_postgres_period_close_journal_serialization_red.py +++ b/tests/test_postgres_period_close_journal_serialization_red.py @@ -1,4 +1,4 @@ -"""Real PostgreSQL RED for journal admission racing a hard-close snapshot.""" +"""Real PostgreSQL regression for journal admission racing a hard-close snapshot.""" from __future__ import annotations @@ -40,13 +40,14 @@ def setUp(self) -> None: ) def test_hard_close_cannot_freeze_a_snapshot_before_an_admitted_adjustment_commits(self) -> None: - """A late soft-close adjustment is either in the snapshot or rejected before hard-close.""" + """A stale close fails closed, then its exact idempotent retry snapshots the admitted journal.""" close_ledger = PostgresPostingLedger( posting.DATABASE_URL, self.case.policy.tenant_reference ) adjustment_ledger = PostgresPostingLedger( posting.DATABASE_URL, self.case.policy.tenant_reference ) + close_idempotency_key = f"{self.case.policy.tenant_reference}:close-race:hard" pre_lock_snapshot_reached = threading.Event() adjustment_committed = threading.Event() close_result: list[object] = [] @@ -69,9 +70,7 @@ def run_close() -> None: "2026-08", "KRW", period_status_code="hard_closed", - idempotency_key=( - f"{self.case.policy.tenant_reference}:close-race:hard" - ), + idempotency_key=close_idempotency_key, ) ) except BaseException as error: # noqa: BLE001 - thread transports exact failure @@ -116,8 +115,57 @@ def run_close() -> None: closer.join(timeout=20) self.assertFalse(closer.is_alive(), "hard-close remained blocked after adjustment commit") - if close_errors: - raise close_errors[0] + self.assertEqual(close_result, []) + self.assertEqual(len(close_errors), 1) + self.assertIsInstance(close_errors[0], psycopg.errors.SerializationFailure) + + with psycopg.connect(posting.DATABASE_URL) as connection: + status_code, snapshot_count = connection.execute( + """ + SELECT accounting_book_period_control.period_status_code, + ( + SELECT COUNT(*) + FROM accounting_reporting.trial_balance_snapshot + WHERE trial_balance_snapshot.tenant_account_id = %s + AND trial_balance_snapshot.accounting_book_id + = accounting_book_period_control.accounting_book_id + AND trial_balance_snapshot.fiscal_period_id + = accounting_book_period_control.fiscal_period_id + ) + FROM accounting_core.accounting_book_period_control + JOIN accounting_core.accounting_book + ON accounting_book.tenant_account_id + = accounting_book_period_control.tenant_account_id + AND accounting_book.accounting_book_id + = accounting_book_period_control.accounting_book_id + JOIN accounting_core.fiscal_period + ON fiscal_period.tenant_account_id + = accounting_book_period_control.tenant_account_id + AND fiscal_period.fiscal_period_id + = accounting_book_period_control.fiscal_period_id + WHERE accounting_book_period_control.tenant_account_id = %s + AND accounting_book.book_name = %s + AND fiscal_period.period_code = '2026-08' + """, + ( + self.case.tenant_id, + self.case.tenant_id, + self.case.policy.accounting_book_reference, + ), + ).fetchone() + self.assertEqual(status_code, "soft_closed") + self.assertEqual(snapshot_count, 0) + + close_result.append( + close_ledger.close_fiscal_period( + self.case.policy.legal_entity_reference, + self.case.policy.accounting_book_reference, + "2026-08", + "KRW", + period_status_code="hard_closed", + idempotency_key=close_idempotency_key, + ) + ) self.assertEqual(len(close_result), 1) with psycopg.connect(posting.DATABASE_URL) as connection: From a50011b58e6c206cb32178d3ddcebef1b08e5ada Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 09:10:45 +0900 Subject: [PATCH 070/224] test(close): pin journal population serialization fence --- ..._balance_snapshot_immutability_contract.py | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/tests/test_trial_balance_snapshot_immutability_contract.py b/tests/test_trial_balance_snapshot_immutability_contract.py index 8e789788..f96f0f6c 100644 --- a/tests/test_trial_balance_snapshot_immutability_contract.py +++ b/tests/test_trial_balance_snapshot_immutability_contract.py @@ -18,6 +18,9 @@ VALIDATION_MIGRATION = ( ROOT / "database/migrations/0031_trial_balance_line_conservation_validation.sql" ) +JOURNAL_FENCE_MIGRATION = ( + ROOT / "database/migrations/0032_period_close_journal_population_fence.sql" +) class TrialBalanceSnapshotImmutabilityContractTests(unittest.TestCase): @@ -32,6 +35,7 @@ def is_file(path: Path) -> bool: INDEX_MIGRATION.name, IMMUTABILITY_MIGRATION.name, VALIDATION_MIGRATION.name, + JOURNAL_FENCE_MIGRATION.name, }: return False return original_is_file(path) @@ -118,6 +122,24 @@ def test_population_guards_serialize_on_book_period_authority(self) -> None: migration, ) + def test_journal_population_fence_invalidates_a_stale_close_snapshot(self) -> None: + """Every admitted journal must version the same row hard close later locks.""" + migration = JOURNAL_FENCE_MIGRATION.read_text(encoding="utf-8") + self.assertIn("journal_population_revision bigint NOT NULL DEFAULT 0", migration) + self.assertIn("CREATE OR REPLACE FUNCTION accounting_core.guard_period_insert()", migration) + self.assertIn( + "SET journal_population_revision = journal_population_revision + 1", + migration, + ) + self.assertIn("accounting_book_id = NEW.accounting_book_id", migration) + self.assertIn("fiscal_period_id = NEW.fiscal_period_id", migration) + self.assertIn("RETURNING period_status_code", migration) + self.assertIn("period_control_missing", migration) + self.assertIn( + "pg_has_role(session_user, 'accounting_closing_writer', 'MEMBER')", + migration, + ) + def test_snapshot_header_requires_purpose_limited_hard_close_authority(self) -> None: """Capability plus the exact application close-command lock is required.""" migration = IMMUTABILITY_MIGRATION.read_text(encoding="utf-8") From ff06eed352f00eb74d8b74826f694808c57359cd Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 09:11:27 +0900 Subject: [PATCH 071/224] docs(close): record journal population serialization fence --- docs/adr/0006-fiscal-period-close-snapshot.md | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/docs/adr/0006-fiscal-period-close-snapshot.md b/docs/adr/0006-fiscal-period-close-snapshot.md index 2ae03aae..bfbdf17d 100644 --- a/docs/adr/0006-fiscal-period-close-snapshot.md +++ b/docs/adr/0006-fiscal-period-close-snapshot.md @@ -20,15 +20,19 @@ PostgreSQL exposes two-integer advisory-lock keys in `pg_locks.classid` and `pg_ The physical uniqueness boundary is required because PostgreSQL `REPEATABLE READ` retains the transaction snapshot established by its first query or data-modification statement; waiting for the authority-row lock does not make a stale transaction see a snapshot row committed later. The unique index therefore closes the stale-snapshot race even when the trigger-level existence query cannot observe the competing row. PostgreSQL documents that ordinary index creation can block writers for an unacceptable period on a live production table, while `CREATE INDEX CONCURRENTLY` keeps ordinary inserts, updates, and deletes available at the cost of extra scans and longer build time. PostgreSQL also documents converting a concurrently built unique index into a `UNIQUE` constraint with `ALTER TABLE ... ADD CONSTRAINT ... USING INDEX` as the low-blocking deployment pattern. -This is deliberately fail-closed. Raw or legacy SQL cannot create a pre-close snapshot merely because the period is soft-closed; it must run with the purpose-limited closing capability and one of the exact close-command context signals. A visible competing population raises `trial_balance_snapshot_population_conflict`; a fixed-snapshot concurrency conflict is rejected by the named unique boundary. In either case the hard-close transaction cannot establish a second population and the book-period remains non-hard-closed until conflicting retained evidence is resolved through an audited repair. +A separate freshness race exists for journals rather than competing snapshots. `close_fiscal_period` establishes `REPEATABLE READ` before it resolves the book and takes the period command lock. A purpose-limited journal can therefore commit after that MVCC snapshot is fixed but before the close reaches its book-period row lock. Merely making the journal wait on the advisory lock is insufficient: a waiting repeatable-read close would keep the old snapshot after lock grant. Migration `0032_period_close_journal_population_fence.sql` therefore adds `journal_population_revision` to `accounting_book_period_control` and makes every admitted `general_journal` INSERT version that exact tenant/book/period row inside the journal transaction. The hard-close path later takes `FOR UPDATE` on the same row. If a journal commits after the close's repeatable-read snapshot but before that row lock, PostgreSQL rejects the stale close transaction with a serialization failure; the close writes no hard-close state or retained population. Retrying the same idempotent close command starts from a fresh snapshot and must include the admitted journal. If close owns the row first, a later journal admission waits and then evaluates the committed `hard_closed` status, so it is rejected. This row version is the database serialization fence; it does not make an LLM, Billing, or a reporting projection an accounting authority. + +This is deliberately fail-closed. Raw or legacy SQL cannot create a pre-close snapshot merely because the period is soft-closed; it must run with the purpose-limited closing capability and one of the exact close-command context signals. A visible competing population raises `trial_balance_snapshot_population_conflict`; a fixed-snapshot concurrency conflict is rejected by the named unique boundary. A journal/close freshness conflict raises PostgreSQL serialization failure and rolls the stale close transaction back. In each case the hard-close transaction cannot certify stale or competing evidence and the book-period remains non-hard-closed until the command is safely retried or conflicting retained evidence is resolved through an audited repair. ## Consequences -Controllers close books through the posting adapter instead of a raw status update. Ordinary posting is rejected for every non-open period. The database insert guard in `0005_closed_period_guard.sql` permits only purpose-limited AIS close/adjust/reversal journal writes while a period is `soft_closed`; every journal insert is rejected once the period is `hard_closed`. The caller-controlled `accounting_core.journal_write_role` GUC is classification metadata, not sufficient authorization by itself. +Controllers close books through the posting adapter instead of a raw status update. Ordinary posting is rejected for every non-open period. The database insert guard installed initially in `0005_closed_period_guard.sql` and replaced by later book-period controls permits only purpose-limited AIS close/adjust/reversal journal writes while a period is `soft_closed`; every journal insert is rejected once the period is `hard_closed`. The caller-controlled `accounting_core.journal_write_role` GUC is classification metadata, not sufficient authorization by itself. Migration `0030_trial_balance_snapshot_immutability.sql` rejects UPDATE or DELETE of retained snapshot headers and lines, rejects population extension after hard close, rejects a snapshot header unless the soft-closed period is being written by the purpose-limited hard-close capability, rejects a header whose legal entity does not own the selected book, rejects a header whose currency differs from the book's reporting currency, rejects a line whose chart account belongs to another book, rejects new or changed lines whose stored net does not exactly conserve their stored debit and credit totals, and owns snapshot recording chronology at the database boundary. Header and line admission lock the exact tenant/book/period authority row before evaluating status. The header guard checks the current backend's exact transaction advisory lock only once per close snapshot; it does not poll `pg_locks` on journal or report-read hot paths. That row lock serializes status-sensitive admission and gives the normal visible-conflict diagnostic; the exact-scope unique constraint independently enforces one population across transaction snapshots and concurrent writers. Pre-migration history is retained rather than rewritten and is not retroactively attested as canonical solely because the guard was installed. If duplicate populations already exist, the concurrent unique-index build fails rather than blessing ambiguous history. Migration 0031 separately validates inherited debit/credit/net conservation and fails rather than certifying inconsistent monetary evidence. -The index-build, transactional boundary-install, and inherited-row validation phases are intentionally separate. PostgreSQL forbids `CREATE INDEX CONCURRENTLY` inside a transaction block, and PostgreSQL transaction locks are normally held until transaction end; keeping `VALIDATE CONSTRAINT` inside the same transaction as `ADD CONSTRAINT` would therefore retain the stronger 0030 lock throughout the scan. The canonical installer applies each forward migration file separately on an autocommit connection: migration 0029 is the concurrent index build, migration 0030 atomically installs the table/trigger boundary plus the `NOT VALID` arithmetic check, and migration 0031 validates history after 0030 has committed. A failed concurrent unique build may leave an invalid index, which PostgreSQL requires operators to remove or rebuild before retry; a failed 0031 validation leaves the new check enforced for subsequent writes but the inherited population unvalidated. Recovery tooling and release evidence must distinguish those partial-upgrade states rather than claim automatic rollback. +Migration `0032_period_close_journal_population_fence.sql` makes journal admission participate in the same book-period aggregate row used by close. The revision increment is transactional: a rejected journal rolls it back, while an admitted journal advances it exactly once with the journal header. This deliberately turns concurrent writes to one book-period into a visible database serialization boundary. The supported ordinary-post path already uses the canonical period command lock, so this does not create a second authority; it closes direct/purpose-limited database paths that previously could change the journal population without invalidating a stale close snapshot. A serialization failure is not accounting evidence and must not be normalized into success. The same close idempotency key can be retried after rollback. + +The index-build, transactional boundary-install, inherited-row validation, and journal-fence phases are intentionally separate. PostgreSQL forbids `CREATE INDEX CONCURRENTLY` inside a transaction block, and PostgreSQL transaction locks are normally held until transaction end; keeping `VALIDATE CONSTRAINT` inside the same transaction as `ADD CONSTRAINT` would therefore retain the stronger 0030 lock throughout the scan. The canonical installer applies each forward migration file separately on an autocommit connection: migration 0029 is the concurrent index build, migration 0030 atomically installs the table/trigger boundary plus the `NOT VALID` arithmetic check, migration 0031 validates history after 0030 has committed, and migration 0032 installs the journal-population serialization fence. A failed concurrent unique build may leave an invalid index, which PostgreSQL requires operators to remove or rebuild before retry; a failed 0031 validation leaves the new check enforced for subsequent writes but the inherited population unvalidated. Recovery tooling and release evidence must distinguish those partial-upgrade states rather than claim automatic rollback. The former snapshot-on-soft-close and snapshot-reuse-on-upgrade wording in this ADR is superseded by ADR 0023. Operational and reporting code must therefore treat the hard-close snapshot as the only persisted post-close trial-balance population and must never infer that a soft-close created one. From c8f9088f51b6e533cc76ec6a57decc456ad3775c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 09:11:56 +0900 Subject: [PATCH 072/224] fix(close): harden journal population fence function --- .../migrations/0032_period_close_journal_population_fence.sql | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/database/migrations/0032_period_close_journal_population_fence.sql b/database/migrations/0032_period_close_journal_population_fence.sql index 5a46a776..a828b3b8 100644 --- a/database/migrations/0032_period_close_journal_population_fence.sql +++ b/database/migrations/0032_period_close_journal_population_fence.sql @@ -12,7 +12,7 @@ CREATE OR REPLACE FUNCTION accounting_core.guard_period_insert() RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER -SET search_path = pg_catalog, accounting_core +SET search_path = pg_catalog, pg_temp AS $$ DECLARE period_status_value text; @@ -55,4 +55,6 @@ BEGIN END; $$; +REVOKE ALL ON FUNCTION accounting_core.guard_period_insert() FROM PUBLIC; + COMMIT; From 84f5666aa48ba565fb2e4ff763bb0b3ee27fe857 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 09:12:28 +0900 Subject: [PATCH 073/224] test(close): pin fence function hardening --- tests/test_trial_balance_snapshot_immutability_contract.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/test_trial_balance_snapshot_immutability_contract.py b/tests/test_trial_balance_snapshot_immutability_contract.py index f96f0f6c..688c133c 100644 --- a/tests/test_trial_balance_snapshot_immutability_contract.py +++ b/tests/test_trial_balance_snapshot_immutability_contract.py @@ -139,6 +139,12 @@ def test_journal_population_fence_invalidates_a_stale_close_snapshot(self) -> No "pg_has_role(session_user, 'accounting_closing_writer', 'MEMBER')", migration, ) + self.assertIn("SECURITY DEFINER", migration) + self.assertIn("SET search_path = pg_catalog, pg_temp", migration) + self.assertIn( + "REVOKE ALL ON FUNCTION accounting_core.guard_period_insert() FROM PUBLIC", + migration, + ) def test_snapshot_header_requires_purpose_limited_hard_close_authority(self) -> None: """Capability plus the exact application close-command lock is required.""" From 6fe1fdeb1050111b26e557810dbf66b05f75871a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 10:01:38 +0900 Subject: [PATCH 074/224] test(close): expose open-period journal fence hotspot --- ...test_postgres_open_period_journal_fence.py | 65 +++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 tests/test_postgres_open_period_journal_fence.py diff --git a/tests/test_postgres_open_period_journal_fence.py b/tests/test_postgres_open_period_journal_fence.py new file mode 100644 index 00000000..2aed9037 --- /dev/null +++ b/tests/test_postgres_open_period_journal_fence.py @@ -0,0 +1,65 @@ +"""Real PostgreSQL regression for the period-close journal population fence.""" + +from __future__ import annotations + +import unittest + +import psycopg + +from tests import test_postgres_posting as posting + + +class OpenPeriodJournalFencePostgresTests(unittest.TestCase): + """Keep ordinary open-period posting off the close-control write hotspot.""" + + @classmethod + def setUpClass(cls) -> None: + """Install the complete production migration chain used by posting tests.""" + posting.PostgresPostingTests.setUpClass() + + def setUp(self) -> None: + """Create one isolated tenant/book/period fixture in the open state.""" + self.case = posting.PostgresPostingTests("setUp") + self.case.setUp() + self.addCleanup(self.case.doCleanups) + self.addCleanup(self.case.tearDown) + + def _journal_population_revision(self) -> int: + with psycopg.connect(posting.DATABASE_URL) as connection: + row = connection.execute( + """ + SELECT accounting_book_period_control.journal_population_revision + FROM accounting_core.accounting_book_period_control + JOIN accounting_core.accounting_book + ON accounting_book.tenant_account_id + = accounting_book_period_control.tenant_account_id + AND accounting_book.accounting_book_id + = accounting_book_period_control.accounting_book_id + JOIN accounting_core.fiscal_period + ON fiscal_period.tenant_account_id + = accounting_book_period_control.tenant_account_id + AND fiscal_period.fiscal_period_id + = accounting_book_period_control.fiscal_period_id + WHERE accounting_book_period_control.tenant_account_id = %s + AND accounting_book.book_name = %s + AND fiscal_period.period_code = '2026-08' + """, + ( + self.case.tenant_id, + self.case.policy.accounting_book_reference, + ), + ).fetchone() + self.assertIsNotNone(row) + return int(row[0]) + + def test_open_period_posting_does_not_version_close_control_row(self) -> None: + """Open-period journals may share the period fence but must not serialize on one row update.""" + before_revision = self._journal_population_revision() + + self.case.ledger.post(self.case._two_line_proposal(), self.case.policy) + + self.assertEqual(self._journal_population_revision(), before_revision) + + +if __name__ == "__main__": + unittest.main() From 7a979845896869ef0e7fabab710c7a4f3a9863de Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 10:02:25 +0900 Subject: [PATCH 075/224] fix(close): avoid open-period control-row write hotspot --- ..._period_close_journal_population_fence.sql | 66 +++++++++++++++---- 1 file changed, 53 insertions(+), 13 deletions(-) diff --git a/database/migrations/0032_period_close_journal_population_fence.sql b/database/migrations/0032_period_close_journal_population_fence.sql index a828b3b8..f59a7b4b 100644 --- a/database/migrations/0032_period_close_journal_population_fence.sql +++ b/database/migrations/0032_period_close_journal_population_fence.sql @@ -1,9 +1,11 @@ BEGIN; --- Every admitted journal changes the authoritative population for one book-period. --- Materialize that change on the same control row that hard close later locks. Under --- REPEATABLE READ, a close transaction whose snapshot predates a committed journal --- then fails closed with a serialization error instead of freezing stale evidence. +-- Journals admitted while a period is soft-closed change the authoritative +-- population that hard close is about to freeze. Version those close-window +-- writes on the same control row that hard close later locks. Ordinary open- +-- period posting must not UPDATE that row on every journal: concurrent open +-- posting instead holds a shared row lock so a period-state transition cannot +-- overtake a journal that was admitted under the open-state contract. ALTER TABLE accounting_core.accounting_book_period_control ADD COLUMN journal_population_revision bigint NOT NULL DEFAULT 0 CHECK (journal_population_revision >= 0); @@ -16,15 +18,15 @@ SET search_path = pg_catalog, pg_temp AS $$ DECLARE period_status_value text; + locked_period_status_value text; journal_write_role_value text; BEGIN - UPDATE accounting_core.accounting_book_period_control - SET journal_population_revision = journal_population_revision + 1 - WHERE tenant_account_id = NEW.tenant_account_id - AND accounting_book_id = NEW.accounting_book_id - AND fiscal_period_id = NEW.fiscal_period_id - RETURNING period_status_code - INTO period_status_value; + SELECT accounting_book_period_control.period_status_code + INTO period_status_value + FROM accounting_core.accounting_book_period_control + WHERE accounting_book_period_control.tenant_account_id = NEW.tenant_account_id + AND accounting_book_period_control.accounting_book_id = NEW.accounting_book_id + AND accounting_book_period_control.fiscal_period_id = NEW.fiscal_period_id; IF period_status_value IS NULL THEN RAISE EXCEPTION @@ -33,7 +35,26 @@ BEGIN END IF; IF period_status_value = 'open' THEN - RETURN NEW; + -- Many ordinary postings may share this row lock concurrently. A soft-close + -- UPDATE must wait for all journals admitted as open-period work to commit. + -- If the period changed while this statement waited, retry from a fresh + -- transaction rather than silently applying open-period authority to a + -- soft-closed period. + SELECT accounting_book_period_control.period_status_code + INTO locked_period_status_value + FROM accounting_core.accounting_book_period_control + WHERE accounting_book_period_control.tenant_account_id = NEW.tenant_account_id + AND accounting_book_period_control.accounting_book_id = NEW.accounting_book_id + AND accounting_book_period_control.fiscal_period_id = NEW.fiscal_period_id + FOR SHARE; + + IF locked_period_status_value = 'open' THEN + RETURN NEW; + END IF; + + RAISE EXCEPTION + 'accounting book fiscal period changed during journal admission (period_state_changed_retry)' + USING ERRCODE = 'serialization_failure'; END IF; journal_write_role_value := nullif( @@ -45,7 +66,26 @@ BEGIN AND journal_write_role_value IN ('period_closing', 'adjusting', 'reversal') AND pg_has_role(session_user, 'accounting_closing_writer', 'MEMBER') THEN - RETURN NEW; + -- Close-window journals are intentionally serialized on this revision. + -- If hard close already won the row, PostgreSQL either raises a serialization + -- failure under REPEATABLE READ or this predicate stops matching after wait. + locked_period_status_value := NULL; + UPDATE accounting_core.accounting_book_period_control + SET journal_population_revision = journal_population_revision + 1 + WHERE tenant_account_id = NEW.tenant_account_id + AND accounting_book_id = NEW.accounting_book_id + AND fiscal_period_id = NEW.fiscal_period_id + AND period_status_code = 'soft_closed' + RETURNING period_status_code + INTO locked_period_status_value; + + IF locked_period_status_value = 'soft_closed' THEN + RETURN NEW; + END IF; + + RAISE EXCEPTION + 'accounting book fiscal period changed during close-window journal admission (period_state_changed_retry)' + USING ERRCODE = 'serialization_failure'; END IF; RAISE EXCEPTION From 37d4f513c2740b644e45bd7d05bbdadb7e4dbad7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 10:04:02 +0900 Subject: [PATCH 076/224] test(close): ratchet journal fence lock profile --- ...ial_balance_snapshot_immutability_contract.py | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/tests/test_trial_balance_snapshot_immutability_contract.py b/tests/test_trial_balance_snapshot_immutability_contract.py index 688c133c..b078bba9 100644 --- a/tests/test_trial_balance_snapshot_immutability_contract.py +++ b/tests/test_trial_balance_snapshot_immutability_contract.py @@ -122,15 +122,27 @@ def test_population_guards_serialize_on_book_period_authority(self) -> None: migration, ) - def test_journal_population_fence_invalidates_a_stale_close_snapshot(self) -> None: - """Every admitted journal must version the same row hard close later locks.""" + def test_journal_population_fence_preserves_open_period_concurrency(self) -> None: + """Open-period posting shares a lock; only close-window journals version the close row.""" migration = JOURNAL_FENCE_MIGRATION.read_text(encoding="utf-8") self.assertIn("journal_population_revision bigint NOT NULL DEFAULT 0", migration) self.assertIn("CREATE OR REPLACE FUNCTION accounting_core.guard_period_insert()", migration) + self.assertIn("IF period_status_value = 'open' THEN", migration) + self.assertIn("FOR SHARE;", migration) + self.assertIn( + "IF locked_period_status_value = 'open' THEN\n RETURN NEW;", + migration, + ) self.assertIn( "SET journal_population_revision = journal_population_revision + 1", migration, ) + self.assertIn("AND period_status_code = 'soft_closed'", migration) + self.assertLess( + migration.index("FOR SHARE;"), + migration.index("SET journal_population_revision = journal_population_revision + 1"), + ) + self.assertIn("period_state_changed_retry", migration) self.assertIn("accounting_book_id = NEW.accounting_book_id", migration) self.assertIn("fiscal_period_id = NEW.fiscal_period_id", migration) self.assertIn("RETURNING period_status_code", migration) From 815f7055b8e6ddbd470904a4abbe6862e5e160be Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 10:04:37 +0900 Subject: [PATCH 077/224] docs(close): record split journal fence lock profile --- docs/adr/0006-fiscal-period-close-snapshot.md | 85 +++++++++++++------ 1 file changed, 60 insertions(+), 25 deletions(-) diff --git a/docs/adr/0006-fiscal-period-close-snapshot.md b/docs/adr/0006-fiscal-period-close-snapshot.md index bfbdf17d..8926de59 100644 --- a/docs/adr/0006-fiscal-period-close-snapshot.md +++ b/docs/adr/0006-fiscal-period-close-snapshot.md @@ -2,58 +2,93 @@ **Status:** Superseded in part by ADR 0023 +## Problem + +Hard close certifies a retained trial-balance population as Accounting Information Platform evidence. That population must not be caller-shaped, mutable after close, cross an accounting-book aggregate boundary, drift arithmetically, or omit a journal that was validly admitted before close authority won. + +The implementation also has to preserve posting throughput. `accounting_book_period_control` is one row per tenant/accounting-book/fiscal-period. Updating that row for every ordinary open-period journal would turn a correctness fence into a hot exclusive-write point and serialize otherwise independent postings in the busiest lifecycle state. + ## Decision -`PostgresPostingLedger.close_fiscal_period` is the first-class close command. The current authoritative sequence is the two-step rule defined by ADR 0023. A `soft_closed` command changes the fiscal-period status only: it writes no `trial_balance_snapshot`, no `trial_balance_line`, and no period-closing journal. A later `hard_closed` command acquires the tenant/book/period command lock, posts the AIS period-closing journal when one is required, computes the live trial balance for the tenant, legal entity, and accounting book through the period end date, persists exactly one `trial_balance_snapshot` population, and then hard-closes the period in the same governed transaction. Posted journals are never rewritten. +`PostgresPostingLedger.close_fiscal_period` remains the first-class close command. ADR 0023 owns the two-step lifecycle: `soft_closed` changes period state only; it creates no `trial_balance_snapshot`, `trial_balance_line`, or mandatory closing journal. A later `hard_closed` command acquires the tenant/book/period command authority, writes a period-closing journal when required, derives the live trial balance from AIS-owned PostgreSQL facts through the period end, persists one retained snapshot population, and hard-closes the period in the governed transaction. Posted journals are never rewritten. + +An exact hard-close replay returns the retained result and creates no second snapshot, journal, or close event. `hard_closed` cannot transition back to `soft_closed`. Soft-close replay remains snapshot-free. + +### Retained population identity and immutability + +Migration `0029_trial_balance_snapshot_population_unique_index.sql` builds the unique `(tenant_account_id, accounting_book_id, fiscal_period_id)` population identity with `CREATE UNIQUE INDEX CONCURRENTLY` outside a transaction block. Migration `0030_trial_balance_snapshot_immutability.sql` attaches that index as `trial_balance_snapshot_one_population_per_book_period`, installs header/line mutation guards, and serializes snapshot and line admission on the exact `accounting_book_period_control` row. + +Snapshot and line UPDATE/DELETE fail with `trial_balance_snapshot_immutable`. New population after `hard_closed` is rejected. A visible competing population fails with `trial_balance_snapshot_population_conflict`; the unique constraint independently closes the stale-snapshot race when a `REPEATABLE READ` trigger query cannot see a concurrently committed population. + +Every retained line must satisfy the exact PostgreSQL `numeric(38, 6)` invariant -An exact hard-close replay returns the existing snapshot and writes no second snapshot, closing journal, or close event. A `hard_closed` period cannot transition back to `soft_closed`. Soft-close replay remains snapshot-free. +`net_balance_amount = debit_total_amount - credit_total_amount`. -The retained hard-close population is one snapshot per tenant/accounting-book/fiscal-period authority scope. Migration `0029_trial_balance_snapshot_population_unique_index.sql` builds the exact-scope unique index with `CREATE UNIQUE INDEX CONCURRENTLY` outside a transaction block so an upgrade does not hold a write-blocking table lock for the duration of the index build. Migration `0030_trial_balance_snapshot_immutability.sql` attaches that already-built index as the named `trial_balance_snapshot_one_population_per_book_period` table constraint in a short transaction, installs the snapshot/line mutation guards, serializes snapshot admission on the same `accounting_book_period_control` row used by close, and rejects a visible pre-existing population with `trial_balance_snapshot_population_conflict`. +Migration 0030 adds `trial_balance_line_net_balance_conservation` as `NOT VALID`; migration `0031_trial_balance_line_conservation_validation.sql` validates inherited rows after 0030 has committed so the stronger ADD-CONSTRAINT lock is not retained through the validation scan. -Every retained trial-balance line is also one exact arithmetic fact rather than three independently writable monetary values. `trial_balance_line.net_balance_amount` must equal `debit_total_amount - credit_total_amount` exactly in PostgreSQL `numeric(38, 6)`. Migration 0030 adds the named `trial_balance_line_net_balance_conservation` check as `NOT VALID`, so new writes are constrained without scanning inherited rows while that transaction still holds its `ADD CONSTRAINT` lock. After 0030 commits and releases that stronger lock, migration `0031_trial_balance_line_conservation_validation.sql` runs as a separate autocommit statement and validates inherited rows. An upgrade therefore fails closed on pre-existing inconsistent close evidence without needlessly holding the 0030 lock for the validation scan. +### Aggregate and authority scope -The retained population also preserves the accounting-book aggregate boundary at every denormalized reference. A snapshot header's `legal_entity_id` must be the legal entity that owns its `accounting_book_id`; independently valid tenant-scoped identifiers cannot be recombined into a cross-entity snapshot. Its `snapshot_currency_code` must equal that accounting book's `reporting_currency_code`; a purpose-limited writer cannot relabel retained balances into another syntactically valid ISO 4217 currency. Every `trial_balance_line.chart_account_id` must belong to that snapshot's accounting book; a chart account from another book cannot be imported into retained close evidence merely because it belongs to the same tenant. Migration 0030 enforces all three relations before a population can become hard-close evidence. +A retained snapshot header must use the legal entity that owns the selected accounting book, and `snapshot_currency_code` must equal that book's `reporting_currency_code`. Every retained `trial_balance_line.chart_account_id` must belong to the same accounting book. Tenant-scoped identifiers that are independently valid cannot be recombined across those aggregate boundaries. -Snapshot creation is not an ordinary soft-close write. The database admits a new snapshot only while the exact book-period is `soft_closed`, `session_user` is a member of the purpose-limited `accounting_closing_writer` role, and the transaction carries hard-close command context. That context is present either as transaction-local `accounting_core.journal_write_role=period_closing` after a required closing-journal write or as the exact tenant/book/period transaction-level advisory lock acquired unconditionally by `close_fiscal_period` before close evidence is assembled. The GUC and advisory lock are purpose/classification evidence rather than independent authorization; PostgreSQL role membership remains the capability boundary. This distinction matters for a zero-net-income or otherwise already-neutralized period: a valid hard close still persists one retained snapshot even though no period-closing journal is required, so snapshot authority cannot depend on an optional journal INSERT side effect. +Snapshot creation is not an ordinary soft-close write. PostgreSQL admits a new snapshot only while the exact book-period is `soft_closed`, `session_user` belongs to `accounting_closing_writer`, and the transaction carries hard-close command context. That context is either transaction-local `accounting_core.journal_write_role=period_closing` after a required closing-journal write or the canonical tenant/resolved-accounting-book-id/period advisory lock held by `close_fiscal_period`. The GUC and lock classify the command; role membership remains the capability boundary. -PostgreSQL exposes two-integer advisory-lock keys in `pg_locks.classid` and `pg_locks.objid` with `objsubid = 2`. The application resolves the caller-facing accounting-book reference first and then acquires the command lock with `hashtext(tenant_reference)` and `hashtext('period:' || accounting_book_id::text || ':' || period_code)`. The snapshot admission trigger must reconstruct that exact resolved identity; using the caller-facing `book_name` instead would describe a different advisory key and reject valid journal-independent hard closes. A generic raw INSERT on a merely soft-closed period, without either the explicit period-closing classification or that canonical command lock, fails `trial_balance_snapshot_authority_required`. `snapshot_generated_at` is a PostgreSQL-owned system-time fact: the admission trigger replaces any caller-supplied value with `clock_timestamp()`, including values supplied by an otherwise authorized closing writer. +The canonical advisory key is `hashtext(tenant_reference)` plus `hashtext('period:' || accounting_book_id::text || ':' || period_code)`. The trigger reconstructs the resolved accounting-book identity, not the caller-facing `book_name`. `snapshot_generated_at` is database-owned system time and is replaced with `clock_timestamp()` even for an otherwise authorized closing writer. -The physical uniqueness boundary is required because PostgreSQL `REPEATABLE READ` retains the transaction snapshot established by its first query or data-modification statement; waiting for the authority-row lock does not make a stale transaction see a snapshot row committed later. The unique index therefore closes the stale-snapshot race even when the trigger-level existence query cannot observe the competing row. PostgreSQL documents that ordinary index creation can block writers for an unacceptable period on a live production table, while `CREATE INDEX CONCURRENTLY` keeps ordinary inserts, updates, and deletes available at the cost of extra scans and longer build time. PostgreSQL also documents converting a concurrently built unique index into a `UNIQUE` constraint with `ALTER TABLE ... ADD CONSTRAINT ... USING INDEX` as the low-blocking deployment pattern. +### Journal-population freshness without an open-period write hotspot -A separate freshness race exists for journals rather than competing snapshots. `close_fiscal_period` establishes `REPEATABLE READ` before it resolves the book and takes the period command lock. A purpose-limited journal can therefore commit after that MVCC snapshot is fixed but before the close reaches its book-period row lock. Merely making the journal wait on the advisory lock is insufficient: a waiting repeatable-read close would keep the old snapshot after lock grant. Migration `0032_period_close_journal_population_fence.sql` therefore adds `journal_population_revision` to `accounting_book_period_control` and makes every admitted `general_journal` INSERT version that exact tenant/book/period row inside the journal transaction. The hard-close path later takes `FOR UPDATE` on the same row. If a journal commits after the close's repeatable-read snapshot but before that row lock, PostgreSQL rejects the stale close transaction with a serialization failure; the close writes no hard-close state or retained population. Retrying the same idempotent close command starts from a fresh snapshot and must include the admitted journal. If close owns the row first, a later journal admission waits and then evaluates the committed `hard_closed` status, so it is rejected. This row version is the database serialization fence; it does not make an LLM, Billing, or a reporting projection an accounting authority. +A different race exists between an admitted journal and hard close. `close_fiscal_period` uses `REPEATABLE READ`; a close transaction can therefore hold an MVCC snapshot that predates a journal committed while close is waiting on its period authority. Waiting alone does not refresh the repeatable-read snapshot. -This is deliberately fail-closed. Raw or legacy SQL cannot create a pre-close snapshot merely because the period is soft-closed; it must run with the purpose-limited closing capability and one of the exact close-command context signals. A visible competing population raises `trial_balance_snapshot_population_conflict`; a fixed-snapshot concurrency conflict is rejected by the named unique boundary. A journal/close freshness conflict raises PostgreSQL serialization failure and rolls the stale close transaction back. In each case the hard-close transaction cannot certify stale or competing evidence and the book-period remains non-hard-closed until the command is safely retried or conflicting retained evidence is resolved through an audited repair. +Migration `0032_period_close_journal_population_fence.sql` uses the book-period control row as a lifecycle fence, but it does **not** update that row for every journal: -## Consequences +- when the period is `open`, journal admission takes `SELECT ... FOR SHARE` on the exact control row and returns without changing `journal_population_revision`; many open-period journals can hold this shared row lock concurrently, while a period-state UPDATE must wait for them to finish; +- if the period changes while an open-path journal is waiting for that shared lock, admission fails with SQLSTATE `40001` (`serialization_failure`) and the journal command must retry from a fresh transaction rather than inherit stale open-period authority; +- when the period is `soft_closed`, only purpose-limited `period_closing`, `adjusting`, or `reversal` journals from `accounting_closing_writer` are admitted, and those close-window journals increment `journal_population_revision` on the exact control row in the same transaction as the journal header; +- hard close later locks that same row. If a soft-close journal committed after the close transaction's repeatable-read snapshot, PostgreSQL rejects the stale close with serialization failure rather than freezing an older population. If hard close owns the row first, the later journal cannot remain admissible after the committed `hard_closed` state. -Controllers close books through the posting adapter instead of a raw status update. Ordinary posting is rejected for every non-open period. The database insert guard installed initially in `0005_closed_period_guard.sql` and replaced by later book-period controls permits only purpose-limited AIS close/adjust/reversal journal writes while a period is `soft_closed`; every journal insert is rejected once the period is `hard_closed`. The caller-controlled `accounting_core.journal_write_role` GUC is classification metadata, not sufficient authorization by itself. +This split is intentional. Updating the control row for every ordinary journal would create a per-book-period exclusive UPDATE hotspot. `FOR SHARE` blocks status-changing UPDATEs while allowing other `FOR SHARE` holders, so the high-volume open-period path remains concurrent while the lower-volume close window receives the stronger row-version fence required to invalidate stale close snapshots. -Migration `0030_trial_balance_snapshot_immutability.sql` rejects UPDATE or DELETE of retained snapshot headers and lines, rejects population extension after hard close, rejects a snapshot header unless the soft-closed period is being written by the purpose-limited hard-close capability, rejects a header whose legal entity does not own the selected book, rejects a header whose currency differs from the book's reporting currency, rejects a line whose chart account belongs to another book, rejects new or changed lines whose stored net does not exactly conserve their stored debit and credit totals, and owns snapshot recording chronology at the database boundary. Header and line admission lock the exact tenant/book/period authority row before evaluating status. The header guard checks the current backend's exact transaction advisory lock only once per close snapshot; it does not poll `pg_locks` on journal or report-read hot paths. That row lock serializes status-sensitive admission and gives the normal visible-conflict diagnostic; the exact-scope unique constraint independently enforces one population across transaction snapshots and concurrent writers. Pre-migration history is retained rather than rewritten and is not retroactively attested as canonical solely because the guard was installed. If duplicate populations already exist, the concurrent unique-index build fails rather than blessing ambiguous history. Migration 0031 separately validates inherited debit/credit/net conservation and fails rather than certifying inconsistent monetary evidence. +A serialization failure is not accounting evidence. The whole failed transaction is rolled back and the identical idempotency key is retried from the beginning. `tests/test_postgres_period_close_journal_serialization_red.py` exercises stale hard-close rollback and exact-key retry against retained/live amounts. `tests/test_postgres_open_period_journal_fence.py` requires ordinary open-period posting to leave `journal_population_revision` unchanged. `tests/test_trial_balance_snapshot_immutability_contract.py` ratchets the migration shape, including the open-path shared lock and soft-close-only revision update. -Migration `0032_period_close_journal_population_fence.sql` makes journal admission participate in the same book-period aggregate row used by close. The revision increment is transactional: a rejected journal rolls it back, while an admitted journal advances it exactly once with the journal header. This deliberately turns concurrent writes to one book-period into a visible database serialization boundary. The supported ordinary-post path already uses the canonical period command lock, so this does not create a second authority; it closes direct/purpose-limited database paths that previously could change the journal population without invalidating a stale close snapshot. A serialization failure is not accounting evidence and must not be normalized into success. The same close idempotency key can be retried after rollback. +## Alternatives considered -The index-build, transactional boundary-install, inherited-row validation, and journal-fence phases are intentionally separate. PostgreSQL forbids `CREATE INDEX CONCURRENTLY` inside a transaction block, and PostgreSQL transaction locks are normally held until transaction end; keeping `VALIDATE CONSTRAINT` inside the same transaction as `ADD CONSTRAINT` would therefore retain the stronger 0030 lock throughout the scan. The canonical installer applies each forward migration file separately on an autocommit connection: migration 0029 is the concurrent index build, migration 0030 atomically installs the table/trigger boundary plus the `NOT VALID` arithmetic check, migration 0031 validates history after 0030 has committed, and migration 0032 installs the journal-population serialization fence. A failed concurrent unique build may leave an invalid index, which PostgreSQL requires operators to remove or rebuild before retry; a failed 0031 validation leaves the new check enforced for subsequent writes but the inherited population unvalidated. Recovery tooling and release evidence must distinguish those partial-upgrade states rather than claim automatic rollback. +Updating `journal_population_revision` for every admitted journal was rejected after review because it makes one control row the exclusive write point for all ordinary posting in a book-period. It preserves freshness but violates the platform's hot-partition/lock and latency requirements. -The former snapshot-on-soft-close and snapshot-reuse-on-upgrade wording in this ADR is superseded by ADR 0023. Operational and reporting code must therefore treat the hard-close snapshot as the only persisted post-close trial-balance population and must never infer that a soft-close created one. +Relying only on the period advisory lock was rejected because a waiting `REPEATABLE READ` close can retain the snapshot established before lock grant. Lock acquisition and snapshot freshness are separate concerns. -Future fiscal-period reopen/correction is not implemented by this ADR. If a later policy introduces reopen, it must preserve the prior hard-close population and add an explicit successor lineage; it must not mutate retained evidence or silently relax the one-population constraint without a replacement identity/version invariant and migration plan. +Using only a trigger-side existence query for retained snapshots was rejected because a fixed MVCC snapshot cannot observe a competing population committed after that snapshot. Physical unique population identity remains required. -## Exact soft-close command replay +Allowing caller-provided close timestamps, currencies, aggregate identifiers, or retained arithmetic was rejected because those values are accounting evidence and must be derived or verified at the authoritative database boundary. -Soft-close deliberately stores no trial-balance snapshot, but it is still an authoritative state-changing command. Migration `0010_soft_close_command_evidence.sql` records the original tenant-scoped soft-close idempotency key, source-journal count and canonical close-source SHA-256 on the book-period control row in the same transaction as the state transition and outbox event. Exact replay returns those stored facts and never recomputes historical evidence from later ledger state. A different key for an already soft-closed book-period is an idempotency conflict, and database trigger protection prevents rewriting evidence once recorded. +## Consequences and operational evidence -`snapshot_currency_code` remains required on soft-close because it participates in the canonical close-source digest even though no hard-close snapshot row is created. This makes the command evidence exact without representing soft-close as a trial-balance snapshot. +Ordinary open-period posting now participates in period-transition coordination without incrementing a shared revision row on every journal. Period transition can wait on concurrent open journal transactions; this is deliberate because a journal admitted under `open` must commit before the transition can certify a different period state. Close-window journals remain serialized on the control-row revision because they are exceptional writes whose population must invalidate a stale hard-close snapshot. + +Migration 0032 replaces `accounting_core.guard_period_insert()` as a `SECURITY DEFINER` function with `search_path = pg_catalog, pg_temp` and PUBLIC EXECUTE revoked. Unauthorized or rejected journals do not retain a revision change because the statement/transaction is rolled back. + +The migration chain has distinct recovery states. A failed 0029 concurrent index build can leave an invalid index that operators must remove or rebuild before retry. A failed 0031 validation leaves the constraint enforced for subsequent writes but inherited rows uncertified. A 0032 serialization conflict leaves no hard-close evidence from the failed transaction and requires a whole-command retry. Release evidence must distinguish these states; recovery must never normalize or rewrite posted journals, reconciliation evidence, or retained close facts. + +Future reopen/correction is not implemented here. Any later reopen policy must preserve the prior hard-close population through explicit successor lineage and a replacement population identity/version invariant rather than mutating the retained population or silently weakening uniqueness. + +## Exact soft-close replay + +Migration `0010_soft_close_command_evidence.sql` stores the original tenant-scoped soft-close idempotency key, source-journal count, and canonical close-source SHA-256 on the book-period control row in the same transaction as the state transition and outbox event. Exact replay returns those stored facts and never recomputes historical evidence from later ledger state. A different key for an already soft-closed book-period is an idempotency conflict. + +`snapshot_currency_code` remains required on soft close because it participates in the canonical close-source digest even though soft close creates no retained trial-balance snapshot. ## References PostgreSQL Global Development Group. (2026a). *PostgreSQL 18 documentation: SET TRANSACTION*. https://www.postgresql.org/docs/18/sql-set-transaction.html -PostgreSQL Global Development Group. (2026b). *PostgreSQL 18 documentation: CREATE INDEX*. https://www.postgresql.org/docs/18/sql-createindex.html +PostgreSQL Global Development Group. (2026b). *PostgreSQL 18 documentation: Explicit locking*. https://www.postgresql.org/docs/18/explicit-locking.html + +PostgreSQL Global Development Group. (2026c). *PostgreSQL 18 documentation: Serialization failure handling*. https://www.postgresql.org/docs/18/mvcc-serialization-failure-handling.html + +PostgreSQL Global Development Group. (2026d). *PostgreSQL 18 documentation: CREATE INDEX*. https://www.postgresql.org/docs/18/sql-createindex.html -PostgreSQL Global Development Group. (2026c). *PostgreSQL 18 documentation: ALTER TABLE*. https://www.postgresql.org/docs/18/sql-altertable.html +PostgreSQL Global Development Group. (2026e). *PostgreSQL 18 documentation: ALTER TABLE*. https://www.postgresql.org/docs/18/sql-altertable.html -PostgreSQL Global Development Group. (2026d). *PostgreSQL 18 documentation: Unique indexes*. https://www.postgresql.org/docs/18/indexes-unique.html +PostgreSQL Global Development Group. (2026f). *PostgreSQL 18 documentation: Unique indexes*. https://www.postgresql.org/docs/18/indexes-unique.html -PostgreSQL Global Development Group. (2026e). *PostgreSQL 18 documentation: pg_locks*. https://www.postgresql.org/docs/18/view-pg-locks.html +PostgreSQL Global Development Group. (2026g). *PostgreSQL 18 documentation: pg_locks*. https://www.postgresql.org/docs/18/view-pg-locks.html -PostgreSQL Global Development Group. (2026f). *PostgreSQL 18 documentation: System administration functions—Advisory lock functions*. https://www.postgresql.org/docs/18/functions-admin.html#FUNCTIONS-ADVISORY-LOCKS +PostgreSQL Global Development Group. (2026h). *PostgreSQL 18 documentation: System administration functions—Advisory lock functions*. https://www.postgresql.org/docs/18/functions-admin.html#FUNCTIONS-ADVISORY-LOCKS From 2bdb09a6e7da1444a2356d94ae7fde16d9d40686 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 10:07:32 +0900 Subject: [PATCH 078/224] test(close): prove shared fence preserves open posting concurrency --- ...test_postgres_open_period_journal_fence.py | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/tests/test_postgres_open_period_journal_fence.py b/tests/test_postgres_open_period_journal_fence.py index 2aed9037..8f4bc70b 100644 --- a/tests/test_postgres_open_period_journal_fence.py +++ b/tests/test_postgres_open_period_journal_fence.py @@ -2,6 +2,7 @@ from __future__ import annotations +import threading import unittest import psycopg @@ -52,6 +53,33 @@ def _journal_population_revision(self) -> int: self.assertIsNotNone(row) return int(row[0]) + def _hold_book_period_share_lock(self, connection: object) -> None: + row = connection.execute( + """ + SELECT accounting_book_period_control.period_status_code + FROM accounting_core.accounting_book_period_control + JOIN accounting_core.accounting_book + ON accounting_book.tenant_account_id + = accounting_book_period_control.tenant_account_id + AND accounting_book.accounting_book_id + = accounting_book_period_control.accounting_book_id + JOIN accounting_core.fiscal_period + ON fiscal_period.tenant_account_id + = accounting_book_period_control.tenant_account_id + AND fiscal_period.fiscal_period_id + = accounting_book_period_control.fiscal_period_id + WHERE accounting_book_period_control.tenant_account_id = %s + AND accounting_book.book_name = %s + AND fiscal_period.period_code = '2026-08' + FOR SHARE OF accounting_book_period_control + """, + ( + self.case.tenant_id, + self.case.policy.accounting_book_reference, + ), + ).fetchone() + self.assertEqual(row[0], "open") + def test_open_period_posting_does_not_version_close_control_row(self) -> None: """Open-period journals may share the period fence but must not serialize on one row update.""" before_revision = self._journal_population_revision() @@ -60,6 +88,35 @@ def test_open_period_posting_does_not_version_close_control_row(self) -> None: self.assertEqual(self._journal_population_revision(), before_revision) + def test_open_period_posting_can_progress_while_peer_holds_share_fence(self) -> None: + """A peer shared fence must not turn ordinary journal admission into an exclusive-row queue.""" + posting_finished = threading.Event() + posting_errors: list[BaseException] = [] + + def post_journal() -> None: + try: + self.case.ledger.post(self.case._two_line_proposal(), self.case.policy) + except BaseException as error: # noqa: BLE001 - thread transports exact failure + posting_errors.append(error) + finally: + posting_finished.set() + + with psycopg.connect(posting.DATABASE_URL) as fence_connection: + self._hold_book_period_share_lock(fence_connection) + worker = threading.Thread(target=post_journal, daemon=True) + worker.start() + progressed_while_share_lock_held = posting_finished.wait(timeout=5) + fence_connection.rollback() + worker.join(timeout=10) + + self.assertFalse(worker.is_alive(), "ordinary posting remained blocked after fence release") + self.assertEqual(posting_errors, []) + self.assertTrue( + progressed_while_share_lock_held, + "ordinary open-period posting blocked behind a peer shared book-period fence", + ) + self.assertEqual(self._journal_population_revision(), 0) + if __name__ == "__main__": unittest.main() From 5f1da1fc4cc033e09e4b0d6c1cc214471e571767 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 10:09:38 +0900 Subject: [PATCH 079/224] docs(close): trace journal fence authority and lock tradeoff --- ...PERIOD_CLOSE_JOURNAL_FENCE_TRACEABILITY.md | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 docs/doctoring/PERIOD_CLOSE_JOURNAL_FENCE_TRACEABILITY.md diff --git a/docs/doctoring/PERIOD_CLOSE_JOURNAL_FENCE_TRACEABILITY.md b/docs/doctoring/PERIOD_CLOSE_JOURNAL_FENCE_TRACEABILITY.md new file mode 100644 index 00000000..9bde9c54 --- /dev/null +++ b/docs/doctoring/PERIOD_CLOSE_JOURNAL_FENCE_TRACEABILITY.md @@ -0,0 +1,52 @@ +# Period Close journal-population fence traceability + +## Decision scope + +This note traces the concurrency control used when an authoritative journal population approaches fiscal-period hard close. It does not create accounting policy, reopen a closed period, grant Billing posting authority, or make PostgreSQL locking semantics an IFRS requirement. + +IAS 10 distinguishes adjusting events that provide evidence about conditions existing at the reporting-period end from non-adjusting events arising later. The product-level implication already adopted by ADR 0006/ADR 0023 is that a governed close must not certify retained balances while an admissible accounting adjustment for that period is concurrently being committed. IAS 10 does **not** prescribe PostgreSQL row locks, MVCC isolation, retry codes, or the `journal_population_revision` design; those are implementation controls chosen by AIP. + +## Authoritative technical basis + +PostgreSQL 18 row-level `FOR SHARE` is compatible with another `FOR SHARE`/`FOR KEY SHARE`, while blocking `UPDATE`, `DELETE`, `FOR UPDATE`, and `FOR NO KEY UPDATE` on the same row. Row locks are held through transaction end. PostgreSQL also states that a row-lock request in `REPEATABLE READ`/`SERIALIZABLE` fails if the row changed since the transaction began. Serialization failures use SQLSTATE `40001`, and the complete transaction must be retried rather than resuming from the failed statement. + +The current PostgreSQL documentation line is PostgreSQL 18, whose current supported minor at this decision date is 18.6. Minor-version operability evidence remains owned by the PostgreSQL runtime-baseline lane; this control relies on PostgreSQL 18 semantics rather than claiming a clean-image bump proves an existing-cluster upgrade. + +## Control mapping + +| Concern | Chosen control | Rejected alternative | Executable evidence | +|---|---|---|---| +| Ordinary `open` posting throughput | `accounting_core.guard_period_insert()` takes `FOR SHARE` on the exact `accounting_book_period_control` row and does not change `journal_population_revision` | Increment the revision for every journal; this makes one book-period row an exclusive UPDATE hotspot | `tests/test_postgres_open_period_journal_fence.py::test_open_period_posting_does_not_version_close_control_row` | +| Compatible open-path coordination | Multiple open journals may hold the shared fence concurrently; a state-changing UPDATE must wait | No period fence; soft close could overtake a journal admitted under the prior `open` state | `tests/test_postgres_open_period_journal_fence.py::test_open_period_posting_can_progress_while_peer_holds_share_fence` | +| State changes while an open journal waits | Fail with SQLSTATE `40001` (`serialization_failure`) and retry the whole journal command from a fresh transaction | Continue after wait using stale open-state authority | `database/migrations/0032_period_close_journal_population_fence.sql`; static contract in `tests/test_trial_balance_snapshot_immutability_contract.py` | +| `soft_closed` adjusting/reversal/closing journal freshness | Purpose-limited close-window journals increment `journal_population_revision` on the exact tenant/book/period row in the journal transaction | Advisory-lock wait only; a pre-existing `REPEATABLE READ` snapshot would remain stale after lock grant | `tests/test_postgres_period_close_journal_serialization_red.py` | +| Stale hard close | PostgreSQL serialization failure rolls the close transaction back before retained population/hard-close state becomes authoritative; retry the same close idempotency key from a fresh snapshot | Freeze older live totals or normalize the conflict into success | `tests/test_postgres_period_close_journal_serialization_red.py` retained/live amount comparison | +| Capability | Replacement guard remains `SECURITY DEFINER`, fixes `search_path = pg_catalog, pg_temp`, and revokes PUBLIC execute | Caller GUC alone as authorization | `database/migrations/0032_period_close_journal_population_fence.sql`; `tests/test_trial_balance_snapshot_immutability_contract.py` | + +## TDD lineage + +- Journal/close stale-population RED: `306f4c14212a0dfbb89a6934bbb493b1e179479e`. +- First freshness candidate through `84f5666aa48ba565fb2e4ff763bb0b3ee27fe857` invalidated stale close by updating the control-row revision for every journal. +- Open-period hotspot RED: `6fe1fdeb1050111b26e557810dbf66b05f75871a`. +- Split-lock causal repair: `7a979845896869ef0e7fabab710c7a4f3a9863de`. +- Static lock-profile ratchet: `37d4f513c2740b644e45bd7d05bbdadb7e4dbad7`. +- ADR alignment: `815f7055b8e6ddbd470904a4abbe6862e5e160be`. +- Real-PostgreSQL compatible-shared-lock regression: `2bdb09a6e7da1444a2356d94ae7fde16d9d40686`. + +These commits are development evidence, not protected-head release evidence. Exact-head CI, independent review, central workflow gates, package/SBOM/provenance, migration/recovery verification, and measured performance must be reacquired after every head change. + +## Residual risk and release acceptance + +`FOR SHARE` avoids the deliberate per-journal exclusive row UPDATE, but row locking itself is not free and PostgreSQL notes that row locking can cause disk writes. This repair therefore establishes a concurrency-control shape and a realistic non-blocking regression; it does not prove the buyer-path p95 ≤ 20 ms target. Release acceptance still requires measured concurrent posting/period-transition load at the exact candidate head, with lock waits and tail latency reported rather than hidden by cache warm-up, reduced samples, or excluded failures. + +A `40001` result is not accounting evidence. The calling command must retry its entire transaction and preserve the same immutable source-payload identity/idempotency contract. Recovery must never rewrite posted journals, retained trial-balance evidence, or reconciliation authority rows to make a failed close appear successful. + +## References + +IFRS Foundation. (n.d.). *IAS 10 Events after the Reporting Period*. https://www.ifrs.org/issued-standards/list-of-standards/ias-10-events-after-the-reporting-period.html/content/ + +PostgreSQL Global Development Group. (2026a). *PostgreSQL 18 documentation: Explicit locking*. https://www.postgresql.org/docs/18/explicit-locking.html + +PostgreSQL Global Development Group. (2026b). *PostgreSQL 18 documentation: Serialization failure handling*. https://www.postgresql.org/docs/18/mvcc-serialization-failure-handling.html + +PostgreSQL Global Development Group. (2026c). *PostgreSQL documentation*. https://www.postgresql.org/docs/ From 70a9b196da23fc0cbedd9ceafa806710794f13e3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 11:08:55 +0900 Subject: [PATCH 080/224] test(close): expose stale open-period hard-close race --- ...res_open_period_close_serialization_red.py | 223 ++++++++++++++++++ 1 file changed, 223 insertions(+) create mode 100644 tests/test_postgres_open_period_close_serialization_red.py diff --git a/tests/test_postgres_open_period_close_serialization_red.py b/tests/test_postgres_open_period_close_serialization_red.py new file mode 100644 index 00000000..76262480 --- /dev/null +++ b/tests/test_postgres_open_period_close_serialization_red.py @@ -0,0 +1,223 @@ +"""Real PostgreSQL regression for an open-period journal racing hard close.""" + +from __future__ import annotations + +import threading +import unittest +import uuid +from datetime import date +from decimal import Decimal +from unittest import mock + +import psycopg + +from accounting_information_platform import PostedJournalLine, PostgresPostingLedger +from tests import test_postgres_posting as posting + + +class OpenPeriodCloseSerializationPostgresTests(unittest.TestCase): + """Require direct open-to-hard-close evidence to include every admitted journal.""" + + @classmethod + def setUpClass(cls) -> None: + """Install the complete production migration chain used by posting tests.""" + posting.PostgresPostingTests.setUpClass() + + def setUp(self) -> None: + """Create one open book-period with an initial authoritative journal.""" + self.case = posting.PostgresPostingTests("setUp") + self.case.setUp() + self.addCleanup(self.case.doCleanups) + self.addCleanup(self.case.tearDown) + self.case.ledger.post(self.case._two_line_proposal(), self.case.policy) + + def test_open_period_journal_committed_after_close_snapshot_invalidates_stale_close(self) -> None: + """A stale direct hard close fails, then exact-key retry retains the admitted journal.""" + close_ledger = PostgresPostingLedger( + posting.DATABASE_URL, self.case.policy.tenant_reference + ) + posting_ledger = PostgresPostingLedger( + posting.DATABASE_URL, self.case.policy.tenant_reference + ) + close_idempotency_key = f"{self.case.policy.tenant_reference}:open-close-race:hard" + pre_period_row_lock_reached = threading.Event() + concurrent_post_committed = threading.Event() + close_result: list[object] = [] + close_errors: list[BaseException] = [] + original_lock_book_period = close_ledger._lock_book_period + + def pause_before_period_row_lock(*args: object, **kwargs: object) -> object: + pre_period_row_lock_reached.set() + if not concurrent_post_committed.wait(timeout=10): + raise AssertionError("open-period journal did not commit before close resumed") + return original_lock_book_period(*args, **kwargs) + + def run_close() -> None: + try: + close_result.append( + close_ledger.close_fiscal_period( + self.case.policy.legal_entity_reference, + self.case.policy.accounting_book_reference, + "2026-08", + "KRW", + period_status_code="hard_closed", + idempotency_key=close_idempotency_key, + ) + ) + except BaseException as error: # noqa: BLE001 - thread transports exact failure + close_errors.append(error) + + with mock.patch.object( + close_ledger, "_lock_book_period", side_effect=pause_before_period_row_lock + ): + closer = threading.Thread(target=run_close, daemon=True) + closer.start() + self.assertTrue( + pre_period_row_lock_reached.wait(timeout=10), + "hard-close did not reach the deterministic pre-period-row-lock boundary", + ) + posting_ledger.post_adjusting_journal( + legal_entity_reference=self.case.policy.legal_entity_reference, + accounting_book_reference=self.case.policy.accounting_book_reference, + period_code="2026-08", + journal_date=date(2026, 8, 31), + idempotency_key=f"{self.case.policy.tenant_reference}:open-close-race:journal", + source_payload_hash="sha256:" + "b" * 64, + proposal_id=str(uuid.uuid4()), + transaction_currency="KRW", + lines=( + PostedJournalLine( + line_number=1, + chart_account_code="110100", + account_role_code="accounts_receivable", + debit_amount=Decimal("11.750000"), + credit_amount=Decimal("0"), + ), + PostedJournalLine( + line_number=2, + chart_account_code="410100", + account_role_code="usage_revenue", + debit_amount=Decimal("0"), + credit_amount=Decimal("11.750000"), + ), + ), + ) + concurrent_post_committed.set() + closer.join(timeout=20) + + self.assertFalse(closer.is_alive(), "hard-close remained blocked after open journal commit") + self.assertEqual(close_result, []) + self.assertEqual(len(close_errors), 1) + self.assertIsInstance(close_errors[0], psycopg.errors.SerializationFailure) + + with psycopg.connect(posting.DATABASE_URL) as connection: + status_code, snapshot_count = connection.execute( + """ + SELECT accounting_book_period_control.period_status_code, + ( + SELECT COUNT(*) + FROM accounting_reporting.trial_balance_snapshot + WHERE trial_balance_snapshot.tenant_account_id = %s + AND trial_balance_snapshot.accounting_book_id + = accounting_book_period_control.accounting_book_id + AND trial_balance_snapshot.fiscal_period_id + = accounting_book_period_control.fiscal_period_id + ) + FROM accounting_core.accounting_book_period_control + JOIN accounting_core.accounting_book + ON accounting_book.tenant_account_id + = accounting_book_period_control.tenant_account_id + AND accounting_book.accounting_book_id + = accounting_book_period_control.accounting_book_id + JOIN accounting_core.fiscal_period + ON fiscal_period.tenant_account_id + = accounting_book_period_control.tenant_account_id + AND fiscal_period.fiscal_period_id + = accounting_book_period_control.fiscal_period_id + WHERE accounting_book_period_control.tenant_account_id = %s + AND accounting_book.book_name = %s + AND fiscal_period.period_code = '2026-08' + """, + ( + self.case.tenant_id, + self.case.tenant_id, + self.case.policy.accounting_book_reference, + ), + ).fetchone() + self.assertEqual(status_code, "open") + self.assertEqual(snapshot_count, 0) + + close_result.append( + close_ledger.close_fiscal_period( + self.case.policy.legal_entity_reference, + self.case.policy.accounting_book_reference, + "2026-08", + "KRW", + period_status_code="hard_closed", + idempotency_key=close_idempotency_key, + ) + ) + self.assertEqual(len(close_result), 1) + + with psycopg.connect(posting.DATABASE_URL) as connection: + live_debit, live_credit, retained_debit, retained_credit = connection.execute( + """ + SELECT + COALESCE(( + SELECT SUM(journal_entry_line.debit_amount) + FROM accounting_core.journal_entry_line + JOIN accounting_core.general_journal + ON general_journal.tenant_account_id = journal_entry_line.tenant_account_id + AND general_journal.general_journal_id = journal_entry_line.general_journal_id + JOIN accounting_core.chart_account + ON chart_account.tenant_account_id = journal_entry_line.tenant_account_id + AND chart_account.chart_account_id = journal_entry_line.chart_account_id + JOIN accounting_core.fiscal_period + ON fiscal_period.tenant_account_id = general_journal.tenant_account_id + AND fiscal_period.fiscal_period_id = general_journal.fiscal_period_id + WHERE general_journal.tenant_account_id = %s + AND chart_account.chart_account_code = '110100' + AND fiscal_period.period_code = '2026-08' + ), 0), + COALESCE(( + SELECT SUM(journal_entry_line.credit_amount) + FROM accounting_core.journal_entry_line + JOIN accounting_core.general_journal + ON general_journal.tenant_account_id = journal_entry_line.tenant_account_id + AND general_journal.general_journal_id = journal_entry_line.general_journal_id + JOIN accounting_core.chart_account + ON chart_account.tenant_account_id = journal_entry_line.tenant_account_id + AND chart_account.chart_account_id = journal_entry_line.chart_account_id + JOIN accounting_core.fiscal_period + ON fiscal_period.tenant_account_id = general_journal.tenant_account_id + AND fiscal_period.fiscal_period_id = general_journal.fiscal_period_id + WHERE general_journal.tenant_account_id = %s + AND chart_account.chart_account_code = '110100' + AND fiscal_period.period_code = '2026-08' + ), 0), + trial_balance_line.debit_total_amount, + trial_balance_line.credit_total_amount + FROM accounting_reporting.trial_balance_line + JOIN accounting_reporting.trial_balance_snapshot + ON trial_balance_snapshot.tenant_account_id = trial_balance_line.tenant_account_id + AND trial_balance_snapshot.trial_balance_snapshot_id + = trial_balance_line.trial_balance_snapshot_id + JOIN accounting_core.chart_account + ON chart_account.tenant_account_id = trial_balance_line.tenant_account_id + AND chart_account.chart_account_id = trial_balance_line.chart_account_id + JOIN accounting_core.fiscal_period + ON fiscal_period.tenant_account_id = trial_balance_snapshot.tenant_account_id + AND fiscal_period.fiscal_period_id = trial_balance_snapshot.fiscal_period_id + WHERE trial_balance_snapshot.tenant_account_id = %s + AND chart_account.chart_account_code = '110100' + AND fiscal_period.period_code = '2026-08' + """, + (self.case.tenant_id, self.case.tenant_id, self.case.tenant_id), + ).fetchone() + + self.assertEqual(Decimal(live_debit), Decimal(retained_debit)) + self.assertEqual(Decimal(live_credit), Decimal(retained_credit)) + + +if __name__ == "__main__": + unittest.main() From ac3a2a7eac2929e3ff76908d9bb64a4a38acb7dd Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 11:10:21 +0900 Subject: [PATCH 081/224] fix(close): stripe open-period journal population fence --- ...3_open_period_journal_population_fence.sql | 368 ++++++++++++++++++ 1 file changed, 368 insertions(+) create mode 100644 database/migrations/0033_open_period_journal_population_fence.sql diff --git a/database/migrations/0033_open_period_journal_population_fence.sql b/database/migrations/0033_open_period_journal_population_fence.sql new file mode 100644 index 00000000..88546189 --- /dev/null +++ b/database/migrations/0033_open_period_journal_population_fence.sql @@ -0,0 +1,368 @@ +BEGIN; + +-- Direct open-to-close transitions need a freshness witness for journals that +-- commit after a close transaction has established its REPEATABLE READ snapshot. +-- A single per-period revision row would serialize the high-volume posting path, +-- so ordinary open-period journals version one of 64 pre-existing fence rows. +CREATE TABLE accounting_core.period_journal_population_fence ( + tenant_account_id uuid NOT NULL, + accounting_book_id uuid NOT NULL, + fiscal_period_id uuid NOT NULL, + fence_slot smallint NOT NULL + CHECK (fence_slot >= 0 AND fence_slot < 64), + journal_population_revision bigint NOT NULL DEFAULT 0 + CHECK (journal_population_revision >= 0), + PRIMARY KEY ( + tenant_account_id, + accounting_book_id, + fiscal_period_id, + fence_slot + ), + FOREIGN KEY ( + tenant_account_id, + accounting_book_id, + fiscal_period_id + ) REFERENCES accounting_core.accounting_book_period_control ( + tenant_account_id, + accounting_book_id, + fiscal_period_id + ) +); + +REVOKE ALL ON accounting_core.period_journal_population_fence FROM PUBLIC; +ALTER TABLE accounting_core.period_journal_population_fence ENABLE ROW LEVEL SECURITY; +ALTER TABLE accounting_core.period_journal_population_fence FORCE ROW LEVEL SECURITY; +CREATE POLICY period_journal_population_fence_isolation + ON accounting_core.period_journal_population_fence + USING (tenant_account_id = accounting_core.current_tenant_account_id()) + WITH CHECK (tenant_account_id = accounting_core.current_tenant_account_id()); + +-- Fence rows must pre-date any REPEATABLE READ close snapshot. Creating a fence +-- lazily after a stale snapshot would let the close miss the new row entirely. +INSERT INTO accounting_core.period_journal_population_fence ( + tenant_account_id, + accounting_book_id, + fiscal_period_id, + fence_slot +) +SELECT period_control.tenant_account_id, + period_control.accounting_book_id, + period_control.fiscal_period_id, + generated_slot.fence_slot::smallint +FROM accounting_core.accounting_book_period_control AS period_control +CROSS JOIN generate_series(0, 63) AS generated_slot(fence_slot); + +CREATE OR REPLACE FUNCTION accounting_core.seed_period_journal_population_fence() +RETURNS trigger +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = pg_catalog, pg_temp +AS $$ +BEGIN + INSERT INTO accounting_core.period_journal_population_fence ( + tenant_account_id, + accounting_book_id, + fiscal_period_id, + fence_slot + ) + SELECT NEW.tenant_account_id, + NEW.accounting_book_id, + NEW.fiscal_period_id, + generated_slot.fence_slot::smallint + FROM generate_series(0, 63) AS generated_slot(fence_slot) + ON CONFLICT ( + tenant_account_id, + accounting_book_id, + fiscal_period_id, + fence_slot + ) DO NOTHING; + + RETURN NEW; +END; +$$; + +REVOKE ALL ON FUNCTION accounting_core.seed_period_journal_population_fence() + FROM PUBLIC; + +CREATE TRIGGER period_journal_population_fence_seed + AFTER INSERT + ON accounting_core.accounting_book_period_control + FOR EACH ROW + EXECUTE FUNCTION accounting_core.seed_period_journal_population_fence(); + +CREATE OR REPLACE FUNCTION accounting_core.guard_period_insert() +RETURNS trigger +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = pg_catalog, pg_temp +AS $$ +DECLARE + period_status_value text; + locked_period_status_value text; + journal_write_role_value text; + fence_slot_value smallint; + affected_fence_rows integer; +BEGIN + SELECT accounting_book_period_control.period_status_code + INTO period_status_value + FROM accounting_core.accounting_book_period_control + WHERE accounting_book_period_control.tenant_account_id = NEW.tenant_account_id + AND accounting_book_period_control.accounting_book_id = NEW.accounting_book_id + AND accounting_book_period_control.fiscal_period_id = NEW.fiscal_period_id; + + IF period_status_value IS NULL THEN + RAISE EXCEPTION + 'accounting book fiscal period control is missing for this journal insert (period_control_missing)' + USING ERRCODE = 'check_violation'; + END IF; + + IF period_status_value = 'open' THEN + SELECT accounting_book_period_control.period_status_code + INTO locked_period_status_value + FROM accounting_core.accounting_book_period_control + WHERE accounting_book_period_control.tenant_account_id = NEW.tenant_account_id + AND accounting_book_period_control.accounting_book_id = NEW.accounting_book_id + AND accounting_book_period_control.fiscal_period_id = NEW.fiscal_period_id + FOR SHARE; + + IF locked_period_status_value IS DISTINCT FROM 'open' THEN + RAISE EXCEPTION + 'accounting book fiscal period changed during journal admission (period_state_changed_retry)' + USING ERRCODE = 'serialization_failure'; + END IF; + + -- UUID identity supplies a stable, caller-independent distribution key. + -- Only journals choosing the same slot contend with each other; the + -- authoritative transition later inspects every pre-existing slot. + fence_slot_value := ( + get_byte(uuid_send(NEW.general_journal_id), 15) % 64 + )::smallint; + UPDATE accounting_core.period_journal_population_fence + SET journal_population_revision = journal_population_revision + 1 + WHERE tenant_account_id = NEW.tenant_account_id + AND accounting_book_id = NEW.accounting_book_id + AND fiscal_period_id = NEW.fiscal_period_id + AND fence_slot = fence_slot_value; + GET DIAGNOSTICS affected_fence_rows = ROW_COUNT; + + IF affected_fence_rows <> 1 THEN + RAISE EXCEPTION + 'open-period journal population fence is incomplete (period_journal_population_fence_missing)' + USING ERRCODE = 'check_violation'; + END IF; + + RETURN NEW; + END IF; + + journal_write_role_value := nullif( + current_setting('accounting_core.journal_write_role', true), + '' + ); + + IF period_status_value = 'soft_closed' + AND journal_write_role_value IN ('period_closing', 'adjusting', 'reversal') + AND pg_has_role(session_user, 'accounting_closing_writer', 'MEMBER') + THEN + locked_period_status_value := NULL; + UPDATE accounting_core.accounting_book_period_control + SET journal_population_revision = journal_population_revision + 1 + WHERE tenant_account_id = NEW.tenant_account_id + AND accounting_book_id = NEW.accounting_book_id + AND fiscal_period_id = NEW.fiscal_period_id + AND period_status_code = 'soft_closed' + RETURNING period_status_code + INTO locked_period_status_value; + + IF locked_period_status_value = 'soft_closed' THEN + RETURN NEW; + END IF; + + RAISE EXCEPTION + 'accounting book fiscal period changed during close-window journal admission (period_state_changed_retry)' + USING ERRCODE = 'serialization_failure'; + END IF; + + RAISE EXCEPTION + 'Accounting book fiscal period is % (period_closed). Ordinary journals cannot be inserted after close. Post the AIS closing journal before hard-close; do not insert a later ordinary or reversal journal into a locked book period.', + period_status_value + USING ERRCODE = 'check_violation'; +END; +$$; + +REVOKE ALL ON FUNCTION accounting_core.guard_period_insert() FROM PUBLIC; + +-- A state transition must observe every stripe under FOR UPDATE. If an open +-- journal committed to any stripe after this REPEATABLE READ transaction's +-- snapshot was fixed, PostgreSQL raises SQLSTATE 40001 rather than allowing a +-- close receipt or snapshot derived from a stale journal population to commit. +CREATE OR REPLACE FUNCTION accounting_core.guard_period_state_transition_freshness() +RETURNS trigger +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = pg_catalog, pg_temp +AS $$ +DECLARE + locked_fence_rows integer; +BEGIN + PERFORM period_fence.fence_slot + FROM accounting_core.period_journal_population_fence AS period_fence + WHERE period_fence.tenant_account_id = NEW.tenant_account_id + AND period_fence.accounting_book_id = NEW.accounting_book_id + AND period_fence.fiscal_period_id = NEW.fiscal_period_id + ORDER BY period_fence.fence_slot + FOR UPDATE; + + GET DIAGNOSTICS locked_fence_rows = ROW_COUNT; + IF locked_fence_rows <> 64 THEN + RAISE EXCEPTION + 'period journal population fence is incomplete for close transition (period_journal_population_fence_missing)' + USING ERRCODE = 'check_violation'; + END IF; + + RETURN NEW; +END; +$$; + +REVOKE ALL ON FUNCTION accounting_core.guard_period_state_transition_freshness() + FROM PUBLIC; + +CREATE TRIGGER period_state_transition_population_fence + BEFORE UPDATE OF period_status_code + ON accounting_core.accounting_book_period_control + FOR EACH ROW + WHEN (OLD.period_status_code IS DISTINCT FROM NEW.period_status_code) + EXECUTE FUNCTION accounting_core.guard_period_state_transition_freshness(); + +-- Preserve the purpose-limited snapshot writer while restoring the supported +-- direct open-to-hard-close command. An open-period snapshot requires the exact +-- tenant/book/period close advisory lock; a bare role/GUC cannot pre-populate it. +CREATE OR REPLACE FUNCTION accounting_reporting.guard_trial_balance_snapshot_insert() +RETURNS trigger +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = pg_catalog, pg_temp +AS $$ +DECLARE + period_status_value text; + book_legal_entity_id uuid; + book_reporting_currency_code text; + journal_write_role_value text; + close_command_lock_held boolean; +BEGIN + SELECT accounting_book_period_control.period_status_code + INTO period_status_value + FROM accounting_core.accounting_book_period_control + WHERE accounting_book_period_control.tenant_account_id = NEW.tenant_account_id + AND accounting_book_period_control.accounting_book_id = NEW.accounting_book_id + AND accounting_book_period_control.fiscal_period_id = NEW.fiscal_period_id + FOR UPDATE; + + IF period_status_value IS NULL THEN + RAISE EXCEPTION + 'trial balance snapshot has no matching book-period authority (trial_balance_snapshot_scope_missing)' + USING ERRCODE = 'check_violation'; + END IF; + + SELECT accounting_book.legal_entity_id, + accounting_book.reporting_currency_code + INTO book_legal_entity_id, + book_reporting_currency_code + FROM accounting_core.accounting_book + WHERE accounting_book.tenant_account_id = NEW.tenant_account_id + AND accounting_book.accounting_book_id = NEW.accounting_book_id; + + IF book_legal_entity_id IS NOT NULL + AND book_legal_entity_id IS DISTINCT FROM NEW.legal_entity_id THEN + RAISE EXCEPTION + 'trial balance snapshot legal entity must own the accounting book (trial_balance_snapshot_book_entity_mismatch)' + USING ERRCODE = 'check_violation'; + END IF; + + IF book_reporting_currency_code IS NOT NULL + AND book_reporting_currency_code IS DISTINCT FROM NEW.snapshot_currency_code THEN + RAISE EXCEPTION + 'trial balance snapshot currency must match the accounting book reporting currency (trial_balance_snapshot_currency_mismatch)' + USING ERRCODE = 'check_violation'; + END IF; + + IF period_status_value = 'hard_closed' THEN + RAISE EXCEPTION + 'hard-close trial balance evidence is immutable (trial_balance_snapshot_immutable)' + USING ERRCODE = 'check_violation'; + END IF; + + journal_write_role_value := nullif( + current_setting('accounting_core.journal_write_role', true), + '' + ); + + SELECT EXISTS ( + SELECT 1 + FROM pg_catalog.pg_locks AS held_lock + JOIN accounting_core.tenant_account + ON tenant_account.tenant_account_id = NEW.tenant_account_id + JOIN accounting_core.accounting_book + ON accounting_book.tenant_account_id = NEW.tenant_account_id + AND accounting_book.accounting_book_id = NEW.accounting_book_id + JOIN accounting_core.fiscal_period + ON fiscal_period.tenant_account_id = NEW.tenant_account_id + AND fiscal_period.fiscal_period_id = NEW.fiscal_period_id + WHERE held_lock.locktype = 'advisory' + AND held_lock.pid = pg_backend_pid() + AND held_lock.database = ( + SELECT pg_database.oid + FROM pg_catalog.pg_database + WHERE pg_database.datname = current_database() + ) + AND held_lock.mode = 'ExclusiveLock' + AND held_lock.granted + AND held_lock.objsubid = 2 + AND held_lock.classid::bigint = ( + hashtext(tenant_account.tenant_account_code)::bigint & 4294967295 + ) + AND held_lock.objid::bigint = ( + hashtext( + 'period:' || accounting_book.accounting_book_id::text || ':' || fiscal_period.period_code + )::bigint & 4294967295 + ) + ) INTO close_command_lock_held; + + IF NOT pg_has_role(session_user, 'accounting_closing_writer', 'MEMBER') + OR ( + period_status_value = 'open' + AND NOT close_command_lock_held + ) + OR ( + period_status_value = 'soft_closed' + AND journal_write_role_value IS DISTINCT FROM 'period_closing' + AND NOT close_command_lock_held + ) + OR period_status_value NOT IN ('open', 'soft_closed') + THEN + RAISE EXCEPTION + 'trial balance snapshot creation requires the purpose-limited hard-close writer (trial_balance_snapshot_authority_required)' + USING ERRCODE = 'check_violation'; + END IF; + + NEW.snapshot_generated_at := clock_timestamp(); + + IF EXISTS ( + SELECT 1 + FROM accounting_reporting.trial_balance_snapshot + WHERE trial_balance_snapshot.tenant_account_id = NEW.tenant_account_id + AND trial_balance_snapshot.accounting_book_id = NEW.accounting_book_id + AND trial_balance_snapshot.fiscal_period_id = NEW.fiscal_period_id + ) THEN + RAISE EXCEPTION + 'trial balance snapshot population already occupies this book-period (trial_balance_snapshot_population_conflict)' + USING ERRCODE = 'check_violation'; + END IF; + + RETURN NEW; +END; +$$; + +REVOKE ALL ON FUNCTION accounting_reporting.guard_trial_balance_snapshot_insert() + FROM PUBLIC; + +COMMIT; From 7cafddd91affeb0166956a96c260a8c17f06ac42 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 11:10:38 +0900 Subject: [PATCH 082/224] fix(close): install open-period population fence --- src/accounting_information_platform/migration_install.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/accounting_information_platform/migration_install.py b/src/accounting_information_platform/migration_install.py index 8eb5fcc3..afbf87fc 100644 --- a/src/accounting_information_platform/migration_install.py +++ b/src/accounting_information_platform/migration_install.py @@ -29,6 +29,7 @@ def apply_foundation_migration(database_url: str, migration_path: Path) -> None: migration_path.parent / "0030_trial_balance_snapshot_immutability.sql", migration_path.parent / "0031_trial_balance_line_conservation_validation.sql", migration_path.parent / "0032_period_close_journal_population_fence.sql", + migration_path.parent / "0033_open_period_journal_population_fence.sql", ) for forward_migration_path in forward_migration_paths: if not forward_migration_path.is_file(): From 7279c9a45cb6e515a9e88b0171fb8390c823ccba Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 11:11:09 +0900 Subject: [PATCH 083/224] test(close): ratchet striped open-period freshness fence --- ..._balance_snapshot_immutability_contract.py | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/tests/test_trial_balance_snapshot_immutability_contract.py b/tests/test_trial_balance_snapshot_immutability_contract.py index b078bba9..e3dfd84d 100644 --- a/tests/test_trial_balance_snapshot_immutability_contract.py +++ b/tests/test_trial_balance_snapshot_immutability_contract.py @@ -21,6 +21,9 @@ JOURNAL_FENCE_MIGRATION = ( ROOT / "database/migrations/0032_period_close_journal_population_fence.sql" ) +OPEN_PERIOD_FENCE_MIGRATION = ( + ROOT / "database/migrations/0033_open_period_journal_population_fence.sql" +) class TrialBalanceSnapshotImmutabilityContractTests(unittest.TestCase): @@ -36,6 +39,7 @@ def is_file(path: Path) -> bool: IMMUTABILITY_MIGRATION.name, VALIDATION_MIGRATION.name, JOURNAL_FENCE_MIGRATION.name, + OPEN_PERIOD_FENCE_MIGRATION.name, }: return False return original_is_file(path) @@ -158,6 +162,44 @@ def test_journal_population_fence_preserves_open_period_concurrency(self) -> Non migration, ) + def test_open_period_freshness_uses_stripes_not_one_exclusive_revision_row(self) -> None: + """Open journals version one stripe while a period transition validates all stripes.""" + migration = OPEN_PERIOD_FENCE_MIGRATION.read_text(encoding="utf-8") + self.assertIn("CREATE TABLE accounting_core.period_journal_population_fence", migration) + self.assertIn("fence_slot >= 0 AND fence_slot < 64", migration) + self.assertIn("CROSS JOIN generate_series(0, 63)", migration) + self.assertIn("period_journal_population_fence_seed", migration) + self.assertIn("get_byte(uuid_send(NEW.general_journal_id), 15) % 64", migration) + self.assertIn("SET journal_population_revision = journal_population_revision + 1", migration) + self.assertIn("period_state_transition_population_fence", migration) + self.assertIn("ORDER BY period_fence.fence_slot", migration) + self.assertIn("FOR UPDATE;", migration) + self.assertIn("locked_fence_rows <> 64", migration) + self.assertIn("period_journal_population_fence_missing", migration) + self.assertIn("FOR SHARE;", migration) + self.assertNotIn( + "IF period_status_value = 'open' THEN\n UPDATE accounting_core.accounting_book_period_control", + migration, + ) + self.assertIn("ENABLE ROW LEVEL SECURITY", migration) + self.assertIn("FORCE ROW LEVEL SECURITY", migration) + self.assertIn("period_journal_population_fence_isolation", migration) + self.assertGreaterEqual(migration.count("SECURITY DEFINER"), 3) + self.assertGreaterEqual(migration.count("SET search_path = pg_catalog, pg_temp"), 3) + + def test_direct_open_hard_close_requires_exact_close_lock(self) -> None: + """A governed direct hard close is admitted; a bare open-period role/GUC is not.""" + migration = OPEN_PERIOD_FENCE_MIGRATION.read_text(encoding="utf-8") + self.assertIn("period_status_value = 'open'", migration) + self.assertIn("AND NOT close_command_lock_held", migration) + self.assertIn("period_status_value = 'soft_closed'", migration) + self.assertIn("journal_write_role_value IS DISTINCT FROM 'period_closing'", migration) + self.assertIn( + "pg_has_role(session_user, 'accounting_closing_writer', 'MEMBER')", + migration, + ) + self.assertIn("trial_balance_snapshot_authority_required", migration) + def test_snapshot_header_requires_purpose_limited_hard_close_authority(self) -> None: """Capability plus the exact application close-command lock is required.""" migration = IMMUTABILITY_MIGRATION.read_text(encoding="utf-8") From a6d32fc35f6f0f48fbcec6b08fa16b1a89eb5f80 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 11:12:05 +0900 Subject: [PATCH 084/224] fix(close): seed population fences before forcing RLS --- .../0033_open_period_journal_population_fence.sql | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/database/migrations/0033_open_period_journal_population_fence.sql b/database/migrations/0033_open_period_journal_population_fence.sql index 88546189..1ce57f29 100644 --- a/database/migrations/0033_open_period_journal_population_fence.sql +++ b/database/migrations/0033_open_period_journal_population_fence.sql @@ -30,15 +30,11 @@ CREATE TABLE accounting_core.period_journal_population_fence ( ); REVOKE ALL ON accounting_core.period_journal_population_fence FROM PUBLIC; -ALTER TABLE accounting_core.period_journal_population_fence ENABLE ROW LEVEL SECURITY; -ALTER TABLE accounting_core.period_journal_population_fence FORCE ROW LEVEL SECURITY; -CREATE POLICY period_journal_population_fence_isolation - ON accounting_core.period_journal_population_fence - USING (tenant_account_id = accounting_core.current_tenant_account_id()) - WITH CHECK (tenant_account_id = accounting_core.current_tenant_account_id()); -- Fence rows must pre-date any REPEATABLE READ close snapshot. Creating a fence -- lazily after a stale snapshot would let the close miss the new row entirely. +-- Seed the migration-owned backfill before FORCE RLS so a non-superuser schema +-- owner can initialize every tenant without borrowing one runtime tenant scope. INSERT INTO accounting_core.period_journal_population_fence ( tenant_account_id, accounting_book_id, @@ -52,6 +48,13 @@ SELECT period_control.tenant_account_id, FROM accounting_core.accounting_book_period_control AS period_control CROSS JOIN generate_series(0, 63) AS generated_slot(fence_slot); +ALTER TABLE accounting_core.period_journal_population_fence ENABLE ROW LEVEL SECURITY; +ALTER TABLE accounting_core.period_journal_population_fence FORCE ROW LEVEL SECURITY; +CREATE POLICY period_journal_population_fence_isolation + ON accounting_core.period_journal_population_fence + USING (tenant_account_id = accounting_core.current_tenant_account_id()) + WITH CHECK (tenant_account_id = accounting_core.current_tenant_account_id()); + CREATE OR REPLACE FUNCTION accounting_core.seed_period_journal_population_fence() RETURNS trigger LANGUAGE plpgsql From e5a95dec7200bb188eba579230e245ebf502b6c9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 11:13:02 +0900 Subject: [PATCH 085/224] docs(close): trace direct open-period freshness fence --- ...PERIOD_CLOSE_JOURNAL_FENCE_TRACEABILITY.md | 56 +++++++++++-------- 1 file changed, 33 insertions(+), 23 deletions(-) diff --git a/docs/doctoring/PERIOD_CLOSE_JOURNAL_FENCE_TRACEABILITY.md b/docs/doctoring/PERIOD_CLOSE_JOURNAL_FENCE_TRACEABILITY.md index 9bde9c54..c9b09e40 100644 --- a/docs/doctoring/PERIOD_CLOSE_JOURNAL_FENCE_TRACEABILITY.md +++ b/docs/doctoring/PERIOD_CLOSE_JOURNAL_FENCE_TRACEABILITY.md @@ -2,44 +2,54 @@ ## Decision scope -This note traces the concurrency control used when an authoritative journal population approaches fiscal-period hard close. It does not create accounting policy, reopen a closed period, grant Billing posting authority, or make PostgreSQL locking semantics an IFRS requirement. +This note traces the concurrency control used when an authoritative journal population approaches fiscal-period close. It does not create accounting policy, reopen a closed period, grant Billing posting authority, or make PostgreSQL locking semantics an IFRS requirement. -IAS 10 distinguishes adjusting events that provide evidence about conditions existing at the reporting-period end from non-adjusting events arising later. The product-level implication already adopted by ADR 0006/ADR 0023 is that a governed close must not certify retained balances while an admissible accounting adjustment for that period is concurrently being committed. IAS 10 does **not** prescribe PostgreSQL row locks, MVCC isolation, retry codes, or the `journal_population_revision` design; those are implementation controls chosen by AIP. +IAS 10 distinguishes adjusting events that provide evidence about conditions existing at the reporting-period end from non-adjusting events arising later. The product-level implication already adopted by ADR 0006/ADR 0023 is narrower: a governed close must not certify retained balances while a journal that is validly admissible for that period is concurrently being committed. IAS 10 does **not** prescribe PostgreSQL row locks, MVCC isolation, retry codes, revision counters, or striped fence rows; those are AIP implementation controls. ## Authoritative technical basis -PostgreSQL 18 row-level `FOR SHARE` is compatible with another `FOR SHARE`/`FOR KEY SHARE`, while blocking `UPDATE`, `DELETE`, `FOR UPDATE`, and `FOR NO KEY UPDATE` on the same row. Row locks are held through transaction end. PostgreSQL also states that a row-lock request in `REPEATABLE READ`/`SERIALIZABLE` fails if the row changed since the transaction began. Serialization failures use SQLSTATE `40001`, and the complete transaction must be retried rather than resuming from the failed statement. +PostgreSQL 18 documents that `FOR SHARE` is compatible with another `FOR SHARE`/`FOR KEY SHARE`, while blocking `UPDATE`, `DELETE`, `FOR UPDATE`, and `FOR NO KEY UPDATE` on the same row. Row locks are held through transaction end. PostgreSQL also states that a row-lock request in `REPEATABLE READ` or `SERIALIZABLE` errors when the row to be locked changed after the transaction began. Serialization failures use SQLSTATE `40001`, and the complete transaction must be retried from the beginning rather than resumed after the failed statement. -The current PostgreSQL documentation line is PostgreSQL 18, whose current supported minor at this decision date is 18.6. Minor-version operability evidence remains owned by the PostgreSQL runtime-baseline lane; this control relies on PostgreSQL 18 semantics rather than claiming a clean-image bump proves an existing-cluster upgrade. +The current PostgreSQL documentation line is PostgreSQL 18; PostgreSQL 18.6 was released on 2026-08-13. Minor-version operability evidence remains owned by the PostgreSQL runtime-baseline lane. This control relies on documented PostgreSQL 18 concurrency semantics and does not treat a clean CI image as evidence that an existing production cluster was upgraded safely. ## Control mapping | Concern | Chosen control | Rejected alternative | Executable evidence | |---|---|---|---| -| Ordinary `open` posting throughput | `accounting_core.guard_period_insert()` takes `FOR SHARE` on the exact `accounting_book_period_control` row and does not change `journal_population_revision` | Increment the revision for every journal; this makes one book-period row an exclusive UPDATE hotspot | `tests/test_postgres_open_period_journal_fence.py::test_open_period_posting_does_not_version_close_control_row` | -| Compatible open-path coordination | Multiple open journals may hold the shared fence concurrently; a state-changing UPDATE must wait | No period fence; soft close could overtake a journal admitted under the prior `open` state | `tests/test_postgres_open_period_journal_fence.py::test_open_period_posting_can_progress_while_peer_holds_share_fence` | -| State changes while an open journal waits | Fail with SQLSTATE `40001` (`serialization_failure`) and retry the whole journal command from a fresh transaction | Continue after wait using stale open-state authority | `database/migrations/0032_period_close_journal_population_fence.sql`; static contract in `tests/test_trial_balance_snapshot_immutability_contract.py` | -| `soft_closed` adjusting/reversal/closing journal freshness | Purpose-limited close-window journals increment `journal_population_revision` on the exact tenant/book/period row in the journal transaction | Advisory-lock wait only; a pre-existing `REPEATABLE READ` snapshot would remain stale after lock grant | `tests/test_postgres_period_close_journal_serialization_red.py` | -| Stale hard close | PostgreSQL serialization failure rolls the close transaction back before retained population/hard-close state becomes authoritative; retry the same close idempotency key from a fresh snapshot | Freeze older live totals or normalize the conflict into success | `tests/test_postgres_period_close_journal_serialization_red.py` retained/live amount comparison | -| Capability | Replacement guard remains `SECURITY DEFINER`, fixes `search_path = pg_catalog, pg_temp`, and revokes PUBLIC execute | Caller GUC alone as authorization | `database/migrations/0032_period_close_journal_population_fence.sql`; `tests/test_trial_balance_snapshot_immutability_contract.py` | +| Ordinary `open` posting throughput | `guard_period_insert()` takes `FOR SHARE` on the exact book-period control row, then increments one of 64 pre-existing journal-population fence rows selected from the journal UUID | Increment one shared book-period revision for every journal; that serializes all ordinary posting on one row | `tests/test_postgres_open_period_journal_fence.py`; migration `0033_open_period_journal_population_fence.sql` | +| Direct `open` → close freshness | Before a period status transition commits, PostgreSQL locks all 64 pre-existing fence rows in slot order. A fence modified after the close transaction's `REPEATABLE READ` snapshot causes SQLSTATE `40001`, so stale close evidence rolls back | A shared control-row lock alone; it prevents transition overtaking an in-flight journal but carries no row version showing that a journal committed after the close snapshot | `tests/test_postgres_open_period_close_serialization_red.py` | +| Compatible open-path coordination | Open journals continue to share the control-row lock; only journals landing on the same stripe compete for the stripe UPDATE | No period fence; a state transition could certify an older journal population | `tests/test_postgres_open_period_journal_fence.py::test_open_period_posting_can_progress_while_peer_holds_share_fence` | +| State changes while an open journal waits | Fail with SQLSTATE `40001` and retry the whole journal command from a fresh transaction | Continue using stale open-state authority after waiting | migrations `0032`/`0033`; `tests/test_trial_balance_snapshot_immutability_contract.py` | +| `soft_closed` adjusting/reversal/closing journal freshness | Purpose-limited close-window journals increment `accounting_book_period_control.journal_population_revision` in the journal transaction | Advisory-lock wait only; a pre-existing repeatable-read snapshot remains stale after lock grant | `tests/test_postgres_period_close_journal_serialization_red.py` | +| Direct hard-close snapshot authority | A snapshot while the book-period is still `open` is admitted only when the session has `accounting_closing_writer` capability **and** holds the canonical tenant/resolved-book-id/period close advisory lock; a bare GUC/role cannot pre-populate open-period evidence | Require `soft_closed` for every snapshot, which breaks the published direct `open` → `hard_closed` command; or accept a bare role/GUC, which permits pre-population | migration `0033`; direct-open retry path in `tests/test_postgres_open_period_close_serialization_red.py` | +| Fence existence | 64 rows are seeded for every existing book-period before FORCE RLS is enabled, and an AFTER INSERT trigger seeds every future control row | Lazy per-journal fence creation; a stale repeatable-read snapshot could fail to see a newly created row and therefore fail to detect the concurrent journal | migration `0033`; static contract | +| Capability/security | Fence and guard functions are `SECURITY DEFINER`, fix `search_path = pg_catalog, pg_temp`, revoke PUBLIC execute, and the fence table is tenant RLS/FORCE RLS protected | Caller GUC alone as authorization or an unscoped shared fence | migration `0033`; static contract | -## TDD lineage +## Why migration 0032 alone was insufficient -- Journal/close stale-population RED: `306f4c14212a0dfbb89a6934bbb493b1e179479e`. -- First freshness candidate through `84f5666aa48ba565fb2e4ff763bb0b3ee27fe857` invalidated stale close by updating the control-row revision for every journal. -- Open-period hotspot RED: `6fe1fdeb1050111b26e557810dbf66b05f75871a`. -- Split-lock causal repair: `7a979845896869ef0e7fabab710c7a4f3a9863de`. -- Static lock-profile ratchet: `37d4f513c2740b644e45bd7d05bbdadb7e4dbad7`. -- ADR alignment: `815f7055b8e6ddbd470904a4abbe6862e5e160be`. -- Real-PostgreSQL compatible-shared-lock regression: `2bdb09a6e7da1444a2356d94ae7fde16d9d40686`. +Migration 0032 corrected the first stale-close race for `soft_closed` journals without preserving the rejected all-journal single-row UPDATE. Its `open` path deliberately left `journal_population_revision` unchanged and held only `FOR SHARE` on `accounting_book_period_control`. -These commits are development evidence, not protected-head release evidence. Exact-head CI, independent review, central workflow gates, package/SBOM/provenance, migration/recovery verification, and measured performance must be reacquired after every head change. +That lock prevents a status UPDATE from overtaking a journal transaction that is still in flight, but it does not record that the journal committed after a close transaction established its `REPEATABLE READ` snapshot. A close can therefore establish an old snapshot, wait for an open journal's shared control-row lock, acquire the unchanged control row after the journal commits, and continue deriving close evidence from the older snapshot. Migration 0033 supplies a bounded, pre-existing row-version witness without restoring one global write hotspot. + +The 64-slot count is an engineering trade-off, not an accounting standard and not a performance result. It bounds fence-row fan-out during the low-frequency state transition while reducing expected ordinary-post collisions relative to one shared row. Exact-head load testing still has to measure collision rate, lock waits, WAL/write cost, and buyer-path p95; the slot count must be changed only from measured evidence. + +## TDD and repair lineage + +- Soft-close journal / stale hard-close RED: `306f4c14212a0dfbb89a6934bbb493b1e179479e`. +- First freshness candidate through `84f5666aa48ba565fb2e4ff763bb0b3ee27fe857` updated the control-row revision for every journal. +- Single-row open-period hotspot RED: `6fe1fdeb1050111b26e557810dbf66b05f75871a`. +- Split control-row repair: `7a979845896869ef0e7fabab710c7a4f3a9863de`; compatible-lock regression `2bdb09a6e7da1444a2356d94ae7fde16d9d40686`. +- Direct open-period stale-close RED: `70a9b196da23fc0cbedd9ceafa806710794f13e3`. +- Initial 64-stripe database repair: `ac3a2a7eac2929e3ff76908d9bb64a4a38acb7dd`; canonical installer inclusion: `7cafddd91affeb0166956a96c260a8c17f06ac42`; static contract: `7279c9a45cb6e515a9e88b0171fb8390c823ccba`. +- Migration self-review moved the cross-tenant backfill before FORCE RLS so a non-superuser schema owner is not forced to borrow one runtime tenant scope while seeding all existing book-periods: `a6d32fc35f6f0f48fbcec6b08fa16b1a89eb5f80`. + +These commits are development evidence, not protected-head release evidence. The direct-open RED was added before the causal repair, but queued GitHub runners have not provided an observed RED/GREEN transition for the current lineage. Exact-head PostgreSQL CI, security/SAST/dependency evidence, independent review, central workflow gates, package/SBOM/provenance, migration/recovery verification, and measured performance must be reacquired after every head change. ## Residual risk and release acceptance -`FOR SHARE` avoids the deliberate per-journal exclusive row UPDATE, but row locking itself is not free and PostgreSQL notes that row locking can cause disk writes. This repair therefore establishes a concurrency-control shape and a realistic non-blocking regression; it does not prove the buyer-path p95 ≤ 20 ms target. Release acceptance still requires measured concurrent posting/period-transition load at the exact candidate head, with lock waits and tail latency reported rather than hidden by cache warm-up, reduced samples, or excluded failures. +Striping removes the deliberate single-row write hotspot but does not make row versioning free. PostgreSQL notes that row locking can cause writes, and same-slot journals still serialize on the selected stripe. Release acceptance therefore requires measured concurrent posting plus period-transition load at the exact candidate head, reporting lock waits, stripe collision distribution, retries, and tail latency rather than hiding them with cache warm-up, reduced samples, or excluded failures. -A `40001` result is not accounting evidence. The calling command must retry its entire transaction and preserve the same immutable source-payload identity/idempotency contract. Recovery must never rewrite posted journals, retained trial-balance evidence, or reconciliation authority rows to make a failed close appear successful. +A `40001` result is not accounting evidence. The caller must retry the complete command with the same immutable source-payload identity/idempotency contract. A failed stale close must leave no retained snapshot, closing journal, close event, or authoritative state transition. Recovery must never rewrite posted journals, retained trial-balance evidence, or reconciliation authority rows to make a failed close appear successful. ## References @@ -47,6 +57,6 @@ IFRS Foundation. (n.d.). *IAS 10 Events after the Reporting Period*. https://www PostgreSQL Global Development Group. (2026a). *PostgreSQL 18 documentation: Explicit locking*. https://www.postgresql.org/docs/18/explicit-locking.html -PostgreSQL Global Development Group. (2026b). *PostgreSQL 18 documentation: Serialization failure handling*. https://www.postgresql.org/docs/18/mvcc-serialization-failure-handling.html +PostgreSQL Global Development Group. (2026b). *PostgreSQL 18 documentation: Transaction isolation*. https://www.postgresql.org/docs/18/transaction-iso.html -PostgreSQL Global Development Group. (2026c). *PostgreSQL documentation*. https://www.postgresql.org/docs/ +PostgreSQL Global Development Group. (2026c). *PostgreSQL 18 documentation: Serialization failure handling*. https://www.postgresql.org/docs/18/mvcc-serialization-failure-handling.html From 73f12045636973a77df2bb2150e4805d2ca21bbb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 11:13:37 +0900 Subject: [PATCH 086/224] docs(close): align ADR with striped freshness witness --- docs/adr/0006-fiscal-period-close-snapshot.md | 58 ++++++++++++------- 1 file changed, 37 insertions(+), 21 deletions(-) diff --git a/docs/adr/0006-fiscal-period-close-snapshot.md b/docs/adr/0006-fiscal-period-close-snapshot.md index 8926de59..b946079a 100644 --- a/docs/adr/0006-fiscal-period-close-snapshot.md +++ b/docs/adr/0006-fiscal-period-close-snapshot.md @@ -6,11 +6,11 @@ Hard close certifies a retained trial-balance population as Accounting Information Platform evidence. That population must not be caller-shaped, mutable after close, cross an accounting-book aggregate boundary, drift arithmetically, or omit a journal that was validly admitted before close authority won. -The implementation also has to preserve posting throughput. `accounting_book_period_control` is one row per tenant/accounting-book/fiscal-period. Updating that row for every ordinary open-period journal would turn a correctness fence into a hot exclusive-write point and serialize otherwise independent postings in the busiest lifecycle state. +The implementation also has to preserve posting throughput. `accounting_book_period_control` is one row per tenant/accounting-book/fiscal-period. Updating that row for every ordinary open-period journal would turn a correctness fence into a hot exclusive-write point and serialize otherwise independent postings in the busiest lifecycle state. A shared lock on that row alone is also insufficient: it prevents a period transition from overtaking a still-running journal, but it does not prove that a journal committed after a close transaction established its `REPEATABLE READ` snapshot. ## Decision -`PostgresPostingLedger.close_fiscal_period` remains the first-class close command. ADR 0023 owns the two-step lifecycle: `soft_closed` changes period state only; it creates no `trial_balance_snapshot`, `trial_balance_line`, or mandatory closing journal. A later `hard_closed` command acquires the tenant/book/period command authority, writes a period-closing journal when required, derives the live trial balance from AIS-owned PostgreSQL facts through the period end, persists one retained snapshot population, and hard-closes the period in the governed transaction. Posted journals are never rewritten. +`PostgresPostingLedger.close_fiscal_period` remains the first-class close command. ADR 0023 owns the lifecycle: `soft_closed` changes period state only and creates no retained trial-balance population. `hard_closed` acquires tenant/resolved-book/period command authority, writes a period-closing journal when required, derives the live trial balance from AIS-owned PostgreSQL facts through the period end, persists one retained snapshot population, and hard-closes the period in one governed transaction. Direct `open` → `hard_closed` remains supported; `open` → `soft_closed` → `hard_closed` is also supported. Posted journals are never rewritten. An exact hard-close replay returns the retained result and creates no second snapshot, journal, or close event. `hard_closed` cannot transition back to `soft_closed`. Soft-close replay remains snapshot-free. @@ -30,30 +30,46 @@ Migration 0030 adds `trial_balance_line_net_balance_conservation` as `NOT VALID` A retained snapshot header must use the legal entity that owns the selected accounting book, and `snapshot_currency_code` must equal that book's `reporting_currency_code`. Every retained `trial_balance_line.chart_account_id` must belong to the same accounting book. Tenant-scoped identifiers that are independently valid cannot be recombined across those aggregate boundaries. -Snapshot creation is not an ordinary soft-close write. PostgreSQL admits a new snapshot only while the exact book-period is `soft_closed`, `session_user` belongs to `accounting_closing_writer`, and the transaction carries hard-close command context. That context is either transaction-local `accounting_core.journal_write_role=period_closing` after a required closing-journal write or the canonical tenant/resolved-accounting-book-id/period advisory lock held by `close_fiscal_period`. The GUC and lock classify the command; role membership remains the capability boundary. +The canonical hard-close advisory key is `hashtext(tenant_reference)` plus `hashtext('period:' || accounting_book_id::text || ':' || period_code)`. PostgreSQL reconstructs the resolved accounting-book identity rather than trusting caller-facing `book_name`. `snapshot_generated_at` is database-owned system time and is replaced with `clock_timestamp()` even for an otherwise authorized closing writer. -The canonical advisory key is `hashtext(tenant_reference)` plus `hashtext('period:' || accounting_book_id::text || ':' || period_code)`. The trigger reconstructs the resolved accounting-book identity, not the caller-facing `book_name`. `snapshot_generated_at` is database-owned system time and is replaced with `clock_timestamp()` even for an otherwise authorized closing writer. +Migration `0033_open_period_journal_population_fence.sql` preserves the purpose-limited snapshot writer while restoring the supported direct `open` → `hard_closed` path. A snapshot created while the book-period is still `open` requires `accounting_closing_writer` capability **and** the exact tenant/resolved-book/period close advisory lock; a bare role plus `accounting_core.journal_write_role` cannot pre-populate open-period retained evidence. While `soft_closed`, the existing purpose-limited `period_closing` command context or the canonical close lock remains sufficient together with role membership. -### Journal-population freshness without an open-period write hotspot +### Journal-population freshness without a single-row posting hotspot -A different race exists between an admitted journal and hard close. `close_fiscal_period` uses `REPEATABLE READ`; a close transaction can therefore hold an MVCC snapshot that predates a journal committed while close is waiting on its period authority. Waiting alone does not refresh the repeatable-read snapshot. +`close_fiscal_period` uses `REPEATABLE READ`. A close transaction can therefore hold an MVCC snapshot that predates a journal committed while close is waiting on period authority. Waiting for a lock does not refresh that snapshot. -Migration `0032_period_close_journal_population_fence.sql` uses the book-period control row as a lifecycle fence, but it does **not** update that row for every journal: +Migration `0032_period_close_journal_population_fence.sql` first split the control profile: -- when the period is `open`, journal admission takes `SELECT ... FOR SHARE` on the exact control row and returns without changing `journal_population_revision`; many open-period journals can hold this shared row lock concurrently, while a period-state UPDATE must wait for them to finish; -- if the period changes while an open-path journal is waiting for that shared lock, admission fails with SQLSTATE `40001` (`serialization_failure`) and the journal command must retry from a fresh transaction rather than inherit stale open-period authority; -- when the period is `soft_closed`, only purpose-limited `period_closing`, `adjusting`, or `reversal` journals from `accounting_closing_writer` are admitted, and those close-window journals increment `journal_population_revision` on the exact control row in the same transaction as the journal header; -- hard close later locks that same row. If a soft-close journal committed after the close transaction's repeatable-read snapshot, PostgreSQL rejects the stale close with serialization failure rather than freezing an older population. If hard close owns the row first, the later journal cannot remain admissible after the committed `hard_closed` state. +- an `open` journal takes `SELECT ... FOR SHARE` on the exact book-period control row so a state transition cannot overtake a journal already admitted as open; +- a purpose-limited `soft_closed` period-closing/adjusting/reversal journal increments `journal_population_revision` on that control row, so a stale hard close that later locks the row fails with SQLSTATE `40001`. -This split is intentional. Updating the control row for every ordinary journal would create a per-book-period exclusive UPDATE hotspot. `FOR SHARE` blocks status-changing UPDATEs while allowing other `FOR SHARE` holders, so the high-volume open-period path remains concurrent while the lower-volume close window receives the stronger row-version fence required to invalidate stale close snapshots. +Review then exposed a remaining direct-open race. The open journal changed no row visible to a stale close. After waiting for the shared control-row lock, the close could acquire the unchanged row and continue from its older MVCC snapshot. -A serialization failure is not accounting evidence. The whole failed transaction is rolled back and the identical idempotency key is retried from the beginning. `tests/test_postgres_period_close_journal_serialization_red.py` exercises stale hard-close rollback and exact-key retry against retained/live amounts. `tests/test_postgres_open_period_journal_fence.py` requires ordinary open-period posting to leave `journal_population_revision` unchanged. `tests/test_trial_balance_snapshot_immutability_contract.py` ratchets the migration shape, including the open-path shared lock and soft-close-only revision update. +Migration `0033_open_period_journal_population_fence.sql` adds a bounded row-version witness without restoring one global write hotspot: + +- every book-period owns exactly 64 pre-existing `period_journal_population_fence` rows; migration backfill creates them before FORCE RLS is enabled, and an AFTER INSERT trigger seeds future book-period controls; +- after confirming `open` under `FOR SHARE`, each journal increments exactly one fence row selected from its database journal UUID; two journals contend only when they choose the same slot; +- before `period_status_code` changes, a transition trigger locks all 64 rows in deterministic slot order with `FOR UPDATE` and requires the complete population; +- if any fence row was committed after the close transaction's repeatable-read snapshot, PostgreSQL raises serialization failure instead of allowing the stale transition to commit; +- if close owns the control row first, a later open journal waits and then cannot retain stale open-state admission after the transition. + +The 64-slot count is a measured-performance hypothesis, not accounting policy and not an IFRS requirement. It bounds the low-frequency transition fan-out while reducing expected ordinary-post collisions relative to one shared row. Exact-head load tests must still measure slot collisions, lock waits, WAL/write cost, retry rate, and buyer-path p95. A future slot-count change requires measured evidence and a migration-compatible design. + +A serialization failure is not accounting evidence. The entire transaction rolls back and the command retries from the beginning with the same immutable source-payload identity and idempotency key. No failed close may leave a retained snapshot, period-closing journal, close event, or authoritative period transition. + +`tests/test_postgres_period_close_journal_serialization_red.py` exercises a `soft_closed` adjustment racing hard close. `tests/test_postgres_open_period_close_serialization_red.py` exercises a journal committed after a direct close snapshot but before the close acquires the period row, then requires exact-key retry to retain the live population. `tests/test_postgres_open_period_journal_fence.py` protects ordinary open-path concurrency, and `tests/test_trial_balance_snapshot_immutability_contract.py` ratchets the migration/security shape. ## Alternatives considered -Updating `journal_population_revision` for every admitted journal was rejected after review because it makes one control row the exclusive write point for all ordinary posting in a book-period. It preserves freshness but violates the platform's hot-partition/lock and latency requirements. +Updating one `journal_population_revision` for every admitted journal was rejected because it makes one book-period row the exclusive write point for all ordinary posting. It preserves freshness but violates the platform's hot-partition/lock and latency goals. + +Using only the control-row `FOR SHARE`/`FOR UPDATE` protocol was rejected after the direct-open race review. It orders transaction completion around the state change but supplies no row version proving that an open journal committed after a pre-existing repeatable-read snapshot. + +Relying only on the period advisory lock was rejected because a waiting repeatable-read close can retain the snapshot established before lock grant. Advisory-lock ownership and MVCC freshness are separate facts. + +A session-lock-before-snapshot protocol was considered because reconciliation lifecycle already uses a committed session-lease pattern. It was not selected here because the required journal admission coordination can remain a database-owned book-period invariant without extending application-session lock lifetime across transaction boundaries. The striped witness also keeps correctness at the SQL boundary for purpose-limited writers. This decision can be revisited if measured stripe contention or transition fan-out is unacceptable. -Relying only on the period advisory lock was rejected because a waiting `REPEATABLE READ` close can retain the snapshot established before lock grant. Lock acquisition and snapshot freshness are separate concerns. +Lazy creation of a fence row during journal admission was rejected because a repeatable-read close whose snapshot predates that INSERT can fail to see the new row. The complete fence population must exist before any journal/close race. Using only a trigger-side existence query for retained snapshots was rejected because a fixed MVCC snapshot cannot observe a competing population committed after that snapshot. Physical unique population identity remains required. @@ -61,13 +77,13 @@ Allowing caller-provided close timestamps, currencies, aggregate identifiers, or ## Consequences and operational evidence -Ordinary open-period posting now participates in period-transition coordination without incrementing a shared revision row on every journal. Period transition can wait on concurrent open journal transactions; this is deliberate because a journal admitted under `open` must commit before the transition can certify a different period state. Close-window journals remain serialized on the control-row revision because they are exceptional writes whose population must invalidate a stale hard-close snapshot. +Open-period posting now performs a shared control-row lock plus one striped revision UPDATE rather than one exclusive UPDATE on the common book-period row. This removes the deliberate single-row hotspot but does not prove the p95 ≤ 20 ms buyer target. PostgreSQL row locking can itself cause writes, and same-slot journals can still queue. Release evidence must therefore use realistic concurrent posting and transition workloads and report failures, retry rates, lock waits, stripe distribution, and tail latency without sample reduction or excluded errors. -Migration 0032 replaces `accounting_core.guard_period_insert()` as a `SECURITY DEFINER` function with `search_path = pg_catalog, pg_temp` and PUBLIC EXECUTE revoked. Unauthorized or rejected journals do not retain a revision change because the statement/transaction is rolled back. +Migration 0033 creates a new tenant-scoped table under RLS/FORCE RLS. The cross-tenant migration backfill occurs before FORCE RLS is enabled so a non-superuser schema owner can seed every existing book-period without impersonating one runtime tenant. Future rows are seeded from the book-period-control INSERT transaction and stay in that tenant scope. Guard functions are `SECURITY DEFINER`, use `search_path = pg_catalog, pg_temp`, and revoke PUBLIC execute. -The migration chain has distinct recovery states. A failed 0029 concurrent index build can leave an invalid index that operators must remove or rebuild before retry. A failed 0031 validation leaves the constraint enforced for subsequent writes but inherited rows uncertified. A 0032 serialization conflict leaves no hard-close evidence from the failed transaction and requires a whole-command retry. Release evidence must distinguish these states; recovery must never normalize or rewrite posted journals, reconciliation evidence, or retained close facts. +The migration chain has distinct recovery states. A failed 0029 concurrent index build can leave an invalid index that operators must remove or rebuild before retry. A failed 0031 validation leaves the constraint enforced for subsequent writes but inherited rows uncertified. A failed 0033 migration transaction rolls back its table, trigger, function, policy, and seed population together. A runtime SQLSTATE `40001` leaves no authoritative close result and requires a whole-command retry. Recovery must never normalize or rewrite posted journals, reconciliation evidence, or retained close facts. -Future reopen/correction is not implemented here. Any later reopen policy must preserve the prior hard-close population through explicit successor lineage and a replacement population identity/version invariant rather than mutating the retained population or silently weakening uniqueness. +Future reopen/correction is not implemented here. Any later reopen policy must preserve the prior hard-close population through explicit successor lineage and a replacement population identity/version invariant rather than mutating retained evidence or weakening uniqueness. ## Exact soft-close replay @@ -77,7 +93,7 @@ Migration `0010_soft_close_command_evidence.sql` stores the original tenant-scop ## References -PostgreSQL Global Development Group. (2026a). *PostgreSQL 18 documentation: SET TRANSACTION*. https://www.postgresql.org/docs/18/sql-set-transaction.html +PostgreSQL Global Development Group. (2026a). *PostgreSQL 18 documentation: Transaction isolation*. https://www.postgresql.org/docs/18/transaction-iso.html PostgreSQL Global Development Group. (2026b). *PostgreSQL 18 documentation: Explicit locking*. https://www.postgresql.org/docs/18/explicit-locking.html @@ -91,4 +107,4 @@ PostgreSQL Global Development Group. (2026f). *PostgreSQL 18 documentation: Uniq PostgreSQL Global Development Group. (2026g). *PostgreSQL 18 documentation: pg_locks*. https://www.postgresql.org/docs/18/view-pg-locks.html -PostgreSQL Global Development Group. (2026h). *PostgreSQL 18 documentation: System administration functions—Advisory lock functions*. https://www.postgresql.org/docs/18/functions-admin.html#FUNCTIONS-ADVISORY-LOCKS +PostgreSQL Global Development Group. (2026h). *PostgreSQL 18 documentation: Advisory lock functions*. https://www.postgresql.org/docs/18/functions-admin.html#FUNCTIONS-ADVISORY-LOCKS From cda5bb46a5e4a2fd02f58c86c96da7bf8d9bbbe5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 11:14:13 +0900 Subject: [PATCH 087/224] test(close): require canonical 0033 installer boundary --- ...st_open_period_fence_installer_contract.py | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 tests/test_open_period_fence_installer_contract.py diff --git a/tests/test_open_period_fence_installer_contract.py b/tests/test_open_period_fence_installer_contract.py new file mode 100644 index 00000000..888d6182 --- /dev/null +++ b/tests/test_open_period_fence_installer_contract.py @@ -0,0 +1,53 @@ +"""Installer and migration-order contracts for the open-period freshness fence.""" + +from __future__ import annotations + +import unittest +from pathlib import Path +from unittest.mock import patch + +from accounting_information_platform import AccountingValidationError, apply_foundation_migration + + +ROOT = Path(__file__).resolve().parents[1] +OPEN_PERIOD_FENCE_MIGRATION = ( + ROOT / "database/migrations/0033_open_period_journal_population_fence.sql" +) + + +class OpenPeriodFenceInstallerContractTests(unittest.TestCase): + """Prevent supported installs from omitting or tenant-scoping the 0033 backfill.""" + + def test_installer_fails_closed_when_0033_is_missing(self) -> None: + """The canonical migration chain may not stop before the open-period freshness fence.""" + original_is_file = Path.is_file + + def is_file(path: Path) -> bool: + if path.name == OPEN_PERIOD_FENCE_MIGRATION.name: + return False + return original_is_file(path) + + with patch.object(Path, "is_file", is_file): + with self.assertRaises(AccountingValidationError): + apply_foundation_migration( + "postgresql://unused", + ROOT / "database/migrations/0001_accounting_foundation.sql", + ) + + def test_cross_tenant_fence_backfill_precedes_force_rls(self) -> None: + """Migration-owned seed data is complete before runtime tenant isolation is forced.""" + migration = OPEN_PERIOD_FENCE_MIGRATION.read_text(encoding="utf-8") + backfill = "INSERT INTO accounting_core.period_journal_population_fence" + force_rls = ( + "ALTER TABLE accounting_core.period_journal_population_fence " + "FORCE ROW LEVEL SECURITY" + ) + self.assertIn(backfill, migration) + self.assertIn(force_rls, migration) + self.assertLess(migration.index(backfill), migration.index(force_rls)) + self.assertIn("CROSS JOIN generate_series(0, 63)", migration) + self.assertIn("period_journal_population_fence_seed", migration) + + +if __name__ == "__main__": + unittest.main() From 35c76f4dfda3b2d299b82eb28e06a9c2a9a6ba49 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 11:17:15 +0900 Subject: [PATCH 088/224] test(close): cover open snapshot and fence completeness --- ...res_open_period_close_serialization_red.py | 107 ++++++++++++++++++ 1 file changed, 107 insertions(+) diff --git a/tests/test_postgres_open_period_close_serialization_red.py b/tests/test_postgres_open_period_close_serialization_red.py index 76262480..aacb6cb8 100644 --- a/tests/test_postgres_open_period_close_serialization_red.py +++ b/tests/test_postgres_open_period_close_serialization_red.py @@ -31,6 +31,113 @@ def setUp(self) -> None: self.addCleanup(self.case.tearDown) self.case.ledger.post(self.case._two_line_proposal(), self.case.policy) + def _scope(self, connection: object) -> tuple[object, object, object]: + row = connection.execute( + """ + SELECT legal_entity_record.legal_entity_id, + accounting_book.accounting_book_id, + fiscal_period.fiscal_period_id + FROM accounting_core.legal_entity_record + JOIN accounting_core.accounting_book + ON accounting_book.tenant_account_id = legal_entity_record.tenant_account_id + AND accounting_book.legal_entity_id = legal_entity_record.legal_entity_id + JOIN accounting_core.accounting_book_period_control + ON accounting_book_period_control.tenant_account_id = accounting_book.tenant_account_id + AND accounting_book_period_control.accounting_book_id = accounting_book.accounting_book_id + JOIN accounting_core.fiscal_period + ON fiscal_period.tenant_account_id = accounting_book_period_control.tenant_account_id + AND fiscal_period.fiscal_period_id = accounting_book_period_control.fiscal_period_id + WHERE legal_entity_record.tenant_account_id = %s + AND legal_entity_record.legal_entity_code = %s + AND accounting_book.book_name = %s + AND fiscal_period.period_code = '2026-08' + """, + ( + self.case.tenant_id, + self.case.policy.legal_entity_reference, + self.case.policy.accounting_book_reference, + ), + ).fetchone() + self.assertIsNotNone(row) + assert row is not None + return row + + def test_open_period_role_context_without_close_lock_cannot_prepopulate_snapshot(self) -> None: + """Closing capability plus a GUC is not enough to forge open-period retained evidence.""" + with psycopg.connect(posting.DATABASE_URL) as connection: + legal_entity_id, accounting_book_id, fiscal_period_id = self._scope(connection) + connection.execute( + "SELECT set_config('accounting_core.journal_write_role', 'period_closing', true)" + ) + with self.assertRaisesRegex( + psycopg.errors.CheckViolation, + "trial_balance_snapshot_authority_required", + ): + connection.execute( + """ + INSERT INTO accounting_reporting.trial_balance_snapshot ( + tenant_account_id, + legal_entity_id, + accounting_book_id, + fiscal_period_id, + snapshot_currency_code, + source_journal_count, + source_payload_hash, + close_idempotency_key + ) + VALUES (%s, %s, %s, %s, 'KRW', 1, %s, %s) + """, + ( + self.case.tenant_id, + legal_entity_id, + accounting_book_id, + fiscal_period_id, + "sha256:" + "c" * 64, + f"{self.case.policy.tenant_reference}:open-close-race:forged", + ), + ) + + def test_incomplete_preseeded_fence_population_blocks_period_transition(self) -> None: + """A missing stripe cannot degrade freshness validation into best-effort close.""" + with psycopg.connect(posting.DATABASE_URL) as connection: + _legal_entity_id, accounting_book_id, fiscal_period_id = self._scope(connection) + fence_count = connection.execute( + """ + SELECT count(*) + FROM accounting_core.period_journal_population_fence + WHERE tenant_account_id = %s + AND accounting_book_id = %s + AND fiscal_period_id = %s + """, + (self.case.tenant_id, accounting_book_id, fiscal_period_id), + ).fetchone()[0] + self.assertEqual(fence_count, 64) + connection.execute( + """ + DELETE FROM accounting_core.period_journal_population_fence + WHERE tenant_account_id = %s + AND accounting_book_id = %s + AND fiscal_period_id = %s + AND fence_slot = 63 + """, + (self.case.tenant_id, accounting_book_id, fiscal_period_id), + ) + with self.assertRaisesRegex( + psycopg.errors.CheckViolation, + "period_journal_population_fence_missing", + ): + connection.execute( + """ + UPDATE accounting_core.accounting_book_period_control + SET period_status_code = 'soft_closed' + WHERE tenant_account_id = %s + AND accounting_book_id = %s + AND fiscal_period_id = %s + """, + (self.case.tenant_id, accounting_book_id, fiscal_period_id), + ) + connection.rollback() + def test_open_period_journal_committed_after_close_snapshot_invalidates_stale_close(self) -> None: """A stale direct hard close fails, then exact-key retry retains the admitted journal.""" close_ledger = PostgresPostingLedger( From 1683fd5f8e21e907a187bea7c239e3d30f8d0bdb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 11:20:52 +0900 Subject: [PATCH 089/224] test(posting): expose period advisory serialization --- ...test_postgres_open_period_journal_fence.py | 85 ++++++++++++++++++- 1 file changed, 84 insertions(+), 1 deletion(-) diff --git a/tests/test_postgres_open_period_journal_fence.py b/tests/test_postgres_open_period_journal_fence.py index 8f4bc70b..566b3333 100644 --- a/tests/test_postgres_open_period_journal_fence.py +++ b/tests/test_postgres_open_period_journal_fence.py @@ -4,14 +4,17 @@ import threading import unittest +import uuid +from unittest import mock import psycopg +from accounting_information_platform import PostgresPostingLedger from tests import test_postgres_posting as posting class OpenPeriodJournalFencePostgresTests(unittest.TestCase): - """Keep ordinary open-period posting off the close-control write hotspot.""" + """Keep ordinary open-period posting off close-control serialization points.""" @classmethod def setUpClass(cls) -> None: @@ -117,6 +120,86 @@ def post_journal() -> None: ) self.assertEqual(self._journal_population_revision(), 0) + def test_open_period_postings_do_not_serialize_on_application_period_lock(self) -> None: + """Two ordinary posts may overlap before either journal row is written.""" + first_ledger = PostgresPostingLedger( + posting.DATABASE_URL, tenant_reference=self.case.policy.tenant_reference + ) + second_ledger = PostgresPostingLedger( + posting.DATABASE_URL, tenant_reference=self.case.policy.tenant_reference + ) + first_period_admission_reached = threading.Event() + release_first_post = threading.Event() + second_post_finished = threading.Event() + first_errors: list[BaseException] = [] + second_errors: list[BaseException] = [] + original_require_open_period = first_ledger._require_open_book_period_bounds + + first_proposal = self.case._two_line_proposal( + proposal_id=str(uuid.uuid4()), + idempotency_key=f"{self.case.policy.tenant_reference}:open-concurrency:first", + source_payload_hash="sha256:" + "d" * 64, + source_event_references=( + f"{self.case.policy.tenant_reference}:open-concurrency:first", + ), + ) + second_proposal = self.case._two_line_proposal( + proposal_id=str(uuid.uuid4()), + idempotency_key=f"{self.case.policy.tenant_reference}:open-concurrency:second", + source_payload_hash="sha256:" + "e" * 64, + source_event_references=( + f"{self.case.policy.tenant_reference}:open-concurrency:second", + ), + ) + + def pause_first_after_period_admission(*args: object, **kwargs: object) -> object: + result = original_require_open_period(*args, **kwargs) + first_period_admission_reached.set() + if not release_first_post.wait(timeout=10): + raise AssertionError("first open posting was not released after concurrency probe") + return result + + def post_first() -> None: + try: + first_ledger.post(first_proposal, self.case.policy) + except BaseException as error: # noqa: BLE001 - thread transports exact failure + first_errors.append(error) + + def post_second() -> None: + try: + second_ledger.post(second_proposal, self.case.policy) + except BaseException as error: # noqa: BLE001 - thread transports exact failure + second_errors.append(error) + finally: + second_post_finished.set() + + with mock.patch.object( + first_ledger, + "_require_open_book_period_bounds", + side_effect=pause_first_after_period_admission, + ): + first_worker = threading.Thread(target=post_first, daemon=True) + first_worker.start() + self.assertTrue( + first_period_admission_reached.wait(timeout=10), + "first posting did not reach the deterministic pre-journal boundary", + ) + second_worker = threading.Thread(target=post_second, daemon=True) + second_worker.start() + second_progressed_while_first_was_paused = second_post_finished.wait(timeout=5) + release_first_post.set() + first_worker.join(timeout=10) + second_worker.join(timeout=10) + + self.assertFalse(first_worker.is_alive(), "first posting did not finish after release") + self.assertFalse(second_worker.is_alive(), "second posting did not finish after first release") + self.assertEqual(first_errors, []) + self.assertEqual(second_errors, []) + self.assertTrue( + second_progressed_while_first_was_paused, + "ordinary open-period postings are serialized by the application period advisory lock", + ) + if __name__ == "__main__": unittest.main() From 839e930a4f24eda1083742578894479a8ed968bf Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 11:23:05 +0900 Subject: [PATCH 090/224] test(posting): ratchet open-period advisory hotspot --- ...t_open_period_application_lock_contract.py | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 tests/test_open_period_application_lock_contract.py diff --git a/tests/test_open_period_application_lock_contract.py b/tests/test_open_period_application_lock_contract.py new file mode 100644 index 00000000..6e74ef48 --- /dev/null +++ b/tests/test_open_period_application_lock_contract.py @@ -0,0 +1,36 @@ +"""Static regression for the ordinary open-period application lock profile.""" + +from __future__ import annotations + +import unittest +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +PERSISTENCE = ROOT / "src/accounting_information_platform/persistence.py" + + +class OpenPeriodApplicationLockContractTests(unittest.TestCase): + """Keep ordinary posting from collapsing onto the close-command advisory mutex.""" + + def test_open_period_lookup_does_not_take_exclusive_close_command_lock(self) -> None: + """Database admission owns journal/transition ordering; ordinary posts need no close mutex.""" + source = PERSISTENCE.read_text(encoding="utf-8") + helper_start = source.index(" def _require_open_book_period_bounds(") + helper_end = source.index(" def _require_adjusting_period(", helper_start) + helper_source = source[helper_start:helper_end] + + self.assertNotIn( + 'self._acquire_command_lock(connection, f"period:{book_id}:{period_code}")', + helper_source, + "ordinary open-period posting still serializes on the exclusive period-close advisory lock", + ) + self.assertGreaterEqual( + helper_source.count("period_status_code"), + 2, + "removing the advisory mutex must retain the before/after open-state verification", + ) + + +if __name__ == "__main__": + unittest.main() From e32e498b9e29fc595037a51162e2b355b79dc570 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 11:23:47 +0900 Subject: [PATCH 091/224] docs(posting): expose remaining advisory serialization --- ...PERIOD_CLOSE_JOURNAL_FENCE_TRACEABILITY.md | 41 ++++++++++++------- 1 file changed, 27 insertions(+), 14 deletions(-) diff --git a/docs/doctoring/PERIOD_CLOSE_JOURNAL_FENCE_TRACEABILITY.md b/docs/doctoring/PERIOD_CLOSE_JOURNAL_FENCE_TRACEABILITY.md index c9b09e40..15a70493 100644 --- a/docs/doctoring/PERIOD_CLOSE_JOURNAL_FENCE_TRACEABILITY.md +++ b/docs/doctoring/PERIOD_CLOSE_JOURNAL_FENCE_TRACEABILITY.md @@ -4,7 +4,7 @@ This note traces the concurrency control used when an authoritative journal population approaches fiscal-period close. It does not create accounting policy, reopen a closed period, grant Billing posting authority, or make PostgreSQL locking semantics an IFRS requirement. -IAS 10 distinguishes adjusting events that provide evidence about conditions existing at the reporting-period end from non-adjusting events arising later. The product-level implication already adopted by ADR 0006/ADR 0023 is narrower: a governed close must not certify retained balances while a journal that is validly admissible for that period is concurrently being committed. IAS 10 does **not** prescribe PostgreSQL row locks, MVCC isolation, retry codes, revision counters, or striped fence rows; those are AIP implementation controls. +IAS 10 distinguishes adjusting events that provide evidence about conditions existing at the reporting-period end from non-adjusting events arising later. The product-level implication already adopted by ADR 0006/ADR 0023 is narrower: a governed close must not certify retained balances while a journal that is validly admissible for that period is concurrently being committed. IAS 10 does **not** prescribe PostgreSQL row locks, advisory locks, MVCC isolation, retry codes, revision counters, or striped fence rows; those are AIP implementation controls. ## Authoritative technical basis @@ -14,40 +14,53 @@ The current PostgreSQL documentation line is PostgreSQL 18; PostgreSQL 18.6 was ## Control mapping -| Concern | Chosen control | Rejected alternative | Executable evidence | +| Concern | Chosen / required control | Rejected alternative or current finding | Executable evidence | |---|---|---|---| -| Ordinary `open` posting throughput | `guard_period_insert()` takes `FOR SHARE` on the exact book-period control row, then increments one of 64 pre-existing journal-population fence rows selected from the journal UUID | Increment one shared book-period revision for every journal; that serializes all ordinary posting on one row | `tests/test_postgres_open_period_journal_fence.py`; migration `0033_open_period_journal_population_fence.sql` | +| Database admission for ordinary `open` journals | `guard_period_insert()` takes `FOR SHARE` on the exact book-period control row, then increments one of 64 pre-existing journal-population fence rows selected from the journal UUID | Increment one shared book-period revision for every journal; that serializes all ordinary posting on one row | `tests/test_postgres_open_period_journal_fence.py`; migration `0033_open_period_journal_population_fence.sql` | +| Application-path ordinary posting concurrency | Ordinary Billing-owned proposals must not acquire the same exclusive tenant/book/period advisory mutex used to serialize close commands; the database fence is the final journal/transition authority | **Current repair finding:** `_require_open_book_period_bounds()` still calls `_acquire_command_lock(connection, f"period:{book_id}:{period_code}")`, so otherwise independent ordinary postings in one book-period are serialized before the striped database boundary | `tests/test_postgres_open_period_journal_fence.py::test_open_period_postings_do_not_serialize_on_application_period_lock`; `tests/test_open_period_application_lock_contract.py` | | Direct `open` → close freshness | Before a period status transition commits, PostgreSQL locks all 64 pre-existing fence rows in slot order. A fence modified after the close transaction's `REPEATABLE READ` snapshot causes SQLSTATE `40001`, so stale close evidence rolls back | A shared control-row lock alone; it prevents transition overtaking an in-flight journal but carries no row version showing that a journal committed after the close snapshot | `tests/test_postgres_open_period_close_serialization_red.py` | -| Compatible open-path coordination | Open journals continue to share the control-row lock; only journals landing on the same stripe compete for the stripe UPDATE | No period fence; a state transition could certify an older journal population | `tests/test_postgres_open_period_journal_fence.py::test_open_period_posting_can_progress_while_peer_holds_share_fence` | +| Compatible database open-path coordination | Open journals share the control-row lock; only journals landing on the same stripe compete for the stripe UPDATE | Treating the database lock profile as proof of end-to-end posting parallelism while the application still holds an exclusive advisory period lock | `tests/test_postgres_open_period_journal_fence.py::test_open_period_posting_can_progress_while_peer_holds_share_fence` | | State changes while an open journal waits | Fail with SQLSTATE `40001` and retry the whole journal command from a fresh transaction | Continue using stale open-state authority after waiting | migrations `0032`/`0033`; `tests/test_trial_balance_snapshot_immutability_contract.py` | | `soft_closed` adjusting/reversal/closing journal freshness | Purpose-limited close-window journals increment `accounting_book_period_control.journal_population_revision` in the journal transaction | Advisory-lock wait only; a pre-existing repeatable-read snapshot remains stale after lock grant | `tests/test_postgres_period_close_journal_serialization_red.py` | -| Direct hard-close snapshot authority | A snapshot while the book-period is still `open` is admitted only when the session has `accounting_closing_writer` capability **and** holds the canonical tenant/resolved-book-id/period close advisory lock; a bare GUC/role cannot pre-populate open-period evidence | Require `soft_closed` for every snapshot, which breaks the published direct `open` → `hard_closed` command; or accept a bare role/GUC, which permits pre-population | migration `0033`; direct-open retry path in `tests/test_postgres_open_period_close_serialization_red.py` | -| Fence existence | 64 rows are seeded for every existing book-period before FORCE RLS is enabled, and an AFTER INSERT trigger seeds every future control row | Lazy per-journal fence creation; a stale repeatable-read snapshot could fail to see a newly created row and therefore fail to detect the concurrent journal | migration `0033`; static contract | +| Direct hard-close snapshot authority | A snapshot while the book-period is still `open` is admitted only when the session has `accounting_closing_writer` capability **and** holds the canonical tenant/resolved-book-id/period close advisory lock; a bare GUC/role cannot pre-populate open-period evidence | Require `soft_closed` for every snapshot, which breaks the published direct `open` → `hard_closed` command; or accept a bare role/GUC, which permits pre-population | migration `0033`; `tests/test_postgres_open_period_close_serialization_red.py` | +| Fence existence | 64 rows are seeded for every existing book-period before FORCE RLS is enabled, and an AFTER INSERT trigger seeds every future control row | Lazy per-journal fence creation; a stale repeatable-read snapshot could fail to see a newly created row and therefore fail to detect the concurrent journal | migration `0033`; installer/static/PostgreSQL contracts | | Capability/security | Fence and guard functions are `SECURITY DEFINER`, fix `search_path = pg_catalog, pg_temp`, revoke PUBLIC execute, and the fence table is tenant RLS/FORCE RLS protected | Caller GUC alone as authorization or an unscoped shared fence | migration `0033`; static contract | ## Why migration 0032 alone was insufficient Migration 0032 corrected the first stale-close race for `soft_closed` journals without preserving the rejected all-journal single-row UPDATE. Its `open` path deliberately left `journal_population_revision` unchanged and held only `FOR SHARE` on `accounting_book_period_control`. -That lock prevents a status UPDATE from overtaking a journal transaction that is still in flight, but it does not record that the journal committed after a close transaction established its `REPEATABLE READ` snapshot. A close can therefore establish an old snapshot, wait for an open journal's shared control-row lock, acquire the unchanged control row after the journal commits, and continue deriving close evidence from the older snapshot. Migration 0033 supplies a bounded, pre-existing row-version witness without restoring one global write hotspot. +That row lock is not itself a freshness witness. A journal path that does not share the close advisory mutex can commit after a close transaction establishes its `REPEATABLE READ` snapshot. The close may then continue with an older journal population unless a pre-existing row version exposes that commit to PostgreSQL's repeatable-read conflict detection. The direct-open regression uses the AIS adjusting-journal path for exactly this reason: that command has its own idempotency mutex and can legitimately write while the book-period is `open`; it does **not** acquire the ordinary Billing proposal's tenant/book/period advisory lock. Migration 0033 supplies a bounded, pre-existing row-version witness for that database-authority race. -The 64-slot count is an engineering trade-off, not an accounting standard and not a performance result. It bounds fence-row fan-out during the low-frequency state transition while reducing expected ordinary-post collisions relative to one shared row. Exact-head load testing still has to measure collision rate, lock waits, WAL/write cost, and buyer-path p95; the slot count must be changed only from measured evidence. +The 64-slot count is an engineering trade-off, not an accounting standard and not a performance result. It bounds fence-row fan-out during the low-frequency state transition while reducing expected ordinary-post collisions relative to one shared revision row. Exact-head load testing still has to measure collision rate, lock waits, WAL/write cost, and buyer-path p95; the slot count must be changed only from measured evidence. + +## Remaining application serialization finding + +The database repair does not yet remove every high-volume serialization point. `PostgresPostingLedger._require_open_book_period_bounds()` still acquires the canonical exclusive advisory lock `period:{book_id}:{period_code}` for every ordinary proposal. `close_fiscal_period()` deliberately uses the same identity to serialize close commands. Consequently two unrelated Billing proposals for the same open book-period can queue at the application boundary before either reaches the 64-stripe database fence. + +This is a separate defect from the stale-close correctness race. The source repair must remove that close-command advisory mutex from the ordinary open-posting helper while preserving both open-state checks. The database `FOR SHARE` + striped revision boundary then owns journal-versus-transition ordering, while the period advisory lock remains for close-command serialization. The repair must not weaken idempotency locks, snapshot authority, role checks, or the database transition fence. + +Real-PostgreSQL RED `1683fd5f8e21e907a187bea7c239e3d30f8d0bdb` pauses one ordinary proposal after open-period admission but before journal persistence, then requires a second ordinary proposal to complete before the first is released. Current source is expected to fail that contract because both commands take the same exclusive period advisory mutex. Static RED `839e930a4f24eda1083742578894479a8ed968bf` pins the causal source requirement directly. These REDs are not GREEN until the production helper is changed and exact-head PostgreSQL execution proves the overlap. ## TDD and repair lineage - Soft-close journal / stale hard-close RED: `306f4c14212a0dfbb89a6934bbb493b1e179479e`. - First freshness candidate through `84f5666aa48ba565fb2e4ff763bb0b3ee27fe857` updated the control-row revision for every journal. -- Single-row open-period hotspot RED: `6fe1fdeb1050111b26e557810dbf66b05f75871a`. -- Split control-row repair: `7a979845896869ef0e7fabab710c7a4f3a9863de`; compatible-lock regression `2bdb09a6e7da1444a2356d94ae7fde16d9d40686`. -- Direct open-period stale-close RED: `70a9b196da23fc0cbedd9ceafa806710794f13e3`. +- Single-row database hotspot RED: `6fe1fdeb1050111b26e557810dbf66b05f75871a`. +- Split control-row repair: `7a979845896869ef0e7fabab710c7a4f3a9863de`; compatible-row-lock regression `2bdb09a6e7da1444a2356d94ae7fde16d9d40686`. +- Direct open-period stale-close RED using the adjusting-journal path: `70a9b196da23fc0cbedd9ceafa806710794f13e3`. - Initial 64-stripe database repair: `ac3a2a7eac2929e3ff76908d9bb64a4a38acb7dd`; canonical installer inclusion: `7cafddd91affeb0166956a96c260a8c17f06ac42`; static contract: `7279c9a45cb6e515a9e88b0171fb8390c823ccba`. -- Migration self-review moved the cross-tenant backfill before FORCE RLS so a non-superuser schema owner is not forced to borrow one runtime tenant scope while seeding all existing book-periods: `a6d32fc35f6f0f48fbcec6b08fa16b1a89eb5f80`. +- Migration self-review moved the cross-tenant backfill before FORCE RLS: `a6d32fc35f6f0f48fbcec6b08fa16b1a89eb5f80`. +- Direct-open authority/fence-completeness PostgreSQL cases: `35c76f4dfda3b2d299b82eb28e06a9c2a9a6ba49`. +- End-to-end application advisory serialization RED: `1683fd5f8e21e907a187bea7c239e3d30f8d0bdb`; causal source ratchet: `839e930a4f24eda1083742578894479a8ed968bf`. -These commits are development evidence, not protected-head release evidence. The direct-open RED was added before the causal repair, but queued GitHub runners have not provided an observed RED/GREEN transition for the current lineage. Exact-head PostgreSQL CI, security/SAST/dependency evidence, independent review, central workflow gates, package/SBOM/provenance, migration/recovery verification, and measured performance must be reacquired after every head change. +These commits are development evidence, not protected-head release evidence. The REDs were committed before their respective causal repairs, but queued GitHub runners have not supplied an observed RED/GREEN transition for the current application-lock finding. Exact-head PostgreSQL CI, security/SAST/dependency evidence, independent review, central workflow gates, package/SBOM/provenance, migration/recovery verification, and measured performance must be reacquired after every head change. ## Residual risk and release acceptance -Striping removes the deliberate single-row write hotspot but does not make row versioning free. PostgreSQL notes that row locking can cause writes, and same-slot journals still serialize on the selected stripe. Release acceptance therefore requires measured concurrent posting plus period-transition load at the exact candidate head, reporting lock waits, stripe collision distribution, retries, and tail latency rather than hiding them with cache warm-up, reduced samples, or excluded failures. +Migration 0033 removes the deliberate **database** single-row revision hotspot but does not make end-to-end posting concurrent while the application period advisory mutex remains. That source finding is therefore release-blocking for the stated hot-path goal and must not be hidden behind database-only lock evidence. + +After the source repair, striping still has a cost. PostgreSQL notes that row locking can cause writes, and same-slot journals can still serialize on the selected stripe. Release acceptance therefore requires measured concurrent posting plus period-transition load at the exact candidate head, reporting advisory-lock waits, row-lock waits, stripe collision distribution, retries, WAL/write cost, and tail latency rather than hiding them with cache warm-up, reduced samples, or excluded failures. A `40001` result is not accounting evidence. The caller must retry the complete command with the same immutable source-payload identity/idempotency contract. A failed stale close must leave no retained snapshot, closing journal, close event, or authoritative state transition. Recovery must never rewrite posted journals, retained trial-balance evidence, or reconciliation authority rows to make a failed close appear successful. From e58828907d3880fe6b7fb3b72aac14bca169398a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 11:24:31 +0900 Subject: [PATCH 092/224] docs(posting): record advisory-lock repair prerequisite --- docs/adr/0006-fiscal-period-close-snapshot.md | 38 ++++++++++++------- 1 file changed, 25 insertions(+), 13 deletions(-) diff --git a/docs/adr/0006-fiscal-period-close-snapshot.md b/docs/adr/0006-fiscal-period-close-snapshot.md index b946079a..e0a84e74 100644 --- a/docs/adr/0006-fiscal-period-close-snapshot.md +++ b/docs/adr/0006-fiscal-period-close-snapshot.md @@ -34,40 +34,50 @@ The canonical hard-close advisory key is `hashtext(tenant_reference)` plus `hash Migration `0033_open_period_journal_population_fence.sql` preserves the purpose-limited snapshot writer while restoring the supported direct `open` → `hard_closed` path. A snapshot created while the book-period is still `open` requires `accounting_closing_writer` capability **and** the exact tenant/resolved-book/period close advisory lock; a bare role plus `accounting_core.journal_write_role` cannot pre-populate open-period retained evidence. While `soft_closed`, the existing purpose-limited `period_closing` command context or the canonical close lock remains sufficient together with role membership. -### Journal-population freshness without a single-row posting hotspot +### Journal-population freshness without a single database-row hotspot `close_fiscal_period` uses `REPEATABLE READ`. A close transaction can therefore hold an MVCC snapshot that predates a journal committed while close is waiting on period authority. Waiting for a lock does not refresh that snapshot. -Migration `0032_period_close_journal_population_fence.sql` first split the control profile: +Migration `0032_period_close_journal_population_fence.sql` first split the database control profile: - an `open` journal takes `SELECT ... FOR SHARE` on the exact book-period control row so a state transition cannot overtake a journal already admitted as open; - a purpose-limited `soft_closed` period-closing/adjusting/reversal journal increments `journal_population_revision` on that control row, so a stale hard close that later locks the row fails with SQLSTATE `40001`. -Review then exposed a remaining direct-open race. The open journal changed no row visible to a stale close. After waiting for the shared control-row lock, the close could acquire the unchanged row and continue from its older MVCC snapshot. +Review then exposed a direct-open race for journal commands that do not share the close advisory mutex. The direct-open regression uses the AIS adjusting-journal path: it has its own idempotency mutex and may legitimately write while the book-period is `open`, but it does not acquire the ordinary Billing proposal's tenant/book/period advisory lock. A close can therefore establish an older repeatable-read snapshot before that journal commits. -Migration `0033_open_period_journal_population_fence.sql` adds a bounded row-version witness without restoring one global write hotspot: +Migration `0033_open_period_journal_population_fence.sql` adds a bounded row-version witness: - every book-period owns exactly 64 pre-existing `period_journal_population_fence` rows; migration backfill creates them before FORCE RLS is enabled, and an AFTER INSERT trigger seeds future book-period controls; -- after confirming `open` under `FOR SHARE`, each journal increments exactly one fence row selected from its database journal UUID; two journals contend only when they choose the same slot; +- after confirming `open` under `FOR SHARE`, each journal increments exactly one fence row selected from its database journal UUID; - before `period_status_code` changes, a transition trigger locks all 64 rows in deterministic slot order with `FOR UPDATE` and requires the complete population; - if any fence row was committed after the close transaction's repeatable-read snapshot, PostgreSQL raises serialization failure instead of allowing the stale transition to commit; -- if close owns the control row first, a later open journal waits and then cannot retain stale open-state admission after the transition. +- if the transition owns the control row first, a later journal cannot retain stale open-state admission after waiting. -The 64-slot count is a measured-performance hypothesis, not accounting policy and not an IFRS requirement. It bounds the low-frequency transition fan-out while reducing expected ordinary-post collisions relative to one shared row. Exact-head load tests must still measure slot collisions, lock waits, WAL/write cost, retry rate, and buyer-path p95. A future slot-count change requires measured evidence and a migration-compatible design. +The 64-slot count is an engineering hypothesis, not accounting policy and not an IFRS requirement. Exact-head load tests must measure slot collisions, row-lock waits, WAL/write cost, retry rate, and buyer-path p95. A future slot-count change requires measured evidence and a migration-compatible design. A serialization failure is not accounting evidence. The entire transaction rolls back and the command retries from the beginning with the same immutable source-payload identity and idempotency key. No failed close may leave a retained snapshot, period-closing journal, close event, or authoritative period transition. -`tests/test_postgres_period_close_journal_serialization_red.py` exercises a `soft_closed` adjustment racing hard close. `tests/test_postgres_open_period_close_serialization_red.py` exercises a journal committed after a direct close snapshot but before the close acquires the period row, then requires exact-key retry to retain the live population. `tests/test_postgres_open_period_journal_fence.py` protects ordinary open-path concurrency, and `tests/test_trial_balance_snapshot_immutability_contract.py` ratchets the migration/security shape. +### Remaining application advisory-lock hotspot + +The striped database fence is not yet an end-to-end posting-concurrency GREEN. `PostgresPostingLedger._require_open_book_period_bounds()` still acquires the same exclusive tenant/resolved-book/period advisory lock used by `close_fiscal_period()` for every ordinary Billing proposal. That makes unrelated ordinary postings for one open book-period queue before either reaches the striped database boundary. + +This application mutex is no longer selected as ordinary journal-versus-transition authority. The intended causal repair is to remove the `period:{book_id}:{period_code}` advisory acquisition from `_require_open_book_period_bounds()` while retaining its before/after open-state verification. Proposal idempotency locks remain. Close commands retain the canonical period advisory lock. PostgreSQL `FOR SHARE` plus the pre-existing striped witness remains the authoritative journal-versus-state-transition fence. + +`tests/test_postgres_open_period_journal_fence.py::test_open_period_postings_do_not_serialize_on_application_period_lock` is a real-PostgreSQL RED for this hotspot: it pauses one ordinary proposal after period admission but before journal persistence and requires another ordinary proposal to complete before the first resumes. `tests/test_open_period_application_lock_contract.py` pins the exact source boundary. Until production source changes and both tests are exact-head GREEN, the branch must not claim end-to-end open-post concurrency or the p95 target. + +`tests/test_postgres_period_close_journal_serialization_red.py` exercises a `soft_closed` adjustment racing hard close. `tests/test_postgres_open_period_close_serialization_red.py` exercises an open-period adjusting journal committed after a direct-close snapshot, then requires stale-close rollback and exact-key retry to retain the live population. The latter remains a valid correctness case because the adjusting command does not take the ordinary proposal's book-period advisory mutex. ## Alternatives considered -Updating one `journal_population_revision` for every admitted journal was rejected because it makes one book-period row the exclusive write point for all ordinary posting. It preserves freshness but violates the platform's hot-partition/lock and latency goals. +Updating one `journal_population_revision` for every admitted journal was rejected because it makes one book-period row the exclusive write point for all ordinary posting. + +Using only the control-row `FOR SHARE`/`FOR UPDATE` protocol was rejected because it orders transaction completion around the state change but supplies no row version proving that a journal committed after a pre-existing repeatable-read snapshot. -Using only the control-row `FOR SHARE`/`FOR UPDATE` protocol was rejected after the direct-open race review. It orders transaction completion around the state change but supplies no row version proving that an open journal committed after a pre-existing repeatable-read snapshot. +Using the canonical close advisory mutex for every ordinary proposal is now rejected as a throughput strategy. It serializes independent postings at application scope and duplicates ordering already owned by the database admission/transition fence. The mutex remains appropriate for close-command serialization and close-snapshot authority. -Relying only on the period advisory lock was rejected because a waiting repeatable-read close can retain the snapshot established before lock grant. Advisory-lock ownership and MVCC freshness are separate facts. +Relying only on advisory-lock wait for freshness was rejected because a waiting repeatable-read transaction retains the snapshot established before lock grant. Advisory-lock ownership and MVCC freshness are separate facts. -A session-lock-before-snapshot protocol was considered because reconciliation lifecycle already uses a committed session-lease pattern. It was not selected here because the required journal admission coordination can remain a database-owned book-period invariant without extending application-session lock lifetime across transaction boundaries. The striped witness also keeps correctness at the SQL boundary for purpose-limited writers. This decision can be revisited if measured stripe contention or transition fan-out is unacceptable. +A session-lock-before-snapshot protocol was considered because reconciliation lifecycle already uses a committed session-lease pattern. It was not selected here because journal admission coordination can remain a database-owned book-period invariant without extending application-session lock lifetime across transaction boundaries. This can be revisited if measured stripe contention or transition fan-out is unacceptable. Lazy creation of a fence row during journal admission was rejected because a repeatable-read close whose snapshot predates that INSERT can fail to see the new row. The complete fence population must exist before any journal/close race. @@ -77,7 +87,9 @@ Allowing caller-provided close timestamps, currencies, aggregate identifiers, or ## Consequences and operational evidence -Open-period posting now performs a shared control-row lock plus one striped revision UPDATE rather than one exclusive UPDATE on the common book-period row. This removes the deliberate single-row hotspot but does not prove the p95 ≤ 20 ms buyer target. PostgreSQL row locking can itself cause writes, and same-slot journals can still queue. Release evidence must therefore use realistic concurrent posting and transition workloads and report failures, retry rates, lock waits, stripe distribution, and tail latency without sample reduction or excluded errors. +At the database boundary, open-period posting performs a shared control-row lock plus one striped revision UPDATE rather than one exclusive UPDATE on the common book-period row. This removes the deliberate database single-row hotspot. The current application advisory mutex still serializes ordinary proposals and is a release-blocking repair finding for the stated hot-path goal. + +After that source repair, PostgreSQL row locking and same-slot collisions still have measurable cost. Release evidence must use realistic concurrent posting and transition workloads and report advisory-lock waits, row-lock waits, stripe distribution, failures, retry rates, WAL/write cost, and tail latency without sample reduction, excluded failures, or artificial cache warm-up. Migration 0033 creates a new tenant-scoped table under RLS/FORCE RLS. The cross-tenant migration backfill occurs before FORCE RLS is enabled so a non-superuser schema owner can seed every existing book-period without impersonating one runtime tenant. Future rows are seeded from the book-period-control INSERT transaction and stay in that tenant scope. Guard functions are `SECURITY DEFINER`, use `search_path = pg_catalog, pg_temp`, and revoke PUBLIC execute. From e5f40ca368a60394d0975d75baf249edfe876552 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 11:43:36 +0900 Subject: [PATCH 093/224] test(close): require control seed for every book period --- tests/test_book_period_control_seed.py | 99 ++++++++++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 tests/test_book_period_control_seed.py diff --git a/tests/test_book_period_control_seed.py b/tests/test_book_period_control_seed.py new file mode 100644 index 00000000..849b3ea9 --- /dev/null +++ b/tests/test_book_period_control_seed.py @@ -0,0 +1,99 @@ +"""Real PostgreSQL regressions for book-period control and freshness-fence seeding.""" + +from __future__ import annotations + +import unittest +import uuid +from datetime import date + +import psycopg + +from tests import test_postgres_posting as posting + + +class BookPeriodControlSeedTests(unittest.TestCase): + """Require every active book-period pair to exist before journals can be admitted.""" + + @classmethod + def setUpClass(cls) -> None: + """Install the complete current migration chain into the PostgreSQL fixture.""" + posting.PostgresPostingTests.setUpClass() + + def setUp(self) -> None: + """Seed one isolated tenant whose period and book are created after migration install.""" + self.case = posting.PostgresPostingTests("setUp") + self.case.setUp() + self.addCleanup(self.case.doCleanups) + self.addCleanup(self.case.tearDown) + + def test_seeded_book_and_existing_period_have_control_and_all_fences(self) -> None: + """Creating an active book after its period must materialize close authority immediately.""" + control_count, fence_count = self._control_and_fence_counts("2026-08") + + self.assertEqual(control_count, 1) + self.assertEqual(fence_count, 64) + + def test_period_open_seeds_control_and_all_fences_for_existing_book(self) -> None: + """Opening a later period must materialize control and freshness rows for active books.""" + period_code = "2026-09" + self.case.ledger.open_fiscal_period( + self.case.policy.legal_entity_reference, + period_code, + date(2026, 9, 1), + date(2026, 9, 30), + idempotency_key=f"period-open:{uuid.uuid4()}", + source_payload_hash="sha256:" + "7" * 64, + ) + + control_count, fence_count = self._control_and_fence_counts(period_code) + + self.assertEqual(control_count, 1) + self.assertEqual(fence_count, 64) + + def _control_and_fence_counts(self, period_code: str) -> tuple[int, int]: + """Return retained control and stripe cardinality for this fixture's primary book-period.""" + with psycopg.connect(posting.DATABASE_URL) as connection: + book_id = connection.execute( + """ + SELECT accounting_book_id + FROM accounting_core.accounting_book + WHERE tenant_account_id = %s + AND book_name = %s + AND valid_to IS NULL + """, + (self.case.tenant_id, self.case.policy.accounting_book_reference), + ).fetchone()[0] + period_id = connection.execute( + """ + SELECT fiscal_period_id + FROM accounting_core.fiscal_period + WHERE tenant_account_id = %s + AND period_code = %s + """, + (self.case.tenant_id, period_code), + ).fetchone()[0] + control_count = connection.execute( + """ + SELECT COUNT(*) + FROM accounting_core.accounting_book_period_control + WHERE tenant_account_id = %s + AND accounting_book_id = %s + AND fiscal_period_id = %s + """, + (self.case.tenant_id, book_id, period_id), + ).fetchone()[0] + fence_count = connection.execute( + """ + SELECT COUNT(*) + FROM accounting_core.period_journal_population_fence + WHERE tenant_account_id = %s + AND accounting_book_id = %s + AND fiscal_period_id = %s + """, + (self.case.tenant_id, book_id, period_id), + ).fetchone()[0] + return int(control_count), int(fence_count) + + +if __name__ == "__main__": + unittest.main() From e22c2a6d9ad945eba986ec81f599cbd7dea60392 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 11:44:27 +0900 Subject: [PATCH 094/224] fix(close): seed book-period control for future master data --- .../0034_book_period_control_seed.sql | 118 ++++++++++++++++++ 1 file changed, 118 insertions(+) create mode 100644 database/migrations/0034_book_period_control_seed.sql diff --git a/database/migrations/0034_book_period_control_seed.sql b/database/migrations/0034_book_period_control_seed.sql new file mode 100644 index 00000000..7f7120af --- /dev/null +++ b/database/migrations/0034_book_period_control_seed.sql @@ -0,0 +1,118 @@ +BEGIN; + +-- Migration 0033 requires the tenant/book/period authority row and all 64 +-- journal-population fence rows to exist before ordinary posting or close +-- evaluation. 0009 backfilled only rows that existed at migration time, so +-- later fiscal periods or accounting books could otherwise reach the journal +-- guard without a materialized book-period authority. +CREATE OR REPLACE FUNCTION accounting_core.seed_book_period_control_for_period() +RETURNS trigger +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = pg_catalog, pg_temp +AS $$ +BEGIN + INSERT INTO accounting_core.accounting_book_period_control ( + tenant_account_id, + accounting_book_id, + fiscal_period_id, + period_status_code, + period_closed_at + ) + SELECT NEW.tenant_account_id, + accounting_book.accounting_book_id, + NEW.fiscal_period_id, + NEW.period_status_code, + NEW.period_closed_at + FROM accounting_core.accounting_book + WHERE accounting_book.tenant_account_id = NEW.tenant_account_id + AND accounting_book.valid_to IS NULL + ON CONFLICT ( + tenant_account_id, + accounting_book_id, + fiscal_period_id + ) DO NOTHING; + + RETURN NEW; +END; +$$; + +REVOKE ALL ON FUNCTION accounting_core.seed_book_period_control_for_period() + FROM PUBLIC; + +CREATE TRIGGER book_period_control_seed_for_period + AFTER INSERT + ON accounting_core.fiscal_period + FOR EACH ROW + EXECUTE FUNCTION accounting_core.seed_book_period_control_for_period(); + +CREATE OR REPLACE FUNCTION accounting_core.seed_book_period_control_for_book() +RETURNS trigger +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = pg_catalog, pg_temp +AS $$ +BEGIN + IF NEW.valid_to IS NOT NULL THEN + RETURN NEW; + END IF; + + INSERT INTO accounting_core.accounting_book_period_control ( + tenant_account_id, + accounting_book_id, + fiscal_period_id, + period_status_code, + period_closed_at + ) + SELECT NEW.tenant_account_id, + NEW.accounting_book_id, + fiscal_period.fiscal_period_id, + fiscal_period.period_status_code, + fiscal_period.period_closed_at + FROM accounting_core.fiscal_period + WHERE fiscal_period.tenant_account_id = NEW.tenant_account_id + ON CONFLICT ( + tenant_account_id, + accounting_book_id, + fiscal_period_id + ) DO NOTHING; + + RETURN NEW; +END; +$$; + +REVOKE ALL ON FUNCTION accounting_core.seed_book_period_control_for_book() + FROM PUBLIC; + +CREATE TRIGGER book_period_control_seed_for_book + AFTER INSERT + ON accounting_core.accounting_book + FOR EACH ROW + EXECUTE FUNCTION accounting_core.seed_book_period_control_for_book(); + +-- Repair databases that installed 0009 before later master-data rows existed. +-- Each inserted control row synchronously fires migration 0033's fence seeder, +-- so no post-migration active book-period pair can lack its pre-existing stripes. +INSERT INTO accounting_core.accounting_book_period_control ( + tenant_account_id, + accounting_book_id, + fiscal_period_id, + period_status_code, + period_closed_at +) +SELECT accounting_book.tenant_account_id, + accounting_book.accounting_book_id, + fiscal_period.fiscal_period_id, + fiscal_period.period_status_code, + fiscal_period.period_closed_at +FROM accounting_core.accounting_book +JOIN accounting_core.fiscal_period + ON fiscal_period.tenant_account_id = accounting_book.tenant_account_id +WHERE accounting_book.valid_to IS NULL +ON CONFLICT ( + tenant_account_id, + accounting_book_id, + fiscal_period_id +) DO NOTHING; + +COMMIT; From 0d619c27cc2a0f56d501ed33fe50ed8746f4f2e9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 11:45:17 +0900 Subject: [PATCH 095/224] fix(close): install book-period control seed migration --- src/accounting_information_platform/migration_install.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/accounting_information_platform/migration_install.py b/src/accounting_information_platform/migration_install.py index afbf87fc..825198fc 100644 --- a/src/accounting_information_platform/migration_install.py +++ b/src/accounting_information_platform/migration_install.py @@ -30,6 +30,7 @@ def apply_foundation_migration(database_url: str, migration_path: Path) -> None: migration_path.parent / "0031_trial_balance_line_conservation_validation.sql", migration_path.parent / "0032_period_close_journal_population_fence.sql", migration_path.parent / "0033_open_period_journal_population_fence.sql", + migration_path.parent / "0034_book_period_control_seed.sql", ) for forward_migration_path in forward_migration_paths: if not forward_migration_path.is_file(): From 3d80b4de182862aba3efb4cedafa2ca44abca3a6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 11:48:21 +0900 Subject: [PATCH 096/224] docs(close): trace post-install book-period authority seeding --- .../PERIOD_CLOSE_JOURNAL_FENCE_TRACEABILITY.md | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/docs/doctoring/PERIOD_CLOSE_JOURNAL_FENCE_TRACEABILITY.md b/docs/doctoring/PERIOD_CLOSE_JOURNAL_FENCE_TRACEABILITY.md index 15a70493..8cd23a94 100644 --- a/docs/doctoring/PERIOD_CLOSE_JOURNAL_FENCE_TRACEABILITY.md +++ b/docs/doctoring/PERIOD_CLOSE_JOURNAL_FENCE_TRACEABILITY.md @@ -23,7 +23,8 @@ The current PostgreSQL documentation line is PostgreSQL 18; PostgreSQL 18.6 was | State changes while an open journal waits | Fail with SQLSTATE `40001` and retry the whole journal command from a fresh transaction | Continue using stale open-state authority after waiting | migrations `0032`/`0033`; `tests/test_trial_balance_snapshot_immutability_contract.py` | | `soft_closed` adjusting/reversal/closing journal freshness | Purpose-limited close-window journals increment `accounting_book_period_control.journal_population_revision` in the journal transaction | Advisory-lock wait only; a pre-existing repeatable-read snapshot remains stale after lock grant | `tests/test_postgres_period_close_journal_serialization_red.py` | | Direct hard-close snapshot authority | A snapshot while the book-period is still `open` is admitted only when the session has `accounting_closing_writer` capability **and** holds the canonical tenant/resolved-book-id/period close advisory lock; a bare GUC/role cannot pre-populate open-period evidence | Require `soft_closed` for every snapshot, which breaks the published direct `open` → `hard_closed` command; or accept a bare role/GUC, which permits pre-population | migration `0033`; `tests/test_postgres_open_period_close_serialization_red.py` | -| Fence existence | 64 rows are seeded for every existing book-period before FORCE RLS is enabled, and an AFTER INSERT trigger seeds every future control row | Lazy per-journal fence creation; a stale repeatable-read snapshot could fail to see a newly created row and therefore fail to detect the concurrent journal | migration `0033`; installer/static/PostgreSQL contracts | +| Book-period authority existence | Every active book-period pair must have one `accounting_book_period_control` row before posting or close evaluation. Migration 0034 backfills missing pairs and seeds them transactionally when either a new fiscal period or a new active accounting book is inserted | Treat migration 0009's one-time backfill as sufficient. It leaves post-install master data without a control row, so migration 0033 correctly fails closed but ordinary posting becomes unavailable | migration `0034_book_period_control_seed.sql`; `tests/test_book_period_control_seed.py` | +| Fence existence | Migration 0033 seeds 64 rows for every control row, and migration 0034 guarantees that future active book-period pairs create that control row before journal admission | Lazy per-journal fence creation; a stale repeatable-read snapshot could fail to see a newly created row and therefore fail to detect the concurrent journal | migrations `0033`/`0034`; `tests/test_book_period_control_seed.py`; installer/static/PostgreSQL contracts | | Capability/security | Fence and guard functions are `SECURITY DEFINER`, fix `search_path = pg_catalog, pg_temp`, revoke PUBLIC execute, and the fence table is tenant RLS/FORCE RLS protected | Caller GUC alone as authorization or an unscoped shared fence | migration `0033`; static contract | ## Why migration 0032 alone was insufficient @@ -34,6 +35,14 @@ That row lock is not itself a freshness witness. A journal path that does not sh The 64-slot count is an engineering trade-off, not an accounting standard and not a performance result. It bounds fence-row fan-out during the low-frequency state transition while reducing expected ordinary-post collisions relative to one shared revision row. Exact-head load testing still has to measure collision rate, lock waits, WAL/write cost, and buyer-path p95; the slot count must be changed only from measured evidence. +## Why migration 0034 is required + +Migration 0009 created `accounting_book_period_control` and backfilled only the accounting books and fiscal periods that existed at installation time. Migration 0033 correctly requires that control row and its complete 64-row fence population to pre-exist before journal admission; it must not lazily create a witness after a close transaction may already have established its snapshot. + +That combination exposed a lifecycle gap for master data created after migration installation. A later `fiscal_period` could be opened while active books already existed, or a later active `accounting_book` could be created after fiscal periods existed. In either order there was no database command that materialized the Cartesian book-period control pair before posting. The journal guard would therefore fail closed with missing period-control authority even though the business period was legitimately open. + +Migration 0034 keeps ownership in the database control boundary. An `AFTER INSERT` trigger on `fiscal_period` creates controls for all active books of the same tenant; a complementary `AFTER INSERT` trigger on `accounting_book` creates controls for all existing periods when the new book is active. Both use conflict-safe inserts. Each newly inserted control row synchronously invokes migration 0033's fence seeder, so all 64 stripes exist in the same transaction before the new master-data row becomes visible. The migration also backfills any active book-period pairs missed by earlier installation order. No journal amount, accounting policy, or foreign commercial truth is derived by these seeders. + ## Remaining application serialization finding The database repair does not yet remove every high-volume serialization point. `PostgresPostingLedger._require_open_book_period_bounds()` still acquires the canonical exclusive advisory lock `period:{book_id}:{period_code}` for every ordinary proposal. `close_fiscal_period()` deliberately uses the same identity to serialize close commands. Consequently two unrelated Billing proposals for the same open book-period can queue at the application boundary before either reaches the 64-stripe database fence. @@ -53,12 +62,13 @@ Real-PostgreSQL RED `1683fd5f8e21e907a187bea7c239e3d30f8d0bdb` pauses one ordina - Migration self-review moved the cross-tenant backfill before FORCE RLS: `a6d32fc35f6f0f48fbcec6b08fa16b1a89eb5f80`. - Direct-open authority/fence-completeness PostgreSQL cases: `35c76f4dfda3b2d299b82eb28e06a9c2a9a6ba49`. - End-to-end application advisory serialization RED: `1683fd5f8e21e907a187bea7c239e3d30f8d0bdb`; causal source ratchet: `839e930a4f24eda1083742578894479a8ed968bf`. +- Post-install master-data control/fence RED: `e5f40ca368a60394d0975d75baf249edfe876552`; database-owned dual-side seeding repair: `e22c2a6d9ad945eba986ec81f599cbd7dea60392`; canonical installer inclusion: `0d619c27cc2a0f56d501ed33fe50ed8746f4f2e9`. -These commits are development evidence, not protected-head release evidence. The REDs were committed before their respective causal repairs, but queued GitHub runners have not supplied an observed RED/GREEN transition for the current application-lock finding. Exact-head PostgreSQL CI, security/SAST/dependency evidence, independent review, central workflow gates, package/SBOM/provenance, migration/recovery verification, and measured performance must be reacquired after every head change. +These commits are development evidence, not protected-head release evidence. The REDs were committed before their respective causal repairs, but queued GitHub runners have not supplied an observed RED/GREEN transition for the current application-lock finding or the new book-period seeding repair. Exact-head PostgreSQL CI, security/SAST/dependency evidence, independent review, central workflow gates, package/SBOM/provenance, migration/recovery verification, and measured performance must be reacquired after every head change. ## Residual risk and release acceptance -Migration 0033 removes the deliberate **database** single-row revision hotspot but does not make end-to-end posting concurrent while the application period advisory mutex remains. That source finding is therefore release-blocking for the stated hot-path goal and must not be hidden behind database-only lock evidence. +Migrations 0033/0034 remove the deliberate **database** single-row revision hotspot and ensure its authority rows exist for post-install master data, but they do not make end-to-end posting concurrent while the application period advisory mutex remains. That source finding is therefore release-blocking for the stated hot-path goal and must not be hidden behind database-only lock evidence. After the source repair, striping still has a cost. PostgreSQL notes that row locking can cause writes, and same-slot journals can still serialize on the selected stripe. Release acceptance therefore requires measured concurrent posting plus period-transition load at the exact candidate head, reporting advisory-lock waits, row-lock waits, stripe collision distribution, retries, WAL/write cost, and tail latency rather than hiding them with cache warm-up, reduced samples, or excluded failures. From e7378d800755a750306604fba1a24dff2cf48646 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 11:49:05 +0900 Subject: [PATCH 097/224] test(close): ratchet future book-period authority seeding --- .../test_book_period_control_seed_contract.py | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 tests/test_book_period_control_seed_contract.py diff --git a/tests/test_book_period_control_seed_contract.py b/tests/test_book_period_control_seed_contract.py new file mode 100644 index 00000000..1df6cf39 --- /dev/null +++ b/tests/test_book_period_control_seed_contract.py @@ -0,0 +1,54 @@ +"""Static contracts for post-install book-period authority seeding.""" + +from __future__ import annotations + +import unittest +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +MIGRATION = ROOT / "database/migrations/0034_book_period_control_seed.sql" +INSTALLER = ROOT / "src/accounting_information_platform/migration_install.py" + + +class BookPeriodControlSeedContractTests(unittest.TestCase): + """Keep future master data on the same database-owned period authority boundary.""" + + def test_period_and_book_creation_both_seed_controls(self) -> None: + """Either master-data creation order must materialize the book-period pair.""" + source = MIGRATION.read_text(encoding="utf-8") + + self.assertIn("seed_book_period_control_for_period", source) + self.assertIn("AFTER INSERT\n ON accounting_core.fiscal_period", source) + self.assertIn("seed_book_period_control_for_book", source) + self.assertIn("AFTER INSERT\n ON accounting_core.accounting_book", source) + self.assertGreaterEqual( + source.count("INSERT INTO accounting_core.accounting_book_period_control"), + 3, + ) + self.assertGreaterEqual(source.count("ON CONFLICT"), 3) + + def test_trigger_functions_use_hardened_execution_context(self) -> None: + """Master-data triggers must not inherit caller-controlled object resolution.""" + source = MIGRATION.read_text(encoding="utf-8") + + self.assertEqual(source.count("SECURITY DEFINER"), 2) + self.assertEqual(source.count("SET search_path = pg_catalog, pg_temp"), 2) + self.assertIn( + "REVOKE ALL ON FUNCTION accounting_core.seed_book_period_control_for_period()", + source, + ) + self.assertIn( + "REVOKE ALL ON FUNCTION accounting_core.seed_book_period_control_for_book()", + source, + ) + + def test_canonical_installer_includes_seed_migration(self) -> None: + """Supported foundation installs cannot stop before future-pair seeding exists.""" + source = INSTALLER.read_text(encoding="utf-8") + + self.assertIn('"0034_book_period_control_seed.sql"', source) + + +if __name__ == "__main__": + unittest.main() From 800716a2b44370e41b0a5e65d86d4e30d1008765 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 12:04:00 +0900 Subject: [PATCH 098/224] test(close): require owner-safe book-period backfill --- .../test_book_period_control_seed_contract.py | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/tests/test_book_period_control_seed_contract.py b/tests/test_book_period_control_seed_contract.py index 1df6cf39..3777df11 100644 --- a/tests/test_book_period_control_seed_contract.py +++ b/tests/test_book_period_control_seed_contract.py @@ -8,6 +8,7 @@ ROOT = Path(__file__).resolve().parents[1] MIGRATION = ROOT / "database/migrations/0034_book_period_control_seed.sql" +BOOK_PERIOD_MIGRATION = ROOT / "database/migrations/0009_accounting_book_period_control.sql" INSTALLER = ROOT / "src/accounting_information_platform/migration_install.py" @@ -43,6 +44,43 @@ def test_trigger_functions_use_hardened_execution_context(self) -> None: source, ) + def test_cross_tenant_backfills_are_owner_safe_without_disabling_rls(self) -> None: + """Unbound non-superuser migration owners need an owner-only window before FORCE RLS.""" + initial_source = BOOK_PERIOD_MIGRATION.read_text(encoding="utf-8") + initial_backfill = initial_source.index( + "INSERT INTO accounting_core.accounting_book_period_control (" + ) + initial_force = initial_source.index( + "ALTER TABLE accounting_core.accounting_book_period_control FORCE ROW LEVEL SECURITY;" + ) + self.assertLess( + initial_backfill, + initial_force, + "0009 forces its table owner through tenant RLS before the all-tenant upgrade backfill", + ) + + repair_source = MIGRATION.read_text(encoding="utf-8") + repair_backfill = repair_source.rindex( + "INSERT INTO accounting_core.accounting_book_period_control (" + ) + control_no_force = repair_source.index( + "ALTER TABLE accounting_core.accounting_book_period_control NO FORCE ROW LEVEL SECURITY;" + ) + fence_no_force = repair_source.index( + "ALTER TABLE accounting_core.period_journal_population_fence NO FORCE ROW LEVEL SECURITY;" + ) + fence_force = repair_source.rindex( + "ALTER TABLE accounting_core.period_journal_population_fence FORCE ROW LEVEL SECURITY;" + ) + control_force = repair_source.rindex( + "ALTER TABLE accounting_core.accounting_book_period_control FORCE ROW LEVEL SECURITY;" + ) + self.assertLess(control_no_force, repair_backfill) + self.assertLess(fence_no_force, repair_backfill) + self.assertLess(repair_backfill, fence_force) + self.assertLess(repair_backfill, control_force) + self.assertNotIn("DISABLE ROW LEVEL SECURITY", repair_source) + def test_canonical_installer_includes_seed_migration(self) -> None: """Supported foundation installs cannot stop before future-pair seeding exists.""" source = INSTALLER.read_text(encoding="utf-8") From 92789123dca0a119e17df3f4b1d994c780f80264 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 12:04:15 +0900 Subject: [PATCH 099/224] fix(close): seed book-period authority before forced RLS --- .../0009_accounting_book_period_control.sql | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/database/migrations/0009_accounting_book_period_control.sql b/database/migrations/0009_accounting_book_period_control.sql index dec50703..e1aad6c4 100644 --- a/database/migrations/0009_accounting_book_period_control.sql +++ b/database/migrations/0009_accounting_book_period_control.sql @@ -22,13 +22,10 @@ CREATE INDEX accounting_book_period_scope_index tenant_account_id, accounting_book_id, fiscal_period_id, period_status_code ); -ALTER TABLE accounting_core.accounting_book_period_control ENABLE ROW LEVEL SECURITY; -ALTER TABLE accounting_core.accounting_book_period_control FORCE ROW LEVEL SECURITY; -CREATE POLICY accounting_book_period_isolation - ON accounting_core.accounting_book_period_control - USING (tenant_account_id = accounting_core.current_tenant_account_id()) - WITH CHECK (tenant_account_id = accounting_core.current_tenant_account_id()); - +-- The migration owner can be an unbound NOSUPERUSER/NOBYPASSRLS role. Seed +-- existing tenant/book/period authority before FORCE RLS makes the table owner +-- subject to the runtime tenant policy. Runtime access is still never granted +-- before the migration transaction commits. INSERT INTO accounting_core.accounting_book_period_control ( tenant_account_id, accounting_book_id, fiscal_period_id, period_status_code, period_closed_at @@ -44,6 +41,13 @@ JOIN accounting_core.fiscal_period WHERE accounting_book.valid_to IS NULL ON CONFLICT (tenant_account_id, accounting_book_id, fiscal_period_id) DO NOTHING; +ALTER TABLE accounting_core.accounting_book_period_control ENABLE ROW LEVEL SECURITY; +ALTER TABLE accounting_core.accounting_book_period_control FORCE ROW LEVEL SECURITY; +CREATE POLICY accounting_book_period_isolation + ON accounting_core.accounting_book_period_control + USING (tenant_account_id = accounting_core.current_tenant_account_id()) + WITH CHECK (tenant_account_id = accounting_core.current_tenant_account_id()); + CREATE OR REPLACE FUNCTION accounting_core.guard_period_insert() RETURNS trigger LANGUAGE plpgsql From a19be19b059834e04e965463230d56b3fa9c8aa7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 12:04:30 +0900 Subject: [PATCH 100/224] fix(close): preserve forced RLS across cross-tenant seed repair --- .../migrations/0034_book_period_control_seed.sql | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/database/migrations/0034_book_period_control_seed.sql b/database/migrations/0034_book_period_control_seed.sql index 7f7120af..0bfaf17a 100644 --- a/database/migrations/0034_book_period_control_seed.sql +++ b/database/migrations/0034_book_period_control_seed.sql @@ -91,8 +91,15 @@ CREATE TRIGGER book_period_control_seed_for_book EXECUTE FUNCTION accounting_core.seed_book_period_control_for_book(); -- Repair databases that installed 0009 before later master-data rows existed. --- Each inserted control row synchronously fires migration 0033's fence seeder, --- so no post-migration active book-period pair can lack its pre-existing stripes. +-- FORCE RLS intentionally remains enabled for runtime, but it would also make +-- an unbound non-superuser table owner fail its cross-tenant upgrade backfill. +-- NO FORCE restores only the table-owner bypass; RLS stays enabled for every +-- non-owner role. The migration transaction restores FORCE before commit. +-- Fence seeding is included because the SECURITY DEFINER trigger runs as the +-- migration owner and must be able to populate the same cross-tenant repair. +ALTER TABLE accounting_core.accounting_book_period_control NO FORCE ROW LEVEL SECURITY; +ALTER TABLE accounting_core.period_journal_population_fence NO FORCE ROW LEVEL SECURITY; + INSERT INTO accounting_core.accounting_book_period_control ( tenant_account_id, accounting_book_id, @@ -115,4 +122,7 @@ ON CONFLICT ( fiscal_period_id ) DO NOTHING; +ALTER TABLE accounting_core.period_journal_population_fence FORCE ROW LEVEL SECURITY; +ALTER TABLE accounting_core.accounting_book_period_control FORCE ROW LEVEL SECURITY; + COMMIT; From dc06c5cbe6c9d3dba03b8fb07901838611e673fa Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 12:06:39 +0900 Subject: [PATCH 101/224] docs(close): record owner-safe forced-RLS migration boundary --- docs/adr/0006-fiscal-period-close-snapshot.md | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/docs/adr/0006-fiscal-period-close-snapshot.md b/docs/adr/0006-fiscal-period-close-snapshot.md index e0a84e74..c327bbea 100644 --- a/docs/adr/0006-fiscal-period-close-snapshot.md +++ b/docs/adr/0006-fiscal-period-close-snapshot.md @@ -57,6 +57,14 @@ The 64-slot count is an engineering hypothesis, not accounting policy and not an A serialization failure is not accounting evidence. The entire transaction rolls back and the command retries from the beginning with the same immutable source-payload identity and idempotency key. No failed close may leave a retained snapshot, period-closing journal, close event, or authoritative period transition. +### Master-data seeding under forced tenant RLS + +Migration `0034_book_period_control_seed.sql` makes the book-period authority lifecycle complete after installation: inserting either a new fiscal period or a new active accounting book materializes the missing `accounting_book_period_control` pairs and synchronously seeds all 64 freshness rows. Its upgrade backfill is intentionally cross-tenant because it repairs every pre-existing active book-period pair. + +PostgreSQL normally lets a table owner bypass row security, but `FORCE ROW LEVEL SECURITY` makes that owner subject to the policy; `SUPERUSER` and `BYPASSRLS` remain separate exceptional capabilities. A production migration owner is not required to hold either capability or a runtime tenant binding. Migration 0009 therefore performs its one-time cross-tenant control backfill before FORCE RLS is enabled. Migration 0034 keeps RLS enabled but temporarily applies `NO FORCE ROW LEVEL SECURITY` to both the control table and the 64-stripe fence table for the owner-only repair window, then restores FORCE on both before the migration transaction commits. This is not a runtime gate weakening: non-owner roles remain subject to RLS throughout, and the committed schema remains FORCE RLS protected. + +The migration must run as the owner of those tables; it must not solve installation by granting `BYPASSRLS`, impersonating a tenant, disabling row security, or copying tenant truth into an installer-side cache. `tests/test_book_period_control_seed_contract.py` pins the ordering and requires that the repair never uses `DISABLE ROW LEVEL SECURITY`. + ### Remaining application advisory-lock hotspot The striped database fence is not yet an end-to-end posting-concurrency GREEN. `PostgresPostingLedger._require_open_book_period_bounds()` still acquires the same exclusive tenant/resolved-book/period advisory lock used by `close_fiscal_period()` for every ordinary Billing proposal. That makes unrelated ordinary postings for one open book-period queue before either reaches the striped database boundary. @@ -85,15 +93,17 @@ Using only a trigger-side existence query for retained snapshots was rejected be Allowing caller-provided close timestamps, currencies, aggregate identifiers, or retained arithmetic was rejected because those values are accounting evidence and must be derived or verified at the authoritative database boundary. +Granting the migration role `BYPASSRLS`, disabling RLS for the backfill, or binding an infrastructure role to a fabricated runtime tenant was rejected. Those approaches turn an installation concern into standing or misleading runtime authority. Owner-only `NO FORCE` inside the uncommitted migration preserves the tenant policy for runtime roles and restores FORCE before any new schema state commits. + ## Consequences and operational evidence At the database boundary, open-period posting performs a shared control-row lock plus one striped revision UPDATE rather than one exclusive UPDATE on the common book-period row. This removes the deliberate database single-row hotspot. The current application advisory mutex still serializes ordinary proposals and is a release-blocking repair finding for the stated hot-path goal. After that source repair, PostgreSQL row locking and same-slot collisions still have measurable cost. Release evidence must use realistic concurrent posting and transition workloads and report advisory-lock waits, row-lock waits, stripe distribution, failures, retry rates, WAL/write cost, and tail latency without sample reduction, excluded failures, or artificial cache warm-up. -Migration 0033 creates a new tenant-scoped table under RLS/FORCE RLS. The cross-tenant migration backfill occurs before FORCE RLS is enabled so a non-superuser schema owner can seed every existing book-period without impersonating one runtime tenant. Future rows are seeded from the book-period-control INSERT transaction and stay in that tenant scope. Guard functions are `SECURITY DEFINER`, use `search_path = pg_catalog, pg_temp`, and revoke PUBLIC execute. +Migration 0033 creates a new tenant-scoped table under RLS/FORCE RLS. Its initial cross-tenant fence backfill occurs before FORCE RLS is enabled so a non-superuser schema owner can seed every existing book-period without impersonating one runtime tenant. Migration 0034 later repairs missing book-period controls under an owner-only `NO FORCE` window on both forced-RLS target tables and restores FORCE before commit. Future rows are seeded from the book-period-control INSERT transaction and stay in that tenant scope. Guard functions are `SECURITY DEFINER`, use `search_path = pg_catalog, pg_temp`, and revoke PUBLIC execute. -The migration chain has distinct recovery states. A failed 0029 concurrent index build can leave an invalid index that operators must remove or rebuild before retry. A failed 0031 validation leaves the constraint enforced for subsequent writes but inherited rows uncertified. A failed 0033 migration transaction rolls back its table, trigger, function, policy, and seed population together. A runtime SQLSTATE `40001` leaves no authoritative close result and requires a whole-command retry. Recovery must never normalize or rewrite posted journals, reconciliation evidence, or retained close facts. +The migration chain has distinct recovery states. A failed 0029 concurrent index build can leave an invalid index that operators must remove or rebuild before retry. A failed 0031 validation leaves the constraint enforced for subsequent writes but inherited rows uncertified. Failed 0033 or 0034 migration transactions roll back their tables/triggers/functions/policies or owner-force toggles and seed population together. A runtime SQLSTATE `40001` leaves no authoritative close result and requires a whole-command retry. Recovery must never normalize or rewrite posted journals, reconciliation evidence, or retained close facts. Future reopen/correction is not implemented here. Any later reopen policy must preserve the prior hard-close population through explicit successor lineage and a replacement population identity/version invariant rather than mutating retained evidence or weakening uniqueness. @@ -120,3 +130,5 @@ PostgreSQL Global Development Group. (2026f). *PostgreSQL 18 documentation: Uniq PostgreSQL Global Development Group. (2026g). *PostgreSQL 18 documentation: pg_locks*. https://www.postgresql.org/docs/18/view-pg-locks.html PostgreSQL Global Development Group. (2026h). *PostgreSQL 18 documentation: Advisory lock functions*. https://www.postgresql.org/docs/18/functions-admin.html#FUNCTIONS-ADVISORY-LOCKS + +PostgreSQL Global Development Group. (2026i). *PostgreSQL 18 documentation: Row security policies*. https://www.postgresql.org/docs/18/ddl-rowsecurity.html From e06325838f5f8f7028b3bbcaffbbd5319bbcd247 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 12:07:03 +0900 Subject: [PATCH 102/224] docs(close): trace forced-RLS migration-owner repair --- ...RIOD_CONTROL_RLS_MIGRATION_TRACEABILITY.md | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 docs/doctoring/BOOK_PERIOD_CONTROL_RLS_MIGRATION_TRACEABILITY.md diff --git a/docs/doctoring/BOOK_PERIOD_CONTROL_RLS_MIGRATION_TRACEABILITY.md b/docs/doctoring/BOOK_PERIOD_CONTROL_RLS_MIGRATION_TRACEABILITY.md new file mode 100644 index 00000000..204ba4f0 --- /dev/null +++ b/docs/doctoring/BOOK_PERIOD_CONTROL_RLS_MIGRATION_TRACEABILITY.md @@ -0,0 +1,46 @@ +# Book-period control RLS migration traceability + +## Scope + +This note records an installation and upgrade control for the authoritative `accounting_book_period_control` and `period_journal_population_fence` relations. It does not grant a runtime tenant, change accounting policy, weaken posted-journal immutability, or transfer accounting authority to Billing or another source system. + +## Finding + +`accounting_core.current_tenant_account_id()` resolves a runtime tenant from `session_user`. A production schema/migration owner is not required to have such a runtime binding and should remain `NOSUPERUSER` / `NOBYPASSRLS`. + +PostgreSQL 18 documents three distinct facts that matter here: ordinary table owners normally bypass row-level security; `FORCE ROW LEVEL SECURITY` makes the table owner subject to its policies; and `SUPERUSER` / `BYPASSRLS` are exceptional capabilities that bypass those policies. Therefore an all-tenant migration backfill cannot assume that the forced-RLS table owner can insert rows selected across tenants when the policy is `tenant_account_id = accounting_core.current_tenant_account_id()` and the migration login has no runtime tenant binding. + +The defect existed in two development-only paths. Migration 0009 enabled and forced tenant RLS before its upgrade backfill of existing accounting books × fiscal periods. Migration 0034 also attempted a cross-tenant repair after both `accounting_book_period_control` and the 64-stripe `period_journal_population_fence` were already FORCE RLS protected. Migration 0033 had already encoded the correct principle for its initial fence population by seeding before FORCE RLS. + +There are no GitHub tags or releases for this repository at this repair point, so migration 0009 is not an immutable released artifact. The branch therefore repairs the unreleased migration order rather than adding an ineffective later migration that a failing 0009 upgrade could never reach. + +## Selected control + +Migration 0009 now performs its existing all-tenant control-row backfill before enabling and forcing RLS. Runtime access is not granted inside that migration transaction, so there is no committed runtime interval without the intended forced-RLS policy. + +Migration 0034 keeps RLS **enabled** on both target tables. For its owner-only repair phase it executes `NO FORCE ROW LEVEL SECURITY` on `accounting_book_period_control` and `period_journal_population_fence`, performs the cross-tenant backfill, then restores `FORCE ROW LEVEL SECURITY` on both tables before `COMMIT`. `NO FORCE` restores the normal table-owner bypass; it does not disable RLS for non-owner runtime roles. The fence table must participate in the same owner window because every inserted control synchronously invokes migration 0033's `SECURITY DEFINER` 64-stripe seeder. + +The migration role must own these tables. Installation must not be made to work by granting `BYPASSRLS`, using a superuser as the normal deployment identity, assigning a fabricated runtime tenant to the migration role, setting `row_security=off` as a bypass, or executing `DISABLE ROW LEVEL SECURITY`. + +## TDD and exact implementation evidence + +- Test-first static RED: `800716a2b44370e41b0a5e65d86d4e30d1008765`, `tests/test_book_period_control_seed_contract.py`. It requires the 0009 backfill to precede FORCE RLS, requires 0034's owner-only `NO FORCE` window to surround its repair backfill on both forced-RLS tables, and forbids `DISABLE ROW LEVEL SECURITY`. +- Initial migration-order repair: `92789123dca0a119e17df3f4b1d994c780f80264`, `database/migrations/0009_accounting_book_period_control.sql`. +- Post-install repair: `a19be19b059834e04e965463230d56b3fa9c8aa7`, `database/migrations/0034_book_period_control_seed.sql`. +- ADR alignment: `dc06c5cbe6c9d3dba03b8fb07901838611e673fa`, `docs/adr/0006-fiscal-period-close-snapshot.md`. + +These commits are development evidence only. The RED commit was created before the SQL repair, but no claim of runner-observed RED or exact-head GREEN is valid until GitHub Actions executes the corresponding heads. The final release candidate still requires the real PostgreSQL migration chain, tenant isolation, security/SAST/dependency checks, migration/recovery evidence, independent review, and protected integration gates. + +## Recovery and security effect + +Both SQL repairs are transactional. A failed migration must roll back the owner-force toggle and all inserted control/fence rows together. Operators must retry the complete migration after correcting the root cause; they must not fabricate book-period controls, delete posted journals, rewrite retained trial-balance evidence, or weaken tenant policies to make the migration appear successful. + +The committed runtime state remains `ENABLE ROW LEVEL SECURITY` + `FORCE ROW LEVEL SECURITY` for both authority relations. This repair changes migration-owner behavior only and does not alter the runtime single-writer boundary, tenant policy expression, close authority, maker-checker rules, or financial values. + +## References + +PostgreSQL Global Development Group. (2026a). *PostgreSQL 18 documentation: Row security policies*. https://www.postgresql.org/docs/18/ddl-rowsecurity.html + +PostgreSQL Global Development Group. (2026b). *PostgreSQL 18 documentation: ALTER TABLE*. https://www.postgresql.org/docs/18/sql-altertable.html + +PostgreSQL Global Development Group. (2026c). *PostgreSQL 18 documentation: CREATE ROLE*. https://www.postgresql.org/docs/18/sql-createrole.html From e040f3447700abfa5291237fa094c88019068e9f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 12:11:09 +0900 Subject: [PATCH 103/224] docs(security): distinguish migration owner from runtime tenant --- docs/adr/0049-runtime-tenant-database-binding.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/docs/adr/0049-runtime-tenant-database-binding.md b/docs/adr/0049-runtime-tenant-database-binding.md index 2c6d5d3f..36940b8d 100644 --- a/docs/adr/0049-runtime-tenant-database-binding.md +++ b/docs/adr/0049-runtime-tenant-database-binding.md @@ -12,13 +12,15 @@ Forced PostgreSQL row-level security is only a tenant boundary if the value used `accounting_core.current_tenant_account_id()` is a `STABLE SECURITY DEFINER` SQL function with `search_path = pg_catalog, accounting_core`. It takes no caller argument and derives the active tenant by joining the immutable session login (`session_user`) to the admin-owned binding and `pg_catalog.pg_roles`. Existing forced-RLS policies continue to call this function. The legacy `app.tenant_account_id` custom setting is no longer an authorization input; changing it cannot rebind a runtime session. -`PostgresPostingLedger` still receives an explicit tenant reference from the authenticated application boundary. It resolves that reference to the accounting tenant row and requires it to equal the database login binding. A bound mismatch or an unbound ordinary runtime identity fails closed before accounting work. Migration/superuser or `BYPASSRLS` identities are treated as administrative break-glass paths for migration/testing only and are not normal application credentials. +`PostgresPostingLedger` still receives an explicit tenant reference from the authenticated application boundary. It resolves that reference to the accounting tenant row and requires it to equal the database login binding. A bound mismatch or an unbound ordinary runtime identity fails closed before accounting work. + +A normal schema/migration owner is an infrastructure identity, not a runtime tenant. It may remain unbound, `NOSUPERUSER`, and `NOBYPASSRLS`. Cross-tenant data-shape migrations must therefore arrange owner-safe migration ordering explicitly rather than borrowing a tenant identity or requiring standing RLS-bypass authority. For the book-period authority repair, migrations 0009/0034 seed while the table owner is not forced through the runtime tenant policy and restore committed `FORCE ROW LEVEL SECURITY` before runtime use; RLS is not disabled for non-owner roles. `SUPERUSER` or `BYPASSRLS` credentials are administrative break-glass/testing paths only, not a prerequisite or normal deployment credential. Provisioning or rotating an application DB login therefore requires an owner-controlled insert of its current PostgreSQL role OID, role name, and tenant into `runtime_tenant_binding`. Tenant reassignment closes the old binding and creates a new one; runtime credentials never mutate this table. ## Consequences -A compromised ordinary database credential cannot cross tenant scope merely by changing a request field, a session GUC, or `SET ROLE`. The database credential itself becomes purpose- and tenant-bound, complementing rather than replacing HTTP/OIDC authorization. Operators must provision the binding before switching traffic to a new runtime login, and backup/restore or role recreation must re-establish the current role OID deliberately. This is defense-in-depth evidence readiness, not a claim of SOC 2, CSAP, or jurisdictional certification. +A compromised ordinary database credential cannot cross tenant scope merely by changing a request field, a session GUC, or `SET ROLE`. The database credential itself becomes purpose- and tenant-bound, complementing rather than replacing HTTP/OIDC authorization. Operators must provision the binding before switching traffic to a new runtime login, and backup/restore or role recreation must re-establish the current role OID deliberately. Migration tooling must preserve table ownership and the documented owner-only RLS transition when cross-tenant upgrade backfill is required; a failure is repaired by retrying the migration, not by granting runtime tenant authority or weakening the committed policy. This is defense-in-depth evidence readiness, not a claim of SOC 2, CSAP, or jurisdictional certification. ## References @@ -27,3 +29,5 @@ PostgreSQL Global Development Group. (2026e). *PostgreSQL 18 documentation: Syst PostgreSQL Global Development Group. (2026f). *PostgreSQL 18 documentation: Function security*. https://www.postgresql.org/docs/18/perm-functions.html PostgreSQL Global Development Group. (2026g). *PostgreSQL 18 documentation: CREATE POLICY*. https://www.postgresql.org/docs/18/sql-createpolicy.html + +PostgreSQL Global Development Group. (2026h). *PostgreSQL 18 documentation: Row security policies*. https://www.postgresql.org/docs/18/ddl-rowsecurity.html From 29808a77426e403d2c0277264ef6d2217f0e52d1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 12:12:57 +0900 Subject: [PATCH 104/224] test(close): require owner visibility on forced-RLS seed sources --- .../test_book_period_control_seed_contract.py | 56 +++++++++++-------- 1 file changed, 34 insertions(+), 22 deletions(-) diff --git a/tests/test_book_period_control_seed_contract.py b/tests/test_book_period_control_seed_contract.py index 3777df11..57538b93 100644 --- a/tests/test_book_period_control_seed_contract.py +++ b/tests/test_book_period_control_seed_contract.py @@ -45,40 +45,52 @@ def test_trigger_functions_use_hardened_execution_context(self) -> None: ) def test_cross_tenant_backfills_are_owner_safe_without_disabling_rls(self) -> None: - """Unbound non-superuser migration owners need an owner-only window before FORCE RLS.""" + """Unbound migration owners need owner-only visibility on source and target tables.""" initial_source = BOOK_PERIOD_MIGRATION.read_text(encoding="utf-8") initial_backfill = initial_source.index( "INSERT INTO accounting_core.accounting_book_period_control (" ) - initial_force = initial_source.index( + initial_control_force = initial_source.index( "ALTER TABLE accounting_core.accounting_book_period_control FORCE ROW LEVEL SECURITY;" ) - self.assertLess( - initial_backfill, - initial_force, - "0009 forces its table owner through tenant RLS before the all-tenant upgrade backfill", + initial_book_no_force = initial_source.index( + "ALTER TABLE accounting_core.accounting_book NO FORCE ROW LEVEL SECURITY;" ) + initial_period_no_force = initial_source.index( + "ALTER TABLE accounting_core.fiscal_period NO FORCE ROW LEVEL SECURITY;" + ) + initial_book_force = initial_source.rindex( + "ALTER TABLE accounting_core.accounting_book FORCE ROW LEVEL SECURITY;" + ) + initial_period_force = initial_source.rindex( + "ALTER TABLE accounting_core.fiscal_period FORCE ROW LEVEL SECURITY;" + ) + self.assertLess(initial_book_no_force, initial_backfill) + self.assertLess(initial_period_no_force, initial_backfill) + self.assertLess(initial_backfill, initial_book_force) + self.assertLess(initial_backfill, initial_period_force) + self.assertLess(initial_backfill, initial_control_force) repair_source = MIGRATION.read_text(encoding="utf-8") repair_backfill = repair_source.rindex( "INSERT INTO accounting_core.accounting_book_period_control (" ) - control_no_force = repair_source.index( - "ALTER TABLE accounting_core.accounting_book_period_control NO FORCE ROW LEVEL SECURITY;" - ) - fence_no_force = repair_source.index( - "ALTER TABLE accounting_core.period_journal_population_fence NO FORCE ROW LEVEL SECURITY;" - ) - fence_force = repair_source.rindex( - "ALTER TABLE accounting_core.period_journal_population_fence FORCE ROW LEVEL SECURITY;" - ) - control_force = repair_source.rindex( - "ALTER TABLE accounting_core.accounting_book_period_control FORCE ROW LEVEL SECURITY;" - ) - self.assertLess(control_no_force, repair_backfill) - self.assertLess(fence_no_force, repair_backfill) - self.assertLess(repair_backfill, fence_force) - self.assertLess(repair_backfill, control_force) + for table_name in ( + "accounting_book", + "fiscal_period", + "accounting_book_period_control", + "period_journal_population_fence", + ): + no_force = repair_source.index( + f"ALTER TABLE accounting_core.{table_name} NO FORCE ROW LEVEL SECURITY;" + ) + force = repair_source.rindex( + f"ALTER TABLE accounting_core.{table_name} FORCE ROW LEVEL SECURITY;" + ) + self.assertLess(no_force, repair_backfill) + self.assertLess(repair_backfill, force) + + self.assertNotIn("DISABLE ROW LEVEL SECURITY", initial_source) self.assertNotIn("DISABLE ROW LEVEL SECURITY", repair_source) def test_canonical_installer_includes_seed_migration(self) -> None: From f5a28af32f70de66be5d702c5cf404b735546699 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 12:13:29 +0900 Subject: [PATCH 105/224] fix(close): expose forced-RLS seed sources to migration owner --- .../0009_accounting_book_period_control.sql | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/database/migrations/0009_accounting_book_period_control.sql b/database/migrations/0009_accounting_book_period_control.sql index e1aad6c4..c99d6131 100644 --- a/database/migrations/0009_accounting_book_period_control.sql +++ b/database/migrations/0009_accounting_book_period_control.sql @@ -22,10 +22,14 @@ CREATE INDEX accounting_book_period_scope_index tenant_account_id, accounting_book_id, fiscal_period_id, period_status_code ); --- The migration owner can be an unbound NOSUPERUSER/NOBYPASSRLS role. Seed --- existing tenant/book/period authority before FORCE RLS makes the table owner --- subject to the runtime tenant policy. Runtime access is still never granted --- before the migration transaction commits. +-- Migrations 0005+ force the table owner through tenant RLS on both source +-- relations. An unbound NOSUPERUSER/NOBYPASSRLS migration owner therefore +-- needs its normal owner visibility restored on the sources before this +-- all-tenant backfill. RLS stays enabled for non-owner roles, and FORCE is +-- restored before the migration transaction commits. +ALTER TABLE accounting_core.accounting_book NO FORCE ROW LEVEL SECURITY; +ALTER TABLE accounting_core.fiscal_period NO FORCE ROW LEVEL SECURITY; + INSERT INTO accounting_core.accounting_book_period_control ( tenant_account_id, accounting_book_id, fiscal_period_id, period_status_code, period_closed_at @@ -41,6 +45,9 @@ JOIN accounting_core.fiscal_period WHERE accounting_book.valid_to IS NULL ON CONFLICT (tenant_account_id, accounting_book_id, fiscal_period_id) DO NOTHING; +ALTER TABLE accounting_core.fiscal_period FORCE ROW LEVEL SECURITY; +ALTER TABLE accounting_core.accounting_book FORCE ROW LEVEL SECURITY; + ALTER TABLE accounting_core.accounting_book_period_control ENABLE ROW LEVEL SECURITY; ALTER TABLE accounting_core.accounting_book_period_control FORCE ROW LEVEL SECURITY; CREATE POLICY accounting_book_period_isolation From 027fae479a7ae52b1db3119acd6549f50aa6dba2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 12:13:46 +0900 Subject: [PATCH 106/224] fix(close): include forced-RLS seed sources in owner repair --- .../0034_book_period_control_seed.sql | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/database/migrations/0034_book_period_control_seed.sql b/database/migrations/0034_book_period_control_seed.sql index 0bfaf17a..6c68f496 100644 --- a/database/migrations/0034_book_period_control_seed.sql +++ b/database/migrations/0034_book_period_control_seed.sql @@ -91,12 +91,15 @@ CREATE TRIGGER book_period_control_seed_for_book EXECUTE FUNCTION accounting_core.seed_book_period_control_for_book(); -- Repair databases that installed 0009 before later master-data rows existed. --- FORCE RLS intentionally remains enabled for runtime, but it would also make --- an unbound non-superuser table owner fail its cross-tenant upgrade backfill. --- NO FORCE restores only the table-owner bypass; RLS stays enabled for every --- non-owner role. The migration transaction restores FORCE before commit. --- Fence seeding is included because the SECURITY DEFINER trigger runs as the --- migration owner and must be able to populate the same cross-tenant repair. +-- accounting_book/fiscal_period as well as the control/fence targets are +-- already FORCE RLS protected at this point. An unbound NOSUPERUSER / +-- NOBYPASSRLS migration owner would otherwise see no source rows and could not +-- populate the targets. NO FORCE restores only ordinary table-owner bypass; +-- RLS remains enabled for non-owner roles. The same transaction restores FORCE +-- on every source/target table before commit. ALTER TABLE's locking also keeps +-- concurrent runtime traffic from observing a partially changed owner policy. +ALTER TABLE accounting_core.accounting_book NO FORCE ROW LEVEL SECURITY; +ALTER TABLE accounting_core.fiscal_period NO FORCE ROW LEVEL SECURITY; ALTER TABLE accounting_core.accounting_book_period_control NO FORCE ROW LEVEL SECURITY; ALTER TABLE accounting_core.period_journal_population_fence NO FORCE ROW LEVEL SECURITY; @@ -124,5 +127,7 @@ ON CONFLICT ( ALTER TABLE accounting_core.period_journal_population_fence FORCE ROW LEVEL SECURITY; ALTER TABLE accounting_core.accounting_book_period_control FORCE ROW LEVEL SECURITY; +ALTER TABLE accounting_core.fiscal_period FORCE ROW LEVEL SECURITY; +ALTER TABLE accounting_core.accounting_book FORCE ROW LEVEL SECURITY; COMMIT; From c5e950a28c41af4a2b921a19d10d385aa636adc1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 12:14:19 +0900 Subject: [PATCH 107/224] docs(close): trace forced-RLS source visibility repair --- ...RIOD_CONTROL_RLS_MIGRATION_TRACEABILITY.md | 30 ++++++++++--------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/docs/doctoring/BOOK_PERIOD_CONTROL_RLS_MIGRATION_TRACEABILITY.md b/docs/doctoring/BOOK_PERIOD_CONTROL_RLS_MIGRATION_TRACEABILITY.md index 204ba4f0..d5cc6e12 100644 --- a/docs/doctoring/BOOK_PERIOD_CONTROL_RLS_MIGRATION_TRACEABILITY.md +++ b/docs/doctoring/BOOK_PERIOD_CONTROL_RLS_MIGRATION_TRACEABILITY.md @@ -2,40 +2,42 @@ ## Scope -This note records an installation and upgrade control for the authoritative `accounting_book_period_control` and `period_journal_population_fence` relations. It does not grant a runtime tenant, change accounting policy, weaken posted-journal immutability, or transfer accounting authority to Billing or another source system. +This note records an installation and upgrade control for the authoritative `accounting_book_period_control` and `period_journal_population_fence` relations and their `accounting_book` / `fiscal_period` seed sources. It does not grant a runtime tenant, change accounting policy, weaken posted-journal immutability, or transfer accounting authority to Billing or another source system. ## Finding `accounting_core.current_tenant_account_id()` resolves a runtime tenant from `session_user`. A production schema/migration owner is not required to have such a runtime binding and should remain `NOSUPERUSER` / `NOBYPASSRLS`. -PostgreSQL 18 documents three distinct facts that matter here: ordinary table owners normally bypass row-level security; `FORCE ROW LEVEL SECURITY` makes the table owner subject to its policies; and `SUPERUSER` / `BYPASSRLS` are exceptional capabilities that bypass those policies. Therefore an all-tenant migration backfill cannot assume that the forced-RLS table owner can insert rows selected across tenants when the policy is `tenant_account_id = accounting_core.current_tenant_account_id()` and the migration login has no runtime tenant binding. +PostgreSQL 18 documents three distinct facts that matter here: ordinary table owners normally bypass row-level security; `FORCE ROW LEVEL SECURITY` makes the table owner subject to its policies; and `SUPERUSER` / `BYPASSRLS` are exceptional capabilities that bypass those policies. Therefore an all-tenant migration backfill cannot assume that the forced-RLS table owner can either read seed rows or write target rows when the policy derives a runtime tenant from an unbound `session_user`. -The defect existed in two development-only paths. Migration 0009 enabled and forced tenant RLS before its upgrade backfill of existing accounting books × fiscal periods. Migration 0034 also attempted a cross-tenant repair after both `accounting_book_period_control` and the 64-stripe `period_journal_population_fence` were already FORCE RLS protected. Migration 0033 had already encoded the correct principle for its initial fence population by seeding before FORCE RLS. +The first repair pass covered only target visibility. Fresh review then found the deeper source-side defect: migration 0005 had already forced `accounting_book` and `fiscal_period` through tenant RLS. Moving migration 0009's target backfill before FORCE on the newly created control table was still insufficient because the same unbound table owner could see no source books or periods. Likewise, migration 0034's owner-only window on the control/fence targets could not repair rows that its all-tenant `SELECT` could not see. Migration 0033's initial fence seed had already encoded the correct target-side principle by seeding before FORCE RLS, but it did not solve later source visibility. -There are no GitHub tags or releases for this repository at this repair point, so migration 0009 is not an immutable released artifact. The branch therefore repairs the unreleased migration order rather than adding an ineffective later migration that a failing 0009 upgrade could never reach. +There are no GitHub tags or releases for this repository at this repair point, so migration 0009 is not an immutable released artifact. The branch therefore repairs the unreleased migration order rather than adding a later migration that could not rescue an upgrade already failing at 0009. ## Selected control -Migration 0009 now performs its existing all-tenant control-row backfill before enabling and forcing RLS. Runtime access is not granted inside that migration transaction, so there is no committed runtime interval without the intended forced-RLS policy. +Migration 0009 temporarily applies `NO FORCE ROW LEVEL SECURITY` to the already forced `accounting_book` and `fiscal_period` source tables, performs the existing all-tenant control-row seed while the new target table is not yet exposed through runtime policy, restores FORCE on both sources, and only then enables/forces RLS on `accounting_book_period_control`. -Migration 0034 keeps RLS **enabled** on both target tables. For its owner-only repair phase it executes `NO FORCE ROW LEVEL SECURITY` on `accounting_book_period_control` and `period_journal_population_fence`, performs the cross-tenant backfill, then restores `FORCE ROW LEVEL SECURITY` on both tables before `COMMIT`. `NO FORCE` restores the normal table-owner bypass; it does not disable RLS for non-owner runtime roles. The fence table must participate in the same owner window because every inserted control synchronously invokes migration 0033's `SECURITY DEFINER` 64-stripe seeder. +Migration 0034 keeps RLS **enabled** everywhere. For its owner-only repair phase it applies `NO FORCE ROW LEVEL SECURITY` to all four participating relations: `accounting_book`, `fiscal_period`, `accounting_book_period_control`, and `period_journal_population_fence`. It performs the cross-tenant backfill, then restores `FORCE ROW LEVEL SECURITY` on every source and target before `COMMIT`. `NO FORCE` restores normal table-owner bypass; it does not disable RLS for non-owner runtime roles. The fence table participates because every inserted control synchronously invokes migration 0033's `SECURITY DEFINER` 64-stripe seeder. -The migration role must own these tables. Installation must not be made to work by granting `BYPASSRLS`, using a superuser as the normal deployment identity, assigning a fabricated runtime tenant to the migration role, setting `row_security=off` as a bypass, or executing `DISABLE ROW LEVEL SECURITY`. +These `ALTER TABLE` operations take PostgreSQL table locks inside the same migration transaction. Runtime traffic is not allowed to observe a committed half-state in which one of these relations permanently loses FORCE. The migration role must own all participating relations. Installation must not be made to work by granting `BYPASSRLS`, using a superuser as the normal deployment identity, assigning a fabricated runtime tenant to the migration role, treating `row_security=off` as a bypass, or executing `DISABLE ROW LEVEL SECURITY`. ## TDD and exact implementation evidence -- Test-first static RED: `800716a2b44370e41b0a5e65d86d4e30d1008765`, `tests/test_book_period_control_seed_contract.py`. It requires the 0009 backfill to precede FORCE RLS, requires 0034's owner-only `NO FORCE` window to surround its repair backfill on both forced-RLS tables, and forbids `DISABLE ROW LEVEL SECURITY`. -- Initial migration-order repair: `92789123dca0a119e17df3f4b1d994c780f80264`, `database/migrations/0009_accounting_book_period_control.sql`. -- Post-install repair: `a19be19b059834e04e965463230d56b3fa9c8aa7`, `database/migrations/0034_book_period_control_seed.sql`. -- ADR alignment: `dc06c5cbe6c9d3dba03b8fb07901838611e673fa`, `docs/adr/0006-fiscal-period-close-snapshot.md`. +- First static RED `800716a2b44370e41b0a5e65d86d4e30d1008765` required owner-safe target backfill without RLS disablement. +- Initial target-side repair: 0009 `92789123dca0a119e17df3f4b1d994c780f80264`; 0034 `a19be19b059834e04e965463230d56b3fa9c8aa7`. +- Fresh source-visibility RED `29808a77426e403d2c0277264ef6d2217f0e52d1` extends `tests/test_book_period_control_seed_contract.py` to require the owner-only window on `accounting_book` and `fiscal_period` as well as the control/fence targets. +- 0009 source-side repair `f5a28af32f70de66be5d702c5cf404b735546699` restores owner visibility on the forced-RLS seed sources and restores FORCE before target policy activation. +- 0034 full source/target repair `027fae479a7ae52b1db3119acd6549f50aa6dba2` surrounds the repair backfill with owner-only visibility on all four participating tables and restores FORCE on all four before commit. +- ADR alignment began with `dc06c5cbe6c9d3dba03b8fb07901838611e673fa`; runtime-identity clarification `e040f3447700abfa5291237fa094c88019068e9f` makes an ordinary unbound `NOSUPERUSER`/`NOBYPASSRLS` migration owner distinct from runtime tenant and break-glass identities. -These commits are development evidence only. The RED commit was created before the SQL repair, but no claim of runner-observed RED or exact-head GREEN is valid until GitHub Actions executes the corresponding heads. The final release candidate still requires the real PostgreSQL migration chain, tenant isolation, security/SAST/dependency checks, migration/recovery evidence, independent review, and protected integration gates. +These commits are development evidence only. Both static RED commits preceded their corresponding SQL repairs, but no runner-observed RED or exact-head GREEN claim is valid until GitHub Actions executes the corresponding heads. The final release candidate still requires the real PostgreSQL migration chain with a production-like unbound non-bypass owner, tenant-isolation acceptance, security/SAST/dependency checks, migration/recovery evidence, independent review, and protected integration gates. ## Recovery and security effect -Both SQL repairs are transactional. A failed migration must roll back the owner-force toggle and all inserted control/fence rows together. Operators must retry the complete migration after correcting the root cause; they must not fabricate book-period controls, delete posted journals, rewrite retained trial-balance evidence, or weaken tenant policies to make the migration appear successful. +Both SQL repairs are transactional. A failed migration must roll back every owner-force toggle and all inserted control/fence rows together. Operators must retry the complete migration after correcting the root cause; they must not fabricate book-period controls, delete posted journals, rewrite retained trial-balance evidence, or weaken tenant policies to make the migration appear successful. -The committed runtime state remains `ENABLE ROW LEVEL SECURITY` + `FORCE ROW LEVEL SECURITY` for both authority relations. This repair changes migration-owner behavior only and does not alter the runtime single-writer boundary, tenant policy expression, close authority, maker-checker rules, or financial values. +The committed runtime state remains `ENABLE ROW LEVEL SECURITY` + `FORCE ROW LEVEL SECURITY` on the tenant-scoped source and authority relations. This repair changes migration-owner visibility only and does not alter the runtime single-writer boundary, tenant policy expression, close authority, maker-checker rules, or financial values. ## References From 4a87f3522e7a2f7f8dc1faa4cde6e0d6f3ebb3dd Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 12:15:01 +0900 Subject: [PATCH 108/224] docs(close): include forced-RLS seed sources in owner decision --- docs/adr/0006-fiscal-period-close-snapshot.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/docs/adr/0006-fiscal-period-close-snapshot.md b/docs/adr/0006-fiscal-period-close-snapshot.md index c327bbea..520eff35 100644 --- a/docs/adr/0006-fiscal-period-close-snapshot.md +++ b/docs/adr/0006-fiscal-period-close-snapshot.md @@ -61,9 +61,11 @@ A serialization failure is not accounting evidence. The entire transaction rolls Migration `0034_book_period_control_seed.sql` makes the book-period authority lifecycle complete after installation: inserting either a new fiscal period or a new active accounting book materializes the missing `accounting_book_period_control` pairs and synchronously seeds all 64 freshness rows. Its upgrade backfill is intentionally cross-tenant because it repairs every pre-existing active book-period pair. -PostgreSQL normally lets a table owner bypass row security, but `FORCE ROW LEVEL SECURITY` makes that owner subject to the policy; `SUPERUSER` and `BYPASSRLS` remain separate exceptional capabilities. A production migration owner is not required to hold either capability or a runtime tenant binding. Migration 0009 therefore performs its one-time cross-tenant control backfill before FORCE RLS is enabled. Migration 0034 keeps RLS enabled but temporarily applies `NO FORCE ROW LEVEL SECURITY` to both the control table and the 64-stripe fence table for the owner-only repair window, then restores FORCE on both before the migration transaction commits. This is not a runtime gate weakening: non-owner roles remain subject to RLS throughout, and the committed schema remains FORCE RLS protected. +PostgreSQL normally lets a table owner bypass row security, but `FORCE ROW LEVEL SECURITY` makes that owner subject to the policy; `SUPERUSER` and `BYPASSRLS` remain separate exceptional capabilities. A production migration owner is not required to hold either capability or a runtime tenant binding. Migration 0005 has already forced the `accounting_book` and `fiscal_period` seed sources through tenant RLS by the time migration 0009 runs, so target ordering alone is insufficient: an unbound owner can otherwise see no source rows. -The migration must run as the owner of those tables; it must not solve installation by granting `BYPASSRLS`, impersonating a tenant, disabling row security, or copying tenant truth into an installer-side cache. `tests/test_book_period_control_seed_contract.py` pins the ordering and requires that the repair never uses `DISABLE ROW LEVEL SECURITY`. +Migration 0009 therefore restores normal owner visibility with `NO FORCE ROW LEVEL SECURITY` on `accounting_book` and `fiscal_period`, performs the all-tenant seed, restores FORCE on both sources, and only then enables/forces RLS on the new control target. Migration 0034 keeps RLS enabled but temporarily applies `NO FORCE ROW LEVEL SECURITY` to all four participating tables—`accounting_book`, `fiscal_period`, `accounting_book_period_control`, and `period_journal_population_fence`—for the owner-only repair window, then restores FORCE on all four before the migration transaction commits. This is not a runtime gate weakening: non-owner roles remain subject to RLS throughout and the committed schema remains FORCE RLS protected. + +The migration must run as owner of the participating relations; it must not solve installation by granting `BYPASSRLS`, impersonating a tenant, disabling row security, or copying tenant truth into an installer-side cache. `tests/test_book_period_control_seed_contract.py` pins source and target ordering and requires that the repair never uses `DISABLE ROW LEVEL SECURITY`. Exact release acceptance still needs a real PostgreSQL migration run with a production-like unbound `NOSUPERUSER`/`NOBYPASSRLS` owner; static ordering evidence alone is not execution GREEN. ### Remaining application advisory-lock hotspot @@ -93,7 +95,7 @@ Using only a trigger-side existence query for retained snapshots was rejected be Allowing caller-provided close timestamps, currencies, aggregate identifiers, or retained arithmetic was rejected because those values are accounting evidence and must be derived or verified at the authoritative database boundary. -Granting the migration role `BYPASSRLS`, disabling RLS for the backfill, or binding an infrastructure role to a fabricated runtime tenant was rejected. Those approaches turn an installation concern into standing or misleading runtime authority. Owner-only `NO FORCE` inside the uncommitted migration preserves the tenant policy for runtime roles and restores FORCE before any new schema state commits. +Granting the migration role `BYPASSRLS`, disabling RLS for the backfill, or binding an infrastructure role to a fabricated runtime tenant was rejected. Those approaches turn an installation concern into standing or misleading runtime authority. Owner-only `NO FORCE` inside the uncommitted migration preserves RLS for runtime roles and restores FORCE before any new schema state commits. ## Consequences and operational evidence @@ -101,7 +103,7 @@ At the database boundary, open-period posting performs a shared control-row lock After that source repair, PostgreSQL row locking and same-slot collisions still have measurable cost. Release evidence must use realistic concurrent posting and transition workloads and report advisory-lock waits, row-lock waits, stripe distribution, failures, retry rates, WAL/write cost, and tail latency without sample reduction, excluded failures, or artificial cache warm-up. -Migration 0033 creates a new tenant-scoped table under RLS/FORCE RLS. Its initial cross-tenant fence backfill occurs before FORCE RLS is enabled so a non-superuser schema owner can seed every existing book-period without impersonating one runtime tenant. Migration 0034 later repairs missing book-period controls under an owner-only `NO FORCE` window on both forced-RLS target tables and restores FORCE before commit. Future rows are seeded from the book-period-control INSERT transaction and stay in that tenant scope. Guard functions are `SECURITY DEFINER`, use `search_path = pg_catalog, pg_temp`, and revoke PUBLIC execute. +Migration 0033 creates a new tenant-scoped table under RLS/FORCE RLS. Its initial cross-tenant fence backfill occurs before FORCE RLS is enabled. Migrations 0009/0034 now also account for the already forced source tables: the migration owner gets only a transactional owner-bypass window on the exact participating relations, while RLS remains enabled for non-owner roles and FORCE is restored before commit. Future rows are seeded from the book-period-control INSERT transaction and stay in that tenant scope. Guard functions are `SECURITY DEFINER`, use `search_path = pg_catalog, pg_temp`, and revoke PUBLIC execute. The migration chain has distinct recovery states. A failed 0029 concurrent index build can leave an invalid index that operators must remove or rebuild before retry. A failed 0031 validation leaves the constraint enforced for subsequent writes but inherited rows uncertified. Failed 0033 or 0034 migration transactions roll back their tables/triggers/functions/policies or owner-force toggles and seed population together. A runtime SQLSTATE `40001` leaves no authoritative close result and requires a whole-command retry. Recovery must never normalize or rewrite posted journals, reconciliation evidence, or retained close facts. From cca0f5a7f9b1b4450933aea389e035863021503a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 12:15:31 +0900 Subject: [PATCH 109/224] test(close): verify owner repair restores forced RLS --- tests/test_book_period_control_seed.py | 30 ++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/tests/test_book_period_control_seed.py b/tests/test_book_period_control_seed.py index 849b3ea9..c198198c 100644 --- a/tests/test_book_period_control_seed.py +++ b/tests/test_book_period_control_seed.py @@ -50,6 +50,36 @@ def test_period_open_seeds_control_and_all_fences_for_existing_book(self) -> Non self.assertEqual(control_count, 1) self.assertEqual(fence_count, 64) + def test_seed_sources_and_targets_finish_with_forced_rls(self) -> None: + """Owner-only migration visibility must never leak into the committed runtime schema.""" + expected_relations = { + "accounting_book", + "fiscal_period", + "accounting_book_period_control", + "period_journal_population_fence", + } + with psycopg.connect(posting.DATABASE_URL) as connection: + rows = connection.execute( + """ + SELECT pg_class.relname, + pg_class.relrowsecurity, + pg_class.relforcerowsecurity + FROM pg_catalog.pg_class + JOIN pg_catalog.pg_namespace + ON pg_namespace.oid = pg_class.relnamespace + WHERE pg_namespace.nspname = 'accounting_core' + AND pg_class.relname = ANY(%s) + """, + (list(expected_relations),), + ).fetchall() + + actual = { + str(name): (bool(rls_enabled), bool(rls_forced)) + for name, rls_enabled, rls_forced in rows + } + self.assertEqual(set(actual), expected_relations) + self.assertEqual(actual, {name: (True, True) for name in expected_relations}) + def _control_and_fence_counts(self, period_code: str) -> tuple[int, int]: """Return retained control and stripe cardinality for this fixture's primary book-period.""" with psycopg.connect(posting.DATABASE_URL) as connection: From 895a1d144a3734e354f43e5f4b597be35f5e41b7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 12:15:58 +0900 Subject: [PATCH 110/224] docs(close): add real PostgreSQL forced-RLS runtime acceptance --- .../BOOK_PERIOD_CONTROL_RLS_MIGRATION_TRACEABILITY.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/doctoring/BOOK_PERIOD_CONTROL_RLS_MIGRATION_TRACEABILITY.md b/docs/doctoring/BOOK_PERIOD_CONTROL_RLS_MIGRATION_TRACEABILITY.md index d5cc6e12..8ae80b7f 100644 --- a/docs/doctoring/BOOK_PERIOD_CONTROL_RLS_MIGRATION_TRACEABILITY.md +++ b/docs/doctoring/BOOK_PERIOD_CONTROL_RLS_MIGRATION_TRACEABILITY.md @@ -29,7 +29,8 @@ These `ALTER TABLE` operations take PostgreSQL table locks inside the same migra - Fresh source-visibility RED `29808a77426e403d2c0277264ef6d2217f0e52d1` extends `tests/test_book_period_control_seed_contract.py` to require the owner-only window on `accounting_book` and `fiscal_period` as well as the control/fence targets. - 0009 source-side repair `f5a28af32f70de66be5d702c5cf404b735546699` restores owner visibility on the forced-RLS seed sources and restores FORCE before target policy activation. - 0034 full source/target repair `027fae479a7ae52b1db3119acd6549f50aa6dba2` surrounds the repair backfill with owner-only visibility on all four participating tables and restores FORCE on all four before commit. -- ADR alignment began with `dc06c5cbe6c9d3dba03b8fb07901838611e673fa`; runtime-identity clarification `e040f3447700abfa5291237fa094c88019068e9f` makes an ordinary unbound `NOSUPERUSER`/`NOBYPASSRLS` migration owner distinct from runtime tenant and break-glass identities. +- ADR 0006 alignment `4a87f3522e7a2f7f8dc1faa4cde6e0d6f3ebb3dd`; runtime-identity clarification `e040f3447700abfa5291237fa094c88019068e9f` makes an ordinary unbound `NOSUPERUSER`/`NOBYPASSRLS` migration owner distinct from runtime tenant and break-glass identities. +- Real-PostgreSQL runtime-state acceptance `cca0f5a7f9b1b4450933aea389e035863021503a` verifies through `pg_catalog.pg_class` that `accounting_book`, `fiscal_period`, `accounting_book_period_control`, and `period_journal_population_fence` all finish the installed migration chain with both RLS enabled and FORCE restored. This verifies the committed-state half of the owner-window contract; production-like execution as an unbound non-bypass owner remains separate release evidence. These commits are development evidence only. Both static RED commits preceded their corresponding SQL repairs, but no runner-observed RED or exact-head GREEN claim is valid until GitHub Actions executes the corresponding heads. The final release candidate still requires the real PostgreSQL migration chain with a production-like unbound non-bypass owner, tenant-isolation acceptance, security/SAST/dependency checks, migration/recovery evidence, independent review, and protected integration gates. From 430f4dde6757c8bf09243a00787dabcfa97ab49c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 13:25:42 +0900 Subject: [PATCH 111/224] fix(posting): remove open-period close mutex --- src/accounting_information_platform/persistence.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/accounting_information_platform/persistence.py b/src/accounting_information_platform/persistence.py index d1ca1037..1d27c239 100644 --- a/src/accounting_information_platform/persistence.py +++ b/src/accounting_information_platform/persistence.py @@ -4451,7 +4451,6 @@ def _require_open_book_period_bounds( "Create an open fiscal period on the tenant calendar, then retry posting." ) period_id, period_code = row[0], row[1] - self._acquire_command_lock(connection, f"period:{book_id}:{period_code}") row = connection.execute( """ SELECT fiscal_period.fiscal_period_id, From 70c07aba7c51391b9ee965fe3948b23c9546642d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 13:30:00 +0900 Subject: [PATCH 112/224] test(posting): ratchet open-period lock removal --- ...test_open_period_application_lock_contract.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/tests/test_open_period_application_lock_contract.py b/tests/test_open_period_application_lock_contract.py index 6e74ef48..e62d46e2 100644 --- a/tests/test_open_period_application_lock_contract.py +++ b/tests/test_open_period_application_lock_contract.py @@ -11,9 +11,9 @@ class OpenPeriodApplicationLockContractTests(unittest.TestCase): - """Keep ordinary posting from collapsing onto the close-command advisory mutex.""" + """Keep ordinary posting from collapsing onto close-command advisory mutexes.""" - def test_open_period_lookup_does_not_take_exclusive_close_command_lock(self) -> None: + def test_open_period_lookup_does_not_take_close_command_lock(self) -> None: """Database admission owns journal/transition ordering; ordinary posts need no close mutex.""" source = PERSISTENCE.read_text(encoding="utf-8") helper_start = source.index(" def _require_open_book_period_bounds(") @@ -21,14 +21,14 @@ def test_open_period_lookup_does_not_take_exclusive_close_command_lock(self) -> helper_source = source[helper_start:helper_end] self.assertNotIn( - 'self._acquire_command_lock(connection, f"period:{book_id}:{period_code}")', + "self._acquire_command_lock(", helper_source, - "ordinary open-period posting still serializes on the exclusive period-close advisory lock", + "ordinary open-period lookup must not acquire a command-level advisory mutex", ) - self.assertGreaterEqual( - helper_source.count("period_status_code"), - 2, - "removing the advisory mutex must retain the before/after open-state verification", + self.assertIn( + 'if row[2] != "open":', + helper_source, + "removing the advisory mutex must retain fail-closed application validation for a non-open period", ) From a5eee539a07180d5e25fbadd2cf98e654aead5f7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 13:32:41 +0900 Subject: [PATCH 113/224] docs(architecture): align period-close fence ownership --- docs/ARCHITECTURE.md | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 042c6a08..6cbd4105 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -67,6 +67,15 @@ The PostgreSQL 18 foundation is installed in order: 24. `database/migrations/0023_reconciliation_authority_outbox_orphan_guard.sql` — reserves reconciliation exception-resolution and run-reconciled event types for immutable command-backed authority, rejects orphan authority-shaped events at commit, and refuses installation over pre-existing orphan events after a migration-only forced-RLS visibility preflight. 25. `database/migrations/0024_reconciliation_control_recording_time_authority.sql` — makes reconciliation exception and retained review-evidence `recorded_at` database-owned at insertion while preserving their separate valid/business `effective_at`, so caller-shaped system time cannot manufacture temporal provenance used by maker-checker admission. 26. `database/migrations/0025_reconciliation_lifecycle_recording_time_authority.sql` — makes lifecycle-transition `recorded_at` database-owned at insertion and rejects a transition whose business-valid `effective_at` is later than PostgreSQL's recording time, preventing a future-effective decision from becoming current reconciliation authority. +27. `database/migrations/0026_reconciliation_lifecycle_source_payload_identity.sql` — binds reconciliation lifecycle authority to immutable source-payload identity rather than caller-shaped mutable transition evidence. +28. `database/migrations/0027_reconciliation_lifecycle_session_lock_authority.sql` — proves lifecycle snapshot freshness through the canonical session-lock lease and fresh authority transaction boundary. +29. `database/migrations/0028_reconciliation_lifecycle_capability_privileges.sql` — revokes generic PUBLIC execution of lifecycle session-lock helpers so later runtime grants can remain purpose-limited. +30. `database/migrations/0029_trial_balance_snapshot_population_unique_index.sql` — establishes one retained trial-balance snapshot population per tenant, accounting book, and fiscal period without rewriting retained evidence. +31. `database/migrations/0030_trial_balance_snapshot_immutability.sql` — freezes retained snapshot headers and lines, owns snapshot system time, and enforces book/entity/currency/account scope and hard-close admission at PostgreSQL. +32. `database/migrations/0031_trial_balance_line_conservation_validation.sql` — validates exact retained debit/credit/net conservation separately from the stronger installation lock phase. +33. `database/migrations/0032_period_close_journal_population_fence.sql` — invalidates stale `REPEATABLE READ` close attempts when purpose-limited soft-close journal population changes. +34. `database/migrations/0033_open_period_journal_population_fence.sql` — places ordinary open-period journals on a 64-stripe PostgreSQL freshness fence and makes period transitions lock the complete ordered fence set instead of serializing every post on one close-control row. +35. `database/migrations/0034_book_period_control_seed.sql` — materializes missing book-period controls and all 64 freshness rows for newly admitted active books or fiscal periods while restoring FORCE RLS before commit. `0005_closed_period_guard.sql` makes `accounting_closing_writer` a `NOLOGIN` capability role. A soft-closed insert is admitted only when the session login is a member of that role **and** the transaction-local journal classification is `period_closing`, `adjusting`, or `reversal`. The GUC alone is not authority. Hard-closed periods reject every later journal insert. @@ -74,6 +83,8 @@ Deferred constraint triggers recompute persisted journal lines at commit. A dura `0020_reconciliation_exception_resolution_command.sql` keeps exception review inside the reconciliation aggregate. A direct `open -> resolved/superseded` row update is rejected unless a matching immutable command exists in the same transaction; the command and terminal status must commit as a pair, and terminal exception evidence freezes afterward. `0021_reconciliation_exception_resolution_outbox_pair.sql` extends that pair to the matching accounting outbox event and adds only the child resolution-evidence overlay. `0022_reconciliation_authority_outbox_retention.sql` then protects the committed authority/event relationship from later detachment or ambiguity: the sole matching event cannot be deleted or re-keyed away, a duplicate exact event cannot be inserted, and an unrelated event cannot be re-keyed into the same authority identity. `0023_reconciliation_authority_outbox_orphan_guard.sql` closes the inverse admission gap: the reserved `reconciliation_exception_resolved`, `reconciliation_exception_superseded`, and `reconciliation_run_reconciled` event types cannot exist without the exact immutable command whose tenant, aggregate reference, payload reference and command hash they claim to publish. Its upgrade preflight uses temporary current-user SELECT policies only to inspect forced-RLS history and removes them before the durable runtime guard is installed. `0024_reconciliation_control_recording_time_authority.sql` closes the temporal provenance gap on the maker and retained review artifact themselves: `recorded_at` is overwritten by PostgreSQL at INSERT while `effective_at` remains the business-valid-time fact. `0025_reconciliation_lifecycle_recording_time_authority.sql` applies the same database-clock boundary to the run-finalization command and fails closed when its valid time lies after the recording instant; caller-supplied lifecycle system time is not authority. Publication may still update `published_at`. The parent `accounting_reconciliation_transition_database_authority_guard` derives the bridge and all three transition identities first; child `accounting_reconciliation_transition_evidence_snapshot_guard` composes immutable resolution commands second; `accounting_reconciliation_transition_hash_guard` binds the final snapshot and population identities; the later recording-time trigger assigns only database system time and validates temporal causality. Final reconciliation accepts a terminal exception only when its target status agrees with the retained command. Exception resolution does not post a journal: any correcting journal remains a separate General Ledger command. +Migrations 0029–0034 keep Period Close evidence inside the `close_control` / `trial_balance` boundary. A retained snapshot cannot be relabelled across legal entity, accounting book, reporting currency, period, or chart-account scope, and its exact monetary population cannot be mutated after hard close. Journal-population freshness is a PostgreSQL concurrency control, not an alternate accounting authority: a stale close fails closed and the complete command must retry from a fresh transaction with the same immutable source identity/idempotency key. + ## Runtime identity boundary The application runtime database login is separate from the migration owner and from administrative / break-glass identities. Tenant-scoped tables use RLS and the runtime path is tested with a non-owner, non-superuser, non-`BYPASSRLS` login. Purpose-limited soft-close exceptions use explicit role membership; ordinary runtime identities do not inherit `accounting_closing_writer`. @@ -82,20 +93,23 @@ The HTTP surface currently binds tenant identity through the configured AIS tena ## Posting transaction -A proposal follows one authoritative transaction boundary: +An ordinary proposal follows one authoritative transaction boundary: ```text validate published proposal -> bind tenant / entity / book / open period -> resolve semantic roles to chart accounts - -> acquire tenant/command transaction lock and shared fiscal-period advisory lock + -> acquire tenant/proposal/idempotency command locks -> persist immutable proposal evidence + -> admit journal through PostgreSQL book-period guard and 64-stripe freshness fence -> persist balanced journal header and lines -> persist authoritative posting receipt -> persist transactional outbox evidence -> COMMIT ``` +Ordinary open-period posting does **not** acquire the canonical period-close advisory mutex. PostgreSQL owns journal-versus-period-transition ordering: open-period journal admission uses the book-period guard plus a deterministic stripe, while a period transition locks the complete ordered fence population. The close command retains its canonical tenant/resolved-book-id/period advisory authority. This avoids turning an open book-period into one application-level posting mutex without weakening fail-closed close semantics. + Exact replay returns the original receipt. Reuse of an idempotency key with changed immutable evidence fails closed. Posted journal facts are append-only; corrections use reversal and, when required, a separately posted replacement. ## Reversal boundary @@ -108,7 +122,7 @@ A reversal accounting date may not precede the original accounting date. Soft-cl Soft-close changes the fiscal-period state but writes no hard-close snapshot. Ordinary posting is blocked; purpose-limited adjusting / closing / reversal paths may remain available under database authorization. -Hard-close loads one repeatable-read close package, posts the AIS-owned period-closing journal when required, stores the hard-close trial-balance snapshot and locks the period. Later ordinary or reversal inserts into that period are rejected. Close replay is idempotent and does not create a second snapshot or closing journal. +Hard-close loads one repeatable-read close package, posts the AIS-owned period-closing journal when required, stores the hard-close trial-balance snapshot and locks the period. A journal population change that races a stale close must invalidate that close rather than allow retained evidence to omit an admitted journal. Later ordinary or reversal inserts into the hard-closed period are rejected. Close replay is idempotent and does not create a second snapshot or closing journal. ## Read models From 9cbac184054543adfc25ddc908bd68c8df3ab2aa Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 13:36:28 +0900 Subject: [PATCH 114/224] docs(trace): close resolved posting mutex finding --- ...PERIOD_CLOSE_JOURNAL_FENCE_TRACEABILITY.md | 23 +++++++++++-------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/docs/doctoring/PERIOD_CLOSE_JOURNAL_FENCE_TRACEABILITY.md b/docs/doctoring/PERIOD_CLOSE_JOURNAL_FENCE_TRACEABILITY.md index 8cd23a94..a78dfa5c 100644 --- a/docs/doctoring/PERIOD_CLOSE_JOURNAL_FENCE_TRACEABILITY.md +++ b/docs/doctoring/PERIOD_CLOSE_JOURNAL_FENCE_TRACEABILITY.md @@ -14,12 +14,12 @@ The current PostgreSQL documentation line is PostgreSQL 18; PostgreSQL 18.6 was ## Control mapping -| Concern | Chosen / required control | Rejected alternative or current finding | Executable evidence | +| Concern | Chosen / required control | Rejected alternative or repaired finding | Executable evidence | |---|---|---|---| | Database admission for ordinary `open` journals | `guard_period_insert()` takes `FOR SHARE` on the exact book-period control row, then increments one of 64 pre-existing journal-population fence rows selected from the journal UUID | Increment one shared book-period revision for every journal; that serializes all ordinary posting on one row | `tests/test_postgres_open_period_journal_fence.py`; migration `0033_open_period_journal_population_fence.sql` | -| Application-path ordinary posting concurrency | Ordinary Billing-owned proposals must not acquire the same exclusive tenant/book/period advisory mutex used to serialize close commands; the database fence is the final journal/transition authority | **Current repair finding:** `_require_open_book_period_bounds()` still calls `_acquire_command_lock(connection, f"period:{book_id}:{period_code}")`, so otherwise independent ordinary postings in one book-period are serialized before the striped database boundary | `tests/test_postgres_open_period_journal_fence.py::test_open_period_postings_do_not_serialize_on_application_period_lock`; `tests/test_open_period_application_lock_contract.py` | +| Application-path ordinary posting concurrency | Ordinary Billing-owned proposals do not acquire the exclusive tenant/book/period advisory mutex used to serialize close commands; proposal/idempotency coordination remains application-owned and the database fence is the final journal/transition authority | Repaired defect: `_require_open_book_period_bounds()` previously acquired `_acquire_command_lock(connection, f"period:{book_id}:{period_code}")`, serializing unrelated ordinary postings before the striped database boundary | `tests/test_postgres_open_period_journal_fence.py::test_open_period_postings_do_not_serialize_on_application_period_lock`; `tests/test_open_period_application_lock_contract.py`; production repair `430f4dde6757c8bf09243a00787dabcfa97ab49c` | | Direct `open` → close freshness | Before a period status transition commits, PostgreSQL locks all 64 pre-existing fence rows in slot order. A fence modified after the close transaction's `REPEATABLE READ` snapshot causes SQLSTATE `40001`, so stale close evidence rolls back | A shared control-row lock alone; it prevents transition overtaking an in-flight journal but carries no row version showing that a journal committed after the close snapshot | `tests/test_postgres_open_period_close_serialization_red.py` | -| Compatible database open-path coordination | Open journals share the control-row lock; only journals landing on the same stripe compete for the stripe UPDATE | Treating the database lock profile as proof of end-to-end posting parallelism while the application still holds an exclusive advisory period lock | `tests/test_postgres_open_period_journal_fence.py::test_open_period_posting_can_progress_while_peer_holds_share_fence` | +| Compatible database open-path coordination | Open journals share the control-row lock; only journals landing on the same stripe compete for the stripe UPDATE | Treating database lock compatibility, or removal of the application period mutex, as a performance result without exact-head concurrent measurement | `tests/test_postgres_open_period_journal_fence.py::test_open_period_posting_can_progress_while_peer_holds_share_fence` | | State changes while an open journal waits | Fail with SQLSTATE `40001` and retry the whole journal command from a fresh transaction | Continue using stale open-state authority after waiting | migrations `0032`/`0033`; `tests/test_trial_balance_snapshot_immutability_contract.py` | | `soft_closed` adjusting/reversal/closing journal freshness | Purpose-limited close-window journals increment `accounting_book_period_control.journal_population_revision` in the journal transaction | Advisory-lock wait only; a pre-existing repeatable-read snapshot remains stale after lock grant | `tests/test_postgres_period_close_journal_serialization_red.py` | | Direct hard-close snapshot authority | A snapshot while the book-period is still `open` is admitted only when the session has `accounting_closing_writer` capability **and** holds the canonical tenant/resolved-book-id/period close advisory lock; a bare GUC/role cannot pre-populate open-period evidence | Require `soft_closed` for every snapshot, which breaks the published direct `open` → `hard_closed` command; or accept a bare role/GUC, which permits pre-population | migration `0033`; `tests/test_postgres_open_period_close_serialization_red.py` | @@ -43,13 +43,15 @@ That combination exposed a lifecycle gap for master data created after migration Migration 0034 keeps ownership in the database control boundary. An `AFTER INSERT` trigger on `fiscal_period` creates controls for all active books of the same tenant; a complementary `AFTER INSERT` trigger on `accounting_book` creates controls for all existing periods when the new book is active. Both use conflict-safe inserts. Each newly inserted control row synchronously invokes migration 0033's fence seeder, so all 64 stripes exist in the same transaction before the new master-data row becomes visible. The migration also backfills any active book-period pairs missed by earlier installation order. No journal amount, accounting policy, or foreign commercial truth is derived by these seeders. -## Remaining application serialization finding +## Application serialization repair -The database repair does not yet remove every high-volume serialization point. `PostgresPostingLedger._require_open_book_period_bounds()` still acquires the canonical exclusive advisory lock `period:{book_id}:{period_code}` for every ordinary proposal. `close_fiscal_period()` deliberately uses the same identity to serialize close commands. Consequently two unrelated Billing proposals for the same open book-period can queue at the application boundary before either reaches the 64-stripe database fence. +The database striping repair originally left a separate application bottleneck: `PostgresPostingLedger._require_open_book_period_bounds()` acquired the same canonical exclusive advisory identity `period:{book_id}:{period_code}` that `close_fiscal_period()` uses for close-command serialization. Two unrelated Billing proposals for one open book-period therefore queued before either reached the 64-stripe database fence. -This is a separate defect from the stale-close correctness race. The source repair must remove that close-command advisory mutex from the ordinary open-posting helper while preserving both open-state checks. The database `FOR SHARE` + striped revision boundary then owns journal-versus-transition ordering, while the period advisory lock remains for close-command serialization. The repair must not weaken idempotency locks, snapshot authority, role checks, or the database transition fence. +Real-PostgreSQL RED `1683fd5f8e21e907a187bea7c239e3d30f8d0bdb` pauses one ordinary proposal after open-period admission but before journal persistence, then requires a second ordinary proposal to complete before the first is released. Static RED `839e930a4f24eda1083742578894479a8ed968bf` pinned removal of the close-command advisory acquisition from the ordinary helper. -Real-PostgreSQL RED `1683fd5f8e21e907a187bea7c239e3d30f8d0bdb` pauses one ordinary proposal after open-period admission but before journal persistence, then requires a second ordinary proposal to complete before the first is released. Current source is expected to fail that contract because both commands take the same exclusive period advisory mutex. Static RED `839e930a4f24eda1083742578894479a8ed968bf` pins the causal source requirement directly. These REDs are not GREEN until the production helper is changed and exact-head PostgreSQL execution proves the overlap. +Production repair `430f4dde6757c8bf09243a00787dabcfa97ab49c` removes only that period-close advisory acquisition from `_require_open_book_period_bounds()`. It does not weaken proposal/idempotency locks, the explicit application `open` check, the PostgreSQL period guard, snapshot authority, role checks, or the transition fence. Close commands continue to own their canonical period advisory mutex. Successor `70c07aba7c51391b9ee965fe3948b23c9546642d` strengthens the static contract so **any** command-lock acquisition inside the ordinary helper fails the ratchet; it also removes the earlier token-count assertion that incorrectly described two application-level status checks when the implementation has one explicit post-query `open` validation. + +The source repair closes the causal application mutex defect, but it is not promoted to execution GREEN merely because the diff is correct. The exact unchanged successor head still has to execute the real PostgreSQL overlap regression and the complete Accounting Foundation/security/dependency/SAST gate set. Performance acceptance additionally requires measured lock waits, stripe collisions, retry denominator, WAL/write cost, and buyer-path p95. ## TDD and repair lineage @@ -62,15 +64,16 @@ Real-PostgreSQL RED `1683fd5f8e21e907a187bea7c239e3d30f8d0bdb` pauses one ordina - Migration self-review moved the cross-tenant backfill before FORCE RLS: `a6d32fc35f6f0f48fbcec6b08fa16b1a89eb5f80`. - Direct-open authority/fence-completeness PostgreSQL cases: `35c76f4dfda3b2d299b82eb28e06a9c2a9a6ba49`. - End-to-end application advisory serialization RED: `1683fd5f8e21e907a187bea7c239e3d30f8d0bdb`; causal source ratchet: `839e930a4f24eda1083742578894479a8ed968bf`. +- Open-period application mutex production repair: `430f4dde6757c8bf09243a00787dabcfa97ab49c`; stronger static no-command-lock ratchet: `70c07aba7c51391b9ee965fe3948b23c9546642d`. - Post-install master-data control/fence RED: `e5f40ca368a60394d0975d75baf249edfe876552`; database-owned dual-side seeding repair: `e22c2a6d9ad945eba986ec81f599cbd7dea60392`; canonical installer inclusion: `0d619c27cc2a0f56d501ed33fe50ed8746f4f2e9`. -These commits are development evidence, not protected-head release evidence. The REDs were committed before their respective causal repairs, but queued GitHub runners have not supplied an observed RED/GREEN transition for the current application-lock finding or the new book-period seeding repair. Exact-head PostgreSQL CI, security/SAST/dependency evidence, independent review, central workflow gates, package/SBOM/provenance, migration/recovery verification, and measured performance must be reacquired after every head change. +These commits are development evidence, not protected-head release evidence. The REDs were committed before their respective causal repairs. Exact-head PostgreSQL CI, security/SAST/dependency evidence, independent review, central workflow gates, package/SBOM/provenance, migration/recovery verification, and measured performance must be reacquired after every head change. ## Residual risk and release acceptance -Migrations 0033/0034 remove the deliberate **database** single-row revision hotspot and ensure its authority rows exist for post-install master data, but they do not make end-to-end posting concurrent while the application period advisory mutex remains. That source finding is therefore release-blocking for the stated hot-path goal and must not be hidden behind database-only lock evidence. +Migrations 0033/0034 remove the deliberate database single-row revision hotspot, and the application repair removes the separate close-command period mutex from ordinary posting. Neither fact proves buyer-path latency. Same-slot journals can still serialize on a selected fence row, open journals still hold the book-period `FOR SHARE` lock through transaction end, and a period transition deliberately locks all 64 rows. PostgreSQL notes that row locking can cause writes and blocking; the selected stripe count remains a measured engineering parameter rather than a correctness theorem. -After the source repair, striping still has a cost. PostgreSQL notes that row locking can cause writes, and same-slot journals can still serialize on the selected stripe. Release acceptance therefore requires measured concurrent posting plus period-transition load at the exact candidate head, reporting advisory-lock waits, row-lock waits, stripe collision distribution, retries, WAL/write cost, and tail latency rather than hiding them with cache warm-up, reduced samples, or excluded failures. +Release acceptance therefore requires measured concurrent posting plus period-transition load at the exact candidate head, reporting advisory-lock waits, row-lock waits, stripe collision distribution, retries and failure denominator, WAL/write cost, and tail latency rather than hiding them with cache warm-up, reduced samples, or excluded failures. A `40001` result is not accounting evidence. The caller must retry the complete command with the same immutable source-payload identity/idempotency contract. A failed stale close must leave no retained snapshot, closing journal, close event, or authoritative state transition. Recovery must never rewrite posted journals, retained trial-balance evidence, or reconciliation authority rows to make a failed close appear successful. From f109228a31f47223c97285fe614fa32c4aff5d99 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 13:38:07 +0900 Subject: [PATCH 115/224] docs(adr): record open-post mutex repair --- docs/adr/0006-fiscal-period-close-snapshot.md | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/docs/adr/0006-fiscal-period-close-snapshot.md b/docs/adr/0006-fiscal-period-close-snapshot.md index 520eff35..724792b4 100644 --- a/docs/adr/0006-fiscal-period-close-snapshot.md +++ b/docs/adr/0006-fiscal-period-close-snapshot.md @@ -67,13 +67,17 @@ Migration 0009 therefore restores normal owner visibility with `NO FORCE ROW LEV The migration must run as owner of the participating relations; it must not solve installation by granting `BYPASSRLS`, impersonating a tenant, disabling row security, or copying tenant truth into an installer-side cache. `tests/test_book_period_control_seed_contract.py` pins source and target ordering and requires that the repair never uses `DISABLE ROW LEVEL SECURITY`. Exact release acceptance still needs a real PostgreSQL migration run with a production-like unbound `NOSUPERUSER`/`NOBYPASSRLS` owner; static ordering evidence alone is not execution GREEN. -### Remaining application advisory-lock hotspot +### Application advisory-lock repair -The striped database fence is not yet an end-to-end posting-concurrency GREEN. `PostgresPostingLedger._require_open_book_period_bounds()` still acquires the same exclusive tenant/resolved-book/period advisory lock used by `close_fiscal_period()` for every ordinary Billing proposal. That makes unrelated ordinary postings for one open book-period queue before either reaches the striped database boundary. +The striped database fence initially was not an end-to-end posting-concurrency repair. `PostgresPostingLedger._require_open_book_period_bounds()` acquired the same exclusive tenant/resolved-book/period advisory lock used by `close_fiscal_period()` for every ordinary Billing proposal. That made unrelated ordinary postings for one open book-period queue before either reached the striped database boundary. -This application mutex is no longer selected as ordinary journal-versus-transition authority. The intended causal repair is to remove the `period:{book_id}:{period_code}` advisory acquisition from `_require_open_book_period_bounds()` while retaining its before/after open-state verification. Proposal idempotency locks remain. Close commands retain the canonical period advisory lock. PostgreSQL `FOR SHARE` plus the pre-existing striped witness remains the authoritative journal-versus-state-transition fence. +This application mutex is rejected as ordinary journal-versus-transition authority. Real-PostgreSQL RED `1683fd5f8e21e907a187bea7c239e3d30f8d0bdb` pauses one ordinary proposal after period admission but before journal persistence and requires another ordinary proposal to complete before the first resumes. Static causal RED `839e930a4f24eda1083742578894479a8ed968bf` pinned removal of the close-command advisory acquisition from `_require_open_book_period_bounds()`. -`tests/test_postgres_open_period_journal_fence.py::test_open_period_postings_do_not_serialize_on_application_period_lock` is a real-PostgreSQL RED for this hotspot: it pauses one ordinary proposal after period admission but before journal persistence and requires another ordinary proposal to complete before the first resumes. `tests/test_open_period_application_lock_contract.py` pins the exact source boundary. Until production source changes and both tests are exact-head GREEN, the branch must not claim end-to-end open-post concurrency or the p95 target. +Production repair `430f4dde6757c8bf09243a00787dabcfa97ab49c` removes only the `period:{book_id}:{period_code}` advisory acquisition from the ordinary helper. Proposal/idempotency locks remain. The helper retains explicit fail-closed application validation when the resolved book-period is not `open`. Close commands retain the canonical period advisory lock. PostgreSQL `FOR SHARE` plus the pre-existing striped witness remains the authoritative journal-versus-state-transition fence. + +Successor `70c07aba7c51391b9ee965fe3948b23c9546642d` strengthens `tests/test_open_period_application_lock_contract.py`: any command-lock acquisition inside the ordinary helper now fails the source ratchet, and the test requires the explicit non-open-period validation to remain. It deliberately removes an earlier `period_status_code` token-count assertion that overstated a before/after application check not present in the implementation. + +The causal source defect is repaired, but exact-head execution evidence is still required before the branch claims end-to-end GREEN or the p95 target. `tests/test_postgres_open_period_journal_fence.py::test_open_period_postings_do_not_serialize_on_application_period_lock` is the real PostgreSQL overlap acceptance, and the same unchanged exact head must also pass the complete Accounting Foundation/security/SAST/dependency/release evidence chain. `tests/test_postgres_period_close_journal_serialization_red.py` exercises a `soft_closed` adjustment racing hard close. `tests/test_postgres_open_period_close_serialization_red.py` exercises an open-period adjusting journal committed after a direct-close snapshot, then requires stale-close rollback and exact-key retry to retain the live population. The latter remains a valid correctness case because the adjusting command does not take the ordinary proposal's book-period advisory mutex. @@ -83,7 +87,7 @@ Updating one `journal_population_revision` for every admitted journal was reject Using only the control-row `FOR SHARE`/`FOR UPDATE` protocol was rejected because it orders transaction completion around the state change but supplies no row version proving that a journal committed after a pre-existing repeatable-read snapshot. -Using the canonical close advisory mutex for every ordinary proposal is now rejected as a throughput strategy. It serializes independent postings at application scope and duplicates ordering already owned by the database admission/transition fence. The mutex remains appropriate for close-command serialization and close-snapshot authority. +Using the canonical close advisory mutex for every ordinary proposal is rejected as a throughput strategy. It serializes independent postings at application scope and duplicates ordering already owned by the database admission/transition fence. The mutex remains appropriate for close-command serialization and close-snapshot authority. Relying only on advisory-lock wait for freshness was rejected because a waiting repeatable-read transaction retains the snapshot established before lock grant. Advisory-lock ownership and MVCC freshness are separate facts. @@ -99,9 +103,9 @@ Granting the migration role `BYPASSRLS`, disabling RLS for the backfill, or bind ## Consequences and operational evidence -At the database boundary, open-period posting performs a shared control-row lock plus one striped revision UPDATE rather than one exclusive UPDATE on the common book-period row. This removes the deliberate database single-row hotspot. The current application advisory mutex still serializes ordinary proposals and is a release-blocking repair finding for the stated hot-path goal. +At the database boundary, open-period posting performs a shared control-row lock plus one striped revision UPDATE rather than one exclusive UPDATE on the common book-period row. The application no longer adds the close-command period advisory mutex to ordinary proposals. This removes the two deliberate common serialization points identified by the RED lineage, but it is not itself a latency result. -After that source repair, PostgreSQL row locking and same-slot collisions still have measurable cost. Release evidence must use realistic concurrent posting and transition workloads and report advisory-lock waits, row-lock waits, stripe distribution, failures, retry rates, WAL/write cost, and tail latency without sample reduction, excluded failures, or artificial cache warm-up. +PostgreSQL row locking and same-slot collisions still have measurable cost. Release evidence must use realistic concurrent posting and transition workloads and report advisory-lock waits, row-lock waits, stripe distribution, failures, retry rates, WAL/write cost, and tail latency without sample reduction, excluded failures, or artificial cache warm-up. Migration 0033 creates a new tenant-scoped table under RLS/FORCE RLS. Its initial cross-tenant fence backfill occurs before FORCE RLS is enabled. Migrations 0009/0034 now also account for the already forced source tables: the migration owner gets only a transactional owner-bypass window on the exact participating relations, while RLS remains enabled for non-owner roles and FORCE is restored before commit. Future rows are seeded from the book-period-control INSERT transaction and stay in that tenant scope. Guard functions are `SECURITY DEFINER`, use `search_path = pg_catalog, pg_temp`, and revoke PUBLIC execute. From 7bde5a856a299c7bec69d0b5041cd5e9d4462fd7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 13:40:06 +0900 Subject: [PATCH 116/224] chore(docs): restore canonical documentation ownership --- CHANGELOG.md | 1 - docs/doctoring/STANDARD_TRACEABILITY.md | 9 ++++----- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e8adb5b..7e6ec286 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,6 @@ ## [Unreleased] -- Added migration `0029_trial_balance_snapshot_immutability.sql` to make retained hard-close trial-balance evidence fail closed at the PostgreSQL boundary. Snapshot headers and lines cannot be updated or deleted; header/line admission serializes on the exact tenant/book/period authority row; no new population is admitted after `hard_closed`; and a pre-existing snapshot population causes canonical hard close to reject with `trial_balance_snapshot_population_conflict` rather than silently adopt a caller-shaped row or let a forged future `snapshot_generated_at` win latest-snapshot ordering. ADR 0006 records the one-population authority boundary and future reopen/correction must use explicit successor lineage rather than mutate retained evidence. - Added migration `0020_reconciliation_exception_resolution_command.sql` and a tenant/run/exception-scoped immutable maker-checker exception-resolution command. The command binds the complete incoming JSON command through `source_payload_hash`, retains reviewed resolution evidence separately, requires a reviewer distinct from the exception owner, preserves exact replay and changed-payload conflict semantics, emits the matching accounting outbox event atomically, and fails migration closed when legacy terminal exceptions lack provable maker-checker provenance. Resolution evidence cannot post or reverse journals, close periods, change accounting policy, or write foreign product truth. ADR 0062 records the authority boundary. - Added migration `0021_reconciliation_exception_resolution_outbox_pair.sql`: reconciliation exception-resolution command/status/outbox authority must commit as one database-checked unit, and run-finalization snapshots compose immutable resolution-command evidence without replacing the parent PostgreSQL-owned statement/book population identities. - Added migration `0022_reconciliation_authority_outbox_retention.sql`: after commit, exactly one matching reconciliation authority outbox event must remain for each immutable exception-resolution or run-transition command; deletion, identity re-key, duplicate authority insertion, and re-keying an unrelated event into the same authority identity fail closed while publication metadata may advance. diff --git a/docs/doctoring/STANDARD_TRACEABILITY.md b/docs/doctoring/STANDARD_TRACEABILITY.md index d0919d24..d5275176 100644 --- a/docs/doctoring/STANDARD_TRACEABILITY.md +++ b/docs/doctoring/STANDARD_TRACEABILITY.md @@ -6,29 +6,28 @@ | IAS 1 | Statement of financial position presents assets, liabilities, and equity; statement of profit or loss presents income and expenses; AIS stores that split as `account_class_code`. Hard-close transfers period profit or loss into equity account 310100 so the next sheet does not need a floating earnings plug. Billing unapplied cash is catalog liability 210200 and does not park into retained earnings. Controllers also read a leftover-cash rollforward that ties park, apply, and refund journals to that 210200 credit-normal closing. Output VAT payable stays catalog liability 210100; controllers also read a period VAT register that ties issued-invoice tax credits and issued-invoice-void tax debits to that 210100 credit-normal closing. The period-close binder always includes that same register document. A HomeTax filing command on AIS requires that register before any transmission attempt and fail-closes without calling NTS when the register or the purpose-limited HomeTax credential is missing. Wage-income withholding, including year-end settlement, is reserved off 210100 and waits for a published Orgmetra assignment plus a portal, Billing, or HRIS role code. Comparative information for a prior period is an optional query on the same statement GET. Year-to-date profit or loss is an optional scope on that same GET. The complete set also includes a statement of changes in equity that rolls opening equity plus period profit or loss plus other equity movements to closing equity. As-of chart-account balances and a one-account opening + period + closing rollforward accompany those statements from the same trial-balance snapshot or live books. Controllers also read that complete set as one close pack, an entity-level receivable aging worksheet that ties to the AR account-balance net, an entity-level payable aging worksheet that ties to the catalog tax-payable account-balance net, and a period-close binder that accompanies those statements with period status, the omit-basis trial balance, receivable aging, payable aging, leftover-cash rollforward on catalog 210200, the period VAT register on catalog 210100, and the durable hard-close receipt when one exists | Chart-account class migration, HTTP financial-statement read, HTTP changes in equity, HTTP account-balance inquiry, HTTP account rollforward, HTTP financial-statement package, HTTP receivable aging, HTTP payable aging, HTTP period-close package, HTTP unapplied-cash catalog, HTTP leftover-cash rollforward, HTTP period VAT register, HTTP fail-closed HomeTax submission, wage-income withholding reservation, ADR 0024, ADR 0025, ADR 0028, ADR 0032, ADR 0034, ADR 0035, ADR 0037, ADR 0039, ADR 0040, ADR 0041, ADR 0043, ADR 0044, ADR 0045, ADR 0046, and ADR 0047 | | IAS 7 | Statement of cash flows presents period cash movements by the indirect method: operational net income, non-cash working-capital change, investing, and financing, reconciling opening cash to closing cash that equals the balance-sheet cash total. Cash is the current `cash_receipt` mapping. AIS does not invent a cash-flow class code | HTTP cash-flow statement and ADR 0033 | | IAS 34 | Interim year-to-date income statements cover the current financial year through the requested period; the balance sheet remains a point-in-time statement as of that period | HTTP `statement_scope_code=year_to_date` and ADR 0028 | -| IAS 10 | Soft-close rejects ordinary posts and allows AIS-owned adjusting journals plus append-only reversing adjustments before hard-close snapshots and locks the period. Hard-close loads the close package in one consistent read, parks period earnings on 310100, and stores the close `idempotency_key` on the snapshot so a later journal or a different close key fails closed. Soft-close creates no persisted trial-balance snapshot; hard-close admits exactly one retained tenant/book/period snapshot population under the same database authority-row lock, and a pre-existing competing population makes the hard-close transaction fail closed instead of adopting caller-shaped evidence or selecting a forged future `snapshot_generated_at`. Retained snapshot headers and lines cannot later be extended, rewritten, or deleted. Auditors read durable hard-close receipts from stored snapshots, not reconstructed soft-close history. Controllers also read the unadjusted, adjusted, and post-close trial-balance worksheet on the existing TB GET, and list the adjusting worksheet population on the existing journal list | Two-step `POST /period-closes`, HTTP `POST /journals` adjusting write, HTTP period-close list, HTTP trial-balance basis, HTTP journal-source list, `0029_trial_balance_snapshot_population_unique_index.sql`, `0030_trial_balance_snapshot_immutability.sql`, real PostgreSQL snapshot-authority regressions, ADR 0006, ADR 0023, ADR 0024, ADR 0030, ADR 0031, ADR 0036, and ADR 0038 | +| IAS 10 | Soft-close rejects ordinary posts and allows AIS-owned adjusting journals plus append-only reversing adjustments before hard-close snapshots and locks the period. Hard-close loads the close package in one consistent read, parks period earnings on 310100, and stores the close `idempotency_key` on the snapshot so a later journal or a different close key fails closed. Auditors later read those durable hard-close receipts from stored snapshots, not reconstructed soft-close history. Controllers also read the unadjusted, adjusted, and post-close trial-balance worksheet on the existing TB GET, and list the adjusting worksheet population on the existing journal list | Two-step `POST /period-closes`, HTTP `POST /journals` adjusting write, HTTP period-close list, HTTP trial-balance basis, HTTP journal-source list, ADR 0023, ADR 0024, ADR 0030, ADR 0031, ADR 0036, and ADR 0038 | | IFRS 9 | Receivable aging is control evidence of credit risk at the legal-entity book, not a customer subledger and not an expected-credit-loss allowance. AIS ages posted AR FIFO through period end and does not invent `party_reference`. A Billing issued-invoice void credits AR through ordinary ingest on `{tenant}:issued_invoice_void:{issued_invoice_void_id}:{void.source_payload_hash}:v{issued_invoice_void_contract_version}` and drops entity receivable aging by that inclusive amount; it is not a collection-status command and does not require Billing `reversed_journal_proposal_id` or `invoice_draft_id`. A Billing issued-credit-note void debits AR through ordinary ingest on `{tenant}:issued_credit_note_void:{issued_credit_note_void_id}:{void.source_payload_hash}:v{issued_credit_note_void_contract_version}` and raises entity receivable aging by that inclusive amount the credit had reduced; it does not require Billing `journal_entry_id`. A Billing collection write-off of a financial asset posts debit `write_off_expense` 510100 / credit AR through ordinary ingest, reduces entity receivable aging by that amount, and parks that expense into retained earnings on hard-close; it is not an ECL allowance and is not `period_closing` | HTTP receivable aging, HTTP issued-invoice-void consume, HTTP collection write-off catalog, ADR 0039, and ADR 0042 | | IFRS 18 | Financial-statement presentation is a versioned projection separate from the journal core | Reporting boundary and roadmap | | ISO 20022-1:2026 / ISO 20022-4:2026 / ISO 20022-9:2026 / RA camt.053.001.14 | Bank-statement adapter pins `BankToCustomerStatementV14`, vendors SHA-256 adapter evidence, rejects other revisions, stores hashes/locators rather than raw XML, records `TxDtls/AmtDtls/TxAmt/Amt` only, and fail-closes when the statement account-identifier hash does not match the registered bank account. Deterministic bank-to-book matching consumes that normalized evidence but treats matching precedence as an AIS control rather than an ISO-prescribed algorithm: present provider/end-to-end/account-servicer identities outrank the weaker exact-money/date rule; amount, currency, and CRDT/DBIT economic direction must agree; direction conflicts fail closed as `direction_mismatch`; the reconciliation evidence boundary accepts only finite, strictly positive `Decimal` amounts, rejecting binary floats, zero, negative, `NaN`, and infinite values before candidate comparison; `date_window_days` must be a non-negative integer (boolean, fractional, and negative values fail at policy construction); ambiguous populations abstain and a proposal never posts a journal. Reconciliation result structure is also fail-closed evidence: the default deterministic `reconciliation-decision/v1` contract requires a `match` to carry exactly one journal, while explicit reviewed `reconciliation-decision/v2` evidence may carry multiple journal references but still requires a non-empty journal population. Both versions require a finite strictly positive exact `Decimal` allocation and no exception code, and neither version grants reconciliation approval, period-close, journal-posting, or accounting-policy authority; an `abstain` carries no matched journal, exact zero `Decimal` allocation, and a non-empty exception code, so direct callers cannot forge success-shaped close-review input. CRDT/DBIT remains separate direction evidence rather than a signed-amount convention. The exact book-to-bank bridge is likewise an AIS close control rather than an ISO rule: it independently proves statement opening + movements = closing, posted-book opening + movements = closing, and reconciled book + outstanding book - outstanding bank = statement closing with exact `Decimal` values, retains run/statement/book population provenance, never tolerance-rounds a difference, and cannot post, reverse, or approve a journal. When a bridge enters period-close review it also carries immutable tenant/legal-entity/accounting-book/bank-account-assignment identity. The buyer close-review projection is a read-only AIS presentation over those controls: it requires its supplied scope to equal the bridge-bound scope, rejects unbound or relabelled same-currency bridges, carries that scope plus run/population provenance, exact bank/book/reconciled/outstanding/unexplained values, unresolved statement-entry references and preceding-run deltas; eligibility requires exactly one decision for every expected immutable statement entry, so missing, duplicate, or extraneous decisions fail closed; preceding-run deltas require both current and preceding bridges to be bound to the same immutable scope rather than currency equality or caller assertions alone; JSON/CSV preserve monetary values as decimal strings; `suitable_for_period_close_review` is evidence eligibility only and never reconciliation approval, period-close authority, or journal-posting permission. Split and aggregate allocation proposals are exact-`Decimal` conservation evidence: a split sums exactly to the statement amount, an aggregate conserves the exact journal-side total on both sides, and every `ReconciliationAllocation` is immutable, tenant- and run-scoped with no double consumption. Persisted `reconciliation_candidate` / `reconciliation_match` / `statement_match_allocation` / `journal_match_allocation` rows are forced-RLS tenant-scoped; migration 0015 enforces source-level allocation conservation so multiple disjoint approved matches remain legal without double consumption, while migration 0016 binds approval to the database-owned candidate/allocation snapshot and freezes late allocations. Allocation planning and persistence still never post, reverse, approve, or adjust a journal | Immutable bank-statement evidence registry, deterministic reconciliation proposal engine, exact book-to-bank bridge projection, close-review projection and exact-value export regressions, population/scope and bridge-scope regressions, decision-structure regressions, direction, monetary-domain, policy, and bridge regressions, ADR 0052, ADR 0054, ADR 0055 | | PostgreSQL 18.4 | Use current supported minor release, UUIDv7, exact numeric types, composite foreign keys, forced row-level security, database-controlled `session_user` → tenant runtime binding, transaction-level advisory locks, bounded lock waits, shared fiscal-period command locks, close row locks, tenant-leading high-write indexes, and a partition migration contract that preserves partition-key identity. The journal header binds tenant + legal entity + accounting book through a composite foreign key so independently valid identifiers cannot cross legal-entity scope. The normalized journal line keeps no redundant book column; a database trigger instead rejects any chart account whose accounting book differs from the parent journal. Ordinary runtime credentials cannot select or mutate the binding table and caller-controlled GUCs are not tenant authority | Initial migration, book-scope PostgreSQL regressions, data-model contract, runtime-tenant binding migration, real restricted-runtime RLS tests, ADR 0049, ADR 0050 | | PostgreSQL 18.4 test environment | The real regression environment uses PostgreSQL 18.4, matching the repository's PostgreSQL 18.4 compatibility pin. PostgreSQL's built-in `sha256(bytea)` and `encode(..., 'hex')` provide the database-owned approval snapshot digest. A row-level `BEFORE` trigger overwrites caller-supplied snapshot input, and a shared transaction-level advisory lock serializes approval, allocation, and terminal match transitions so a valid command cannot authorize a changed candidate/allocation population; migration 0017 applies the parent-row-first repair so concurrent approval and allocation cannot form a row/advisory deadlock. Immutable source-payload hash/reference provenance remains separate from that state digest. | `0016_reconciliation_approval_evidence.sql`, `0017_reconciliation_approval_lock_order.sql`, real PostgreSQL snapshot and lock-order regressions, ADR 0055, PostgreSQL binary-string, trigger, and transaction-isolation documentation | -| IFRS Conceptual Framework §§2.12–2.13 / PostgreSQL 18 row constraints | Retained hard-close trial-balance lines must be internally coherent financial evidence: `net_balance_amount = debit_total_amount - credit_total_amount` exactly in the authoritative fixed-scale decimal domain. The IFRS Conceptual Framework supplies the faithful-representation quality rationale only; it does not prescribe this SQL formula. PostgreSQL is the enforcement authority: migration 0030 adds the immutable row-local `trial_balance_line_net_balance_conservation` check as `NOT VALID`, and separate autocommit migration 0031 validates inherited rows after migration 0030 releases its stronger `ADD CONSTRAINT` lock. Inconsistent inherited evidence blocks completion rather than being silently normalized or certified. | `0030_trial_balance_snapshot_immutability.sql`, `0031_trial_balance_line_conservation_validation.sql`, `tests/test_postgres_trial_balance_snapshot_scope_red.py`, `tests/test_trial_balance_snapshot_immutability_contract.py`, `docs/doctoring/TRIAL_BALANCE_CONSERVATION_TRACEABILITY.md`, ADR 0006 | | PostgreSQL source-conservation controls | Migration 0015 serializes candidate amount admission on stable statement (`bank_account_record_id`) and journal source identities before conflict reads, and uses the same stable statement identity across effective-dated assignment rollover during approval capacity checks. Allocation evidence freezes the candidate identity before approval; `superseded` is terminal; and the migration-0016 legacy-row check temporarily grants only the current migration user visibility over forced-RLS match rows before dropping that policy in the same transaction. Migration 0017 keeps approval/allocation lock acquisition parent-row-first. | `0015_reconciliation_multi_match_conservation.sql`, `0016_reconciliation_approval_evidence.sql`, `0017_reconciliation_approval_lock_order.sql`, hardening RED/GREEN regressions, ADR 0054, ADR 0055 | | ISO 20022 balance evidence / PostgreSQL numeric controls | The camt.053 adapter retains every bounded `Bal` as an immutable normalized fact with exact decimal amount, currency, CRDT/DBIT direction, typed effective date/time distinct from statement period and system `recorded_at`, source locator, source hash, and `balance_type_source_code` discriminator; proprietary `CdOrPrtry/Prtry` types remain material evidence even when their codes match standard codes. Migration 0018 stores those values in a forced-RLS relational table; opening and closing balance hashes remain compatibility fields, and a reconciliation bridge must fail closed when numeric balance evidence is absent rather than infer it. | `0018_bank_statement_balance_evidence.sql`, bank-statement parser and persistence regressions, ADR 0057, ISO 20022 references above, PostgreSQL constraints and row-level-security references | | Reconciliation run command evidence / PostgreSQL idempotency controls | Migration 0019 records one immutable tenant/run/statement command identity, canonical command hash, raw bank-statement artifact payload hash/reference distinct from the normalized statement hash, and forced-RLS evidence for opening an `evaluating` run. The public API requires the active statement assignment and exact raw artifact source hash, excludes selected source facts recorded after the knowledge cutoff, requires explicit zero-offset UTC cutoffs before persistence or command hashing, resolves existing command evidence before live assignment validation so exact retries survive assignment rollover or closure, permits separately auditable runs under distinct keys, rejects changed evidence, and the deferred database guard rejects orphan runs and cross-bank statement provenance. The boundary grants no matching, approval, close, posting, or chart-account authority. | `0019_reconciliation_run_command_evidence.sql`, `accept_reconciliation_run`, `lookup_reconciliation_run`, HTTP and direct-SQL reconciliation-run regressions, ADR 0058 | | Reconciliation lifecycle snapshot and system-time authority / PostgreSQL trigger and transaction semantics | Lifecycle finalization acquires the tenant/run session advisory lock before opening a fresh `REPEATABLE READ` authority transaction. Parent overlay `0019_reconciliation_run_database_snapshot_authority.sql` defines `reconciliation_run_database_snapshot_authority`, independently reconstructs the immutable statement and scoped posted-cash-book populations, reviewed allocations/decisions and exception state, validates exact source capacity and book-to-bank arithmetic, and returns PostgreSQL-owned snapshot plus statement/book population identities. `accounting_reconciliation_transition_database_authority_guard` overwrites all three caller values. Child migration 0021 then runs `accounting_reconciliation_transition_evidence_snapshot_guard`, which composes immutable maker-checker resolution commands into the final snapshot without changing the parent population identities; the existing hash trigger binds that reviewed snapshot. Migration 0025 separately makes `reconciliation_run_transition_command.recorded_at` database-owned at INSERT with PostgreSQL `clock_timestamp()` and rejects `effective_at > recorded_at`, so a future-effective business decision cannot become present lifecycle authority by supplying a caller-shaped system time. Direct SQL therefore cannot promote a forged digest, forged population references, an untied bridge, omitted resolution-command evidence, caller-controlled recording time, or future-effective transition into `reconciled` authority. This remains reconciliation evidence only and cannot post/reverse journals, close periods, or change accounting policy. | `0019_reconciliation_run_database_snapshot_authority.sql`, `0021_reconciliation_exception_resolution_outbox_pair.sql`, `0025_reconciliation_lifecycle_recording_time_authority.sql`, `tests/test_reconciliation_transition_database_snapshot_authority.py`, `tests/test_reconciliation_lifecycle_database_authority_postgres.py`, `tests/test_reconciliation_resolution_snapshot_overlay_contract.py`, `tests/test_reconciliation_lifecycle_recording_time_contract.py`, ADR 0060, database-transition snapshot authority doctoring, PostgreSQL trigger-order, date/time and transaction-isolation documentation | | PostgreSQL SECURITY DEFINER lifecycle capability / NIST SP 800-53 Rev. 5 AC-6 | Migration 0027 creates tenant/run lifecycle session-lock helpers as `SECURITY DEFINER` functions but revokes `PUBLIC EXECUTE` in the same creation transaction, preventing ordinary schema users from inheriting a lock-acquisition capability through PostgreSQL defaults. Migration 0028 repeats the revoke for already-applied predecessor 0027 installations. A real tenant-bound non-owner, non-superuser, non-`BYPASSRLS` runtime with ordinary schema/table access must receive PostgreSQL `InsufficientPrivilege` for both helpers. Issue #44 may later grant only the named lifecycle command capability through an owner-controlled purpose-limited role; raw transition/status/outbox DML remains prohibited. | `0027_reconciliation_lifecycle_session_lock_authority.sql`, `0028_reconciliation_lifecycle_capability_privileges.sql`, `tests/test_reconciliation_lifecycle_session_lock_authority_contract.py`, `tests/test_postgres_runtime_rls.py`, ADR 0066, lifecycle capability privilege doctoring, PostgreSQL 18 privileges documentation, NIST SP 800-53 Rev. 5 AC-6 | -| Reconciliation exception-resolution evidence / PostgreSQL maker-checker controls | Migration 0020 replaces terminal exception status as standalone authority with one immutable tenant/run/exception resolution command. The command separately retains the reviewed evidence digest and the SHA-256 identity of the complete incoming JSON command, uses the shared reconciliation idempotency namespace, freezes maker evidence from exception creation, requires a reviewer distinct from the exception owner, preserves exact replay and changed-payload conflict semantics, and commits command/status/outbox evidence atomically. Migration 0024 makes the source exception and retained review-evidence system chronology database-owned for new rows while preserving pre-migration rows as `legacy_unverified`; those legacy timestamps remain auditable but cannot back a new maker-checker authority decision. A legacy preflight refuses installation over terminal pre-0020 exception rows rather than inventing missing review provenance. Concurrent exact retries use fresh transactions after PostgreSQL serialization failure; run finalization accepts terminal exceptions only when status and durable command agree. This is an AIS accounting-control decision and never posts a journal, closes a period, or changes accounting policy. | `0020_reconciliation_exception_resolution_command.sql`, `0024_reconciliation_control_recording_time_authority.sql`, `resolve_reconciliation_exception`, real PostgreSQL raw-DML/maker-checker/replay/concurrency and recording-time regressions, migration-install regressions, ADR 0062, NIST SP 800-53 Rev. 5 AC-5, PostgreSQL date/time, transaction-isolation and row-security documentation | +| Reconciliation exception-resolution evidence / PostgreSQL maker-checker controls | Migration 0020 replaces terminal exception status as standalone authority with one immutable tenant/run/exception resolution command. The command separately retains the reviewed evidence digest and the SHA-256 identity of the complete incoming JSON command, uses the shared reconciliation idempotency namespace, freezes maker evidence from exception creation, requires reviewer separation and temporal causality, and commits command/status/outbox evidence atomically. Migration 0024 makes the source exception and retained review-evidence system chronology database-owned for new rows while preserving pre-migration rows as `legacy_unverified`; those legacy timestamps remain auditable but cannot back a new maker-checker authority decision. A legacy preflight refuses installation over terminal pre-0020 exception rows rather than inventing missing review provenance. Concurrent exact retries use fresh transactions after PostgreSQL serialization failure; run finalization accepts terminal exceptions only when status and durable command agree. This is an AIS accounting-control decision and never posts a journal, closes a period, or changes accounting policy. | `0020_reconciliation_exception_resolution_command.sql`, `0024_reconciliation_control_recording_time_authority.sql`, `resolve_reconciliation_exception`, real PostgreSQL raw-DML/maker-checker/replay/concurrency and recording-time regressions, migration-install regressions, ADR 0062, NIST SP 800-53 Rev. 5 AC-5, PostgreSQL date/time, transaction-isolation and row-security documentation | | RFC 9112 | The standalone HTTP/1.1 command boundary deliberately does not implement transfer coding: any request carrying `Transfer-Encoding` fails closed with HTTP 400 and connection close rather than being combined with a `Content-Length` interpretation. A valid `Content-Length` is an exact octet contract; premature EOF/short reads are incomplete messages, fail with HTTP 400, and close the connection before JSON/domain processing. This prevents ambiguous message boundaries from becoming request-smuggling or valid-prefix acceptance paths | `JournalProposalHandler._read_body`, HTTP request-boundary RED/GREEN regressions, RFC 9112 §§6.2–6.3 and §8 | | RFC 9562 | New persistence identifiers use UUIDv7 | Initial migration | | CloudEvents 1.0.2 | Commit authoritative events through a transactional outbox and replay by event identity | Outbox table and architecture | | SLSA 1.2 / SPDX 2.3 / GitHub artifact attestations | Exact-head package evidence builds the wheel twice from a source-derived `SOURCE_DATE_EPOCH`, requires byte-identical SHA-256 digests, emits a deterministic SPDX 2.3 SBOM plus `source-provenance.json`, and makes `SHA256SUMS` cover the wheel, SBOM and source-provenance manifest. After checksum verification, the rebuilt wheel is installed with `--require-hashes` from a requirements line that carries the measured `--hash=sha256:` digest. The intermediate public-API smoke test imports the source tree over `PYTHONPATH` instead of an unhashed editable install. The manifest binds the verified source SHA to the wheel digest and SBOM digest before merge. Pull-request-controlled build/test code runs with `contents: read` only; OIDC, attestation and artifact-metadata write permissions are isolated in a distinct push-only `integrated-attestations` job. That job depends on the successful foundation build, downloads the immutable SHA-named evidence bundle, re-verifies checksums and `source_sha == github.sha`, and only then creates GitHub OIDC-backed signed provenance and SBOM attestations on integrated `develop`/`main` heads. A new runtime dependency fails closed until the SBOM generator represents its dependency relationship. This is evidence readiness, not a claimed SLSA level or certification | Accounting Foundation CI, `scripts/generate_supply_chain_evidence.py`, supply-chain evidence tests, GitHub workflow-permissions/OIDC/artifact-attestation guidance, and ADR 0048 | | OSV-Scanner / OSV.dev vulnerability data | Pull-request dependency evidence is tied to the immutable PR head and an independently fetched live base tip. The gate records dependency-manifest diffs and SHA-256 values, rejects stale/non-ancestor base identity, and scans the complete hash-locked exact-head Python dependency set with a digest-pinned OSV-Scanner image. A known vulnerability, scanner failure, skipped/unavailable evidence path or wrong checkout identity is non-passing; aggregate organization workflow success cannot substitute for an unexecuted dependency-review step | `exact-head-dependency-diff` CI job, `tests/test_dependency_review_contract.py`, OSV-Scanner source/lockfile guidance, and ADR 0048 | | AICPA Trust Services Criteria (SOC 2) | Auditors read an append-only history of posted, reversed, and closed facts from existing `outbox_event` rows, including already-published rows, without marking publish. Controllers also list stored `journal_reversal` lineage and durable hard-close receipts over HTTP without SQL. A HomeTax filing command fail-closes and persists a rejected receipt when the VAT register or the purpose-limited HomeTax credential is missing, and this slice never claims `transmitted` | HTTP audit-event history, HTTP journal-reversal list, HTTP period-close list, HTTP fail-closed HomeTax submission, ADR 0027, ADR 0029, ADR 0030, and ADR 0046 | -| W3C PROV-O | Preserve entity, activity, agent, derivation, and attribution references across source proposal, posting, and append-only journal reversal lineage. The in-memory posting oracle scopes those identities by tenant and refuses to overwrite a posted `proposal_id`. Checked-in migrations cannot `UPDATE` or `DELETE` `general_journal` or `journal_entry_line` | Source-reference and receipt contracts, HTTP journal-reversal list, repository migration validation, ADR 0003, ADR 0029 | +| W3C PROV-O | Preserve entity, activity, agent, derivation, and attribution references across source proposal, posting, and append-only journal reversal lineage. The in-memory posting oracle scopes those identities by tenant and refuses to overwrite a posted `proposal_id`. Checked-in migrations cannot `UPDATE` or `DELETE` `general_journal` or `journal_entry_line` | Source-reference and receipt contracts, HTTP journal-reversal list, repository migration validation, ADR 0003, and ADR 0029 | | RFC 3339 / FIPS 180-4 / W3C PROV-O | The read-only reconciliation close package uses canonical UTC second-precision run cutoffs, SHA-256 content integrity, explicit entity/evidence references, and canonical operator-facing next-action guidance. Package schema version 4 binds the complete approved reconciliation-match population to the projection's tenant/run scope and approval command source hash; projection exports are schema version 2. Every match decision must carry its canonical durable identity, and each durable `ReconciliationReviewedMatch` record binds that identity to candidate facts and complete normalized statement/journal allocation populations while remaining in the projection/package export. Bridge and package equations use context-independent sign inversion and operand-derived local Decimal precision so valid minor-unit differences cannot disappear under ambient rounding. The package preserves the evidence-only boundary; a digest does not authenticate an actor or grant approval/close/posting authority | `ReconciliationClosePackage`, `ReconciliationReviewedMatch`, `ReconciliationApprovalEvidence`, close-package exact-value and fail-closed regressions, decision-binding, allocation-population, and Decimal-precision regressions, ADR 0056 | | ISO/IEC/IEEE 42010:2022 | Keep stakeholder concerns, authority boundaries, architecture views, and decisions explicit | Architecture and ADR set | | JSON Schema Draft 2020-12 | Close external objects, version contracts, reject extra fields, and validate exact value formats. Policy-manifest `account_mappings` are unique by `account_role_code` before policy load because `uniqueItems` compares whole objects | Three contract schemas, `load_accounting_policy`, ADR 0005, and ADR 0007 | | XBRL 2.1 | Treat external reporting taxonomy as a versioned projection rather than core ledger columns | Reporting roadmap | -The initial milestone does not claim production compliance with a jurisdiction's accounting, tax, or statutory reporting rules. It establishes controls and traceability required to implement reviewed policies without changing the journal authority model. +The initial milestone does not claim production compliance with a jurisdiction's accounting, tax, or statutory reporting rules. It establishes controls and traceability required to implement reviewed policies without changing the journal authority model. \ No newline at end of file From 6cbcf0e334aab201a35cce2df1f2e887271cefad Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 13:44:20 +0900 Subject: [PATCH 117/224] test(close): expose concurrent book-period seed race --- tests/test_book_period_control_seed.py | 129 +++++++++++++++++++++++++ 1 file changed, 129 insertions(+) diff --git a/tests/test_book_period_control_seed.py b/tests/test_book_period_control_seed.py index c198198c..6baa8a94 100644 --- a/tests/test_book_period_control_seed.py +++ b/tests/test_book_period_control_seed.py @@ -2,6 +2,7 @@ from __future__ import annotations +import threading import unittest import uuid from datetime import date @@ -50,6 +51,134 @@ def test_period_open_seeds_control_and_all_fences_for_existing_book(self) -> Non self.assertEqual(control_count, 1) self.assertEqual(fence_count, 64) + def test_concurrent_new_book_and_period_cannot_commit_without_pair(self) -> None: + """Opposite-side master-data inserts must serialize before either trigger scans its peer.""" + new_book_id = uuid.uuid4() + new_period_id = uuid.uuid4() + period_code = f"seed-race-{uuid.uuid4().hex[:8]}" + with psycopg.connect(posting.DATABASE_URL) as lookup: + legal_entity_id = lookup.execute( + """ + SELECT legal_entity_id + FROM accounting_core.legal_entity_record + WHERE tenant_account_id = %s + ORDER BY recorded_at + LIMIT 1 + """, + (self.case.tenant_id,), + ).fetchone()[0] + fiscal_calendar_id = lookup.execute( + """ + SELECT fiscal_calendar_id + FROM accounting_core.fiscal_calendar + WHERE tenant_account_id = %s + ORDER BY created_at + LIMIT 1 + """, + (self.case.tenant_id,), + ).fetchone()[0] + + book_connection = psycopg.connect(posting.DATABASE_URL) + self.addCleanup(book_connection.close) + book_connection.execute("SET LOCAL lock_timeout = '5s'") + book_connection.execute( + """ + INSERT INTO accounting_core.accounting_book ( + accounting_book_id, + tenant_account_id, + legal_entity_id, + book_role_code, + book_name, + reporting_currency_code, + valid_from + ) + VALUES (%s, %s, %s, %s, %s, 'USD', '2099-01-01T00:00:00Z') + """, + ( + new_book_id, + self.case.tenant_id, + legal_entity_id, + f"seed_race_{new_book_id.hex}", + f"Seed race {new_book_id.hex}", + ), + ) + + period_started = threading.Event() + period_insert_completed = threading.Event() + worker_errors: list[BaseException] = [] + + def insert_period() -> None: + try: + with psycopg.connect(posting.DATABASE_URL) as connection: + connection.execute("SET LOCAL lock_timeout = '5s'") + period_started.set() + connection.execute( + """ + INSERT INTO accounting_core.fiscal_period ( + fiscal_period_id, + tenant_account_id, + fiscal_calendar_id, + period_code, + period_start_date, + period_end_date, + period_status_code + ) + VALUES (%s, %s, %s, %s, DATE '2099-01-01', DATE '2099-01-31', 'open') + """, + ( + new_period_id, + self.case.tenant_id, + fiscal_calendar_id, + period_code, + ), + ) + connection.commit() + period_insert_completed.set() + except BaseException as error: # pragma: no cover - surfaced on the main test thread + worker_errors.append(error) + period_insert_completed.set() + + worker = threading.Thread(target=insert_period, daemon=True) + worker.start() + self.assertTrue(period_started.wait(timeout=2.0)) + completed_before_book_commit = period_insert_completed.wait(timeout=1.0) + + book_connection.commit() + worker.join(timeout=6.0) + + self.assertFalse(worker.is_alive(), "concurrent fiscal-period insert did not finish after peer commit") + if worker_errors: + raise worker_errors[0] + self.assertFalse( + completed_before_book_commit, + "opposite-side seed trigger committed before the uncommitted active book became visible", + ) + + with psycopg.connect(posting.DATABASE_URL) as connection: + control_count = connection.execute( + """ + SELECT COUNT(*) + FROM accounting_core.accounting_book_period_control + WHERE tenant_account_id = %s + AND accounting_book_id = %s + AND fiscal_period_id = %s + """, + (self.case.tenant_id, new_book_id, new_period_id), + ).fetchone()[0] + fence_count = connection.execute( + """ + SELECT COUNT(*) + FROM accounting_core.period_journal_population_fence + WHERE tenant_account_id = %s + AND accounting_book_id = %s + AND fiscal_period_id = %s + """, + (self.case.tenant_id, new_book_id, new_period_id), + ).fetchone()[0] + + self.assertEqual(control_count, 1) + self.assertEqual(fence_count, 64) + def test_seed_sources_and_targets_finish_with_forced_rls(self) -> None: """Owner-only migration visibility must never leak into the committed runtime schema.""" expected_relations = { From a038f8726ed0ef6f88a4ee7ea4920e6873e032f1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 13:44:47 +0900 Subject: [PATCH 118/224] fix(close): serialize opposite-side authority seeding --- .../0034_book_period_control_seed.sql | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/database/migrations/0034_book_period_control_seed.sql b/database/migrations/0034_book_period_control_seed.sql index 6c68f496..ef6b4f51 100644 --- a/database/migrations/0034_book_period_control_seed.sql +++ b/database/migrations/0034_book_period_control_seed.sql @@ -5,6 +5,14 @@ BEGIN; -- evaluation. 0009 backfilled only rows that existed at migration time, so -- later fiscal periods or accounting books could otherwise reach the journal -- guard without a materialized book-period authority. +-- +-- The two AFTER INSERT triggers also form one cross-product invariant. Without +-- a pre-existing common lock, concurrent transactions can each insert one side, +-- scan before the other side commits, and leave the new book-period pair absent. +-- Serialize only this low-frequency tenant master-data boundary on the existing +-- tenant row. FOR NO KEY UPDATE is self-conflicting between seeders while still +-- remaining compatible with the FOR KEY SHARE lock used by unrelated child-row +-- foreign-key checks. CREATE OR REPLACE FUNCTION accounting_core.seed_book_period_control_for_period() RETURNS trigger LANGUAGE plpgsql @@ -12,6 +20,11 @@ SECURITY DEFINER SET search_path = pg_catalog, pg_temp AS $$ BEGIN + PERFORM 1 + FROM accounting_core.tenant_account AS tenant + WHERE tenant.tenant_account_id = NEW.tenant_account_id + FOR NO KEY UPDATE; + INSERT INTO accounting_core.accounting_book_period_control ( tenant_account_id, accounting_book_id, @@ -57,6 +70,11 @@ BEGIN RETURN NEW; END IF; + PERFORM 1 + FROM accounting_core.tenant_account AS tenant + WHERE tenant.tenant_account_id = NEW.tenant_account_id + FOR NO KEY UPDATE; + INSERT INTO accounting_core.accounting_book_period_control ( tenant_account_id, accounting_book_id, From 1d286cdd0d641b3723d90468a62cbe7da41a156f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 13:45:10 +0900 Subject: [PATCH 119/224] test(close): ratchet seed concurrency lock --- .../test_book_period_control_seed_contract.py | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/tests/test_book_period_control_seed_contract.py b/tests/test_book_period_control_seed_contract.py index 57538b93..d8e0b1b0 100644 --- a/tests/test_book_period_control_seed_contract.py +++ b/tests/test_book_period_control_seed_contract.py @@ -29,6 +29,29 @@ def test_period_and_book_creation_both_seed_controls(self) -> None: ) self.assertGreaterEqual(source.count("ON CONFLICT"), 3) + def test_opposite_side_seeders_share_tenant_serialization_row(self) -> None: + """Concurrent book/period creation must not let both trigger scans miss uncommitted peers.""" + source = MIGRATION.read_text(encoding="utf-8") + period_function = source[ + source.index("CREATE OR REPLACE FUNCTION accounting_core.seed_book_period_control_for_period()") : + source.index("CREATE TRIGGER book_period_control_seed_for_period") + ] + book_function = source[ + source.index("CREATE OR REPLACE FUNCTION accounting_core.seed_book_period_control_for_book()") : + source.index("CREATE TRIGGER book_period_control_seed_for_book") + ] + + for function_source in (period_function, book_function): + tenant_lock = function_source.index("FROM accounting_core.tenant_account AS tenant") + no_key_update = function_source.index("FOR NO KEY UPDATE;", tenant_lock) + control_insert = function_source.index( + "INSERT INTO accounting_core.accounting_book_period_control (" + ) + self.assertLess(tenant_lock, no_key_update) + self.assertLess(no_key_update, control_insert) + + self.assertEqual(source.count("FOR NO KEY UPDATE;"), 2) + def test_trigger_functions_use_hardened_execution_context(self) -> None: """Master-data triggers must not inherit caller-controlled object resolution.""" source = MIGRATION.read_text(encoding="utf-8") From 58b5f57b3bc7ae677544f90d0bfaf0c325045629 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 13:46:55 +0900 Subject: [PATCH 120/224] test(close): require repeatable-read seed retry --- tests/test_book_period_control_seed.py | 265 +++++++++++++++++-------- 1 file changed, 182 insertions(+), 83 deletions(-) diff --git a/tests/test_book_period_control_seed.py b/tests/test_book_period_control_seed.py index 6baa8a94..9832d923 100644 --- a/tests/test_book_period_control_seed.py +++ b/tests/test_book_period_control_seed.py @@ -56,51 +56,15 @@ def test_concurrent_new_book_and_period_cannot_commit_without_pair(self) -> None new_book_id = uuid.uuid4() new_period_id = uuid.uuid4() period_code = f"seed-race-{uuid.uuid4().hex[:8]}" - with psycopg.connect(posting.DATABASE_URL) as lookup: - legal_entity_id = lookup.execute( - """ - SELECT legal_entity_id - FROM accounting_core.legal_entity_record - WHERE tenant_account_id = %s - ORDER BY recorded_at - LIMIT 1 - """, - (self.case.tenant_id,), - ).fetchone()[0] - fiscal_calendar_id = lookup.execute( - """ - SELECT fiscal_calendar_id - FROM accounting_core.fiscal_calendar - WHERE tenant_account_id = %s - ORDER BY created_at - LIMIT 1 - """, - (self.case.tenant_id,), - ).fetchone()[0] + legal_entity_id, fiscal_calendar_id = self._seed_scope_ids() book_connection = psycopg.connect(posting.DATABASE_URL) self.addCleanup(book_connection.close) book_connection.execute("SET LOCAL lock_timeout = '5s'") - book_connection.execute( - """ - INSERT INTO accounting_core.accounting_book ( - accounting_book_id, - tenant_account_id, - legal_entity_id, - book_role_code, - book_name, - reporting_currency_code, - valid_from - ) - VALUES (%s, %s, %s, %s, %s, 'USD', '2099-01-01T00:00:00Z') - """, - ( - new_book_id, - self.case.tenant_id, - legal_entity_id, - f"seed_race_{new_book_id.hex}", - f"Seed race {new_book_id.hex}", - ), + self._insert_book( + book_connection, + new_book_id=new_book_id, + legal_entity_id=legal_entity_id, ) period_started = threading.Event() @@ -112,25 +76,11 @@ def insert_period() -> None: with psycopg.connect(posting.DATABASE_URL) as connection: connection.execute("SET LOCAL lock_timeout = '5s'") period_started.set() - connection.execute( - """ - INSERT INTO accounting_core.fiscal_period ( - fiscal_period_id, - tenant_account_id, - fiscal_calendar_id, - period_code, - period_start_date, - period_end_date, - period_status_code - ) - VALUES (%s, %s, %s, %s, DATE '2099-01-01', DATE '2099-01-31', 'open') - """, - ( - new_period_id, - self.case.tenant_id, - fiscal_calendar_id, - period_code, - ), + self._insert_period( + connection, + new_period_id=new_period_id, + fiscal_calendar_id=fiscal_calendar_id, + period_code=period_code, ) connection.commit() period_insert_completed.set() @@ -153,31 +103,64 @@ def insert_period() -> None: completed_before_book_commit, "opposite-side seed trigger committed before the uncommitted active book became visible", ) + self._assert_control_and_fences(new_book_id, new_period_id) - with psycopg.connect(posting.DATABASE_URL) as connection: - control_count = connection.execute( - """ - SELECT COUNT(*) - FROM accounting_core.accounting_book_period_control - WHERE tenant_account_id = %s - AND accounting_book_id = %s - AND fiscal_period_id = %s - """, - (self.case.tenant_id, new_book_id, new_period_id), - ).fetchone()[0] - fence_count = connection.execute( - """ - SELECT COUNT(*) - FROM accounting_core.period_journal_population_fence - WHERE tenant_account_id = %s - AND accounting_book_id = %s - AND fiscal_period_id = %s - """, - (self.case.tenant_id, new_book_id, new_period_id), - ).fetchone()[0] + def test_repeatable_read_seed_race_fails_closed_then_retries_fresh(self) -> None: + """A fixed snapshot must serialize-fail rather than commit a peer-blind master-data pair.""" + new_book_id = uuid.uuid4() + new_period_id = uuid.uuid4() + period_code = f"seed-rr-{uuid.uuid4().hex[:8]}" + legal_entity_id, fiscal_calendar_id = self._seed_scope_ids() - self.assertEqual(control_count, 1) - self.assertEqual(fence_count, 64) + book_connection = psycopg.connect(posting.DATABASE_URL) + self.addCleanup(book_connection.close) + book_connection.execute("SET LOCAL lock_timeout = '5s'") + self._insert_book( + book_connection, + new_book_id=new_book_id, + legal_entity_id=legal_entity_id, + ) + + period_started = threading.Event() + worker_errors: list[BaseException] = [] + + def insert_repeatable_read_period() -> None: + try: + with psycopg.connect(posting.DATABASE_URL) as connection: + connection.execute("SET TRANSACTION ISOLATION LEVEL REPEATABLE READ") + connection.execute("SET LOCAL lock_timeout = '5s'") + period_started.set() + self._insert_period( + connection, + new_period_id=new_period_id, + fiscal_calendar_id=fiscal_calendar_id, + period_code=period_code, + ) + connection.commit() + except BaseException as error: # pragma: no cover - asserted on the main test thread + worker_errors.append(error) + + worker = threading.Thread(target=insert_repeatable_read_period, daemon=True) + worker.start() + self.assertTrue(period_started.wait(timeout=2.0)) + + book_connection.commit() + worker.join(timeout=6.0) + + self.assertFalse(worker.is_alive(), "repeatable-read fiscal-period insert did not terminate") + self.assertEqual(len(worker_errors), 1) + self.assertIsInstance(worker_errors[0], psycopg.errors.SerializationFailure) + + with psycopg.connect(posting.DATABASE_URL) as retry_connection: + self._insert_period( + retry_connection, + new_period_id=new_period_id, + fiscal_calendar_id=fiscal_calendar_id, + period_code=period_code, + ) + retry_connection.commit() + + self._assert_control_and_fences(new_book_id, new_period_id) def test_seed_sources_and_targets_finish_with_forced_rls(self) -> None: """Owner-only migration visibility must never leak into the committed runtime schema.""" @@ -209,6 +192,122 @@ def test_seed_sources_and_targets_finish_with_forced_rls(self) -> None: self.assertEqual(set(actual), expected_relations) self.assertEqual(actual, {name: (True, True) for name in expected_relations}) + def _seed_scope_ids(self) -> tuple[uuid.UUID, uuid.UUID]: + """Return this fixture tenant's legal-entity and fiscal-calendar identifiers.""" + with psycopg.connect(posting.DATABASE_URL) as lookup: + legal_entity_id = lookup.execute( + """ + SELECT legal_entity_id + FROM accounting_core.legal_entity_record + WHERE tenant_account_id = %s + ORDER BY recorded_at + LIMIT 1 + """, + (self.case.tenant_id,), + ).fetchone()[0] + fiscal_calendar_id = lookup.execute( + """ + SELECT fiscal_calendar_id + FROM accounting_core.fiscal_calendar + WHERE tenant_account_id = %s + ORDER BY created_at + LIMIT 1 + """, + (self.case.tenant_id,), + ).fetchone()[0] + return legal_entity_id, fiscal_calendar_id + + def _insert_book( + self, + connection: psycopg.Connection, + *, + new_book_id: uuid.UUID, + legal_entity_id: uuid.UUID, + ) -> None: + """Insert one active book through the database master-data boundary.""" + connection.execute( + """ + INSERT INTO accounting_core.accounting_book ( + accounting_book_id, + tenant_account_id, + legal_entity_id, + book_role_code, + book_name, + reporting_currency_code, + valid_from + ) + VALUES (%s, %s, %s, %s, %s, 'USD', '2099-01-01T00:00:00Z') + """, + ( + new_book_id, + self.case.tenant_id, + legal_entity_id, + f"seed_race_{new_book_id.hex}", + f"Seed race {new_book_id.hex}", + ), + ) + + def _insert_period( + self, + connection: psycopg.Connection, + *, + new_period_id: uuid.UUID, + fiscal_calendar_id: uuid.UUID, + period_code: str, + ) -> None: + """Insert one open period through the database master-data boundary.""" + connection.execute( + """ + INSERT INTO accounting_core.fiscal_period ( + fiscal_period_id, + tenant_account_id, + fiscal_calendar_id, + period_code, + period_start_date, + period_end_date, + period_status_code + ) + VALUES (%s, %s, %s, %s, DATE '2099-01-01', DATE '2099-01-31', 'open') + """, + ( + new_period_id, + self.case.tenant_id, + fiscal_calendar_id, + period_code, + ), + ) + + def _assert_control_and_fences( + self, + accounting_book_id: uuid.UUID, + fiscal_period_id: uuid.UUID, + ) -> None: + """Require one control row and the complete 64-row freshness population.""" + with psycopg.connect(posting.DATABASE_URL) as connection: + control_count = connection.execute( + """ + SELECT COUNT(*) + FROM accounting_core.accounting_book_period_control + WHERE tenant_account_id = %s + AND accounting_book_id = %s + AND fiscal_period_id = %s + """, + (self.case.tenant_id, accounting_book_id, fiscal_period_id), + ).fetchone()[0] + fence_count = connection.execute( + """ + SELECT COUNT(*) + FROM accounting_core.period_journal_population_fence + WHERE tenant_account_id = %s + AND accounting_book_id = %s + AND fiscal_period_id = %s + """, + (self.case.tenant_id, accounting_book_id, fiscal_period_id), + ).fetchone()[0] + + self.assertEqual(control_count, 1) + self.assertEqual(fence_count, 64) + def _control_and_fence_counts(self, period_code: str) -> tuple[int, int]: """Return retained control and stripe cardinality for this fixture's primary book-period.""" with psycopg.connect(posting.DATABASE_URL) as connection: From 521104564345d096836da145140315f5e23fb1df Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 13:47:23 +0900 Subject: [PATCH 121/224] fix(close): version tenant seed fence for fixed snapshots --- .../0034_book_period_control_seed.sql | 31 ++++++++++--------- 1 file changed, 17 insertions(+), 14 deletions(-) diff --git a/database/migrations/0034_book_period_control_seed.sql b/database/migrations/0034_book_period_control_seed.sql index ef6b4f51..f058da06 100644 --- a/database/migrations/0034_book_period_control_seed.sql +++ b/database/migrations/0034_book_period_control_seed.sql @@ -7,12 +7,17 @@ BEGIN; -- guard without a materialized book-period authority. -- -- The two AFTER INSERT triggers also form one cross-product invariant. Without --- a pre-existing common lock, concurrent transactions can each insert one side, --- scan before the other side commits, and leave the new book-period pair absent. --- Serialize only this low-frequency tenant master-data boundary on the existing --- tenant row. FOR NO KEY UPDATE is self-conflicting between seeders while still --- remaining compatible with the FOR KEY SHARE lock used by unrelated child-row --- foreign-key checks. +-- a pre-existing common version witness, concurrent transactions can each +-- insert one side, scan before the other side commits, and leave the new +-- book-period pair absent. Both seeders therefore perform a non-key UPDATE of +-- the existing tenant row before scanning the peer population. Under READ +-- COMMITTED, the later seeder waits and its following statement sees the peer +-- commit. Under REPEATABLE READ/SERIALIZABLE, a transaction whose snapshot +-- predates the competing tenant-row version fails closed with serialization +-- failure and must retry from a fresh transaction. Updating only created_at to +-- its retained value changes no tenant business fact. PostgreSQL acquires the +-- weaker FOR NO KEY UPDATE row lock for this non-key update, so unrelated child +-- foreign-key checks using FOR KEY SHARE remain compatible. CREATE OR REPLACE FUNCTION accounting_core.seed_book_period_control_for_period() RETURNS trigger LANGUAGE plpgsql @@ -20,10 +25,9 @@ SECURITY DEFINER SET search_path = pg_catalog, pg_temp AS $$ BEGIN - PERFORM 1 - FROM accounting_core.tenant_account AS tenant - WHERE tenant.tenant_account_id = NEW.tenant_account_id - FOR NO KEY UPDATE; + UPDATE accounting_core.tenant_account AS tenant + SET created_at = tenant.created_at + WHERE tenant.tenant_account_id = NEW.tenant_account_id; INSERT INTO accounting_core.accounting_book_period_control ( tenant_account_id, @@ -70,10 +74,9 @@ BEGIN RETURN NEW; END IF; - PERFORM 1 - FROM accounting_core.tenant_account AS tenant - WHERE tenant.tenant_account_id = NEW.tenant_account_id - FOR NO KEY UPDATE; + UPDATE accounting_core.tenant_account AS tenant + SET created_at = tenant.created_at + WHERE tenant.tenant_account_id = NEW.tenant_account_id; INSERT INTO accounting_core.accounting_book_period_control ( tenant_account_id, From aa97d22b6f01b111ec3e701e4db229471276b807 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 13:47:39 +0900 Subject: [PATCH 122/224] test(close): ratchet tenant MVCC seed witness --- .../test_book_period_control_seed_contract.py | 21 ++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/tests/test_book_period_control_seed_contract.py b/tests/test_book_period_control_seed_contract.py index d8e0b1b0..206e3068 100644 --- a/tests/test_book_period_control_seed_contract.py +++ b/tests/test_book_period_control_seed_contract.py @@ -29,8 +29,8 @@ def test_period_and_book_creation_both_seed_controls(self) -> None: ) self.assertGreaterEqual(source.count("ON CONFLICT"), 3) - def test_opposite_side_seeders_share_tenant_serialization_row(self) -> None: - """Concurrent book/period creation must not let both trigger scans miss uncommitted peers.""" + def test_opposite_side_seeders_version_one_tenant_serialization_row(self) -> None: + """Peer scans need a common row version so fixed snapshots fail closed instead of missing data.""" source = MIGRATION.read_text(encoding="utf-8") period_function = source[ source.index("CREATE OR REPLACE FUNCTION accounting_core.seed_book_period_control_for_period()") : @@ -42,15 +42,22 @@ def test_opposite_side_seeders_share_tenant_serialization_row(self) -> None: ] for function_source in (period_function, book_function): - tenant_lock = function_source.index("FROM accounting_core.tenant_account AS tenant") - no_key_update = function_source.index("FOR NO KEY UPDATE;", tenant_lock) + tenant_update = function_source.index("UPDATE accounting_core.tenant_account AS tenant") + retained_value = function_source.index( + "SET created_at = tenant.created_at", + tenant_update, + ) control_insert = function_source.index( "INSERT INTO accounting_core.accounting_book_period_control (" ) - self.assertLess(tenant_lock, no_key_update) - self.assertLess(no_key_update, control_insert) + self.assertLess(tenant_update, retained_value) + self.assertLess(retained_value, control_insert) - self.assertEqual(source.count("FOR NO KEY UPDATE;"), 2) + self.assertEqual( + source.count("UPDATE accounting_core.tenant_account AS tenant"), + 2, + ) + self.assertNotIn("SET created_at = clock_timestamp()", source) def test_trigger_functions_use_hardened_execution_context(self) -> None: """Master-data triggers must not inherit caller-controlled object resolution.""" From 29f871cfd63b5b024ed8c93b54c8a6096b43430b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 13:48:12 +0900 Subject: [PATCH 123/224] docs(trace): record book-period seed concurrency fence --- ...RIOD_CONTROL_RLS_MIGRATION_TRACEABILITY.md | 48 ++++++++++++++++--- 1 file changed, 41 insertions(+), 7 deletions(-) diff --git a/docs/doctoring/BOOK_PERIOD_CONTROL_RLS_MIGRATION_TRACEABILITY.md b/docs/doctoring/BOOK_PERIOD_CONTROL_RLS_MIGRATION_TRACEABILITY.md index 8ae80b7f..12a10a9d 100644 --- a/docs/doctoring/BOOK_PERIOD_CONTROL_RLS_MIGRATION_TRACEABILITY.md +++ b/docs/doctoring/BOOK_PERIOD_CONTROL_RLS_MIGRATION_TRACEABILITY.md @@ -2,9 +2,9 @@ ## Scope -This note records an installation and upgrade control for the authoritative `accounting_book_period_control` and `period_journal_population_fence` relations and their `accounting_book` / `fiscal_period` seed sources. It does not grant a runtime tenant, change accounting policy, weaken posted-journal immutability, or transfer accounting authority to Billing or another source system. +This note records installation, upgrade, and post-install master-data controls for the authoritative `accounting_book_period_control` and `period_journal_population_fence` relations and their `accounting_book` / `fiscal_period` seed sources. It does not grant a runtime tenant, change accounting policy, weaken posted-journal immutability, or transfer accounting authority to Billing or another source system. -## Finding +## Migration-owner RLS finding `accounting_core.current_tenant_account_id()` resolves a runtime tenant from `session_user`. A production schema/migration owner is not required to have such a runtime binding and should remain `NOSUPERUSER` / `NOBYPASSRLS`. @@ -14,13 +14,39 @@ The first repair pass covered only target visibility. Fresh review then found th There are no GitHub tags or releases for this repository at this repair point, so migration 0009 is not an immutable released artifact. The branch therefore repairs the unreleased migration order rather than adding a later migration that could not rescue an upgrade already failing at 0009. -## Selected control +## Post-install concurrent-seeding finding + +Migration 0034 originally installed one `AFTER INSERT` trigger on `fiscal_period` and one on active `accounting_book`. Each trigger scanned the opposite population and inserted any missing book-period controls. That covered both sequential creation orders but did not cover the concurrent cross-product race. + +Transaction A could insert a new active book and run its trigger while transaction B's new fiscal period was still uncommitted. Transaction B could independently insert that period and run its trigger while A's book was still uncommitted. Under MVCC neither scan is allowed to read the peer's uncommitted row. If both transactions then committed, the active `(tenant, book, period)` pair existed without its required control row or 64-row journal-population fence. The later journal guard would correctly fail closed, but a legitimate post-install book-period would be unusable until manually repaired. A fail-closed symptom is not a substitute for maintaining the authoritative master-data invariant. + +A shared lock with no row-version change is also insufficient at fixed-snapshot isolation. PostgreSQL 18 Repeatable Read sees only data committed before the transaction snapshot and requires whole-transaction retry when an updater reaches a row that another transaction actually updated after that snapshot. Therefore the seeders need both ordering for Read Committed and a pre-existing MVCC version witness for Repeatable Read/Serializable. + +## Selected controls Migration 0009 temporarily applies `NO FORCE ROW LEVEL SECURITY` to the already forced `accounting_book` and `fiscal_period` source tables, performs the existing all-tenant control-row seed while the new target table is not yet exposed through runtime policy, restores FORCE on both sources, and only then enables/forces RLS on `accounting_book_period_control`. Migration 0034 keeps RLS **enabled** everywhere. For its owner-only repair phase it applies `NO FORCE ROW LEVEL SECURITY` to all four participating relations: `accounting_book`, `fiscal_period`, `accounting_book_period_control`, and `period_journal_population_fence`. It performs the cross-tenant backfill, then restores `FORCE ROW LEVEL SECURITY` on every source and target before `COMMIT`. `NO FORCE` restores normal table-owner bypass; it does not disable RLS for non-owner runtime roles. The fence table participates because every inserted control synchronously invokes migration 0033's `SECURITY DEFINER` 64-stripe seeder. -These `ALTER TABLE` operations take PostgreSQL table locks inside the same migration transaction. Runtime traffic is not allowed to observe a committed half-state in which one of these relations permanently loses FORCE. The migration role must own all participating relations. Installation must not be made to work by granting `BYPASSRLS`, using a superuser as the normal deployment identity, assigning a fabricated runtime tenant to the migration role, treating `row_security=off` as a bypass, or executing `DISABLE ROW LEVEL SECURITY`. +For post-install book/period creation, both migration-0034 trigger functions now update the same pre-existing `tenant_account` row before they scan the opposite master-data population. The statement sets `created_at` to its retained value, so it changes no tenant business fact, but it performs a real non-key row update and therefore provides the common MVCC version witness. + +At PostgreSQL's default Read Committed isolation, a later updater waits for the competing tenant-row update and the following trigger statement receives a new command snapshot that can see the peer commit. At Repeatable Read/Serializable, a seeder whose fixed snapshot predates the competing tenant-row version fails with serialization error rather than committing a peer-blind pair; the complete master-data command must retry from a fresh transaction. Because `created_at` is not a key column, PostgreSQL uses the weaker `FOR NO KEY UPDATE` row-lock class for this update. PostgreSQL documents that `FOR NO KEY UPDATE` does not conflict with `FOR KEY SHARE`, preserving ordinary foreign-key checks by unrelated child inserts while still self-conflicting with the other master-data seeder. + +The tenant-level write is intentionally limited to this low-frequency master-data boundary. It is not used by ordinary journal posting, does not contain a financial amount, and does not replace the per-book-period 64-stripe runtime freshness fence. + +These `ALTER TABLE` operations and row updates remain transactional. Runtime traffic is not allowed to observe a committed half-state in which one of these relations permanently loses FORCE. The migration role must own all participating relations. Installation must not be made to work by granting `BYPASSRLS`, using a superuser as the normal deployment identity, assigning a fabricated runtime tenant to the migration role, treating `row_security=off` as a bypass, or executing `DISABLE ROW LEVEL SECURITY`. + +## Alternatives considered + +A trigger-side scan with no common lock was rejected because opposite-side inserts can each miss the other's uncommitted row and both commit. + +A common `SELECT ... FOR NO KEY UPDATE` lock without changing a pre-existing row version was rejected as insufficient for Repeatable Read: after waiting, the transaction still has its original fixed snapshot and can remain unable to see the peer row. + +A tenant-level `FOR UPDATE` fence was rejected as stronger than necessary because PostgreSQL documents that `FOR UPDATE` conflicts with `FOR KEY SHARE`, which would block unrelated foreign-key checks on the tenant row. The chosen non-key update obtains the weaker `FOR NO KEY UPDATE` class. + +A table-level lock was rejected because it would serialize unrelated tenants and widen a low-frequency same-tenant master-data invariant into a global bottleneck. + +A hash-based advisory lock was not selected because the canonical tenant row already provides a collision-free, pre-existing database identity and the fixed-snapshot case still needs a row-version change or an explicit serialization failure mechanism. ## TDD and exact implementation evidence @@ -31,14 +57,18 @@ These `ALTER TABLE` operations take PostgreSQL table locks inside the same migra - 0034 full source/target repair `027fae479a7ae52b1db3119acd6549f50aa6dba2` surrounds the repair backfill with owner-only visibility on all four participating tables and restores FORCE on all four before commit. - ADR 0006 alignment `4a87f3522e7a2f7f8dc1faa4cde6e0d6f3ebb3dd`; runtime-identity clarification `e040f3447700abfa5291237fa094c88019068e9f` makes an ordinary unbound `NOSUPERUSER`/`NOBYPASSRLS` migration owner distinct from runtime tenant and break-glass identities. - Real-PostgreSQL runtime-state acceptance `cca0f5a7f9b1b4450933aea389e035863021503a` verifies through `pg_catalog.pg_class` that `accounting_book`, `fiscal_period`, `accounting_book_period_control`, and `period_journal_population_fence` all finish the installed migration chain with both RLS enabled and FORCE restored. This verifies the committed-state half of the owner-window contract; production-like execution as an unbound non-bypass owner remains separate release evidence. +- Concurrent cross-product RED `6cbcf0e334aab201a35cce2df1f2e887271cefad` adds a real PostgreSQL case where one transaction holds a newly inserted active book uncommitted while another creates the matching fiscal period. The period side must not commit before the book transaction resolves, and the final pair must own one control plus all 64 fences. +- Initial common-row lock repair `a038f8726ed0ef6f88a4ee7ea4920e6873e032f1` serializes the two trigger scans, with static ratchet `1d286cdd0d641b3723d90468a62cbe7da41a156f`. +- Fixed-snapshot RED `58b5f57b3bc7ae677544f90d0bfaf0c325045629` proves that a lock-only repair is not enough: a concurrent Repeatable Read period transaction must fail with PostgreSQL `SerializationFailure` and succeed only when the complete insert is retried from a fresh transaction. +- Fixed-snapshot causal repair `521104564345d096836da145140315f5e23fb1df` turns the tenant coordination point into a non-key UPDATE version witness without changing tenant business data; static contract `aa97d22b6f01b111ec3e701e4db229471276b807` pins that write before either peer scan and rejects a caller-shaped replacement timestamp. -These commits are development evidence only. Both static RED commits preceded their corresponding SQL repairs, but no runner-observed RED or exact-head GREEN claim is valid until GitHub Actions executes the corresponding heads. The final release candidate still requires the real PostgreSQL migration chain with a production-like unbound non-bypass owner, tenant-isolation acceptance, security/SAST/dependency checks, migration/recovery evidence, independent review, and protected integration gates. +These commits are development evidence only. The RED commits preceded their corresponding causal repairs, but no runner-observed RED or exact-head GREEN claim is valid until GitHub Actions executes the corresponding heads. The final release candidate still requires the real PostgreSQL migration chain with a production-like unbound non-bypass owner, tenant-isolation acceptance, security/SAST/dependency checks, migration/recovery evidence, independent review, and protected integration gates. ## Recovery and security effect -Both SQL repairs are transactional. A failed migration must roll back every owner-force toggle and all inserted control/fence rows together. Operators must retry the complete migration after correcting the root cause; they must not fabricate book-period controls, delete posted journals, rewrite retained trial-balance evidence, or weaken tenant policies to make the migration appear successful. +The SQL repairs are transactional. A failed migration must roll back every owner-force toggle and all inserted control/fence rows together. A runtime master-data serialization failure rolls back the book or period insert and its seed side effects together. Callers must retry the complete immutable master-data command from a fresh transaction; they must not fabricate book-period controls, delete posted journals, rewrite retained trial-balance evidence, or weaken tenant policies to make the operation appear successful. -The committed runtime state remains `ENABLE ROW LEVEL SECURITY` + `FORCE ROW LEVEL SECURITY` on the tenant-scoped source and authority relations. This repair changes migration-owner visibility only and does not alter the runtime single-writer boundary, tenant policy expression, close authority, maker-checker rules, or financial values. +The committed runtime state remains `ENABLE ROW LEVEL SECURITY` + `FORCE ROW LEVEL SECURITY` on the tenant-scoped source and authority relations. This repair changes migration-owner visibility and master-data coordination only. It does not alter the runtime single-writer boundary, tenant policy expression, close authority, maker-checker rules, journal amounts, or financial values. ## References @@ -47,3 +77,7 @@ PostgreSQL Global Development Group. (2026a). *PostgreSQL 18 documentation: Row PostgreSQL Global Development Group. (2026b). *PostgreSQL 18 documentation: ALTER TABLE*. https://www.postgresql.org/docs/18/sql-altertable.html PostgreSQL Global Development Group. (2026c). *PostgreSQL 18 documentation: CREATE ROLE*. https://www.postgresql.org/docs/18/sql-createrole.html + +PostgreSQL Global Development Group. (2026d). *PostgreSQL 18 documentation: Explicit locking*. https://www.postgresql.org/docs/18/explicit-locking.html + +PostgreSQL Global Development Group. (2026e). *PostgreSQL 18 documentation: Transaction isolation*. https://www.postgresql.org/docs/18/transaction-iso.html From 7592a3114f565f65c704b5b5f9f5eca39e420b75 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 14:04:06 +0900 Subject: [PATCH 124/224] test(close): reject tenant-period projection as new-book close authority --- tests/test_book_period_control_seed_contract.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/tests/test_book_period_control_seed_contract.py b/tests/test_book_period_control_seed_contract.py index 206e3068..85c2659f 100644 --- a/tests/test_book_period_control_seed_contract.py +++ b/tests/test_book_period_control_seed_contract.py @@ -29,6 +29,23 @@ def test_period_and_book_creation_both_seed_controls(self) -> None: ) self.assertGreaterEqual(source.count("ON CONFLICT"), 3) + def test_new_book_seed_does_not_project_tenant_period_status_as_book_close_authority(self) -> None: + """A shared calendar projection must not become a new book's authoritative close state.""" + source = MIGRATION.read_text(encoding="utf-8") + book_function = source[ + source.index("CREATE OR REPLACE FUNCTION accounting_core.seed_book_period_control_for_book()") : + source.index("CREATE TRIGGER book_period_control_seed_for_book") + ] + select_start = book_function.index("SELECT NEW.tenant_account_id") + period_source = book_function.index( + "FROM accounting_core.fiscal_period", + select_start, + ) + projected_control_values = book_function[select_start:period_source] + + self.assertNotIn("fiscal_period.period_status_code", projected_control_values) + self.assertNotIn("fiscal_period.period_closed_at", projected_control_values) + def test_opposite_side_seeders_version_one_tenant_serialization_row(self) -> None: """Peer scans need a common row version so fixed snapshots fail closed instead of missing data.""" source = MIGRATION.read_text(encoding="utf-8") From 083488b52f0ef4b9cf0fa21c87abea5159f9039f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 14:05:29 +0900 Subject: [PATCH 125/224] test(close): reject new-book inheritance from shared period status --- tests/test_book_period_control_seed.py | 49 ++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/tests/test_book_period_control_seed.py b/tests/test_book_period_control_seed.py index 9832d923..eaa9dc6f 100644 --- a/tests/test_book_period_control_seed.py +++ b/tests/test_book_period_control_seed.py @@ -34,6 +34,55 @@ def test_seeded_book_and_existing_period_have_control_and_all_fences(self) -> No self.assertEqual(control_count, 1) self.assertEqual(fence_count, 64) + def test_new_book_does_not_inherit_shared_closed_period_projection(self) -> None: + """A new book must not receive close authority copied from the tenant-wide compatibility status.""" + new_book_id = uuid.uuid4() + legal_entity_id, _fiscal_calendar_id = self._seed_scope_ids() + + with psycopg.connect(posting.DATABASE_URL) as connection: + period_id = connection.execute( + """ + UPDATE accounting_core.fiscal_period + SET period_status_code = 'soft_closed', + period_closed_at = clock_timestamp() + WHERE tenant_account_id = %s + AND period_code = '2026-08' + RETURNING fiscal_period_id + """, + (self.case.tenant_id,), + ).fetchone()[0] + self._insert_book( + connection, + new_book_id=new_book_id, + legal_entity_id=legal_entity_id, + ) + connection.commit() + + with psycopg.connect(posting.DATABASE_URL) as connection: + control_row = connection.execute( + """ + SELECT period_status_code, period_closed_at + FROM accounting_core.accounting_book_period_control + WHERE tenant_account_id = %s + AND accounting_book_id = %s + AND fiscal_period_id = %s + """, + (self.case.tenant_id, new_book_id, period_id), + ).fetchone() + fence_count = connection.execute( + """ + SELECT COUNT(*) + FROM accounting_core.period_journal_population_fence + WHERE tenant_account_id = %s + AND accounting_book_id = %s + AND fiscal_period_id = %s + """, + (self.case.tenant_id, new_book_id, period_id), + ).fetchone()[0] + + self.assertIsNone(control_row) + self.assertEqual(fence_count, 0) + def test_period_open_seeds_control_and_all_fences_for_existing_book(self) -> None: """Opening a later period must materialize control and freshness rows for active books.""" period_code = "2026-09" From ac38a098c703d8ea72ba72fd1e7c9dc1a3814227 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 14:06:03 +0900 Subject: [PATCH 126/224] test(close): pin open-only control seeding authority --- .../test_book_period_control_seed_contract.py | 37 +++++++++++++++---- 1 file changed, 29 insertions(+), 8 deletions(-) diff --git a/tests/test_book_period_control_seed_contract.py b/tests/test_book_period_control_seed_contract.py index 85c2659f..46f2be82 100644 --- a/tests/test_book_period_control_seed_contract.py +++ b/tests/test_book_period_control_seed_contract.py @@ -29,22 +29,43 @@ def test_period_and_book_creation_both_seed_controls(self) -> None: ) self.assertGreaterEqual(source.count("ON CONFLICT"), 3) - def test_new_book_seed_does_not_project_tenant_period_status_as_book_close_authority(self) -> None: - """A shared calendar projection must not become a new book's authoritative close state.""" + def test_non_open_projection_never_synthesizes_book_close_authority(self) -> None: + """Only an actually opened period may create a new book-period control automatically.""" source = MIGRATION.read_text(encoding="utf-8") + period_function = source[ + source.index("CREATE OR REPLACE FUNCTION accounting_core.seed_book_period_control_for_period()") : + source.index("CREATE TRIGGER book_period_control_seed_for_period") + ] book_function = source[ source.index("CREATE OR REPLACE FUNCTION accounting_core.seed_book_period_control_for_book()") : source.index("CREATE TRIGGER book_period_control_seed_for_book") ] - select_start = book_function.index("SELECT NEW.tenant_account_id") - period_source = book_function.index( + book_select_start = book_function.index("SELECT NEW.tenant_account_id") + book_period_source = book_function.index( "FROM accounting_core.fiscal_period", - select_start, + book_select_start, ) - projected_control_values = book_function[select_start:period_source] + projected_book_control_values = book_function[book_select_start:book_period_source] + repair_backfill = source[source.rindex("INSERT INTO accounting_core.accounting_book_period_control (") :] + repair_select_start = repair_backfill.index("SELECT accounting_book.tenant_account_id") + repair_period_source = repair_backfill.index( + "FROM accounting_core.accounting_book", + repair_select_start, + ) + projected_repair_values = repair_backfill[repair_select_start:repair_period_source] - self.assertNotIn("fiscal_period.period_status_code", projected_control_values) - self.assertNotIn("fiscal_period.period_closed_at", projected_control_values) + self.assertIn( + "IF NEW.period_status_code IS DISTINCT FROM 'open' THEN\n RETURN NEW;\n END IF;", + period_function, + ) + self.assertNotIn("NEW.period_status_code", period_function[period_function.index("SELECT NEW.tenant_account_id") :]) + self.assertNotIn("NEW.period_closed_at", period_function[period_function.index("SELECT NEW.tenant_account_id") :]) + self.assertNotIn("fiscal_period.period_status_code", projected_book_control_values) + self.assertNotIn("fiscal_period.period_closed_at", projected_book_control_values) + self.assertIn("AND fiscal_period.period_status_code = 'open'", book_function) + self.assertNotIn("fiscal_period.period_status_code", projected_repair_values) + self.assertNotIn("fiscal_period.period_closed_at", projected_repair_values) + self.assertIn("WHERE accounting_book.valid_to IS NULL\n AND fiscal_period.period_status_code = 'open'", repair_backfill) def test_opposite_side_seeders_version_one_tenant_serialization_row(self) -> None: """Peer scans need a common row version so fixed snapshots fail closed instead of missing data.""" From 797cf556d8cf69e959b02cfd62cf4f30685e9658 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 14:06:26 +0900 Subject: [PATCH 127/224] fix(close): keep shared period projection out of book authority --- .../0034_book_period_control_seed.sql | 31 +++++++++++++++---- 1 file changed, 25 insertions(+), 6 deletions(-) diff --git a/database/migrations/0034_book_period_control_seed.sql b/database/migrations/0034_book_period_control_seed.sql index f058da06..8e86ec67 100644 --- a/database/migrations/0034_book_period_control_seed.sql +++ b/database/migrations/0034_book_period_control_seed.sql @@ -6,6 +6,15 @@ BEGIN; -- later fiscal periods or accounting books could otherwise reach the journal -- guard without a materialized book-period authority. -- +-- fiscal_period.period_status_code is only the tenant/calendar compatibility +-- projection after book-scoped close control exists. A newly created book or a +-- newly inserted non-open period has no retained per-book close evidence from +-- which soft_closed/hard_closed could be derived. Automatic seeding therefore +-- creates controls only for periods that are actually open and always starts +-- the new book-period authority as open. Missing controls for non-open periods +-- fail closed until an explicit book-period lifecycle can establish authority; +-- the compatibility projection is never copied into authoritative close state. +-- -- The two AFTER INSERT triggers also form one cross-product invariant. Without -- a pre-existing common version witness, concurrent transactions can each -- insert one side, scan before the other side commits, and leave the new @@ -25,6 +34,10 @@ SECURITY DEFINER SET search_path = pg_catalog, pg_temp AS $$ BEGIN + IF NEW.period_status_code IS DISTINCT FROM 'open' THEN + RETURN NEW; + END IF; + UPDATE accounting_core.tenant_account AS tenant SET created_at = tenant.created_at WHERE tenant.tenant_account_id = NEW.tenant_account_id; @@ -39,8 +52,8 @@ BEGIN SELECT NEW.tenant_account_id, accounting_book.accounting_book_id, NEW.fiscal_period_id, - NEW.period_status_code, - NEW.period_closed_at + 'open', + NULL FROM accounting_core.accounting_book WHERE accounting_book.tenant_account_id = NEW.tenant_account_id AND accounting_book.valid_to IS NULL @@ -88,10 +101,11 @@ BEGIN SELECT NEW.tenant_account_id, NEW.accounting_book_id, fiscal_period.fiscal_period_id, - fiscal_period.period_status_code, - fiscal_period.period_closed_at + 'open', + NULL FROM accounting_core.fiscal_period WHERE fiscal_period.tenant_account_id = NEW.tenant_account_id + AND fiscal_period.period_status_code = 'open' ON CONFLICT ( tenant_account_id, accounting_book_id, @@ -119,6 +133,10 @@ CREATE TRIGGER book_period_control_seed_for_book -- RLS remains enabled for non-owner roles. The same transaction restores FORCE -- on every source/target table before commit. ALTER TABLE's locking also keeps -- concurrent runtime traffic from observing a partially changed owner policy. +-- +-- This repair fills only missing open controls. A missing non-open book-period +-- pair cannot be reconstructed from fiscal_period's compatibility projection, +-- because that projection is not retained per-book close evidence. ALTER TABLE accounting_core.accounting_book NO FORCE ROW LEVEL SECURITY; ALTER TABLE accounting_core.fiscal_period NO FORCE ROW LEVEL SECURITY; ALTER TABLE accounting_core.accounting_book_period_control NO FORCE ROW LEVEL SECURITY; @@ -134,12 +152,13 @@ INSERT INTO accounting_core.accounting_book_period_control ( SELECT accounting_book.tenant_account_id, accounting_book.accounting_book_id, fiscal_period.fiscal_period_id, - fiscal_period.period_status_code, - fiscal_period.period_closed_at + 'open', + NULL FROM accounting_core.accounting_book JOIN accounting_core.fiscal_period ON fiscal_period.tenant_account_id = accounting_book.tenant_account_id WHERE accounting_book.valid_to IS NULL + AND fiscal_period.period_status_code = 'open' ON CONFLICT ( tenant_account_id, accounting_book_id, From 1efd6e7f8c16caba1ff90129fe462759ca93aae7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 14:07:42 +0900 Subject: [PATCH 128/224] docs(close): keep compatibility status out of book close authority --- docs/adr/0006-fiscal-period-close-snapshot.md | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/docs/adr/0006-fiscal-period-close-snapshot.md b/docs/adr/0006-fiscal-period-close-snapshot.md index 724792b4..25a1c8f6 100644 --- a/docs/adr/0006-fiscal-period-close-snapshot.md +++ b/docs/adr/0006-fiscal-period-close-snapshot.md @@ -59,13 +59,19 @@ A serialization failure is not accounting evidence. The entire transaction rolls ### Master-data seeding under forced tenant RLS -Migration `0034_book_period_control_seed.sql` makes the book-period authority lifecycle complete after installation: inserting either a new fiscal period or a new active accounting book materializes the missing `accounting_book_period_control` pairs and synchronously seeds all 64 freshness rows. Its upgrade backfill is intentionally cross-tenant because it repairs every pre-existing active book-period pair. +Migration `0034_book_period_control_seed.sql` keeps the post-install book-period authority lifecycle fail closed without treating `fiscal_period.period_status_code` as per-book close evidence. `fiscal_period` retains tenant/calendar dates and an aggregate compatibility projection; `accounting_book_period_control` is the authoritative tenant/book/period close-state intersection. A newly inserted book therefore cannot inherit `soft_closed`, `hard_closed`, or `period_closed_at` merely because the shared period projection has that value. + +Automatic seeding is limited to actually open periods. A newly inserted `fiscal_period` seeds active books only when its initial status is `open`, and those new controls start as literal `open` with no close timestamp. A newly inserted active `accounting_book` scans only fiscal periods whose compatibility status is currently `open`, again creating literal-open controls. The migration repair backfill follows the same rule for missing rows. A non-open period without an existing book-period control remains absent, so journal and close admission fail with missing-authority errors rather than fabricating a close state. A future command that intentionally makes an existing closed period applicable to a later-created book must define explicit book-period lifecycle/reopen evidence; this migration does not synthesize that authority. + +Migration 0009 is different: it converts the pre-book-scoped schema into the first book-period control population, so the then-existing fiscal-period state is the legacy source being migrated. That one-time conversion is not a precedent for copying the compatibility projection into post-install book authority. + +The two 0034 insert triggers still need a complete cross-product for open periods. Both acquire the same pre-existing tenant-row MVCC version witness before scanning the peer population. At Read Committed, the later writer waits and then observes the peer commit. At Repeatable Read or Serializable, a stale snapshot fails with SQLSTATE `40001` and the complete master-data command must retry from a fresh transaction. This prevents concurrent new-book/new-open-period transactions from each missing the other's row. PostgreSQL normally lets a table owner bypass row security, but `FORCE ROW LEVEL SECURITY` makes that owner subject to the policy; `SUPERUSER` and `BYPASSRLS` remain separate exceptional capabilities. A production migration owner is not required to hold either capability or a runtime tenant binding. Migration 0005 has already forced the `accounting_book` and `fiscal_period` seed sources through tenant RLS by the time migration 0009 runs, so target ordering alone is insufficient: an unbound owner can otherwise see no source rows. -Migration 0009 therefore restores normal owner visibility with `NO FORCE ROW LEVEL SECURITY` on `accounting_book` and `fiscal_period`, performs the all-tenant seed, restores FORCE on both sources, and only then enables/forces RLS on the new control target. Migration 0034 keeps RLS enabled but temporarily applies `NO FORCE ROW LEVEL SECURITY` to all four participating tables—`accounting_book`, `fiscal_period`, `accounting_book_period_control`, and `period_journal_population_fence`—for the owner-only repair window, then restores FORCE on all four before the migration transaction commits. This is not a runtime gate weakening: non-owner roles remain subject to RLS throughout and the committed schema remains FORCE RLS protected. +Migration 0009 therefore restores normal owner visibility with `NO FORCE ROW LEVEL SECURITY` on `accounting_book` and `fiscal_period`, performs the legacy all-tenant conversion, restores FORCE on both sources, and only then enables/forces RLS on the new control target. Migration 0034 keeps RLS enabled but temporarily applies `NO FORCE ROW LEVEL SECURITY` to all four participating tables—`accounting_book`, `fiscal_period`, `accounting_book_period_control`, and `period_journal_population_fence`—for the owner-only repair window, then restores FORCE on all four before the migration transaction commits. This is not a runtime gate weakening: non-owner roles remain subject to RLS throughout and the committed schema remains FORCE RLS protected. -The migration must run as owner of the participating relations; it must not solve installation by granting `BYPASSRLS`, impersonating a tenant, disabling row security, or copying tenant truth into an installer-side cache. `tests/test_book_period_control_seed_contract.py` pins source and target ordering and requires that the repair never uses `DISABLE ROW LEVEL SECURITY`. Exact release acceptance still needs a real PostgreSQL migration run with a production-like unbound `NOSUPERUSER`/`NOBYPASSRLS` owner; static ordering evidence alone is not execution GREEN. +The migration must run as owner of the participating relations; it must not solve installation by granting `BYPASSRLS`, impersonating a tenant, disabling row security, or copying tenant truth into an installer-side cache. `tests/test_book_period_control_seed_contract.py` pins source and target ordering, open-only authority derivation, and the prohibition on `DISABLE ROW LEVEL SECURITY`. `tests/test_book_period_control_seed.py` exercises the real PostgreSQL master-data lifecycle, including the requirement that a later-created book does not inherit a shared non-open period projection. Exact release acceptance still needs a real PostgreSQL migration run with a production-like unbound `NOSUPERUSER`/`NOBYPASSRLS` owner; static ordering evidence alone is not execution GREEN. ### Application advisory-lock repair @@ -97,6 +103,8 @@ Lazy creation of a fence row during journal admission was rejected because a rep Using only a trigger-side existence query for retained snapshots was rejected because a fixed MVCC snapshot cannot observe a competing population committed after that snapshot. Physical unique population identity remains required. +Copying `fiscal_period.period_status_code` or `period_closed_at` into a control for a later-created book was rejected because those fields are only the shared compatibility projection after book-scoped close authority exists. Doing so would manufacture `soft_closed` or `hard_closed` without that book's close command, maker-checker evidence, retained snapshot, or close lineage. Open-only automatic seeding is the conservative post-install behavior; non-open applicability requires an explicit future lifecycle command. + Allowing caller-provided close timestamps, currencies, aggregate identifiers, or retained arithmetic was rejected because those values are accounting evidence and must be derived or verified at the authoritative database boundary. Granting the migration role `BYPASSRLS`, disabling RLS for the backfill, or binding an infrastructure role to a fabricated runtime tenant was rejected. Those approaches turn an installation concern into standing or misleading runtime authority. Owner-only `NO FORCE` inside the uncommitted migration preserves RLS for runtime roles and restores FORCE before any new schema state commits. @@ -107,7 +115,7 @@ At the database boundary, open-period posting performs a shared control-row lock PostgreSQL row locking and same-slot collisions still have measurable cost. Release evidence must use realistic concurrent posting and transition workloads and report advisory-lock waits, row-lock waits, stripe distribution, failures, retry rates, WAL/write cost, and tail latency without sample reduction, excluded failures, or artificial cache warm-up. -Migration 0033 creates a new tenant-scoped table under RLS/FORCE RLS. Its initial cross-tenant fence backfill occurs before FORCE RLS is enabled. Migrations 0009/0034 now also account for the already forced source tables: the migration owner gets only a transactional owner-bypass window on the exact participating relations, while RLS remains enabled for non-owner roles and FORCE is restored before commit. Future rows are seeded from the book-period-control INSERT transaction and stay in that tenant scope. Guard functions are `SECURITY DEFINER`, use `search_path = pg_catalog, pg_temp`, and revoke PUBLIC execute. +Migration 0033 creates a new tenant-scoped table under RLS/FORCE RLS. Its initial cross-tenant fence backfill occurs before FORCE RLS is enabled. Migrations 0009/0034 now also account for the already forced source tables: the migration owner gets only a transactional owner-bypass window on the exact participating relations, while RLS remains enabled for non-owner roles and FORCE is restored before commit. Migration 0034 seeds future and repair rows only where the shared period is open; missing non-open book-period authority remains fail closed rather than being inferred. Future created controls are seeded from the book-period-control INSERT transaction and stay in that tenant scope. Guard functions are `SECURITY DEFINER`, use `search_path = pg_catalog, pg_temp`, and revoke PUBLIC execute. The migration chain has distinct recovery states. A failed 0029 concurrent index build can leave an invalid index that operators must remove or rebuild before retry. A failed 0031 validation leaves the constraint enforced for subsequent writes but inherited rows uncertified. Failed 0033 or 0034 migration transactions roll back their tables/triggers/functions/policies or owner-force toggles and seed population together. A runtime SQLSTATE `40001` leaves no authoritative close result and requires a whole-command retry. Recovery must never normalize or rewrite posted journals, reconciliation evidence, or retained close facts. @@ -137,4 +145,4 @@ PostgreSQL Global Development Group. (2026g). *PostgreSQL 18 documentation: pg_l PostgreSQL Global Development Group. (2026h). *PostgreSQL 18 documentation: Advisory lock functions*. https://www.postgresql.org/docs/18/functions-admin.html#FUNCTIONS-ADVISORY-LOCKS -PostgreSQL Global Development Group. (2026i). *PostgreSQL 18 documentation: Row security policies*. https://www.postgresql.org/docs/18/ddl-rowsecurity.html +PostgreSQL Global Development Group. (2026i). *PostgreSQL 18 documentation: Row security policies*. https://www.postgresql.org/docs/18/ddl-rowsecurity.html \ No newline at end of file From 6c2fa9f4d4e9092476dee3c3c8ae2a3b10a53807 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 14:11:22 +0900 Subject: [PATCH 129/224] docs(close): trace open-only book-period authority seeding --- ...RIOD_CONTROL_RLS_MIGRATION_TRACEABILITY.md | 25 ++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/docs/doctoring/BOOK_PERIOD_CONTROL_RLS_MIGRATION_TRACEABILITY.md b/docs/doctoring/BOOK_PERIOD_CONTROL_RLS_MIGRATION_TRACEABILITY.md index 12a10a9d..9aa9b795 100644 --- a/docs/doctoring/BOOK_PERIOD_CONTROL_RLS_MIGRATION_TRACEABILITY.md +++ b/docs/doctoring/BOOK_PERIOD_CONTROL_RLS_MIGRATION_TRACEABILITY.md @@ -22,13 +22,25 @@ Transaction A could insert a new active book and run its trigger while transacti A shared lock with no row-version change is also insufficient at fixed-snapshot isolation. PostgreSQL 18 Repeatable Read sees only data committed before the transaction snapshot and requires whole-transaction retry when an updater reaches a row that another transaction actually updated after that snapshot. Therefore the seeders need both ordering for Read Committed and a pre-existing MVCC version witness for Repeatable Read/Serializable. +## Post-install close-authority projection finding + +`docs/DATA_MODEL.md` defines `accounting_book_period_control` as the authoritative close-state intersection for one tenant accounting book and fiscal period. The same document explicitly describes `fiscal_period.period_status_code` as an aggregate compatibility projection that must not be used to infer that every sibling book has the same close state. + +Migration 0034 nevertheless copied `fiscal_period.period_status_code` and `fiscal_period.period_closed_at` into a control created for a later-inserted accounting book. That is an authority inversion: a new book could appear `soft_closed` or `hard_closed` even though no close command, maker-checker evidence, retained snapshot, close event, or book-specific chronology existed for that book. The same defect existed in the repair backfill for missing post-install pairs. A direct INSERT of a non-open fiscal period could also have projected that state across every active book. + +This is not an IFRS interpretation. IFRS does not prescribe PostgreSQL aggregate ownership or row-level close-state derivation. It is a DDD/data-authority control required so AIS does not manufacture its own accounting evidence from a compatibility projection. + ## Selected controls -Migration 0009 temporarily applies `NO FORCE ROW LEVEL SECURITY` to the already forced `accounting_book` and `fiscal_period` source tables, performs the existing all-tenant control-row seed while the new target table is not yet exposed through runtime policy, restores FORCE on both sources, and only then enables/forces RLS on `accounting_book_period_control`. +Migration 0009 temporarily applies `NO FORCE ROW LEVEL SECURITY` to the already forced `accounting_book` and `fiscal_period` source tables, performs the existing all-tenant control-row seed while the new target table is not yet exposed through runtime policy, restores FORCE on both sources, and only then enables/forces RLS on `accounting_book_period_control`. This is a one-time conversion from the pre-book-scoped close model; the then-existing fiscal-period status is legacy source state being migrated and is not precedent for post-install projection copying. Migration 0034 keeps RLS **enabled** everywhere. For its owner-only repair phase it applies `NO FORCE ROW LEVEL SECURITY` to all four participating relations: `accounting_book`, `fiscal_period`, `accounting_book_period_control`, and `period_journal_population_fence`. It performs the cross-tenant backfill, then restores `FORCE ROW LEVEL SECURITY` on every source and target before `COMMIT`. `NO FORCE` restores normal table-owner bypass; it does not disable RLS for non-owner runtime roles. The fence table participates because every inserted control synchronously invokes migration 0033's `SECURITY DEFINER` 64-stripe seeder. -For post-install book/period creation, both migration-0034 trigger functions now update the same pre-existing `tenant_account` row before they scan the opposite master-data population. The statement sets `created_at` to its retained value, so it changes no tenant business fact, but it performs a real non-key row update and therefore provides the common MVCC version witness. +Post-install automatic authority creation is now open-only. `seed_book_period_control_for_period()` returns without creating book controls when the inserted fiscal period is not `open`; an open period creates literal `open` controls with `period_closed_at = NULL`. `seed_book_period_control_for_book()` scans only shared fiscal periods whose compatibility projection is currently `open`, and again creates literal `open` controls with no close timestamp. Migration 0034's repair backfill fills only missing open pairs. Missing non-open book-period authority remains absent, causing journal/close admission to fail closed instead of inventing close history. + +This deliberately does not define a reopen/adoption policy for a later-created book and an already non-open period. If that buyer use case is required, it needs an explicit book-period lifecycle command with authenticated capability, maker-checker separation where required, immutable command identity/evidence, and retained successor chronology. Copying `fiscal_period` state or using a manual SQL repair is not an acceptable substitute. + +For post-install open book/period creation, both migration-0034 trigger functions update the same pre-existing `tenant_account` row before they scan the opposite master-data population. The statement sets `created_at` to its retained value, so it changes no tenant business fact, but it performs a real non-key row update and therefore provides the common MVCC version witness. At PostgreSQL's default Read Committed isolation, a later updater waits for the competing tenant-row update and the following trigger statement receives a new command snapshot that can see the peer commit. At Repeatable Read/Serializable, a seeder whose fixed snapshot predates the competing tenant-row version fails with serialization error rather than committing a peer-blind pair; the complete master-data command must retry from a fresh transaction. Because `created_at` is not a key column, PostgreSQL uses the weaker `FOR NO KEY UPDATE` row-lock class for this update. PostgreSQL documents that `FOR NO KEY UPDATE` does not conflict with `FOR KEY SHARE`, preserving ordinary foreign-key checks by unrelated child inserts while still self-conflicting with the other master-data seeder. @@ -48,6 +60,8 @@ A table-level lock was rejected because it would serialize unrelated tenants and A hash-based advisory lock was not selected because the canonical tenant row already provides a collision-free, pre-existing database identity and the fixed-snapshot case still needs a row-version change or an explicit serialization failure mechanism. +Copying `fiscal_period.period_status_code` or `period_closed_at` into a later-created book was rejected because the shared fields are compatibility state after book-scoped authority exists. This would manufacture closure without book-specific evidence. Initializing every historical/non-open pair as `open` was also rejected because that would silently reopen periods and admit backposting. The selected open-only materialization creates authority only where the shared period is already open and leaves non-open applicability fail closed. + ## TDD and exact implementation evidence - First static RED `800716a2b44370e41b0a5e65d86d4e30d1008765` required owner-safe target backfill without RLS disablement. @@ -61,6 +75,11 @@ A hash-based advisory lock was not selected because the canonical tenant row alr - Initial common-row lock repair `a038f8726ed0ef6f88a4ee7ea4920e6873e032f1` serializes the two trigger scans, with static ratchet `1d286cdd0d641b3723d90468a62cbe7da41a156f`. - Fixed-snapshot RED `58b5f57b3bc7ae677544f90d0bfaf0c325045629` proves that a lock-only repair is not enough: a concurrent Repeatable Read period transaction must fail with PostgreSQL `SerializationFailure` and succeed only when the complete insert is retried from a fresh transaction. - Fixed-snapshot causal repair `521104564345d096836da145140315f5e23fb1df` turns the tenant coordination point into a non-key UPDATE version witness without changing tenant business data; static contract `aa97d22b6f01b111ec3e701e4db229471276b807` pins that write before either peer scan and rejects a caller-shaped replacement timestamp. +- Book-state authority static RED `7592a3114f565f65c704b5b5f9f5eca39e420b75` rejects selecting shared period status/timestamp as values for a newly inserted book control. +- Real-PostgreSQL RED `083488b52f0ef4b9cf0fa21c87abea5159f9039f` requires a later-created active book to receive no control or fence population from a non-open shared period projection. +- Static authority ratchet `ac38a098c703d8ea72ba72fd1e7c9dc1a3814227` extends open-only derivation to new-period seeding and the repair backfill. +- Production repair `797cf556d8cf69e959b02cfd62cf4f30685e9658` implements open-only automatic control creation and repair backfill without changing migration 0009's one-time legacy conversion. +- ADR 0006 alignment is `1efd6e7f8c16caba1ff90129fe462759ca93aae7`; this traceability note is its normal fast-forward descendant. These commits are development evidence only. The RED commits preceded their corresponding causal repairs, but no runner-observed RED or exact-head GREEN claim is valid until GitHub Actions executes the corresponding heads. The final release candidate still requires the real PostgreSQL migration chain with a production-like unbound non-bypass owner, tenant-isolation acceptance, security/SAST/dependency checks, migration/recovery evidence, independent review, and protected integration gates. @@ -68,7 +87,7 @@ These commits are development evidence only. The RED commits preceded their corr The SQL repairs are transactional. A failed migration must roll back every owner-force toggle and all inserted control/fence rows together. A runtime master-data serialization failure rolls back the book or period insert and its seed side effects together. Callers must retry the complete immutable master-data command from a fresh transaction; they must not fabricate book-period controls, delete posted journals, rewrite retained trial-balance evidence, or weaken tenant policies to make the operation appear successful. -The committed runtime state remains `ENABLE ROW LEVEL SECURITY` + `FORCE ROW LEVEL SECURITY` on the tenant-scoped source and authority relations. This repair changes migration-owner visibility and master-data coordination only. It does not alter the runtime single-writer boundary, tenant policy expression, close authority, maker-checker rules, journal amounts, or financial values. +The committed runtime state remains `ENABLE ROW LEVEL SECURITY` + `FORCE ROW LEVEL SECURITY` on the tenant-scoped source and authority relations. This repair changes migration-owner visibility, master-data coordination, and post-install authority derivation only. It does not alter the runtime single-writer boundary, tenant policy expression, close command, maker-checker rules, journal amounts, or financial values. ## References From 44795d26abb1b0c1d7502b61160d58a93be95a04 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 14:12:18 +0900 Subject: [PATCH 130/224] style(close): keep authority contract formatter-safe --- .../test_book_period_control_seed_contract.py | 75 ++++++++++++++----- 1 file changed, 55 insertions(+), 20 deletions(-) diff --git a/tests/test_book_period_control_seed_contract.py b/tests/test_book_period_control_seed_contract.py index 46f2be82..bc18bad3 100644 --- a/tests/test_book_period_control_seed_contract.py +++ b/tests/test_book_period_control_seed_contract.py @@ -33,54 +33,88 @@ def test_non_open_projection_never_synthesizes_book_close_authority(self) -> Non """Only an actually opened period may create a new book-period control automatically.""" source = MIGRATION.read_text(encoding="utf-8") period_function = source[ - source.index("CREATE OR REPLACE FUNCTION accounting_core.seed_book_period_control_for_period()") : - source.index("CREATE TRIGGER book_period_control_seed_for_period") + source.index( + "CREATE OR REPLACE FUNCTION " + "accounting_core.seed_book_period_control_for_period()" + ) : source.index("CREATE TRIGGER book_period_control_seed_for_period") ] book_function = source[ - source.index("CREATE OR REPLACE FUNCTION accounting_core.seed_book_period_control_for_book()") : - source.index("CREATE TRIGGER book_period_control_seed_for_book") + source.index( + "CREATE OR REPLACE FUNCTION " + "accounting_core.seed_book_period_control_for_book()" + ) : source.index("CREATE TRIGGER book_period_control_seed_for_book") ] book_select_start = book_function.index("SELECT NEW.tenant_account_id") book_period_source = book_function.index( "FROM accounting_core.fiscal_period", book_select_start, ) - projected_book_control_values = book_function[book_select_start:book_period_source] - repair_backfill = source[source.rindex("INSERT INTO accounting_core.accounting_book_period_control (") :] - repair_select_start = repair_backfill.index("SELECT accounting_book.tenant_account_id") + projected_book_control_values = book_function[ + book_select_start:book_period_source + ] + repair_backfill = source[ + source.rindex( + "INSERT INTO accounting_core.accounting_book_period_control (" + ) : + ] + repair_select_start = repair_backfill.index( + "SELECT accounting_book.tenant_account_id" + ) repair_period_source = repair_backfill.index( "FROM accounting_core.accounting_book", repair_select_start, ) - projected_repair_values = repair_backfill[repair_select_start:repair_period_source] + projected_repair_values = repair_backfill[ + repair_select_start:repair_period_source + ] self.assertIn( - "IF NEW.period_status_code IS DISTINCT FROM 'open' THEN\n RETURN NEW;\n END IF;", + "IF NEW.period_status_code IS DISTINCT FROM 'open' THEN\n" + " RETURN NEW;\n" + " END IF;", period_function, ) - self.assertNotIn("NEW.period_status_code", period_function[period_function.index("SELECT NEW.tenant_account_id") :]) - self.assertNotIn("NEW.period_closed_at", period_function[period_function.index("SELECT NEW.tenant_account_id") :]) - self.assertNotIn("fiscal_period.period_status_code", projected_book_control_values) - self.assertNotIn("fiscal_period.period_closed_at", projected_book_control_values) + period_projection_start = period_function.index( + "SELECT NEW.tenant_account_id" + ) + period_projection = period_function[period_projection_start:] + self.assertNotIn("NEW.period_status_code", period_projection) + self.assertNotIn("NEW.period_closed_at", period_projection) + self.assertNotIn( + "fiscal_period.period_status_code", projected_book_control_values + ) + self.assertNotIn( + "fiscal_period.period_closed_at", projected_book_control_values + ) self.assertIn("AND fiscal_period.period_status_code = 'open'", book_function) self.assertNotIn("fiscal_period.period_status_code", projected_repair_values) self.assertNotIn("fiscal_period.period_closed_at", projected_repair_values) - self.assertIn("WHERE accounting_book.valid_to IS NULL\n AND fiscal_period.period_status_code = 'open'", repair_backfill) + self.assertIn( + "WHERE accounting_book.valid_to IS NULL\n" + " AND fiscal_period.period_status_code = 'open'", + repair_backfill, + ) def test_opposite_side_seeders_version_one_tenant_serialization_row(self) -> None: """Peer scans need a common row version so fixed snapshots fail closed instead of missing data.""" source = MIGRATION.read_text(encoding="utf-8") period_function = source[ - source.index("CREATE OR REPLACE FUNCTION accounting_core.seed_book_period_control_for_period()") : - source.index("CREATE TRIGGER book_period_control_seed_for_period") + source.index( + "CREATE OR REPLACE FUNCTION " + "accounting_core.seed_book_period_control_for_period()" + ) : source.index("CREATE TRIGGER book_period_control_seed_for_period") ] book_function = source[ - source.index("CREATE OR REPLACE FUNCTION accounting_core.seed_book_period_control_for_book()") : - source.index("CREATE TRIGGER book_period_control_seed_for_book") + source.index( + "CREATE OR REPLACE FUNCTION " + "accounting_core.seed_book_period_control_for_book()" + ) : source.index("CREATE TRIGGER book_period_control_seed_for_book") ] for function_source in (period_function, book_function): - tenant_update = function_source.index("UPDATE accounting_core.tenant_account AS tenant") + tenant_update = function_source.index( + "UPDATE accounting_core.tenant_account AS tenant" + ) retained_value = function_source.index( "SET created_at = tenant.created_at", tenant_update, @@ -119,7 +153,8 @@ def test_cross_tenant_backfills_are_owner_safe_without_disabling_rls(self) -> No "INSERT INTO accounting_core.accounting_book_period_control (" ) initial_control_force = initial_source.index( - "ALTER TABLE accounting_core.accounting_book_period_control FORCE ROW LEVEL SECURITY;" + "ALTER TABLE accounting_core.accounting_book_period_control " + "FORCE ROW LEVEL SECURITY;" ) initial_book_no_force = initial_source.index( "ALTER TABLE accounting_core.accounting_book NO FORCE ROW LEVEL SECURITY;" From 614d1164f3abf1f7bab3fe77d520e5b7108e4c69 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 15:04:27 +0900 Subject: [PATCH 131/224] test(close): reject shared-status book authority projection --- ...s_book_period_control_no_projection_red.py | 127 ++++++++++++++++++ 1 file changed, 127 insertions(+) create mode 100644 tests/test_postgres_book_period_control_no_projection_red.py diff --git a/tests/test_postgres_book_period_control_no_projection_red.py b/tests/test_postgres_book_period_control_no_projection_red.py new file mode 100644 index 00000000..2a80fbe3 --- /dev/null +++ b/tests/test_postgres_book_period_control_no_projection_red.py @@ -0,0 +1,127 @@ +"""Real PostgreSQL regression for missing book-period close authority.""" + +from __future__ import annotations + +import unittest +import uuid + +import psycopg + +from accounting_information_platform import AccountingValidationError +from tests import test_postgres_posting as posting + + +class BookPeriodControlNoProjectionTests(unittest.TestCase): + """Require close control to remain book-owned when a shared period is already closed.""" + + @classmethod + def setUpClass(cls) -> None: + """Install the complete current migration chain into the PostgreSQL fixture.""" + posting.PostgresPostingTests.setUpClass() + + def setUp(self) -> None: + """Seed one isolated posting tenant for the authority-boundary regression.""" + self.case = posting.PostgresPostingTests("setUp") + self.case.setUp() + self.addCleanup(self.case.doCleanups) + self.addCleanup(self.case.tearDown) + + def test_close_lock_does_not_synthesize_non_open_book_authority(self) -> None: + """A missing book-period control must not be copied from shared fiscal-period state.""" + new_book_id = uuid.uuid4() + with psycopg.connect(posting.DATABASE_URL) as connection: + legal_entity_id = connection.execute( + """ + SELECT legal_entity_id + FROM accounting_core.legal_entity_record + WHERE tenant_account_id = %s + ORDER BY recorded_at + LIMIT 1 + """, + (self.case.tenant_id,), + ).fetchone()[0] + period_id = connection.execute( + """ + UPDATE accounting_core.fiscal_period + SET period_status_code = 'soft_closed', + period_closed_at = clock_timestamp() + WHERE tenant_account_id = %s + AND period_code = '2026-08' + RETURNING fiscal_period_id + """, + (self.case.tenant_id,), + ).fetchone()[0] + connection.execute( + """ + INSERT INTO accounting_core.accounting_book ( + accounting_book_id, + tenant_account_id, + legal_entity_id, + book_role_code, + book_name, + reporting_currency_code, + valid_from + ) + VALUES (%s, %s, %s, %s, %s, 'USD', '2026-01-01T00:00:00Z') + """, + ( + new_book_id, + self.case.tenant_id, + legal_entity_id, + f"no_projection_{new_book_id.hex}", + f"No projection {new_book_id.hex}", + ), + ) + connection.commit() + + self._assert_no_control_or_fence(new_book_id, period_id) + + with psycopg.connect(posting.DATABASE_URL) as connection: + with self.assertRaisesRegex( + AccountingValidationError, + "has no control row for this accounting book", + ): + self.case.ledger._lock_book_period( + connection, + self.case.tenant_id, + new_book_id, + "2026-08", + ) + connection.rollback() + + self._assert_no_control_or_fence(new_book_id, period_id) + + def _assert_no_control_or_fence( + self, + accounting_book_id: uuid.UUID, + fiscal_period_id: uuid.UUID, + ) -> None: + """Require the unsupported non-open pair to remain absent after close admission.""" + with psycopg.connect(posting.DATABASE_URL) as connection: + control_count = connection.execute( + """ + SELECT COUNT(*) + FROM accounting_core.accounting_book_period_control + WHERE tenant_account_id = %s + AND accounting_book_id = %s + AND fiscal_period_id = %s + """, + (self.case.tenant_id, accounting_book_id, fiscal_period_id), + ).fetchone()[0] + fence_count = connection.execute( + """ + SELECT COUNT(*) + FROM accounting_core.period_journal_population_fence + WHERE tenant_account_id = %s + AND accounting_book_id = %s + AND fiscal_period_id = %s + """, + (self.case.tenant_id, accounting_book_id, fiscal_period_id), + ).fetchone()[0] + + self.assertEqual(control_count, 0) + self.assertEqual(fence_count, 0) + + +if __name__ == "__main__": + unittest.main() From 610d77082eb01c80d2e9e74521e48a3b06e1375a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 15:09:43 +0900 Subject: [PATCH 132/224] fix(close): keep book-period authority on canonical seed path --- .../0034_book_period_control_seed.sql | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/database/migrations/0034_book_period_control_seed.sql b/database/migrations/0034_book_period_control_seed.sql index 8e86ec67..96fd6379 100644 --- a/database/migrations/0034_book_period_control_seed.sql +++ b/database/migrations/0034_book_period_control_seed.sql @@ -170,4 +170,41 @@ ALTER TABLE accounting_core.accounting_book_period_control FORCE ROW LEVEL SECUR ALTER TABLE accounting_core.fiscal_period FORCE ROW LEVEL SECURITY; ALTER TABLE accounting_core.accounting_book FORCE ROW LEVEL SECURITY; +-- After the one-time repair above, new book-period authority may be created only +-- by the two canonical master-data seed triggers. A direct application or SQL +-- INSERT must not reconstruct authority from fiscal_period compatibility state. +-- pg_trigger_depth() is structural rather than a caller-controlled custom GUC: +-- the control-table trigger runs at depth 2 when invoked by either canonical +-- AFTER INSERT seeder and at depth 1 for a direct control-table INSERT. Returning +-- NULL leaves an unsupported direct write unapplied; the close path then reads +-- the still-missing control and fails with its domain validation error. This +-- keeps the database single-writer boundary intact without granting a mutable +-- session flag that another writer could spoof. +CREATE OR REPLACE FUNCTION accounting_core.guard_book_period_control_insert_authority() +RETURNS trigger +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = pg_catalog, pg_temp +AS $$ +BEGIN + IF pg_trigger_depth() < 2 + OR NEW.period_status_code IS DISTINCT FROM 'open' + OR NEW.period_closed_at IS NOT NULL + THEN + RETURN NULL; + END IF; + + RETURN NEW; +END; +$$; + +REVOKE ALL ON FUNCTION accounting_core.guard_book_period_control_insert_authority() + FROM PUBLIC; + +CREATE TRIGGER book_period_control_insert_authority_guard + BEFORE INSERT + ON accounting_core.accounting_book_period_control + FOR EACH ROW + EXECUTE FUNCTION accounting_core.guard_book_period_control_insert_authority(); + COMMIT; From ee233b5c40c942008c7ec034917fd49f1fcf9976 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 15:10:05 +0900 Subject: [PATCH 133/224] test(close): pin canonical book-period insert authority --- ...eriod_control_insert_authority_contract.py | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 tests/test_book_period_control_insert_authority_contract.py diff --git a/tests/test_book_period_control_insert_authority_contract.py b/tests/test_book_period_control_insert_authority_contract.py new file mode 100644 index 00000000..9f8ad597 --- /dev/null +++ b/tests/test_book_period_control_insert_authority_contract.py @@ -0,0 +1,34 @@ +"""Static contract for canonical book-period authority materialization.""" + +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +MIGRATION = ROOT / "database" / "migrations" / "0034_book_period_control_seed.sql" + + +def test_direct_book_period_control_insert_is_not_an_authority_writer() -> None: + """Require post-install control creation to come from nested canonical seed triggers.""" + migration = MIGRATION.read_text(encoding="utf-8") + + assert "guard_book_period_control_insert_authority" in migration + assert "pg_trigger_depth() < 2" in migration + assert "NEW.period_status_code IS DISTINCT FROM 'open'" in migration + assert "NEW.period_closed_at IS NOT NULL" in migration + assert "CREATE TRIGGER book_period_control_insert_authority_guard" in migration + assert "BEFORE INSERT" in migration + assert "RETURN NULL;" in migration + + +def test_authority_guard_is_installed_after_migration_repair() -> None: + """Keep the one-time owner repair outside the post-install runtime insert guard.""" + migration = MIGRATION.read_text(encoding="utf-8") + repair_position = migration.index( + "INSERT INTO accounting_core.accounting_book_period_control (", + migration.index("-- Repair databases"), + ) + guard_position = migration.index( + "CREATE OR REPLACE FUNCTION accounting_core.guard_book_period_control_insert_authority" + ) + + assert repair_position < guard_position From fa117a4a9e223c684cc9da0f80eaeb2fba5c83ba Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 15:11:18 +0900 Subject: [PATCH 134/224] docs(close): trace canonical book-period insert authority --- ...D_CONTROL_INSERT_AUTHORITY_TRACEABILITY.md | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 docs/doctoring/BOOK_PERIOD_CONTROL_INSERT_AUTHORITY_TRACEABILITY.md diff --git a/docs/doctoring/BOOK_PERIOD_CONTROL_INSERT_AUTHORITY_TRACEABILITY.md b/docs/doctoring/BOOK_PERIOD_CONTROL_INSERT_AUTHORITY_TRACEABILITY.md new file mode 100644 index 00000000..020ab32a --- /dev/null +++ b/docs/doctoring/BOOK_PERIOD_CONTROL_INSERT_AUTHORITY_TRACEABILITY.md @@ -0,0 +1,46 @@ +# Book-period control insert-authority traceability + +## Finding + +`accounting_book_period_control` is the authoritative tenant/accounting-book/fiscal-period close-state intersection. Migration 0034 already limits automatic post-install materialization to open periods and deliberately leaves missing non-open pairs absent. Fresh application review found a competing writer in `PostgresPostingLedger._lock_book_period()`: before locking the requested control row, it attempted `INSERT ... SELECT` from the shared `fiscal_period` row and copied `fiscal_period.period_status_code` and `period_closed_at` into book-scoped authority. + +That path could synthesize `soft_closed` or `hard_closed` authority for a later-created book without that book's close command, maker-checker evidence, retained trial-balance snapshot, close event, or chronology. It also selected every active book for the tenant rather than only the requested book. The resulting state contradicted migration 0034's open-only master-data lifecycle and the domain model's single-writer boundary. + +This is a DDD/data-authority defect, not an IFRS interpretation. IFRS does not prescribe PostgreSQL trigger nesting or book-period control rows. + +## Selected control + +Migration `0034_book_period_control_seed.sql` now makes the database relation itself reject direct post-install control creation. The one-time migration repair runs first. After that repair, `book_period_control_insert_authority_guard` is installed as a row-level `BEFORE INSERT` trigger on `accounting_book_period_control`. + +The guard admits a new row only when both conditions hold: + +- the control INSERT is nested under one of migration 0034's canonical master-data seed triggers (`pg_trigger_depth() >= 2`); and +- the new control is literal `open` with `period_closed_at IS NULL`. + +A direct runtime/application INSERT therefore returns no row. `_lock_book_period()` immediately performs its authoritative control lookup; when the requested pair is legitimately absent, the existing domain validation path reports that the accounting book has no control row and requires control-data repair. No shared `fiscal_period` status becomes book close authority. + +A caller-controlled custom GUC was rejected as an authority marker because an arbitrary session setting would itself become a spoofable mutable capability. PostgreSQL's trigger-depth signal is structural: PostgreSQL 18.6 documents `pg_trigger_depth()` as the current trigger nesting level, returning zero outside trigger execution. PostgreSQL also documents that a row-level `BEFORE` trigger can skip the current row operation, which is the fail-closed mechanism used here. + +The guard function remains `SECURITY DEFINER`, fixes its `search_path` to `pg_catalog, pg_temp`, and revokes PUBLIC execute. This does not grant accounting authority to Billing or another foreign context and does not alter posted facts, financial amounts, period transitions, retained snapshots, or reconciliation evidence. + +## TDD and implementation evidence + +Real-PostgreSQL RED `614d1164f3abf1f7bab3fe77d520e5b7108e4c69` creates a tenant period whose shared compatibility projection is `soft_closed`, inserts a later active accounting book, confirms that migration 0034 creates no control/fence for that non-open pair, then calls `_lock_book_period()`. The acceptance requires the call to fail with the existing `AccountingValidationError` and requires both control and fence populations to remain absent. + +Production candidate `610d77082eb01c80d2e9e74521e48a3b06e1375a` installs the post-repair direct-insert guard in migration 0034. Static ratchet `ee233b5c40c942008c7ec034917fd49f1fcf9976` pins the structural nesting check, open/NULL-only invariant, `BEFORE INSERT` placement, and migration-repair ordering. + +These SHAs are development lineage, not release evidence. The RED was authored before the causal repair, but it was not observed failing on a GitHub runner in this run. Exact-head real-PostgreSQL execution, security/SAST/dependency evidence, independent review, protected-stack prerequisites, migration/recovery evidence, and immutable release evidence remain separate gates. + +## Recovery and follow-up + +A rejected direct control INSERT writes no authoritative row and therefore seeds no 64-row journal-population fence. The surrounding close transaction remains free to roll back normally. Operators must not repair a missing non-open pair by copying `fiscal_period` status or by manual SQL. If a later-created book must become applicable to an already non-open period, that requires an explicit book-period lifecycle/adoption/reopen command with authenticated capability, idempotency, maker-checker policy where applicable, and retained chronology. + +The stale application-side `INSERT ... SELECT` is now behaviorally contained by the database single-writer boundary, but its source expression remains a cleanup finding: it should be removed from `_lock_book_period()` when that large persistence module is edited on a current exact head, leaving the helper as read/lock/fail-closed only. Until then, the database guard is authoritative and the branch must not claim the application source is conceptually clean. + +## References + +PostgreSQL Global Development Group. (2026a). *PostgreSQL 18.6 documentation: System information functions and operators*. https://www.postgresql.org/docs/18/functions-info.html + +PostgreSQL Global Development Group. (2026b). *PostgreSQL 18.6 documentation: CREATE TRIGGER*. https://www.postgresql.org/docs/18/sql-createtrigger.html + +PostgreSQL Global Development Group. (2026c). *PostgreSQL 18.6 documentation: Overview of trigger behavior*. https://www.postgresql.org/docs/18/trigger-definition.html From 8a6c283d6fec4f7c94020292ee4f9e2a71cfcf54 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 16:04:53 +0900 Subject: [PATCH 135/224] test(close): reject shared period authority fallback --- ...k_period_application_authority_contract.py | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 tests/test_book_period_application_authority_contract.py diff --git a/tests/test_book_period_application_authority_contract.py b/tests/test_book_period_application_authority_contract.py new file mode 100644 index 00000000..199a3bd5 --- /dev/null +++ b/tests/test_book_period_application_authority_contract.py @@ -0,0 +1,52 @@ +"""Application-side contracts for accounting-book period authority. + +Migration 0034 deliberately leaves unsupported non-open book/period pairs absent. +The persistence adapter must therefore treat a missing accounting_book_period_control +row as missing authority rather than rebuilding or inferring it from fiscal_period. +""" + +from __future__ import annotations + +import inspect +import unittest + +from accounting_information_platform.persistence import PostgresPostingLedger + + +class BookPeriodApplicationAuthorityContractTests(unittest.TestCase): + """Keep application helpers aligned with the book-scoped PostgreSQL authority.""" + + def test_close_lock_is_read_lock_fail_closed_only(self) -> None: + source = inspect.getsource(PostgresPostingLedger._lock_book_period) + + self.assertNotIn( + "INSERT INTO accounting_core.accounting_book_period_control", + source, + "close runtime must not manufacture book-period authority", + ) + self.assertNotIn( + "fiscal_period.period_status_code", + source, + "close runtime must not copy the shared calendar projection into book authority", + ) + self.assertIn("FOR UPDATE OF accounting_book_period_control", source) + + def test_adjusting_state_has_no_shared_calendar_fallback(self) -> None: + source = inspect.getsource(PostgresPostingLedger._load_book_period_state) + + self.assertNotIn("COALESCE(", source) + self.assertNotIn("LEFT JOIN accounting_core.accounting_book_period_control", source) + self.assertIn("JOIN accounting_core.accounting_book_period_control", source) + self.assertIn("accounting_book_period_control.period_status_code", source) + + def test_open_posting_has_no_shared_calendar_fallback(self) -> None: + source = inspect.getsource(PostgresPostingLedger._require_open_book_period_bounds) + + self.assertNotIn("COALESCE(", source) + self.assertNotIn("LEFT JOIN accounting_core.accounting_book_period_control", source) + self.assertIn("JOIN accounting_core.accounting_book_period_control", source) + self.assertIn("accounting_book_period_control.period_status_code", source) + + +if __name__ == "__main__": + unittest.main() From 8221e0a6f9d2a2851b744ccde67bf83fa610d2ed Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 17:17:20 +0900 Subject: [PATCH 136/224] fix(close): fail closed on missing book-period authority --- .../persistence.py | 6236 +---------------- 1 file changed, 124 insertions(+), 6112 deletions(-) diff --git a/src/accounting_information_platform/persistence.py b/src/accounting_information_platform/persistence.py index 1d27c239..b4f5958e 100644 --- a/src/accounting_information_platform/persistence.py +++ b/src/accounting_information_platform/persistence.py @@ -140,8 +140,9 @@ def post_adjusting_journal( ) if period_state is None: raise AccountingValidationError( - f"Fiscal period {period_code} is not recorded for this tenant. " - "Create the fiscal_period row, then retry the journal post." + f"Fiscal period {period_code} has no authoritative control row for this accounting book. " + "Create or repair the book-period control through the canonical period lifecycle, " + "then retry the journal post." ) period_id, period_status_code, period_start, period_end = period_state if journal_date < period_start or journal_date > period_end: @@ -497,6140 +498,151 @@ def load_period_journals( document["journal_source_code"] = journal_source_code return document - def load_journal_reversals( - self, - legal_entity_reference: str, - original_journal_reference: str = "", - period_code: str = "", - *, - page_limit: int = 50, - cursor_after: tuple[datetime, str] | None = None, - ) -> dict[str, object]: - """Return one page of existing journal reversals for a tenant legal entity.""" - with self._session() as connection: - tenant_id = self._require_tenant(connection) - legal_entity_id = self._require_legal_entity( - connection, tenant_id, legal_entity_reference, "the journal-reversal list" - ) - period_id_value: object = _SQL_SKIP_UUID - skip_period = True - if period_code: - period_id_value = self._require_fiscal_period( - connection, tenant_id, period_code, "the journal-reversal list" - )[0] - skip_period = False - if cursor_after is None: - skip_cursor, cursor_posted_at, cursor_reference = ( - True, - _SQL_SKIP_DATETIME, - "", - ) - else: - skip_cursor, cursor_posted_at, cursor_reference = ( - False, - cursor_after[0], - cursor_after[1], - ) - rows = connection.execute( - """ - SELECT reversal_journal.journal_reference, - original_journal.journal_reference, - reversal_journal.accounting_date, - reversal_journal.posted_at, - journal_reversal.reversal_reason_code - FROM accounting_core.journal_reversal - JOIN accounting_core.general_journal AS reversal_journal - ON reversal_journal.tenant_account_id = journal_reversal.tenant_account_id - AND reversal_journal.general_journal_id = journal_reversal.reversal_journal_id - JOIN accounting_core.general_journal AS original_journal - ON original_journal.tenant_account_id = journal_reversal.tenant_account_id - AND original_journal.general_journal_id = journal_reversal.original_journal_id - WHERE journal_reversal.tenant_account_id = %s - AND reversal_journal.legal_entity_id = %s - AND (%s OR original_journal.journal_reference = %s) - AND (%s OR reversal_journal.fiscal_period_id = %s) - AND ( - %s - OR (reversal_journal.posted_at, reversal_journal.journal_reference) - > (%s, %s) - ) - ORDER BY reversal_journal.posted_at, reversal_journal.journal_reference - LIMIT %s - """, - ( - tenant_id, - legal_entity_id, - not original_journal_reference, - original_journal_reference, - skip_period, - period_id_value, - skip_cursor, - cursor_posted_at, - cursor_reference, - page_limit + 1, - ), - ).fetchall() - has_more = len(rows) > page_limit - page_rows = rows[:page_limit] - journal_reversals = [ - { - "reversal_journal_reference": row[0], - "original_journal_reference": row[1], - "reversal_date": row[2].isoformat(), - "posted_at": _format_timestamp(row[3]), - "reversal_reason_code": row[4], - } - for row in page_rows - ] - next_cursor = None - if has_more: - last = page_rows[-1] - next_cursor = f"{_format_timestamp(last[3])}|{last[0]}" - document: dict[str, object] = { - "tenant_reference": self._tenant_reference, - "legal_entity_reference": legal_entity_reference, - "journal_reversals": journal_reversals, - "next_cursor": next_cursor, - } - if original_journal_reference: - document["original_journal_reference"] = original_journal_reference - if period_code: - document["fiscal_period_reference"] = ( - f"urn:cwl:accounting:fiscal_period:{period_code}" - ) - return document - - def load_period_closes( + def _require_open_book_period_bounds( self, - legal_entity_reference: str, - period_code: str = "", - period_status_code: str = "", - *, - page_limit: int = 50, - cursor_after: tuple[datetime, UUID] | None = None, - ) -> dict[str, object]: - """Return one page of durable hard-close receipts for a tenant legal entity.""" - with self._session() as connection: - tenant_id = self._require_tenant(connection) - legal_entity_id = self._require_legal_entity( - connection, tenant_id, legal_entity_reference, "the period-close list" + connection: object, + tenant_id: UUID, + book_id: UUID, + accounting_date: date, + ) -> tuple[UUID, date, date]: + """Return period identity and bounds when this accounting book is authoritatively open.""" + row = connection.execute( + """ + SELECT fiscal_period.fiscal_period_id, + fiscal_period.period_code, + accounting_book_period_control.period_status_code, + fiscal_period.period_start_date, + fiscal_period.period_end_date + FROM accounting_core.fiscal_period + JOIN accounting_core.accounting_book_period_control + ON accounting_book_period_control.tenant_account_id + = fiscal_period.tenant_account_id + AND accounting_book_period_control.fiscal_period_id + = fiscal_period.fiscal_period_id + AND accounting_book_period_control.accounting_book_id = %s + WHERE fiscal_period.tenant_account_id = %s + AND fiscal_period.period_start_date <= %s + AND fiscal_period.period_end_date >= %s + """, + (book_id, tenant_id, accounting_date, accounting_date), + ).fetchone() + if row is None: + raise AccountingValidationError( + f"No authoritative open book-period covers accounting date {accounting_date.isoformat()}. " + "Create the fiscal period and its book-period control through the canonical lifecycle, " + "then retry posting." ) - period_id_value: object = _SQL_SKIP_UUID - skip_period = True - if period_code: - period_id_value = self._require_fiscal_period( - connection, tenant_id, period_code, "the period-close list" - )[0] - skip_period = False - if cursor_after is None: - skip_cursor, cursor_generated_at, cursor_snapshot_id = ( - True, - _SQL_SKIP_DATETIME, - _SQL_SKIP_UUID, - ) - else: - skip_cursor, cursor_generated_at, cursor_snapshot_id = ( - False, - cursor_after[0], - cursor_after[1], - ) - rows = connection.execute( - """ - SELECT trial_balance_snapshot.trial_balance_snapshot_id, - trial_balance_snapshot.snapshot_generated_at, - trial_balance_snapshot.source_journal_count, - trial_balance_snapshot.source_payload_hash, - fiscal_period.period_code, - accounting_book_period_control.period_status_code, - accounting_book.book_name, - legal_entity_record.legal_entity_code - FROM accounting_reporting.trial_balance_snapshot - JOIN accounting_core.fiscal_period - ON fiscal_period.tenant_account_id = trial_balance_snapshot.tenant_account_id - AND fiscal_period.fiscal_period_id = trial_balance_snapshot.fiscal_period_id - JOIN accounting_core.accounting_book - ON accounting_book.tenant_account_id = trial_balance_snapshot.tenant_account_id - AND accounting_book.accounting_book_id = trial_balance_snapshot.accounting_book_id - JOIN accounting_core.accounting_book_period_control - ON accounting_book_period_control.tenant_account_id - = trial_balance_snapshot.tenant_account_id - AND accounting_book_period_control.accounting_book_id - = trial_balance_snapshot.accounting_book_id - AND accounting_book_period_control.fiscal_period_id - = trial_balance_snapshot.fiscal_period_id - JOIN accounting_core.legal_entity_record - ON legal_entity_record.tenant_account_id = trial_balance_snapshot.tenant_account_id - AND legal_entity_record.legal_entity_id = trial_balance_snapshot.legal_entity_id - WHERE trial_balance_snapshot.tenant_account_id = %s - AND trial_balance_snapshot.legal_entity_id = %s - AND (%s OR trial_balance_snapshot.fiscal_period_id = %s) - AND (%s OR accounting_book_period_control.period_status_code = %s) - AND ( - %s - OR ( - trial_balance_snapshot.snapshot_generated_at, - trial_balance_snapshot.trial_balance_snapshot_id - ) > (%s, %s) - ) - ORDER BY trial_balance_snapshot.snapshot_generated_at, - trial_balance_snapshot.trial_balance_snapshot_id - LIMIT %s - """, - ( - tenant_id, - legal_entity_id, - skip_period, - period_id_value, - not period_status_code, - period_status_code, - skip_cursor, - cursor_generated_at, - cursor_snapshot_id, - page_limit + 1, - ), - ).fetchall() - has_more = len(rows) > page_limit - page_rows = rows[:page_limit] - period_closes = [ - { - "tenant_reference": self._tenant_reference, - "legal_entity_reference": row[7], - "accounting_book_reference": row[6], - "period_code": row[4], - "period_status_code": row[5], - "snapshot_record_id": str(row[0]), - "snapshot_generated_at": _format_timestamp(row[1]), - "source_journal_count": int(row[2]), - "source_payload_hash": row[3], - "replayed": False, - } - for row in page_rows - ] - next_cursor = None - if has_more: - last = page_rows[-1] - next_cursor = f"{_format_timestamp(last[1])}|{last[0]}" - document: dict[str, object] = { - "tenant_reference": self._tenant_reference, - "legal_entity_reference": legal_entity_reference, - "period_closes": period_closes, - "next_cursor": next_cursor, - } - if period_code: - document["fiscal_period_reference"] = ( - f"urn:cwl:accounting:fiscal_period:{period_code}" - ) - if period_status_code: - document["period_status_code"] = period_status_code - return document - - def load_unpublished_outbox_events( - self, - event_type_code: str, - *, - page_limit: int = 50, - cursor_after: tuple[datetime, UUID] | None = None, - ) -> dict[str, object]: - """Return one page of unpublished outbox rows for one tenant event type.""" - with self._session() as connection: - tenant_id = self._require_tenant(connection) - if cursor_after is None: - skip_cursor, cursor_created_at, cursor_event_id = ( - True, - _SQL_SKIP_DATETIME, - _SQL_SKIP_UUID, - ) - else: - skip_cursor, cursor_created_at, cursor_event_id = ( - False, - cursor_after[0], - cursor_after[1], - ) - rows = connection.execute( - """ - SELECT outbox_event.outbox_event_id, - outbox_event.event_type_code, - outbox_event.aggregate_reference, - outbox_event.payload_reference, - outbox_event.payload_hash, - outbox_event.created_at - FROM accounting_integration.outbox_event - WHERE outbox_event.tenant_account_id = %s - AND outbox_event.event_type_code = %s - AND outbox_event.published_at IS NULL - AND ( - %s - OR (outbox_event.created_at, outbox_event.outbox_event_id) - > (%s, %s) - ) - ORDER BY outbox_event.created_at, outbox_event.outbox_event_id - LIMIT %s - """, - ( - tenant_id, - event_type_code, - skip_cursor, - cursor_created_at, - cursor_event_id, - page_limit + 1, - ), - ).fetchall() - has_more = len(rows) > page_limit - page_rows = rows[:page_limit] - events = [ - { - "outbox_event_id": str(row[0]), - "event_type_code": row[1], - "aggregate_reference": row[2], - "payload_reference": row[3], - "payload_hash": row[4], - "created_at": _format_timestamp(row[5]), - } - for row in page_rows - ] - next_cursor = None - if has_more: - last = page_rows[-1] - next_cursor = f"{_format_timestamp(last[5])}|{last[0]}" - return { - "tenant_reference": self._tenant_reference, - "event_type_code": event_type_code, - "outbox_events": events, - "next_cursor": next_cursor, - } - - def load_audit_events( - self, - event_type_code: str = "", - *, - page_limit: int = 50, - cursor_after: tuple[datetime, UUID] | None = None, - ) -> dict[str, object]: - """Return one page of published and unpublished outbox rows for one tenant.""" - with self._session() as connection: - tenant_id = self._require_tenant(connection) - if cursor_after is None: - skip_cursor, cursor_created_at, cursor_event_id = ( - True, - _SQL_SKIP_DATETIME, - _SQL_SKIP_UUID, - ) - else: - skip_cursor, cursor_created_at, cursor_event_id = ( - False, - cursor_after[0], - cursor_after[1], - ) - rows = connection.execute( - """ - SELECT outbox_event.outbox_event_id, - outbox_event.event_type_code, - outbox_event.aggregate_reference, - outbox_event.payload_reference, - outbox_event.payload_hash, - outbox_event.created_at, - outbox_event.published_at - FROM accounting_integration.outbox_event - WHERE outbox_event.tenant_account_id = %s - AND (%s OR outbox_event.event_type_code = %s) - AND ( - %s - OR (outbox_event.created_at, outbox_event.outbox_event_id) - > (%s, %s) - ) - ORDER BY outbox_event.created_at, outbox_event.outbox_event_id - LIMIT %s - """, - ( - tenant_id, - not event_type_code, - event_type_code, - skip_cursor, - cursor_created_at, - cursor_event_id, - page_limit + 1, - ), - ).fetchall() - has_more = len(rows) > page_limit - page_rows = rows[:page_limit] - events = [ - { - "outbox_event_id": str(row[0]), - "event_type_code": row[1], - "aggregate_reference": row[2], - "payload_reference": row[3], - "payload_hash": row[4], - "created_at": _format_timestamp(row[5]), - "published_at": ( - None if row[6] is None else _format_timestamp(row[6]) - ), - } - for row in page_rows - ] - next_cursor = None - if has_more: - last = page_rows[-1] - next_cursor = f"{_format_timestamp(last[5])}|{last[0]}" - document: dict[str, object] = { - "tenant_reference": self._tenant_reference, - "audit_events": events, - "next_cursor": next_cursor, - } - if event_type_code: - document["event_type_code"] = event_type_code - return document - - def publish_outbox_event(self, outbox_event_id: str) -> dict[str, object]: - """Set published_at on one tenant outbox row, or replay an already-published row.""" - if not outbox_event_id: + period_id = row[0] + row = connection.execute( + """ + SELECT fiscal_period.fiscal_period_id, + fiscal_period.period_code, + accounting_book_period_control.period_status_code, + fiscal_period.period_start_date, + fiscal_period.period_end_date + FROM accounting_core.fiscal_period + JOIN accounting_core.accounting_book_period_control + ON accounting_book_period_control.tenant_account_id + = fiscal_period.tenant_account_id + AND accounting_book_period_control.fiscal_period_id + = fiscal_period.fiscal_period_id + AND accounting_book_period_control.accounting_book_id = %s + WHERE fiscal_period.tenant_account_id = %s + AND fiscal_period.fiscal_period_id = %s + """, + (book_id, tenant_id, period_id), + ).fetchone() + if row is None: raise AccountingValidationError( - "outbox_event_id is required. " - "Supply the outbox event id, then retry the outbox publish." + f"No authoritative open book-period covers accounting date {accounting_date.isoformat()}. " + "Create the fiscal period and its book-period control through the canonical lifecycle, " + "then retry posting." ) - try: - event_id = UUID(outbox_event_id) - except ValueError as error: + if row[2] != "open": + locked_marker = " (period_closed)" if row[2] == "hard_closed" else "" raise AccountingValidationError( - "outbox_event_id must be a UUID. " - "Supply the outbox event id, then retry the outbox publish." - ) from error - with self._session() as connection: - tenant_id = self._require_tenant(connection) - updated = connection.execute( - """ - UPDATE accounting_integration.outbox_event - SET published_at = clock_timestamp() - WHERE tenant_account_id = %s - AND outbox_event_id = %s - AND published_at IS NULL - RETURNING outbox_event_id, event_type_code, aggregate_reference, - payload_reference, payload_hash, created_at, published_at - """, - (tenant_id, event_id), - ).fetchone() - row = updated - if row is None: - row = connection.execute( - """ - SELECT outbox_event_id, event_type_code, aggregate_reference, - payload_reference, payload_hash, created_at, published_at - FROM accounting_integration.outbox_event - WHERE tenant_account_id = %s AND outbox_event_id = %s - """, - (tenant_id, event_id), - ).fetchone() - if row is None: - raise AccountingValidationError( - "outbox event is missing for this outbox_event_id. " - "Accept the proposal, then retry the outbox publish." - ) - return { - "outbox_event_id": str(row[0]), - "event_type_code": row[1], - "aggregate_reference": row[2], - "payload_reference": row[3], - "payload_hash": row[4], - "created_at": _format_timestamp(row[5]), - "published_at": _format_timestamp(row[6]), - } + f"Fiscal period {row[1]} is {row[2]}{locked_marker}. " + "Open that period or post into an open period for this accounting book; " + "no journal was written." + ) + return row[0], row[3], row[4] - def _persist_proposal( - self, proposal: JournalProposal, policy: AccountingPolicy | None - ) -> PostingReceipt: - """Resolve optional catalog policy and persist *proposal* in one transaction.""" - proposal_uuid = _require_proposal_uuid(proposal.proposal_id) - with self._session() as connection: - tenant_id = self._require_tenant(connection) - self._acquire_command_lock( - connection, f"proposal:{proposal.idempotency_key}" + def _lock_book_period( + self, + connection: object, + tenant_id: UUID, + book_id: UUID, + period_code: str, + ) -> tuple[UUID, str, date]: + """Lock existing authoritative close state for one accounting book.""" + period_row = connection.execute( + """ + SELECT fiscal_period_id + FROM accounting_core.fiscal_period + WHERE tenant_account_id = %s AND period_code = %s + """, + (tenant_id, period_code), + ).fetchone() + if period_row is None: + raise AccountingValidationError( + f"Fiscal period {period_code} is not recorded for this tenant. " + "Create the fiscal_period row, then retry the close." ) - prior = connection.execute( - """ - SELECT source_payload_hash - FROM accounting_integration.journal_proposal_record - WHERE tenant_account_id = %s AND idempotency_key = %s - """, - (tenant_id, proposal.idempotency_key), - ).fetchone() - if prior is not None: - if prior[0] != proposal.source_payload_hash: - raise IdempotencyConflictError( - "idempotency key was already used with a different payload" - ) - return self._receipt_for_idempotency_key(connection, tenant_id, proposal) - if any(line.account_role_code == "retained_earnings" for line in proposal.lines): - raise AccountingValidationError( - "retained_earnings is reserved for AIS period-close. " - "Post revenue and expense through Billing, then hard-close; " - "no journal was written." - ) - if policy is None: - policy = self._resolve_accounting_policy(connection, tenant_id, proposal) - PostingLedger._validate_policy_scope(proposal, policy) - resolved_lines = tuple( - PostingLedger._resolve_line(line, policy) for line in proposal.lines - ) - legal_entity_id = self._require_legal_entity( - connection, tenant_id, proposal.legal_entity_reference - ) - book_id = self._require_book( - connection, - tenant_id, - legal_entity_id, - policy.intended_book_role_code, - policy.accounting_book_reference, - ) - period_id = self._require_open_book_period( - connection, tenant_id, book_id, proposal.accounting_date - ) - journal_reference = f"urn:cwl:accounting:general_journal:{proposal.proposal_id}" - receipt = PostingReceipt( - receipt_reference=f"urn:cwl:accounting:posting_receipt:{proposal.proposal_id}", - journal_reference=journal_reference, - posting_status_code="posted", - source_proposal_id=proposal.proposal_id, - source_payload_hash=proposal.source_payload_hash, - tenant_reference=proposal.tenant_reference, - legal_entity_reference=proposal.legal_entity_reference, - accounting_book_reference=policy.accounting_book_reference, - accounting_policy_version=policy.accounting_policy_version, - posting_rule_version=policy.posting_rule_version, - line_count=len(resolved_lines), - ) - proposal_record_id = connection.execute( - """ - INSERT INTO accounting_integration.journal_proposal_record ( - tenant_account_id, external_proposal_id, proposal_contract_version, - idempotency_key, source_payload_hash, proposal_status_code, processed_at - ) - VALUES (%s, %s, %s, %s, %s, 'posted', clock_timestamp()) - RETURNING proposal_record_id - """, - ( - tenant_id, - proposal_uuid, - proposal.proposal_contract_version, - proposal.idempotency_key, - proposal.source_payload_hash, - ), - ).fetchone()[0] - journal_id = self._insert_journal( - connection, - tenant_id=tenant_id, - legal_entity_id=legal_entity_id, - book_id=book_id, - period_id=period_id, - journal_reference=journal_reference, - proposal=proposal, - policy=policy, - proposal_record_id=proposal_record_id, - lines=resolved_lines, - ) - self._insert_receipt( - connection, tenant_id, proposal_record_id, journal_id, receipt - ) - self._insert_outbox( - connection, - tenant_id, - "posting_receipt", - journal_reference, - receipt.receipt_reference, - receipt, - ) - return receipt - - def close_fiscal_period( - self, - legal_entity_reference: str, - accounting_book_reference: str, - period_code: str, - snapshot_currency_code: str, - period_status_code: str = "hard_closed", - idempotency_key: str = "", - ) -> PeriodCloseReceipt: - """Soft-close or hard-close one fiscal period; only hard-close snapshots the book.""" - _require_reference(legal_entity_reference, "legal entity reference") - _require_reference(accounting_book_reference, "accounting book reference") - if not period_code.strip(): - raise AccountingValidationError( - "period_code is required. Supply the fiscal period code, then retry the close." - ) - close_idempotency_key = idempotency_key.strip() or ( - f"{self._tenant_reference}:period_close:{accounting_book_reference}:{period_code}" - ) - try: - _require_currency(snapshot_currency_code) - except AccountingValidationError as error: - raise AccountingValidationError( - "snapshot_currency_code must be a three-letter ISO currency. " - "Supply the book reporting currency, then retry the close." - ) from error - if period_status_code not in {"soft_closed", "hard_closed"}: - raise AccountingValidationError( - "period_status_code must be soft_closed or hard_closed. " - "Supply one of those codes, then retry the close." - ) - with self._session() as connection: - connection.execute("SET TRANSACTION ISOLATION LEVEL REPEATABLE READ") - self._active_connection = connection - try: - tenant_id = self._require_tenant(connection) - legal_entity_id = self._require_legal_entity( - connection, - tenant_id, - legal_entity_reference, - next_action="the close", - ) - book_id, reporting_currency_code = self._require_book_for_close( - connection, tenant_id, legal_entity_id, accounting_book_reference - ) - self._acquire_command_lock( - connection, f"period:{book_id}:{period_code}" - ) - if snapshot_currency_code != reporting_currency_code: - raise AccountingValidationError( - f"snapshot currency {snapshot_currency_code} does not match book reporting " - f"currency {reporting_currency_code}. Supply the book reporting currency, " - "then retry the close." - ) - period_id, current_status, period_end_date = self._lock_book_period( - connection, tenant_id, book_id, period_code - ) - if current_status == "hard_closed": - if period_status_code == "soft_closed": - raise AccountingValidationError( - f"Fiscal period {period_code} is hard_closed. " - "Hard-closed periods cannot be soft-closed. " - "Open a later period or leave this period hard_closed; " - "no close row was written." - ) - return self._replay_close_receipt( - connection, - tenant_id=tenant_id, - legal_entity_id=legal_entity_id, - book_id=book_id, - period_id=period_id, - period_code=period_code, - current_status=current_status, - legal_entity_reference=legal_entity_reference, - accounting_book_reference=accounting_book_reference, - idempotency_key=close_idempotency_key, - ) - if current_status == period_status_code: - return self._replay_soft_close_receipt( - connection, - tenant_id=tenant_id, - legal_entity_id=legal_entity_id, - book_id=book_id, - period_id=period_id, - period_code=period_code, - period_end_date=period_end_date, - snapshot_currency_code=snapshot_currency_code, - legal_entity_reference=legal_entity_reference, - accounting_book_reference=accounting_book_reference, - idempotency_key=close_idempotency_key, - ) - if period_status_code == "soft_closed": - return self._persist_soft_close( - connection, - tenant_id=tenant_id, - legal_entity_id=legal_entity_id, - book_id=book_id, - period_id=period_id, - period_code=period_code, - period_end_date=period_end_date, - snapshot_currency_code=snapshot_currency_code, - legal_entity_reference=legal_entity_reference, - accounting_book_reference=accounting_book_reference, - idempotency_key=close_idempotency_key, - ) - package = self._assemble_period_close_package( - legal_entity_reference, - accounting_book_reference, - period_code, - ) - self._require_closeable_package(package) - return self._persist_period_close( - connection, - tenant_id=tenant_id, - legal_entity_id=legal_entity_id, - book_id=book_id, - period_id=period_id, - period_code=period_code, - period_end_date=period_end_date, - period_status_code=period_status_code, - snapshot_currency_code=snapshot_currency_code, - legal_entity_reference=legal_entity_reference, - accounting_book_reference=accounting_book_reference, - idempotency_key=close_idempotency_key, - ) - finally: - self._active_connection = None - - def open_fiscal_period( - self, - legal_entity_reference: str, - period_code: str, - period_start_date: date | None = None, - period_end_date: date | None = None, - *, - idempotency_key: str, - source_payload_hash: str, - ) -> dict[str, object]: - """Insert or replay one fiscal-period-open command from durable evidence.""" - if not legal_entity_reference or not period_code: - raise AccountingValidationError( - "legal_entity_reference and fiscal_period_reference are required. " - "Supply those period-open fields, then retry the period open." - ) - command_key = idempotency_key.strip() - if not command_key or command_key != idempotency_key: - raise AccountingValidationError( - "period-open idempotency_key must be a canonical non-empty string. " - "Supply the original command key, then retry the period open." - ) - if re.fullmatch(r"sha256:[0-9a-f]{64}", source_payload_hash) is None: - raise AccountingValidationError( - "period-open source_payload_hash must be a canonical sha256 digest. " - "Supply the immutable command hash, then retry the period open." - ) - with self._session() as connection: - tenant_id = self._require_tenant(connection) - self._acquire_command_lock(connection, f"period-open:{command_key}") - self._acquire_command_lock(connection, f"period:{period_code}") - legal_entity_id, _functional_currency = self._load_legal_entity( - connection, - tenant_id, - legal_entity_reference, - next_action="the period open", - ) - prior = connection.execute( - """ - SELECT period_open_command.legal_entity_id, - fiscal_period.period_code, - period_open_command.requested_period_start_date, - period_open_command.requested_period_end_date, - fiscal_period.period_start_date, - fiscal_period.period_end_date, - period_open_command.source_payload_hash - FROM accounting_integration.fiscal_period_open_command AS period_open_command - JOIN accounting_core.fiscal_period AS fiscal_period - ON fiscal_period.tenant_account_id = period_open_command.tenant_account_id - AND fiscal_period.fiscal_period_id = period_open_command.fiscal_period_id - WHERE period_open_command.tenant_account_id = %s - AND period_open_command.period_open_idempotency_key = %s - """, - (tenant_id, command_key), - ).fetchone() - if prior is not None: - ( - prior_legal_entity_id, - prior_period_code, - prior_requested_start, - prior_requested_end, - stored_start_date, - stored_end_date, - prior_source_hash, - ) = prior - if ( - prior_legal_entity_id != legal_entity_id - or prior_period_code != period_code - or prior_requested_start != period_start_date - or prior_requested_end != period_end_date - or prior_source_hash != source_payload_hash - ): - raise IdempotencyConflictError( - "period-open idempotency key was already used with a different payload" - ) - return self._period_open_document( - legal_entity_reference, - period_code, - stored_start_date, - stored_end_date, - replayed=True, - ) - - existing = self._load_period_state(connection, tenant_id, period_code) - replayed = existing is not None - if existing is not None: - period_id, current_status, stored_start_date, stored_end_date = existing - if current_status != "open": - raise AccountingValidationError( - f"Fiscal period {period_code} is {current_status}. " - "Closed periods cannot be reopened. Open a later period, " - "then retry the period open." - ) - if ( - period_start_date is not None - and period_start_date != stored_start_date - ) or ( - period_end_date is not None and period_end_date != stored_end_date - ): - raise AccountingValidationError( - "period-open dates do not match the already-open fiscal period. " - "Supply its existing dates or omit both dates, then retry." - ) - else: - if period_start_date is None or period_end_date is None: - raise AccountingValidationError( - "period_start_date and period_end_date are required. " - "Supply those fiscal_period dates, then retry the period open." - ) - if period_end_date < period_start_date: - raise AccountingValidationError( - "period_end_date must be on or after period_start_date. " - "Supply a valid date range, then retry the period open." - ) - calendar_id = self._require_tenant_calendar(connection, tenant_id) - period_id = connection.execute( - """ - INSERT INTO accounting_core.fiscal_period ( - tenant_account_id, fiscal_calendar_id, period_code, - period_start_date, period_end_date, period_status_code - ) - VALUES (%s, %s, %s, %s, %s, 'open') - RETURNING fiscal_period_id - """, - ( - tenant_id, - calendar_id, - period_code, - period_start_date, - period_end_date, - ), - ).fetchone()[0] - stored_start_date = period_start_date - stored_end_date = period_end_date - - connection.execute( - """ - INSERT INTO accounting_integration.fiscal_period_open_command ( - tenant_account_id, - legal_entity_id, - fiscal_period_id, - period_open_idempotency_key, - source_payload_hash, - requested_period_start_date, - requested_period_end_date - ) - VALUES (%s, %s, %s, %s, %s, %s, %s) - """, - ( - tenant_id, - legal_entity_id, - period_id, - command_key, - source_payload_hash, - period_start_date, - period_end_date, - ), - ) - return self._period_open_document( - legal_entity_reference, - period_code, - stored_start_date, - stored_end_date, - replayed=replayed, - ) - - def load_fiscal_period( - self, legal_entity_reference: str, period_code: str - ) -> dict[str, object]: - """Return persisted fiscal-period status and dates for one tenant entity.""" - if not legal_entity_reference or not period_code: - raise AccountingValidationError( - "legal_entity_reference and fiscal_period_reference are required. " - "Supply those period fields, then retry the period read." - ) - with self._session() as connection: - tenant_id = self._require_tenant(connection) - self._require_legal_entity( - connection, tenant_id, legal_entity_reference, "the period read" - ) - existing = self._load_period_state(connection, tenant_id, period_code) - if existing is None: - raise AccountingValidationError( - f"Fiscal period {period_code} is not recorded for this tenant. " - "Create the fiscal_period row, then retry the period read." - ) - _period_id, current_status, start_date, end_date = existing - return { - "tenant_reference": self._tenant_reference, - "legal_entity_reference": legal_entity_reference, - "fiscal_period_reference": f"urn:cwl:accounting:fiscal_period:{period_code}", - "period_code": period_code, - "period_status_code": current_status, - "period_start_date": start_date.isoformat(), - "period_end_date": end_date.isoformat(), - } - - def load_fiscal_periods( - self, - legal_entity_reference: str, - *, - page_limit: int = 50, - cursor_after: tuple[date, str] | None = None, - ) -> dict[str, object]: - """Return one page of existing fiscal periods for a tenant legal entity.""" - if not legal_entity_reference: - raise AccountingValidationError( - "legal_entity_reference is required. " - "Supply that period-list field, then retry the period list." - ) - with self._session() as connection: - tenant_id = self._require_tenant(connection) - self._require_legal_entity( - connection, tenant_id, legal_entity_reference, "the period list" - ) - calendar_row = connection.execute( - """ - SELECT fiscal_calendar_id - FROM accounting_core.fiscal_calendar - WHERE tenant_account_id = %s - ORDER BY calendar_code - LIMIT 1 - """, - (tenant_id,), - ).fetchone() - periods: list[dict[str, object]] = [] - next_cursor = None - if calendar_row is not None: - if cursor_after is None: - skip_cursor, cursor_start_date, cursor_period_code = ( - True, - _SQL_SKIP_DATE, - "", - ) - else: - skip_cursor, cursor_start_date, cursor_period_code = ( - False, - cursor_after[0], - cursor_after[1], - ) - rows = connection.execute( - """ - SELECT fiscal_period.period_code, - fiscal_period.period_start_date, - fiscal_period.period_end_date, - fiscal_period.period_status_code - FROM accounting_core.fiscal_period - WHERE fiscal_period.tenant_account_id = %s - AND fiscal_period.fiscal_calendar_id = %s - AND ( - %s - OR (fiscal_period.period_start_date, fiscal_period.period_code) - > (%s, %s) - ) - ORDER BY fiscal_period.period_start_date, fiscal_period.period_code - LIMIT %s - """, - ( - tenant_id, - calendar_row[0], - skip_cursor, - cursor_start_date, - cursor_period_code, - page_limit + 1, - ), - ).fetchall() - has_more = len(rows) > page_limit - page_rows = rows[:page_limit] - periods = [ - { - "fiscal_period_reference": ( - f"urn:cwl:accounting:fiscal_period:{row[0]}" - ), - "period_code": row[0], - "period_start_date": row[1].isoformat(), - "period_end_date": row[2].isoformat(), - "period_status_code": row[3], - } - for row in page_rows - ] - if has_more: - last = page_rows[-1] - next_cursor = f"{last[1].isoformat()}|{last[0]}" - return { - "tenant_reference": self._tenant_reference, - "legal_entity_reference": legal_entity_reference, - "fiscal_periods": periods, - "next_cursor": next_cursor, - } - - def load_account_rollforward( - self, - legal_entity_reference: str, - accounting_book_reference: str, - period_code: str, - chart_account_code: str, - statement_scope_code: str = "", - ) -> dict[str, object]: - """Return opening + period = closing sides for one chart account and scope.""" - if statement_scope_code not in {"", "period", "year_to_date"}: - raise AccountingValidationError( - "statement_scope_code must be period or year_to_date. " - "Supply a known statement scope, then retry the account-rollforward read." - ) - if not chart_account_code: - raise AccountingValidationError( - "chart_account_code is required. " - "Supply that account-rollforward field, then retry the account-rollforward read." - ) - account_classes = self._load_chart_account_classes( - legal_entity_reference, accounting_book_reference - ) - if chart_account_code not in account_classes: - raise AccountingValidationError( - f"Chart account {chart_account_code} is not recorded for this book. " - "Create the chart_account row, then retry the account-rollforward read." - ) - with self._session() as connection: - tenant_id = self._require_tenant(connection) - legal_entity_id = self._require_legal_entity( - connection, - tenant_id, - legal_entity_reference, - next_action="the account-rollforward read", - ) - book_id = self._require_book_for_close( - connection, - tenant_id, - legal_entity_id, - accounting_book_reference, - next_action="the account-rollforward read", - )[0] - self._require_fiscal_period( - connection, - tenant_id, - period_code, - next_action="the account-rollforward read", - ) - period_ids = self._statement_period_ids( - connection, - tenant_id, - period_code, - statement_scope_code, - ) - scope_start = connection.execute( - """ - SELECT MIN(period_start_date) - FROM accounting_core.fiscal_period - WHERE tenant_account_id = %s - AND fiscal_period_id = ANY(%s) - """, - (tenant_id, period_ids), - ).fetchone()[0] - opening_debit_amount, opening_credit_amount = self._opening_account_sides( - connection, - tenant_id, - legal_entity_id, - book_id, - chart_account_code, - scope_start, - ) - period_debit_amount, period_credit_amount = self._period_account_sides( - connection, - tenant_id, - legal_entity_id, - book_id, - chart_account_code, - period_ids, - ) - closing_debit_amount = opening_debit_amount + period_debit_amount - closing_credit_amount = opening_credit_amount + period_credit_amount - document = { - "tenant_reference": self._tenant_reference, - "legal_entity_reference": legal_entity_reference, - "accounting_book_reference": accounting_book_reference, - "book_reference": accounting_book_reference, - "fiscal_period_reference": f"urn:cwl:accounting:fiscal_period:{period_code}", - "chart_account_code": chart_account_code, - "account_class_code": account_classes[chart_account_code], - "opening_debit_amount": _exact_amount_text(opening_debit_amount), - "opening_credit_amount": _exact_amount_text(opening_credit_amount), - "period_debit_amount": _exact_amount_text(period_debit_amount), - "period_credit_amount": _exact_amount_text(period_credit_amount), - "closing_debit_amount": _exact_amount_text(closing_debit_amount), - "closing_credit_amount": _exact_amount_text(closing_credit_amount), - } - if statement_scope_code == "year_to_date": - document["statement_scope_code"] = "year_to_date" - return document - - def load_unapplied_cash_rollforward( - self, - legal_entity_reference: str, - accounting_book_reference: str, - period_code: str, - ) -> dict[str, object]: - """Return leftover-cash opening, park / apply / refund, and closing for 210200.""" - if not legal_entity_reference or not accounting_book_reference or not period_code: - raise AccountingValidationError( - "legal_entity_reference, book_reference, and fiscal_period_reference are required. " - "Supply those unapplied-cash-rollforward fields, then retry the unapplied-cash-rollforward read." - ) - account_classes = self._load_chart_account_classes( - legal_entity_reference, accounting_book_reference - ) - if "210200" not in account_classes: - raise AccountingValidationError( - "Chart account 210200 is not recorded for this book. " - "Create the chart_account row, then retry the unapplied-cash-rollforward read." - ) - with self._session() as connection: - tenant_id = self._require_tenant(connection) - legal_entity_id = self._require_legal_entity( - connection, - tenant_id, - legal_entity_reference, - next_action="the unapplied-cash-rollforward read", - ) - book_id = self._require_book_for_close( - connection, - tenant_id, - legal_entity_id, - accounting_book_reference, - next_action="the unapplied-cash-rollforward read", - )[0] - period_id, _period_status, period_end_date = self._require_fiscal_period( - connection, - tenant_id, - period_code, - next_action="the unapplied-cash-rollforward read", - ) - period_start_date = connection.execute( - """ - SELECT period_start_date - FROM accounting_core.fiscal_period - WHERE tenant_account_id = %s - AND fiscal_period_id = %s - """, - (tenant_id, period_id), - ).fetchone()[0] - opening_debit_amount, opening_credit_amount = self._opening_account_sides( - connection, - tenant_id, - legal_entity_id, - book_id, - "210200", - period_start_date, - ) - line_rows = connection.execute( - """ - SELECT COALESCE(journal_proposal_record.idempotency_key, ''), - general_journal.journal_reference, - journal_entry_line.account_role_code, - chart_account.chart_account_code, - journal_entry_line.debit_amount, - journal_entry_line.credit_amount - FROM accounting_core.journal_entry_line - JOIN accounting_core.general_journal - ON general_journal.tenant_account_id = journal_entry_line.tenant_account_id - AND general_journal.general_journal_id = journal_entry_line.general_journal_id - JOIN accounting_core.chart_account - ON chart_account.tenant_account_id = journal_entry_line.tenant_account_id - AND chart_account.chart_account_id = journal_entry_line.chart_account_id - LEFT JOIN accounting_integration.journal_proposal_record - ON journal_proposal_record.tenant_account_id = general_journal.tenant_account_id - AND journal_proposal_record.proposal_record_id - = general_journal.source_proposal_record_id - WHERE general_journal.tenant_account_id = %s - AND general_journal.legal_entity_id = %s - AND general_journal.accounting_book_id = %s - AND general_journal.accounting_date >= %s - AND general_journal.accounting_date <= %s - AND general_journal.journal_reference NOT LIKE %s - ORDER BY general_journal.journal_reference, journal_entry_line.line_number - """, - ( - tenant_id, - legal_entity_id, - book_id, - period_start_date, - period_end_date, - _CLOSING_JOURNAL_PATTERN, - ), - ).fetchall() - journals: dict[str, dict[str, object]] = {} - for ( - idempotency_key, - journal_reference, - account_role_code, - chart_account_code, - debit_amount, - credit_amount, - ) in line_rows: - bucket = journals.setdefault( - str(journal_reference), - { - "idempotency_key": str(idempotency_key), - "debit_roles": set(), - "credit_roles": set(), - "unapplied_debit_amount": Decimal("0"), - "unapplied_credit_amount": Decimal("0"), - }, - ) - line_debit_amount = Decimal(str(debit_amount)) - line_credit_amount = Decimal(str(credit_amount)) - debit_roles = bucket["debit_roles"] - credit_roles = bucket["credit_roles"] - assert isinstance(debit_roles, set) - assert isinstance(credit_roles, set) - if line_debit_amount > 0: - debit_roles.add(str(account_role_code)) - if line_credit_amount > 0: - credit_roles.add(str(account_role_code)) - if str(chart_account_code) == "210200": - bucket["unapplied_debit_amount"] = ( - Decimal(str(bucket["unapplied_debit_amount"])) + line_debit_amount - ) - bucket["unapplied_credit_amount"] = ( - Decimal(str(bucket["unapplied_credit_amount"])) + line_credit_amount - ) - parked_amount = Decimal("0") - applied_amount = Decimal("0") - refunded_amount = Decimal("0") - other_movement_amount = Decimal("0") - for bucket in journals.values(): - unapplied_debit_amount = Decimal(str(bucket["unapplied_debit_amount"])) - unapplied_credit_amount = Decimal(str(bucket["unapplied_credit_amount"])) - if unapplied_debit_amount == 0 and unapplied_credit_amount == 0: - continue - debit_roles = bucket["debit_roles"] - credit_roles = bucket["credit_roles"] - assert isinstance(debit_roles, set) - assert isinstance(credit_roles, set) - movement_kind = _unapplied_cash_movement_kind( - str(bucket["idempotency_key"]), - debit_roles, - credit_roles, - ) - if movement_kind == "parked": - parked_amount += unapplied_credit_amount - elif movement_kind == "applied": - applied_amount += unapplied_debit_amount - elif movement_kind == "refunded": - refunded_amount += unapplied_debit_amount - else: - other_movement_amount += unapplied_credit_amount - unapplied_debit_amount - opening_amount = opening_credit_amount - opening_debit_amount - closing_amount = ( - opening_amount + parked_amount - applied_amount - refunded_amount + other_movement_amount - ) - document: dict[str, object] = { - "tenant_reference": self._tenant_reference, - "legal_entity_reference": legal_entity_reference, - "accounting_book_reference": accounting_book_reference, - "book_reference": accounting_book_reference, - "fiscal_period_reference": f"urn:cwl:accounting:fiscal_period:{period_code}", - "as_of_date": period_end_date.isoformat(), - "chart_account_code": "210200", - "account_role_code": "unapplied_cash", - "parked_amount": _unsigned_aging_amount_text(parked_amount), - "applied_amount": _unsigned_aging_amount_text(applied_amount), - "refunded_amount": _unsigned_aging_amount_text(refunded_amount), - "opening_amount": _unsigned_aging_amount_text(opening_amount), - "closing_amount": _unsigned_aging_amount_text(closing_amount), - } - if other_movement_amount != 0: - document["other_movement_amount"] = _unsigned_aging_amount_text( - other_movement_amount - ) - return document - - def load_vat_period_register( - self, - legal_entity_reference: str, - accounting_book_reference: str, - period_code: str, - ) -> dict[str, object]: - """Return issued, voided, and closing tax-payable amounts for catalog 210100.""" - if not legal_entity_reference or not accounting_book_reference or not period_code: - raise AccountingValidationError( - "legal_entity_reference, book_reference, and fiscal_period_reference are required. " - "Supply those vat-period-register fields, then retry the vat-period-register read." - ) - account_classes = self._load_chart_account_classes( - legal_entity_reference, accounting_book_reference - ) - if "210100" not in account_classes: - raise AccountingValidationError( - "Chart account 210100 is not recorded for this book. " - "Create the chart_account row, then retry the vat-period-register read." - ) - with self._session() as connection: - tenant_id = self._require_tenant(connection) - legal_entity_id = self._require_legal_entity( - connection, - tenant_id, - legal_entity_reference, - next_action="the vat-period-register read", - ) - book_id = self._require_book_for_close( - connection, - tenant_id, - legal_entity_id, - accounting_book_reference, - next_action="the vat-period-register read", - )[0] - _period_id, _period_status, period_end_date = self._require_fiscal_period( - connection, - tenant_id, - period_code, - next_action="the vat-period-register read", - ) - line_rows = connection.execute( - """ - SELECT COALESCE(journal_proposal_record.idempotency_key, ''), - general_journal.journal_reference, - journal_entry_line.account_role_code, - chart_account.chart_account_code, - journal_entry_line.debit_amount, - journal_entry_line.credit_amount - FROM accounting_core.journal_entry_line - JOIN accounting_core.general_journal - ON general_journal.tenant_account_id = journal_entry_line.tenant_account_id - AND general_journal.general_journal_id = journal_entry_line.general_journal_id - JOIN accounting_core.chart_account - ON chart_account.tenant_account_id = journal_entry_line.tenant_account_id - AND chart_account.chart_account_id = journal_entry_line.chart_account_id - LEFT JOIN accounting_integration.journal_proposal_record - ON journal_proposal_record.tenant_account_id = general_journal.tenant_account_id - AND journal_proposal_record.proposal_record_id - = general_journal.source_proposal_record_id - WHERE general_journal.tenant_account_id = %s - AND general_journal.legal_entity_id = %s - AND general_journal.accounting_book_id = %s - AND general_journal.accounting_date <= %s - AND general_journal.journal_reference NOT LIKE %s - ORDER BY general_journal.journal_reference, journal_entry_line.line_number - """, - ( - tenant_id, - legal_entity_id, - book_id, - period_end_date, - _CLOSING_JOURNAL_PATTERN, - ), - ).fetchall() - journals: dict[str, dict[str, object]] = {} - for ( - idempotency_key, - journal_reference, - account_role_code, - chart_account_code, - debit_amount, - credit_amount, - ) in line_rows: - bucket = journals.setdefault( - str(journal_reference), - { - "idempotency_key": str(idempotency_key), - "debit_roles": set(), - "credit_roles": set(), - "tax_debit_amount": Decimal("0"), - "tax_credit_amount": Decimal("0"), - }, - ) - line_debit_amount = Decimal(str(debit_amount)) - line_credit_amount = Decimal(str(credit_amount)) - debit_roles = bucket["debit_roles"] - credit_roles = bucket["credit_roles"] - assert isinstance(debit_roles, set) - assert isinstance(credit_roles, set) - if line_debit_amount > 0: - debit_roles.add(str(account_role_code)) - if line_credit_amount > 0: - credit_roles.add(str(account_role_code)) - if str(chart_account_code) == "210100": - bucket["tax_debit_amount"] = ( - Decimal(str(bucket["tax_debit_amount"])) + line_debit_amount - ) - bucket["tax_credit_amount"] = ( - Decimal(str(bucket["tax_credit_amount"])) + line_credit_amount - ) - issued_amount = Decimal("0") - voided_amount = Decimal("0") - other_movement_amount = Decimal("0") - for bucket in journals.values(): - tax_debit_amount = Decimal(str(bucket["tax_debit_amount"])) - tax_credit_amount = Decimal(str(bucket["tax_credit_amount"])) - if tax_debit_amount == 0 and tax_credit_amount == 0: - continue - debit_roles = bucket["debit_roles"] - credit_roles = bucket["credit_roles"] - assert isinstance(debit_roles, set) - assert isinstance(credit_roles, set) - movement_kind = _vat_period_movement_kind( - str(bucket["idempotency_key"]), - debit_roles, - credit_roles, - ) - if movement_kind == "issued": - issued_amount += tax_credit_amount - elif movement_kind == "voided": - voided_amount += tax_debit_amount - else: - other_movement_amount += tax_credit_amount - tax_debit_amount - closing_amount = issued_amount - voided_amount + other_movement_amount - document: dict[str, object] = { - "tenant_reference": self._tenant_reference, - "legal_entity_reference": legal_entity_reference, - "accounting_book_reference": accounting_book_reference, - "book_reference": accounting_book_reference, - "fiscal_period_reference": f"urn:cwl:accounting:fiscal_period:{period_code}", - "as_of_date": period_end_date.isoformat(), - "chart_account_code": "210100", - "account_role_code": "tax_payable", - "issued_amount": _unsigned_aging_amount_text(issued_amount), - "voided_amount": _unsigned_aging_amount_text(voided_amount), - "closing_amount": _unsigned_aging_amount_text(closing_amount), - } - if other_movement_amount != 0: - document["other_movement_amount"] = _unsigned_aging_amount_text( - other_movement_amount - ) - return document - - def persist_home_tax_submission( - self, - *, - legal_entity_reference: str, - accounting_book_reference: str, - period_code: str, - submission_idempotency_key: str, - source_payload_hash: str, - source_payload_reference: str, - register_document: dict[str, object], - rejection_reason_code: str, - ) -> dict[str, object]: - """Persist or replay one rejected HomeTax receipt with immutable command provenance.""" - if not submission_idempotency_key: - raise AccountingValidationError( - "submission_idempotency_key is required. " - "Supply the original HomeTax command key, then retry the home-tax-submission." - ) - if re.fullmatch(r"sha256:[0-9a-f]{64}", source_payload_hash) is None: - raise AccountingValidationError( - "source_payload_hash must be a sha256 digest. " - "Supply immutable HomeTax source evidence, then retry the home-tax-submission." - ) - normalized_source_reference = source_payload_reference.strip() - if not normalized_source_reference: - raise AccountingValidationError( - "source_payload_reference is required. " - "Supply the immutable HomeTax source locator, then retry the home-tax-submission." - ) - register_payload_hash = "sha256:" + hashlib.sha256( - json.dumps( - register_document, separators=(",", ":"), sort_keys=True, default=str - ).encode("utf-8") - ).hexdigest() - raw_as_of_date = str(register_document.get("as_of_date") or "") - as_of_date = date.fromisoformat(raw_as_of_date) if raw_as_of_date else None - closing_amount = Decimal(str(register_document.get("closing_amount") or "0")) - with self._session() as connection: - tenant_id = self._require_tenant(connection) - legal_entity_id = self._require_legal_entity( - connection, - tenant_id, - legal_entity_reference, - next_action="the home-tax-submission", - ) - self._acquire_command_lock( - connection, f"home-tax:{submission_idempotency_key}" - ) - book_id = self._require_book_for_close( - connection, - tenant_id, - legal_entity_id, - accounting_book_reference, - next_action="the home-tax-submission", - )[0] - period_id, _period_status, period_end_date = self._require_fiscal_period( - connection, - tenant_id, - period_code, - next_action="the home-tax-submission", - ) - if as_of_date is None: - as_of_date = period_end_date - row = connection.execute( - """ - INSERT INTO accounting_integration.home_tax_submission ( - tenant_account_id, - legal_entity_id, - accounting_book_id, - fiscal_period_id, - submission_idempotency_key, - source_payload_hash, - source_payload_reference, - submission_status_code, - rejection_reason_code, - as_of_date, - closing_amount, - register_payload_hash - ) VALUES (%s, %s, %s, %s, %s, %s, %s, 'rejected', %s, %s, %s, %s) - ON CONFLICT (tenant_account_id, submission_idempotency_key) DO NOTHING - RETURNING home_tax_submission_id, - submission_status_code, - rejection_reason_code, - as_of_date, - closing_amount, - register_payload_hash, - source_payload_hash, - source_payload_reference, - legal_entity_id, - accounting_book_id, - fiscal_period_id - """, - ( - tenant_id, - legal_entity_id, - book_id, - period_id, - submission_idempotency_key, - source_payload_hash, - normalized_source_reference, - rejection_reason_code, - as_of_date, - closing_amount, - register_payload_hash, - ), - ).fetchone() - if row is None: - row = connection.execute( - """ - SELECT home_tax_submission_id, - submission_status_code, - rejection_reason_code, - as_of_date, - closing_amount, - register_payload_hash, - source_payload_hash, - source_payload_reference, - legal_entity_id, - accounting_book_id, - fiscal_period_id - FROM accounting_integration.home_tax_submission - WHERE tenant_account_id = %s - AND submission_idempotency_key = %s - """, - (tenant_id, submission_idempotency_key), - ).fetchone() - if row is None: - raise AccountingValidationError( - "HomeTax command replay could not find its existing receipt. " - "Retry the command with the same idempotency key." - ) - if ( - row[5] != register_payload_hash - or row[6] != source_payload_hash - or row[7] != normalized_source_reference - or row[8] != legal_entity_id - or row[9] != book_id - or row[10] != period_id - ): - raise IdempotencyConflictError( - "HomeTax idempotency key was already used with different evidence or scope. " - "Use a new command key for the changed submission." - ) - receipt_register = _home_tax_register_view(register_document) - if not receipt_register.get("as_of_date"): - receipt_register["as_of_date"] = row[3].isoformat() - return _home_tax_submission_document( - home_tax_submission_id=str(row[0]), - tenant_reference=self._tenant_reference, - legal_entity_reference=legal_entity_reference, - book_reference=accounting_book_reference, - period_code=period_code, - vat_period_register=receipt_register, - rejection_reason_code=str(row[2]), - submission_status_code=str(row[1]), - ) - - def load_home_tax_submissions( - self, - legal_entity_reference: str, - accounting_book_reference: str, - period_code: str, - ) -> dict[str, object]: - """Return persisted HomeTax receipts for one tenant entity, book, and period.""" - if not legal_entity_reference or not accounting_book_reference or not period_code: - raise AccountingValidationError( - "legal_entity_reference, book_reference, and fiscal_period_reference are required. " - "Supply those home-tax-submission fields, then retry the home-tax-submission read." - ) - with self._session() as connection: - tenant_id = self._require_tenant(connection) - legal_entity_id = self._require_legal_entity( - connection, - tenant_id, - legal_entity_reference, - next_action="the home-tax-submission read", - ) - book_id = self._require_book_for_close( - connection, - tenant_id, - legal_entity_id, - accounting_book_reference, - next_action="the home-tax-submission read", - )[0] - period_id, _period_status, _period_end_date = self._require_fiscal_period( - connection, - tenant_id, - period_code, - next_action="the home-tax-submission read", - ) - rows = connection.execute( - """ - SELECT home_tax_submission_id, - submission_status_code, - rejection_reason_code, - as_of_date, - closing_amount - FROM accounting_integration.home_tax_submission - WHERE tenant_account_id = %s - AND legal_entity_id = %s - AND accounting_book_id = %s - AND fiscal_period_id = %s - ORDER BY created_at, home_tax_submission_id - """, - (tenant_id, legal_entity_id, book_id, period_id), - ).fetchall() - return { - "tenant_reference": self._tenant_reference, - "legal_entity_reference": legal_entity_reference, - "book_reference": accounting_book_reference, - "fiscal_period_reference": f"urn:cwl:accounting:fiscal_period:{period_code}", - "home_tax_submissions": [ - _home_tax_submission_document( - home_tax_submission_id=str(row[0]), - tenant_reference=self._tenant_reference, - legal_entity_reference=legal_entity_reference, - book_reference=accounting_book_reference, - period_code=period_code, - vat_period_register={ - "as_of_date": row[3].isoformat(), - "closing_amount": _unsigned_aging_amount_text(Decimal(row[4])), - }, - rejection_reason_code=str(row[2]), - submission_status_code=str(row[1]), - ) - for row in rows - ], - } - - def _opening_account_sides( - self, - connection: object, - tenant_id: UUID, - legal_entity_id: UUID, - book_id: UUID, - chart_account_code: str, - scope_start: date, - ) -> tuple[Decimal, Decimal]: - prior_snapshot = connection.execute( - """ - SELECT trial_balance_snapshot.trial_balance_snapshot_id - FROM accounting_core.fiscal_period - JOIN accounting_reporting.trial_balance_snapshot - ON trial_balance_snapshot.tenant_account_id = fiscal_period.tenant_account_id - AND trial_balance_snapshot.fiscal_period_id = fiscal_period.fiscal_period_id - AND trial_balance_snapshot.legal_entity_id = %s - AND trial_balance_snapshot.accounting_book_id = %s - WHERE fiscal_period.tenant_account_id = %s - AND fiscal_period.period_end_date < %s - AND fiscal_period.period_status_code = 'hard_closed' - ORDER BY fiscal_period.period_end_date DESC, fiscal_period.period_code DESC - LIMIT 1 - """, - (legal_entity_id, book_id, tenant_id, scope_start), - ).fetchone() - if prior_snapshot is not None: - row = connection.execute( - """ - SELECT COALESCE(trial_balance_line.debit_total_amount, 0), - COALESCE(trial_balance_line.credit_total_amount, 0) - FROM accounting_reporting.trial_balance_line - JOIN accounting_core.chart_account - ON chart_account.tenant_account_id = trial_balance_line.tenant_account_id - AND chart_account.chart_account_id = trial_balance_line.chart_account_id - WHERE trial_balance_line.tenant_account_id = %s - AND trial_balance_line.trial_balance_snapshot_id = %s - AND chart_account.chart_account_code = %s - """, - (tenant_id, prior_snapshot[0], chart_account_code), - ).fetchone() - if row is None: - return Decimal("0"), Decimal("0") - return Decimal(row[0]), Decimal(row[1]) - row = connection.execute( - """ - SELECT COALESCE(SUM(journal_entry_line.debit_amount), 0), - COALESCE(SUM(journal_entry_line.credit_amount), 0) - FROM accounting_core.journal_entry_line - JOIN accounting_core.general_journal - ON general_journal.tenant_account_id = journal_entry_line.tenant_account_id - AND general_journal.general_journal_id = journal_entry_line.general_journal_id - JOIN accounting_core.chart_account - ON chart_account.tenant_account_id = journal_entry_line.tenant_account_id - AND chart_account.chart_account_id = journal_entry_line.chart_account_id - WHERE general_journal.tenant_account_id = %s - AND general_journal.legal_entity_id = %s - AND general_journal.accounting_book_id = %s - AND chart_account.chart_account_code = %s - AND general_journal.accounting_date <= %s - """, - ( - tenant_id, - legal_entity_id, - book_id, - chart_account_code, - scope_start - timedelta(days=1), - ), - ).fetchone() - return Decimal(row[0]), Decimal(row[1]) - - def _period_account_sides( - self, - connection: object, - tenant_id: UUID, - legal_entity_id: UUID, - book_id: UUID, - chart_account_code: str, - period_ids: list[UUID], - ) -> tuple[Decimal, Decimal]: - row = connection.execute( - """ - SELECT COALESCE(SUM(journal_entry_line.debit_amount), 0), - COALESCE(SUM(journal_entry_line.credit_amount), 0) - FROM accounting_core.journal_entry_line - JOIN accounting_core.general_journal - ON general_journal.tenant_account_id = journal_entry_line.tenant_account_id - AND general_journal.general_journal_id = journal_entry_line.general_journal_id - JOIN accounting_core.chart_account - ON chart_account.tenant_account_id = journal_entry_line.tenant_account_id - AND chart_account.chart_account_id = journal_entry_line.chart_account_id - WHERE general_journal.tenant_account_id = %s - AND general_journal.legal_entity_id = %s - AND general_journal.accounting_book_id = %s - AND chart_account.chart_account_code = %s - AND general_journal.fiscal_period_id = ANY(%s) - """, - ( - tenant_id, - legal_entity_id, - book_id, - chart_account_code, - period_ids, - ), - ).fetchone() - return Decimal(row[0]), Decimal(row[1]) - - def load_account_balances( - self, - legal_entity_reference: str, - accounting_book_reference: str, - period_code: str, - chart_account_code: str = "", - *, - page_limit: int = 50, - cursor: str = "", - ) -> dict[str, object]: - """Return as-of chart-account balances from the close snapshot or live journals.""" - trial_balance = self.load_period_trial_balance( - legal_entity_reference=legal_entity_reference, - accounting_book_reference=accounting_book_reference, - period_code=period_code, - ) - account_classes = self._load_chart_account_classes( - legal_entity_reference, accounting_book_reference - ) - requested_code = chart_account_code.strip() - if requested_code and requested_code not in account_classes: - raise AccountingValidationError( - f"Chart account {requested_code} is not recorded for this book. " - "Create the chart_account row, then retry the account-balance read." - ) - source_lines = [ - { - "chart_account_code": str(raw_line["chart_account_code"]), - "debit_amount": str(raw_line["debit_amount"]), - "credit_amount": str(raw_line["credit_amount"]), - } - for raw_line in trial_balance["lines"] - ] - if requested_code: - source_lines = [ - raw_line - for raw_line in source_lines - if raw_line["chart_account_code"] == requested_code - ] - if not source_lines: - source_lines = [ - { - "chart_account_code": requested_code, - "debit_amount": "0", - "credit_amount": "0", - } - ] - if cursor: - source_lines = [ - raw_line - for raw_line in source_lines - if raw_line["chart_account_code"] > cursor - ] - has_more = len(source_lines) > page_limit - page_lines = source_lines[:page_limit] - account_balances = [ - { - "chart_account_code": raw_line["chart_account_code"], - "account_class_code": account_classes[str(raw_line["chart_account_code"])], - "debit_amount": _exact_amount_text(Decimal(str(raw_line["debit_amount"]))), - "credit_amount": _exact_amount_text(Decimal(str(raw_line["credit_amount"]))), - } - for raw_line in page_lines - ] - next_cursor = None - if has_more: - next_cursor = str(page_lines[-1]["chart_account_code"]) - return { - "tenant_reference": self._tenant_reference, - "legal_entity_reference": legal_entity_reference, - "accounting_book_reference": accounting_book_reference, - "book_reference": accounting_book_reference, - "fiscal_period_reference": str(trial_balance["fiscal_period_reference"]), - "account_balances": account_balances, - "next_cursor": next_cursor, - } - - def load_receivable_aging( - self, - legal_entity_reference: str, - book_reference: str, - period_code: str, - chart_account_code: str = "", - ) -> dict[str, object]: - """Return entity-level FIFO receivable aging as of the fiscal period end date.""" - return self._load_account_aging( - legal_entity_reference, - book_reference, - period_code, - chart_account_code, - catalog_role_code="accounts_receivable", - increase_is_debit=True, - read_name="receivable-aging", - ) - - def load_payable_aging( - self, - legal_entity_reference: str, - book_reference: str, - period_code: str, - chart_account_code: str = "", - ) -> dict[str, object]: - """Return entity-level FIFO payable aging as of the fiscal period end date.""" - return self._load_account_aging( - legal_entity_reference, - book_reference, - period_code, - chart_account_code, - catalog_role_code="tax_payable", - increase_is_debit=False, - read_name="payable-aging", - ) - - def _load_account_aging( - self, - legal_entity_reference: str, - book_reference: str, - period_code: str, - chart_account_code: str, - *, - catalog_role_code: str, - increase_is_debit: bool, - read_name: str, - ) -> dict[str, object]: - if not legal_entity_reference or not book_reference or not period_code: - raise AccountingValidationError( - "legal_entity_reference, book_reference, and fiscal_period_reference are required. " - f"Supply those {read_name} fields, then retry the {read_name} read." - ) - with self._session() as connection: - tenant_id = self._require_tenant(connection) - legal_entity_id = self._require_legal_entity( - connection, - tenant_id, - legal_entity_reference, - next_action=f"the {read_name} read", - ) - book_id = self._require_book_for_close( - connection, - tenant_id, - legal_entity_id, - book_reference, - next_action=f"the {read_name} read", - )[0] - _period_id, _status, period_end_date = self._require_fiscal_period( - connection, - tenant_id, - period_code, - next_action=f"the {read_name} read", - ) - account_rows = connection.execute( - """ - SELECT chart_account.chart_account_code, - account_role_mapping.account_role_code, - chart_account.account_class_code - FROM accounting_core.chart_account - LEFT JOIN accounting_core.account_role_mapping - ON account_role_mapping.tenant_account_id = chart_account.tenant_account_id - AND account_role_mapping.chart_account_id = chart_account.chart_account_id - AND account_role_mapping.valid_to IS NULL - WHERE chart_account.tenant_account_id = %s - AND chart_account.accounting_book_id = %s - AND chart_account.valid_to IS NULL - """, - (tenant_id, book_id), - ).fetchall() - account_classes = { - str(account_code): str(account_class_code) - for account_code, _role_code, account_class_code in account_rows - } - catalog_account_code = next( - ( - str(account_code) - for account_code, role_code, _class in account_rows - if role_code == catalog_role_code - ), - "", - ) - resolved_account_code = chart_account_code.strip() or catalog_account_code - if resolved_account_code not in account_classes: - raise AccountingValidationError( - f"Chart account {resolved_account_code} is not recorded for this book. " - f"Create the chart_account row, then retry the {read_name} read." - ) - if resolved_account_code != catalog_account_code: - raise AccountingValidationError( - f"chart_account_code must be the catalog {catalog_role_code} account. " - f"Supply that {read_name} account, then retry the {read_name} read." - ) - line_rows = connection.execute( - """ - SELECT general_journal.accounting_date, - general_journal.journal_reference, - journal_entry_line.line_number, - journal_entry_line.debit_amount, - journal_entry_line.credit_amount - FROM accounting_core.journal_entry_line - JOIN accounting_core.general_journal - ON general_journal.tenant_account_id = journal_entry_line.tenant_account_id - AND general_journal.general_journal_id = journal_entry_line.general_journal_id - JOIN accounting_core.chart_account - ON chart_account.tenant_account_id = journal_entry_line.tenant_account_id - AND chart_account.chart_account_id = journal_entry_line.chart_account_id - WHERE general_journal.tenant_account_id = %s - AND general_journal.legal_entity_id = %s - AND general_journal.accounting_book_id = %s - AND chart_account.chart_account_code = %s - AND general_journal.accounting_date <= %s - AND general_journal.journal_reference NOT LIKE %s - ORDER BY general_journal.accounting_date, - CASE - WHEN %s AND journal_entry_line.debit_amount > 0 THEN 0 - WHEN NOT %s AND journal_entry_line.credit_amount > 0 THEN 0 - ELSE 1 - END, - general_journal.journal_reference, - journal_entry_line.line_number - """, - ( - tenant_id, - legal_entity_id, - book_id, - resolved_account_code, - period_end_date, - _CLOSING_JOURNAL_PATTERN, - increase_is_debit, - increase_is_debit, - ), - ).fetchall() - open_items = _fifo_aging_open_items(line_rows, increase_is_debit=increase_is_debit) - bucket_amounts = { - "current": Decimal("0"), - "days_31_60": Decimal("0"), - "days_61_90": Decimal("0"), - "days_over_90": Decimal("0"), - } - for open_item in open_items: - outstanding_days = (period_end_date - open_item[0]).days - bucket_amounts[_receivable_aging_bucket(outstanding_days)] += open_item[1] - total_outstanding_amount = ( - bucket_amounts["current"] - + bucket_amounts["days_31_60"] - + bucket_amounts["days_61_90"] - + bucket_amounts["days_over_90"] - ) - document: dict[str, object] = { - "tenant_reference": self._tenant_reference, - "legal_entity_reference": legal_entity_reference, - "accounting_book_reference": book_reference, - "book_reference": book_reference, - "fiscal_period_reference": f"urn:cwl:accounting:fiscal_period:{period_code}", - "chart_account_code": resolved_account_code, - "account_class_code": account_classes[resolved_account_code], - "as_of_date": period_end_date.isoformat(), - "current_amount": _unsigned_aging_amount_text(bucket_amounts["current"]), - "days_31_60_amount": _unsigned_aging_amount_text(bucket_amounts["days_31_60"]), - "days_61_90_amount": _unsigned_aging_amount_text(bucket_amounts["days_61_90"]), - "days_over_90_amount": _unsigned_aging_amount_text(bucket_amounts["days_over_90"]), - "total_outstanding_amount": _unsigned_aging_amount_text(total_outstanding_amount), - } - if increase_is_debit: - unapplied_credit_amount = Decimal("0") - for _date, _reference, _line_number, debit_amount, credit_amount in line_rows: - unapplied_credit_amount += Decimal(str(credit_amount)) - Decimal( - str(debit_amount) - ) - if unapplied_credit_amount > 0: - document["unapplied_credit_amount"] = _unsigned_aging_amount_text( - unapplied_credit_amount - ) - return document - - def _load_chart_account_classes( - self, legal_entity_reference: str, accounting_book_reference: str - ) -> dict[str, str]: - with self._session() as connection: - tenant_id = self._require_tenant(connection) - legal_entity_id = self._require_legal_entity( - connection, - tenant_id, - legal_entity_reference, - next_action="the account-balance read", - ) - book_id = self._require_book_for_close( - connection, - tenant_id, - legal_entity_id, - accounting_book_reference, - next_action="the account-balance read", - )[0] - rows = connection.execute( - """ - SELECT chart_account.chart_account_code, - chart_account.account_class_code - FROM accounting_core.chart_account - WHERE chart_account.tenant_account_id = %s - AND chart_account.accounting_book_id = %s - AND chart_account.valid_to IS NULL - """, - (tenant_id, book_id), - ).fetchall() - return { - str(account_code): str(account_class_code) - for account_code, account_class_code in rows - } - - def load_account_ledger( - self, - legal_entity_reference: str, - chart_account_code: str, - fiscal_period_reference: str = "", - *, - page_limit: int = 50, - cursor_after: tuple[datetime, str, int] | None = None, - ) -> dict[str, object]: - """Return posted journal lines for one tenant entity and chart account.""" - if not legal_entity_reference: - raise AccountingValidationError( - "legal_entity_reference is required. " - "Supply that ledger field, then retry the account-ledger read." - ) - if not chart_account_code: - raise AccountingValidationError( - "chart_account_code is required. " - "Supply that ledger field, then retry the account-ledger read." - ) - period_code = "" - if fiscal_period_reference: - period_code = fiscal_period_reference - if period_code.startswith("urn:cwl:accounting:fiscal_period:"): - period_code = period_code[len("urn:cwl:accounting:fiscal_period:") :] - with self._session() as connection: - tenant_id = self._require_tenant(connection) - self._require_legal_entity( - connection, tenant_id, legal_entity_reference, "the account-ledger read" - ) - chart_row = connection.execute( - """ - SELECT chart_account_id - FROM accounting_core.chart_account - WHERE tenant_account_id = %s - AND chart_account_code = %s - AND valid_to IS NULL - LIMIT 1 - """, - (tenant_id, chart_account_code), - ).fetchone() - if chart_row is None: - raise AccountingValidationError( - f"Chart account {chart_account_code} is not recorded for this tenant. " - "Create the chart_account row, then retry the account-ledger read." - ) - period_id = None - period_reference: str | None = None - if period_code: - period_id, _status, _end = self._require_fiscal_period( - connection, tenant_id, period_code, "the account-ledger read" - ) - period_reference = f"urn:cwl:accounting:fiscal_period:{period_code}" - cursor_posted_at = None - cursor_journal_reference = None - cursor_line_number = None - if cursor_after is not None: - cursor_posted_at, cursor_journal_reference, cursor_line_number = cursor_after - totals = connection.execute( - """ - SELECT COALESCE(SUM(journal_entry_line.debit_amount), 0), - COALESCE(SUM(journal_entry_line.credit_amount), 0) - FROM accounting_core.journal_entry_line - JOIN accounting_core.general_journal - ON general_journal.tenant_account_id = journal_entry_line.tenant_account_id - AND general_journal.general_journal_id = journal_entry_line.general_journal_id - JOIN accounting_core.chart_account - ON chart_account.tenant_account_id = journal_entry_line.tenant_account_id - AND chart_account.chart_account_id = journal_entry_line.chart_account_id - JOIN accounting_core.legal_entity_record - ON legal_entity_record.tenant_account_id = general_journal.tenant_account_id - AND legal_entity_record.legal_entity_id = general_journal.legal_entity_id - WHERE journal_entry_line.tenant_account_id = %s - AND legal_entity_record.legal_entity_code = %s - AND chart_account.chart_account_code = %s - AND (%s::uuid IS NULL OR general_journal.fiscal_period_id = %s) - """, - ( - tenant_id, - legal_entity_reference, - chart_account_code, - period_id, - period_id, - ), - ).fetchone() - rows = connection.execute( - """ - SELECT general_journal.journal_reference, - general_journal.posted_at, - journal_entry_line.line_number, - chart_account.chart_account_code, - journal_entry_line.account_role_code, - journal_entry_line.debit_amount, - journal_entry_line.credit_amount - FROM accounting_core.journal_entry_line - JOIN accounting_core.general_journal - ON general_journal.tenant_account_id = journal_entry_line.tenant_account_id - AND general_journal.general_journal_id = journal_entry_line.general_journal_id - JOIN accounting_core.chart_account - ON chart_account.tenant_account_id = journal_entry_line.tenant_account_id - AND chart_account.chart_account_id = journal_entry_line.chart_account_id - JOIN accounting_core.legal_entity_record - ON legal_entity_record.tenant_account_id = general_journal.tenant_account_id - AND legal_entity_record.legal_entity_id = general_journal.legal_entity_id - WHERE journal_entry_line.tenant_account_id = %s - AND legal_entity_record.legal_entity_code = %s - AND chart_account.chart_account_code = %s - AND (%s::uuid IS NULL OR general_journal.fiscal_period_id = %s) - AND ( - %s::timestamptz IS NULL - OR ( - general_journal.posted_at, - general_journal.journal_reference, - journal_entry_line.line_number - ) > (%s, %s, %s) - ) - ORDER BY general_journal.posted_at, - general_journal.journal_reference, - journal_entry_line.line_number - LIMIT %s - """, - ( - tenant_id, - legal_entity_reference, - chart_account_code, - period_id, - period_id, - cursor_posted_at, - cursor_posted_at, - cursor_journal_reference, - cursor_line_number, - page_limit + 1, - ), - ).fetchall() - has_more = len(rows) > page_limit - page_rows = rows[:page_limit] - ledger_lines = [ - { - "line_number": row[2], - "chart_account_code": row[3], - "account_role_code": row[4], - "debit_amount": _exact_amount_text(Decimal(row[5])), - "credit_amount": _exact_amount_text(Decimal(row[6])), - "journal_reference": row[0], - "posted_at": _format_timestamp(row[1]), - } - for row in page_rows - ] - next_cursor = None - if has_more: - last = page_rows[-1] - next_cursor = f"{_format_timestamp(last[1])}|{last[0]}|{last[2]}" - return { - "tenant_reference": self._tenant_reference, - "legal_entity_reference": legal_entity_reference, - "chart_account_code": chart_account_code, - "fiscal_period_reference": period_reference, - "ledger_lines": ledger_lines, - "period_debit_total": _exact_amount_text(Decimal(totals[0])), - "period_credit_total": _exact_amount_text(Decimal(totals[1])), - "next_cursor": next_cursor, - } - - def reverse( - self, - journal_reference: str, - reversal_date: date, - reversal_reason_code: str, - policy: AccountingPolicy, - *, - reversal_idempotency_key: str | None = None, - ) -> PostingReceipt: - """Append the exact opposite of one original journal and preserve lineage.""" - _require_code(reversal_reason_code, "reversal reason code") - command_key = ( - f"reversal:{journal_reference}" - if reversal_idempotency_key is None - else reversal_idempotency_key.strip() - ) - if not command_key: - raise AccountingValidationError( - "reversal idempotency key must not be empty. " - "Supply the reversal command identity, then retry reversal." - ) - command_hash = _reversal_command_hash( - tenant_reference=self._tenant_reference, - reversal_idempotency_key=command_key, - original_journal_reference=journal_reference, - reversal_date=reversal_date, - reversal_reason_code=reversal_reason_code, - ) - with self._session() as connection: - tenant_id = self._require_tenant(connection) - self._acquire_command_lock( - connection, f"reversal:{journal_reference}:{command_key}" - ) - existing = connection.execute( - """ - SELECT reversal_journal.journal_reference, - reversal_record.idempotency_key, - reversal_record.source_payload_hash, - original_journal.journal_reference, - journal_reversal.reversal_reason_code, - reversal_journal.accounting_date - FROM accounting_core.journal_reversal - JOIN accounting_core.general_journal AS original_journal - ON original_journal.tenant_account_id = journal_reversal.tenant_account_id - AND original_journal.general_journal_id = journal_reversal.original_journal_id - JOIN accounting_core.general_journal AS reversal_journal - ON reversal_journal.tenant_account_id = journal_reversal.tenant_account_id - AND reversal_journal.general_journal_id = journal_reversal.reversal_journal_id - JOIN accounting_integration.journal_proposal_record AS reversal_record - ON reversal_record.tenant_account_id = reversal_journal.tenant_account_id - AND reversal_record.proposal_record_id = reversal_journal.source_proposal_record_id - WHERE journal_reversal.tenant_account_id = %s - AND original_journal.journal_reference = %s - """, - (tenant_id, journal_reference), - ).fetchone() - if existing is not None: - if str(existing[1]) != command_key: - raise AccountingValidationError( - "journal is already reversed. Use the existing reversal receipt, then retry." - ) - if ( - str(existing[2]) != command_hash - or str(existing[3]) != journal_reference - or str(existing[4]) != reversal_reason_code - or existing[5] != reversal_date - ): - raise IdempotencyConflictError( - "reversal idempotency key was already used with different command evidence. " - "Use a new reversal command identity, then retry." - ) - return self._receipt_for_journal(connection, tenant_id, existing[0]) - prior_command = connection.execute( - """ - SELECT source_payload_hash - FROM accounting_integration.journal_proposal_record - WHERE tenant_account_id = %s AND idempotency_key = %s - """, - (tenant_id, command_key), - ).fetchone() - if prior_command is not None: - raise IdempotencyConflictError( - "reversal idempotency key was already used by another accounting command. Supply a new reversal command identity, then retry." - ) - original = connection.execute( - """ - SELECT general_journal_id, legal_entity_id, accounting_book_id, - transaction_currency_code, functional_currency_code, - source_proposal_record_id, transaction_date, accounting_date - FROM accounting_core.general_journal - WHERE tenant_account_id = %s AND journal_reference = %s - """, - (tenant_id, journal_reference), - ).fetchone() - if original is None: - raise AccountingValidationError( - "journal does not exist. Supply a posted journal reference, then retry reversal." - ) - already_reversal = connection.execute( - """ - SELECT 1 - FROM accounting_core.journal_reversal - WHERE tenant_account_id = %s AND reversal_journal_id = %s - """, - (tenant_id, original[0]), - ).fetchone() - if already_reversal is not None: - raise AccountingValidationError( - "a reversal journal cannot itself be reversed. Reverse the original journal, or post a replacement." - ) - if reversal_date < original[7]: - raise AccountingValidationError( - "reversal date cannot precede original journal accounting date. Supply a reversal_date on or after the original accounting date, then retry reversal." - ) - if not policy.permits(reversal_date): - raise AccountingValidationError("reversal date belongs to a closed fiscal period. Reverse into an open or soft-closed period, then retry reversal.") - if ( - self._tenant_reference != policy.tenant_reference - or self._legal_entity_code(connection, tenant_id, original[1]) - != policy.legal_entity_reference - or self._book_name(connection, tenant_id, original[2]) - != policy.accounting_book_reference - ): - raise AccountingValidationError( - "reversal policy scope does not match original journal. Supply the reversal policy for the original journal's legal entity and book, then retry reversal." - ) - period_id = self._require_adjusting_period(connection, tenant_id, reversal_date) - original_lines = self._load_lines(connection, tenant_id, original[0]) - reversal_lines = tuple( - PostedJournalLine( - line_number=line.line_number, - chart_account_code=line.chart_account_code, - account_role_code=line.account_role_code, - debit_amount=line.credit_amount, - credit_amount=line.debit_amount, - ) - for line in original_lines - ) - reversal_reference = f"{journal_reference}:reversal" - occupant = connection.execute( - """ - SELECT 1 - FROM accounting_core.general_journal - WHERE tenant_account_id = %s AND journal_reference = %s - """, - (tenant_id, reversal_reference), - ).fetchone() - if occupant is not None: - raise AccountingValidationError( - "posted journal is immutable. Reverse the existing journal, " - "then post a replacement." - ) - _original_source_hash, source_proposal_id = self._proposal_identity( - connection, tenant_id, original[5] - ) - receipt = PostingReceipt( - receipt_reference=f"{reversal_reference}:receipt", - journal_reference=reversal_reference, - posting_status_code="posted", - source_proposal_id=source_proposal_id, - source_payload_hash=command_hash, - tenant_reference=policy.tenant_reference, - legal_entity_reference=policy.legal_entity_reference, - accounting_book_reference=policy.accounting_book_reference, - accounting_policy_version=policy.accounting_policy_version, - posting_rule_version=policy.posting_rule_version, - line_count=len(reversal_lines), - reversal_of_journal_reference=journal_reference, - ) - reversal_proposal_id = connection.execute( - """ - INSERT INTO accounting_integration.journal_proposal_record ( - tenant_account_id, external_proposal_id, proposal_contract_version, - idempotency_key, source_payload_hash, proposal_status_code, processed_at - ) - VALUES (%s, uuidv7(), 1, %s, %s, 'posted', clock_timestamp()) - RETURNING proposal_record_id - """, - (tenant_id, command_key, command_hash), - ).fetchone()[0] - reversal_journal_id = self._insert_journal( - connection, - tenant_id=tenant_id, - legal_entity_id=original[1], - book_id=original[2], - period_id=period_id, - journal_reference=reversal_reference, - proposal=_ReversalProposal( - source_payload_hash=command_hash, - transaction_currency=original[3], - transaction_date=original[6], - accounting_date=reversal_date, - source_event_references=(), - ), - policy=policy, - proposal_record_id=reversal_proposal_id, - lines=reversal_lines, - ) - connection.execute( - """ - INSERT INTO accounting_core.journal_reversal ( - tenant_account_id, original_journal_id, reversal_journal_id, - reversal_reason_code - ) - VALUES (%s, %s, %s, %s) - """, - (tenant_id, original[0], reversal_journal_id, reversal_reason_code), - ) - self._insert_receipt( - connection, tenant_id, reversal_proposal_id, reversal_journal_id, receipt - ) - self._insert_outbox( - connection, - tenant_id, - "journal_reversal", - reversal_reference, - receipt.receipt_reference, - receipt, - ) - return receipt - - def load_reversal_policy( - self, journal_reference: str, reversal_date: date - ) -> AccountingPolicy: - """Build catalog policy for reversing *journal_reference* on *reversal_date*.""" - with self._session() as connection: - tenant_id = self._require_tenant(connection) - row = connection.execute( - """ - SELECT legal_entity_record.legal_entity_code, - accounting_book.book_name, - accounting_book.book_role_code, - general_journal.transaction_currency_code, - general_journal.functional_currency_code, - general_journal.accounting_policy_version, - general_journal.posting_rule_version, - general_journal.general_journal_id - FROM accounting_core.general_journal - JOIN accounting_core.legal_entity_record - ON legal_entity_record.tenant_account_id = general_journal.tenant_account_id - AND legal_entity_record.legal_entity_id = general_journal.legal_entity_id - JOIN accounting_core.accounting_book - ON accounting_book.tenant_account_id = general_journal.tenant_account_id - AND accounting_book.accounting_book_id = general_journal.accounting_book_id - WHERE general_journal.tenant_account_id = %s - AND general_journal.journal_reference = %s - """, - (tenant_id, journal_reference), - ).fetchone() - if row is None: - raise AccountingValidationError( - "journal does not exist. Supply a posted journal reference, then retry reversal." - ) - _period_id, period_start, period_end = self._require_adjusting_period_bounds( - connection, tenant_id, reversal_date - ) - lines = self._load_lines(connection, tenant_id, row[7]) - return AccountingPolicy( - tenant_reference=self._tenant_reference, - legal_entity_reference=row[0], - accounting_book_reference=row[1], - intended_book_role_code=row[2], - transaction_currency=row[3], - functional_currency=row[4], - open_period_start=period_start, - open_period_end=period_end, - chart_account_mapping={ - line.account_role_code: line.chart_account_code for line in lines - }, - accounting_policy_version=row[5], - posting_rule_version=row[6], - ) - - def load_account_role_mappings( - self, legal_entity_reference: str, accounting_book_reference: str - ) -> dict[str, object]: - """Return effective account-role mappings for one legal entity and book.""" - if not legal_entity_reference or not accounting_book_reference: - raise AccountingValidationError( - "legal_entity_reference and book_reference are required. " - "Supply those catalog fields, then retry the mapping read." - ) - with self._session() as connection: - tenant_id = self._require_tenant(connection) - legal_entity_id = self._load_legal_entity( - connection, tenant_id, legal_entity_reference, "the mapping read" - )[0] - book_id = self._require_book_for_close( - connection, - tenant_id, - legal_entity_id, - accounting_book_reference, - "the mapping read", - )[0] - rows = connection.execute( - """ - SELECT account_role_mapping.account_role_code, - chart_account.chart_account_code, - account_role_mapping.accounting_policy_version, - account_role_mapping.posting_rule_version - FROM accounting_core.account_role_mapping - JOIN accounting_core.chart_account - ON chart_account.tenant_account_id = account_role_mapping.tenant_account_id - AND chart_account.chart_account_id = account_role_mapping.chart_account_id - WHERE account_role_mapping.tenant_account_id = %s - AND account_role_mapping.accounting_book_id = %s - AND account_role_mapping.valid_to IS NULL - ORDER BY account_role_mapping.account_role_code - """, - (tenant_id, book_id), - ).fetchall() - if not rows: - raise AccountingValidationError( - "No account_role_mapping is recorded for this book. " - "Create the account_role_mapping rows, then retry the mapping read." - ) - return { - "tenant_reference": self._tenant_reference, - "legal_entity_reference": legal_entity_reference, - "accounting_book_reference": accounting_book_reference, - "book_reference": accounting_book_reference, - "mappings": [ - { - "account_role_code": role_code, - "chart_account_code": account_code, - "accounting_policy_version": policy_version, - "posting_rule_version": rule_version, - } - for role_code, account_code, policy_version, rule_version in rows - ], - } - - def load_legal_entities(self) -> dict[str, object]: - """Return existing legal_entity_record rows for the bound tenant.""" - with self._session() as connection: - tenant_id = self._require_tenant(connection) - rows = connection.execute( - """ - SELECT legal_entity_record.legal_entity_code, - legal_entity_record.entity_name - FROM accounting_core.legal_entity_record - WHERE legal_entity_record.tenant_account_id = %s - AND legal_entity_record.valid_to IS NULL - ORDER BY legal_entity_record.legal_entity_code - """, - (tenant_id,), - ).fetchall() - return { - "tenant_reference": self._tenant_reference, - "legal_entities": [ - { - "legal_entity_reference": legal_entity_code, - "entity_name": entity_name, - } - for legal_entity_code, entity_name in rows - ], - } - - def load_accounting_books(self, legal_entity_reference: str) -> dict[str, object]: - """Return existing accounting_book rows for one legal entity.""" - if not legal_entity_reference: - raise AccountingValidationError( - "legal_entity_reference is required. " - "Supply that catalog field, then retry the accounting-book list." - ) - with self._session() as connection: - tenant_id = self._require_tenant(connection) - legal_entity_id = self._load_legal_entity( - connection, tenant_id, legal_entity_reference, "the accounting-book list" - )[0] - rows = connection.execute( - """ - SELECT accounting_book.book_name, - accounting_book.book_role_code - FROM accounting_core.accounting_book - WHERE accounting_book.tenant_account_id = %s - AND accounting_book.legal_entity_id = %s - AND accounting_book.valid_to IS NULL - ORDER BY accounting_book.book_name - """, - (tenant_id, legal_entity_id), - ).fetchall() - return { - "tenant_reference": self._tenant_reference, - "legal_entity_reference": legal_entity_reference, - "accounting_books": [ - { - "accounting_book_reference": book_name, - "book_reference": book_name, - "intended_book_role_code": book_role_code, - "book_name": book_name, - } - for book_name, book_role_code in rows - ], - } - - def load_chart_accounts( - self, legal_entity_reference: str, accounting_book_reference: str - ) -> dict[str, object]: - """Return existing chart_account rows for one legal entity and book.""" - if not legal_entity_reference or not accounting_book_reference: - raise AccountingValidationError( - "legal_entity_reference and book_reference are required. " - "Supply those catalog fields, then retry the chart-account read." - ) - with self._session() as connection: - tenant_id = self._require_tenant(connection) - legal_entity_id = self._load_legal_entity( - connection, tenant_id, legal_entity_reference, "the chart-account read" - )[0] - book_id = self._require_book_for_close( - connection, - tenant_id, - legal_entity_id, - accounting_book_reference, - "the chart-account read", - )[0] - rows = connection.execute( - """ - SELECT chart_account.chart_account_code, - chart_account.account_name, - chart_account.normal_balance_code, - chart_account.account_class_code - FROM accounting_core.chart_account - WHERE chart_account.tenant_account_id = %s - AND chart_account.accounting_book_id = %s - AND chart_account.valid_to IS NULL - ORDER BY chart_account.chart_account_code - """, - (tenant_id, book_id), - ).fetchall() - return { - "tenant_reference": self._tenant_reference, - "legal_entity_reference": legal_entity_reference, - "accounting_book_reference": accounting_book_reference, - "book_reference": accounting_book_reference, - "chart_accounts": [ - { - "chart_account_code": account_code, - "account_name": account_name, - "normal_balance_code": normal_balance_code, - "account_class_code": account_class_code, - } - for ( - account_code, - account_name, - normal_balance_code, - account_class_code, - ) in rows - ], - } - - def trial_balance( - self, - tenant_reference: str, - legal_entity_reference: str, - accounting_book_reference: str, - through_date: date, - ) -> dict[str, AccountBalance]: - """Aggregate posted lines in one tenant/entity/book scope through a date.""" - with self._session() as connection: - tenant_id = self._require_tenant(connection) - if tenant_reference != self._tenant_reference: - return {} - legal_entity_id = connection.execute( - """ - SELECT legal_entity_id - FROM accounting_core.legal_entity_record - WHERE tenant_account_id = %s AND legal_entity_code = %s - """, - (tenant_id, legal_entity_reference), - ).fetchone() - book_id = connection.execute( - """ - SELECT accounting_book_id - FROM accounting_core.accounting_book - WHERE tenant_account_id = %s AND book_name = %s - """, - (tenant_id, accounting_book_reference), - ).fetchone() - if legal_entity_id is None or book_id is None: - return {} - rows = self._aggregate_trial_balance( - connection, tenant_id, legal_entity_id[0], book_id[0], through_date - ) - return { - account_code: AccountBalance(account_code, debit_total, credit_total) - for _account_id, account_code, debit_total, credit_total in rows - } - - def load_period_trial_balance( - self, - legal_entity_reference: str, - accounting_book_reference: str, - period_code: str, - balance_basis_code: str = "", - ) -> dict[str, object]: - """Return snapshot or live trial-balance totals, optionally on an unadjusted, adjusted, or post-close basis.""" - _require_reference(legal_entity_reference, "legal entity reference") - _require_reference(accounting_book_reference, "accounting book reference") - if not period_code.strip(): - raise AccountingValidationError( - "period_code is required. Supply the fiscal period code, then retry the trial-balance read." - ) - if balance_basis_code and balance_basis_code not in { - "unadjusted", - "adjusted", - "post_close", - }: - raise AccountingValidationError( - "balance_basis_code must be unadjusted, adjusted, or post_close. " - "Supply a known trial-balance basis, then retry the trial-balance read." - ) - with self._session() as connection: - tenant_id = self._require_tenant(connection) - legal_entity_id = self._require_legal_entity( - connection, - tenant_id, - legal_entity_reference, - next_action="the trial-balance read", - ) - book_id, _reporting_currency = self._require_book_for_close( - connection, - tenant_id, - legal_entity_id, - accounting_book_reference, - next_action="the trial-balance read", - ) - period_id, period_status_code, period_end_date = self._require_fiscal_period( - connection, - tenant_id, - period_code, - next_action="the trial-balance read", - ) - snapshot_record_id = None - if balance_basis_code == "post_close": - snapshot = self._latest_close_snapshot( - connection, tenant_id, legal_entity_id, book_id, period_id - ) - if snapshot is None: - raise AccountingValidationError( - "balance_basis_code=post_close requires a stored trial_balance_snapshot. " - "Hard-close the period, then retry the trial-balance read." - ) - snapshot_record_id = str(snapshot[0]) - line_rows = self._load_snapshot_balance_lines( - connection, tenant_id, snapshot[0] - ) - balance_source_code = "snapshot" - elif balance_basis_code == "unadjusted": - line_rows = tuple( - (account_code, debit_total, credit_total) - for _account_id, account_code, debit_total, credit_total in self._aggregate_worksheet_trial_balance( - connection, - tenant_id, - legal_entity_id, - book_id, - period_end_date, - exclude_adjusting=True, - ) - ) - balance_source_code = "live" - elif balance_basis_code == "adjusted": - line_rows = tuple( - (account_code, debit_total, credit_total) - for _account_id, account_code, debit_total, credit_total in self._aggregate_worksheet_trial_balance( - connection, - tenant_id, - legal_entity_id, - book_id, - period_end_date, - exclude_adjusting=False, - ) - ) - balance_source_code = "live" - elif period_status_code == "hard_closed": - snapshot = self._latest_close_snapshot( - connection, tenant_id, legal_entity_id, book_id, period_id - ) - if snapshot is None: - raise AccountingValidationError( - f"Fiscal period {period_code} is {period_status_code} without a " - "trial-balance snapshot. Restore the trial_balance_snapshot for this " - "book from the journal population, then retry the trial-balance read." - ) - snapshot_record_id = str(snapshot[0]) - line_rows = self._load_snapshot_balance_lines( - connection, tenant_id, snapshot[0] - ) - balance_source_code = "snapshot" - else: - line_rows = tuple( - (account_code, debit_total, credit_total) - for _account_id, account_code, debit_total, credit_total in self._aggregate_trial_balance( - connection, tenant_id, legal_entity_id, book_id, period_end_date - ) - ) - balance_source_code = "live" - document: dict[str, object] = { - "tenant_reference": self._tenant_reference, - "legal_entity_reference": legal_entity_reference, - "accounting_book_reference": accounting_book_reference, - "book_reference": accounting_book_reference, - "fiscal_period_reference": f"urn:cwl:accounting:fiscal_period:{period_code}", - "period_code": period_code, - "period_status_code": period_status_code, - "balance_source_code": balance_source_code, - "lines": [ - { - "chart_account_code": account_code, - "debit_amount": _exact_amount_text(debit_total), - "credit_amount": _exact_amount_text(credit_total), - "net_balance_amount": _exact_amount_text(debit_total - credit_total), - } - for account_code, debit_total, credit_total in line_rows - ], - } - if snapshot_record_id is not None: - document["snapshot_record_id"] = snapshot_record_id - if balance_basis_code: - document["balance_basis_code"] = balance_basis_code - return document - - def load_financial_statement( - self, - legal_entity_reference: str, - accounting_book_reference: str, - period_code: str, - statement_type_code: str, - comparison_period_code: str = "", - statement_scope_code: str = "", - ) -> dict[str, object]: - """Project income-statement, balance-sheet, changes-in-equity, or cash-flow lines from posted books.""" - if statement_scope_code not in {"", "period", "year_to_date"}: - raise AccountingValidationError( - "statement_scope_code must be period or year_to_date. " - "Supply a known statement scope, then retry the financial-statement read." - ) - if statement_type_code == "income_statement": - allowed_classes = frozenset({"revenue", "expense"}) - elif statement_type_code == "balance_sheet": - allowed_classes = frozenset({"asset", "liability", "equity"}) - elif statement_type_code == "changes_in_equity": - allowed_classes = frozenset({"equity"}) - elif statement_type_code == "cash_flow": - allowed_classes = frozenset() - else: - raise AccountingValidationError( - "statement_type_code must be income_statement, balance_sheet, changes_in_equity, or cash_flow. " - "Supply a known statement type, then retry the financial-statement read." - ) - trial_balance = self.load_period_trial_balance( - legal_entity_reference=legal_entity_reference, - accounting_book_reference=accounting_book_reference, - period_code=period_code, - ) - account_facts = self._load_statement_account_facts( - legal_entity_reference, accounting_book_reference - ) - income_scope_code = ( - "period" if statement_type_code == "balance_sheet" else statement_scope_code - ) - if statement_type_code == "changes_in_equity": - source_lines = self._load_changes_in_equity_lines( - legal_entity_reference=legal_entity_reference, - accounting_book_reference=accounting_book_reference, - period_code=period_code, - statement_scope_code=statement_scope_code, - ) - elif statement_type_code == "cash_flow": - source_lines = self._load_cash_flow_lines( - legal_entity_reference=legal_entity_reference, - accounting_book_reference=accounting_book_reference, - period_code=period_code, - statement_scope_code=statement_scope_code, - ) - elif statement_type_code == "income_statement": - source_lines = self._load_operational_income_lines( - legal_entity_reference=legal_entity_reference, - accounting_book_reference=accounting_book_reference, - period_code=period_code, - statement_scope_code=income_scope_code, - ) - else: - source_lines = [] - for raw_line in trial_balance["lines"]: - account_code = str(raw_line["chart_account_code"]) - account_fact = account_facts.get(account_code) - if account_fact is None: - raise AccountingValidationError( - f"account_role_mapping is missing for chart account {account_code}. " - "Create the account_role_mapping row, then retry the financial-statement read." - ) - account_role_code, account_class_code = account_fact - if account_class_code not in allowed_classes: - continue - source_lines.append( - { - "chart_account_code": account_code, - "account_role_code": account_role_code, - "account_class_code": account_class_code, - "debit_amount": Decimal(str(raw_line["debit_amount"])), - "credit_amount": Decimal(str(raw_line["credit_amount"])), - } - ) - statement_lines: list[dict[str, str]] = [] - total_debit_amount = Decimal("0") - total_credit_amount = Decimal("0") - for raw_line in source_lines: - debit_amount = Decimal(str(raw_line["debit_amount"])) - credit_amount = Decimal(str(raw_line["credit_amount"])) - statement_lines.append( - { - "chart_account_code": str(raw_line["chart_account_code"]), - "account_role_code": str(raw_line["account_role_code"]), - "account_class_code": str(raw_line["account_class_code"]), - "debit_amount": _exact_amount_text(debit_amount), - "credit_amount": _exact_amount_text(credit_amount), - } - ) - total_debit_amount += debit_amount - total_credit_amount += credit_amount - if statement_type_code == "income_statement": - net_income_amount = sum( - ( - Decimal(str(raw_line["credit_amount"])) - - Decimal(str(raw_line["debit_amount"])) - for raw_line in source_lines - ), - Decimal("0"), - ) - elif statement_type_code in {"changes_in_equity", "cash_flow"}: - net_income_amount = next( - Decimal(str(raw_line["credit_amount"])) - - Decimal(str(raw_line["debit_amount"])) - for raw_line in source_lines - if raw_line["account_role_code"] == "period_net_income" - ) - elif str(trial_balance["period_status_code"]) == "hard_closed": - net_income_amount = Decimal("0") - else: - net_income_amount = sum( - ( - Decimal(str(raw_line["credit_amount"])) - - Decimal(str(raw_line["debit_amount"])) - for raw_line in self._load_operational_income_lines( - legal_entity_reference=legal_entity_reference, - accounting_book_reference=accounting_book_reference, - period_code=period_code, - statement_scope_code=income_scope_code, - ) - ), - Decimal("0"), - ) - document = { - "tenant_reference": self._tenant_reference, - "legal_entity_reference": legal_entity_reference, - "accounting_book_reference": accounting_book_reference, - "book_reference": accounting_book_reference, - "fiscal_period_reference": str(trial_balance["fiscal_period_reference"]), - "statement_type_code": statement_type_code, - "statement_lines": statement_lines, - "total_debit_amount": _exact_amount_text(total_debit_amount), - "total_credit_amount": _exact_amount_text(total_credit_amount), - "net_income_amount": _exact_amount_text(net_income_amount), - } - if statement_scope_code == "year_to_date": - document["statement_scope_code"] = "year_to_date" - if comparison_period_code.strip(): - compared = self.load_financial_statement( - legal_entity_reference, - accounting_book_reference, - comparison_period_code.strip(), - statement_type_code, - statement_scope_code=statement_scope_code, - ) - document["comparison_fiscal_period_reference"] = compared[ - "fiscal_period_reference" - ] - document["comparison_statement_lines"] = compared["statement_lines"] - document["comparison_total_debit_amount"] = compared["total_debit_amount"] - document["comparison_total_credit_amount"] = compared["total_credit_amount"] - document["comparison_net_income_amount"] = compared["net_income_amount"] - return document - - def load_financial_statement_package( - self, - legal_entity_reference: str, - accounting_book_reference: str, - period_code: str, - comparison_period_code: str = "", - statement_scope_code: str = "", - ) -> dict[str, object]: - """Return all four financial statements from one REPEATABLE READ snapshot.""" - with self._consistent_read_session(): - return self._assemble_financial_statement_package( - legal_entity_reference, - accounting_book_reference, - period_code, - comparison_period_code=comparison_period_code, - statement_scope_code=statement_scope_code, - ) - - def _assemble_financial_statement_package( - self, - legal_entity_reference: str, - accounting_book_reference: str, - period_code: str, - comparison_period_code: str = "", - statement_scope_code: str = "", - ) -> dict[str, object]: - income_statement = self.load_financial_statement( - legal_entity_reference, - accounting_book_reference, - period_code, - "income_statement", - comparison_period_code, - statement_scope_code, - ) - balance_sheet = self.load_financial_statement( - legal_entity_reference, - accounting_book_reference, - period_code, - "balance_sheet", - comparison_period_code, - statement_scope_code, - ) - changes_in_equity = self.load_financial_statement( - legal_entity_reference, - accounting_book_reference, - period_code, - "changes_in_equity", - comparison_period_code, - statement_scope_code, - ) - cash_flow = self.load_financial_statement( - legal_entity_reference, - accounting_book_reference, - period_code, - "cash_flow", - comparison_period_code, - statement_scope_code, - ) - document: dict[str, object] = { - "tenant_reference": income_statement["tenant_reference"], - "legal_entity_reference": income_statement["legal_entity_reference"], - "accounting_book_reference": income_statement["accounting_book_reference"], - "book_reference": income_statement["book_reference"], - "fiscal_period_reference": income_statement["fiscal_period_reference"], - "income_statement": income_statement, - "balance_sheet": balance_sheet, - "changes_in_equity": changes_in_equity, - "cash_flow": cash_flow, - } - if statement_scope_code == "year_to_date": - document["statement_scope_code"] = "year_to_date" - return document - - def load_period_close_package( - self, - legal_entity_reference: str, - book_reference: str, - period_code: str, - comparison_period_code: str = "", - statement_scope_code: str = "", - ) -> dict[str, object]: - """Return the close-binder worksheets from one REPEATABLE READ ledger snapshot.""" - with self._consistent_read_session(): - return self._assemble_period_close_package( - legal_entity_reference, - book_reference, - period_code, - comparison_period_code=comparison_period_code, - statement_scope_code=statement_scope_code, - ) - - def _assemble_period_close_package( - self, - legal_entity_reference: str, - book_reference: str, - period_code: str, - comparison_period_code: str = "", - statement_scope_code: str = "", - ) -> dict[str, object]: - fiscal_period = self.load_fiscal_period(legal_entity_reference, period_code) - trial_balance = self.load_period_trial_balance( - legal_entity_reference=legal_entity_reference, - accounting_book_reference=book_reference, - period_code=period_code, - ) - income_statement = self.load_financial_statement( - legal_entity_reference, - book_reference, - period_code, - "income_statement", - comparison_period_code, - statement_scope_code, - ) - balance_sheet = self.load_financial_statement( - legal_entity_reference, - book_reference, - period_code, - "balance_sheet", - comparison_period_code, - statement_scope_code, - ) - changes_in_equity = self.load_financial_statement( - legal_entity_reference, - book_reference, - period_code, - "changes_in_equity", - comparison_period_code, - statement_scope_code, - ) - cash_flow = self.load_financial_statement( - legal_entity_reference, - book_reference, - period_code, - "cash_flow", - comparison_period_code, - statement_scope_code, - ) - financial_statement_package: dict[str, object] = { - "tenant_reference": income_statement["tenant_reference"], - "legal_entity_reference": income_statement["legal_entity_reference"], - "accounting_book_reference": income_statement["accounting_book_reference"], - "book_reference": income_statement["book_reference"], - "fiscal_period_reference": income_statement["fiscal_period_reference"], - "income_statement": income_statement, - "balance_sheet": balance_sheet, - "changes_in_equity": changes_in_equity, - "cash_flow": cash_flow, - } - if statement_scope_code == "year_to_date": - financial_statement_package["statement_scope_code"] = "year_to_date" - receivable_aging = self.load_receivable_aging( - legal_entity_reference, - book_reference, - period_code, - ) - payable_aging = self.load_payable_aging( - legal_entity_reference, - book_reference, - period_code, - ) - unapplied_cash_rollforward = self.load_unapplied_cash_rollforward( - legal_entity_reference, - book_reference, - period_code, - ) - vat_period_register = self.load_vat_period_register( - legal_entity_reference, - book_reference, - period_code, - ) - close_page = self.load_period_closes(legal_entity_reference, period_code) - stored_closes = close_page["period_closes"] - period_close = stored_closes[-1] if stored_closes else None - return { - "tenant_reference": trial_balance["tenant_reference"], - "legal_entity_reference": trial_balance["legal_entity_reference"], - "accounting_book_reference": trial_balance["accounting_book_reference"], - "book_reference": trial_balance["book_reference"], - "fiscal_period_reference": trial_balance["fiscal_period_reference"], - "fiscal_period": fiscal_period, - "trial_balance": trial_balance, - "financial_statement_package": financial_statement_package, - "receivable_aging": receivable_aging, - "payable_aging": payable_aging, - "unapplied_cash_rollforward": unapplied_cash_rollforward, - "vat_period_register": vat_period_register, - "period_close": period_close, - } - - def _require_closeable_package(self, package: Mapping[str, object]) -> None: - trial_balance = package["trial_balance"] - lines = trial_balance["lines"] - debit_total = sum( - (Decimal(str(line["debit_amount"])) for line in lines), - Decimal("0"), - ) - credit_total = sum( - (Decimal(str(line["credit_amount"])) for line in lines), - Decimal("0"), - ) - if debit_total != credit_total: - raise AccountingValidationError( - "trial balance does not balance. " - "Correct the posted journals so debit totals equal credit totals, " - "then retry the close." - ) - - def _load_statement_account_facts( - self, legal_entity_reference: str, accounting_book_reference: str - ) -> dict[str, tuple[str, str]]: - with self._session() as connection: - tenant_id = self._require_tenant(connection) - legal_entity_id = self._require_legal_entity( - connection, - tenant_id, - legal_entity_reference, - next_action="the financial-statement read", - ) - book_id = self._require_book_for_close( - connection, - tenant_id, - legal_entity_id, - accounting_book_reference, - next_action="the financial-statement read", - )[0] - rows = connection.execute( - """ - SELECT chart_account.chart_account_code, - account_role_mapping.account_role_code, - chart_account.account_class_code - FROM accounting_core.chart_account - JOIN accounting_core.account_role_mapping - ON account_role_mapping.tenant_account_id = chart_account.tenant_account_id - AND account_role_mapping.chart_account_id = chart_account.chart_account_id - AND account_role_mapping.valid_to IS NULL - WHERE chart_account.tenant_account_id = %s - AND chart_account.accounting_book_id = %s - AND chart_account.valid_to IS NULL - """, - (tenant_id, book_id), - ).fetchall() - return { - str(account_code): (str(account_role_code), str(account_class_code)) - for account_code, account_role_code, account_class_code in rows - } - - def _load_changes_in_equity_lines( - self, - *, - legal_entity_reference: str, - accounting_book_reference: str, - period_code: str, - statement_scope_code: str, - ) -> list[dict[str, object]]: - income_lines = self._load_operational_income_lines( - legal_entity_reference=legal_entity_reference, - accounting_book_reference=accounting_book_reference, - period_code=period_code, - statement_scope_code=statement_scope_code, - ) - period_net_income = sum( - ( - Decimal(str(line["credit_amount"])) - Decimal(str(line["debit_amount"])) - for line in income_lines - ), - Decimal("0"), - ) - with self._session() as connection: - tenant_id = self._require_tenant(connection) - legal_entity_id = self._require_legal_entity( - connection, - tenant_id, - legal_entity_reference, - next_action="the financial-statement read", - ) - book_id = self._require_book_for_close( - connection, - tenant_id, - legal_entity_id, - accounting_book_reference, - next_action="the financial-statement read", - )[0] - period_ids = self._statement_period_ids( - connection, - tenant_id, - period_code, - statement_scope_code, - ) - scope_start = connection.execute( - """ - SELECT MIN(period_start_date) - FROM accounting_core.fiscal_period - WHERE tenant_account_id = %s - AND fiscal_period_id = ANY(%s) - """, - (tenant_id, period_ids), - ).fetchone()[0] - opening_equity = self._opening_equity_amount( - connection, - tenant_id, - legal_entity_id, - book_id, - scope_start, - ) - other_equity_movements = self._other_equity_movement_amount( - connection, - tenant_id, - legal_entity_id, - book_id, - period_ids, - ) - closing_equity = opening_equity + period_net_income + other_equity_movements - return [ - self._equity_movement_line("opening_equity", opening_equity), - self._equity_movement_line("period_net_income", period_net_income), - self._equity_movement_line("other_equity_movements", other_equity_movements), - self._equity_movement_line("closing_equity", closing_equity), - ] - - def _opening_equity_amount( - self, - connection: object, - tenant_id: UUID, - legal_entity_id: UUID, - book_id: UUID, - scope_start: date, - ) -> Decimal: - prior_snapshot = connection.execute( - """ - SELECT trial_balance_snapshot.trial_balance_snapshot_id - FROM accounting_core.fiscal_period - JOIN accounting_reporting.trial_balance_snapshot - ON trial_balance_snapshot.tenant_account_id = fiscal_period.tenant_account_id - AND trial_balance_snapshot.fiscal_period_id = fiscal_period.fiscal_period_id - AND trial_balance_snapshot.legal_entity_id = %s - AND trial_balance_snapshot.accounting_book_id = %s - WHERE fiscal_period.tenant_account_id = %s - AND fiscal_period.period_end_date < %s - AND fiscal_period.period_status_code = 'hard_closed' - ORDER BY fiscal_period.period_end_date DESC, fiscal_period.period_code DESC - LIMIT 1 - """, - (legal_entity_id, book_id, tenant_id, scope_start), - ).fetchone() - if prior_snapshot is not None: - amount = connection.execute( - """ - SELECT COALESCE( - SUM( - trial_balance_line.credit_total_amount - - trial_balance_line.debit_total_amount - ), - 0 - ) - FROM accounting_reporting.trial_balance_line - JOIN accounting_core.chart_account - ON chart_account.tenant_account_id = trial_balance_line.tenant_account_id - AND chart_account.chart_account_id = trial_balance_line.chart_account_id - WHERE trial_balance_line.tenant_account_id = %s - AND trial_balance_line.trial_balance_snapshot_id = %s - AND chart_account.account_class_code = 'equity' - """, - (tenant_id, prior_snapshot[0]), - ).fetchone()[0] - return Decimal(amount) - amount = connection.execute( - """ - SELECT COALESCE( - SUM(journal_entry_line.credit_amount - journal_entry_line.debit_amount), - 0 - ) - FROM accounting_core.journal_entry_line - JOIN accounting_core.general_journal - ON general_journal.tenant_account_id = journal_entry_line.tenant_account_id - AND general_journal.general_journal_id = journal_entry_line.general_journal_id - JOIN accounting_core.chart_account - ON chart_account.tenant_account_id = journal_entry_line.tenant_account_id - AND chart_account.chart_account_id = journal_entry_line.chart_account_id - WHERE general_journal.tenant_account_id = %s - AND general_journal.legal_entity_id = %s - AND general_journal.accounting_book_id = %s - AND general_journal.accounting_date <= %s - AND chart_account.account_class_code = 'equity' - """, - ( - tenant_id, - legal_entity_id, - book_id, - scope_start - timedelta(days=1), - ), - ).fetchone()[0] - return Decimal(amount) - - def _other_equity_movement_amount( - self, - connection: object, - tenant_id: UUID, - legal_entity_id: UUID, - book_id: UUID, - period_ids: list[UUID], - ) -> Decimal: - amount = connection.execute( - """ - SELECT COALESCE( - SUM(journal_entry_line.credit_amount - journal_entry_line.debit_amount), - 0 - ) - FROM accounting_core.journal_entry_line - JOIN accounting_core.general_journal - ON general_journal.tenant_account_id = journal_entry_line.tenant_account_id - AND general_journal.general_journal_id = journal_entry_line.general_journal_id - JOIN accounting_core.chart_account - ON chart_account.tenant_account_id = journal_entry_line.tenant_account_id - AND chart_account.chart_account_id = journal_entry_line.chart_account_id - WHERE general_journal.tenant_account_id = %s - AND general_journal.legal_entity_id = %s - AND general_journal.accounting_book_id = %s - AND general_journal.fiscal_period_id = ANY(%s) - AND chart_account.account_class_code = 'equity' - AND general_journal.journal_reference NOT LIKE %s - """, - ( - tenant_id, - legal_entity_id, - book_id, - period_ids, - _CLOSING_JOURNAL_PATTERN, - ), - ).fetchone()[0] - return Decimal(amount) - - def _equity_movement_line( - self, - account_role_code: str, - amount: Decimal, - account_class_code: str = "equity", - ) -> dict[str, object]: - debit_amount = Decimal("0") if amount >= 0 else -amount - credit_amount = amount if amount >= 0 else Decimal("0") - return { - "chart_account_code": "", - "account_role_code": account_role_code, - "account_class_code": account_class_code, - "debit_amount": debit_amount, - "credit_amount": credit_amount, - } - - def _load_cash_flow_lines( - self, - *, - legal_entity_reference: str, - accounting_book_reference: str, - period_code: str, - statement_scope_code: str, - ) -> list[dict[str, object]]: - income_lines = self._load_operational_income_lines( - legal_entity_reference=legal_entity_reference, - accounting_book_reference=accounting_book_reference, - period_code=period_code, - statement_scope_code=statement_scope_code, - ) - period_net_income = sum( - ( - Decimal(str(line["credit_amount"])) - Decimal(str(line["debit_amount"])) - for line in income_lines - ), - Decimal("0"), - ) - with self._session() as connection: - tenant_id = self._require_tenant(connection) - legal_entity_id = self._require_legal_entity( - connection, - tenant_id, - legal_entity_reference, - next_action="the financial-statement read", - ) - book_id = self._require_book_for_close( - connection, - tenant_id, - legal_entity_id, - accounting_book_reference, - next_action="the financial-statement read", - )[0] - period_ids = self._statement_period_ids( - connection, - tenant_id, - period_code, - statement_scope_code, - ) - scope_start = connection.execute( - """ - SELECT MIN(period_start_date) - FROM accounting_core.fiscal_period - WHERE tenant_account_id = %s - AND fiscal_period_id = ANY(%s) - """, - (tenant_id, period_ids), - ).fetchone()[0] - opening_cash = self._opening_cash_amount( - connection, - tenant_id, - legal_entity_id, - book_id, - scope_start, - ) - operating_working_capital = self._operating_working_capital_amount( - connection, - tenant_id, - legal_entity_id, - book_id, - period_ids, - ) - cash_from_financing = self._other_equity_movement_amount( - connection, - tenant_id, - legal_entity_id, - book_id, - period_ids, - ) - cash_from_investing = Decimal("0") - cash_from_operations = period_net_income + operating_working_capital - net_cash_change = cash_from_operations + cash_from_investing + cash_from_financing - closing_cash = opening_cash + net_cash_change - return [ - self._equity_movement_line("period_net_income", period_net_income, ""), - self._equity_movement_line( - "operating_working_capital", operating_working_capital, "" - ), - self._equity_movement_line("cash_from_operations", cash_from_operations, ""), - self._equity_movement_line("cash_from_investing", cash_from_investing, ""), - self._equity_movement_line("cash_from_financing", cash_from_financing, ""), - self._equity_movement_line("net_cash_change", net_cash_change, ""), - self._equity_movement_line("opening_cash", opening_cash, ""), - self._equity_movement_line("closing_cash", closing_cash, ""), - ] - - def _opening_cash_amount( - self, - connection: object, - tenant_id: UUID, - legal_entity_id: UUID, - book_id: UUID, - scope_start: date, - ) -> Decimal: - prior_snapshot = connection.execute( - """ - SELECT trial_balance_snapshot.trial_balance_snapshot_id - FROM accounting_core.fiscal_period - JOIN accounting_reporting.trial_balance_snapshot - ON trial_balance_snapshot.tenant_account_id = fiscal_period.tenant_account_id - AND trial_balance_snapshot.fiscal_period_id = fiscal_period.fiscal_period_id - AND trial_balance_snapshot.legal_entity_id = %s - AND trial_balance_snapshot.accounting_book_id = %s - WHERE fiscal_period.tenant_account_id = %s - AND fiscal_period.period_end_date < %s - AND fiscal_period.period_status_code = 'hard_closed' - ORDER BY fiscal_period.period_end_date DESC, fiscal_period.period_code DESC - LIMIT 1 - """, - (legal_entity_id, book_id, tenant_id, scope_start), - ).fetchone() - if prior_snapshot is not None: - amount = connection.execute( - """ - SELECT COALESCE( - SUM( - trial_balance_line.debit_total_amount - - trial_balance_line.credit_total_amount - ), - 0 - ) - FROM accounting_reporting.trial_balance_line - JOIN accounting_core.account_role_mapping - ON account_role_mapping.tenant_account_id = trial_balance_line.tenant_account_id - AND account_role_mapping.chart_account_id = trial_balance_line.chart_account_id - AND account_role_mapping.accounting_book_id = %s - AND account_role_mapping.account_role_code = 'cash_receipt' - AND account_role_mapping.valid_to IS NULL - WHERE trial_balance_line.tenant_account_id = %s - AND trial_balance_line.trial_balance_snapshot_id = %s - """, - (book_id, tenant_id, prior_snapshot[0]), - ).fetchone()[0] - return Decimal(amount) - amount = connection.execute( - """ - SELECT COALESCE( - SUM(journal_entry_line.debit_amount - journal_entry_line.credit_amount), - 0 - ) - FROM accounting_core.journal_entry_line - JOIN accounting_core.general_journal - ON general_journal.tenant_account_id = journal_entry_line.tenant_account_id - AND general_journal.general_journal_id = journal_entry_line.general_journal_id - JOIN accounting_core.account_role_mapping - ON account_role_mapping.tenant_account_id = journal_entry_line.tenant_account_id - AND account_role_mapping.chart_account_id = journal_entry_line.chart_account_id - AND account_role_mapping.accounting_book_id = %s - AND account_role_mapping.account_role_code = 'cash_receipt' - AND account_role_mapping.valid_to IS NULL - WHERE general_journal.tenant_account_id = %s - AND general_journal.legal_entity_id = %s - AND general_journal.accounting_book_id = %s - AND general_journal.accounting_date <= %s - """, - ( - book_id, - tenant_id, - legal_entity_id, - book_id, - scope_start - timedelta(days=1), - ), - ).fetchone()[0] - return Decimal(amount) - - def _operating_working_capital_amount( - self, - connection: object, - tenant_id: UUID, - legal_entity_id: UUID, - book_id: UUID, - period_ids: list[UUID], - ) -> Decimal: - amount = connection.execute( - """ - SELECT COALESCE( - SUM(journal_entry_line.credit_amount - journal_entry_line.debit_amount), - 0 - ) - FROM accounting_core.journal_entry_line - JOIN accounting_core.general_journal - ON general_journal.tenant_account_id = journal_entry_line.tenant_account_id - AND general_journal.general_journal_id = journal_entry_line.general_journal_id - JOIN accounting_core.chart_account - ON chart_account.tenant_account_id = journal_entry_line.tenant_account_id - AND chart_account.chart_account_id = journal_entry_line.chart_account_id - WHERE general_journal.tenant_account_id = %s - AND general_journal.legal_entity_id = %s - AND general_journal.accounting_book_id = %s - AND general_journal.fiscal_period_id = ANY(%s) - AND chart_account.account_class_code IN ('asset', 'liability') - AND chart_account.chart_account_id NOT IN ( - SELECT account_role_mapping.chart_account_id - FROM accounting_core.account_role_mapping - WHERE account_role_mapping.tenant_account_id = %s - AND account_role_mapping.accounting_book_id = %s - AND account_role_mapping.account_role_code = 'cash_receipt' - AND account_role_mapping.valid_to IS NULL - ) - AND general_journal.journal_reference NOT LIKE %s - """, - ( - tenant_id, - legal_entity_id, - book_id, - period_ids, - tenant_id, - book_id, - _CLOSING_JOURNAL_PATTERN, - ), - ).fetchone()[0] - return Decimal(amount) - - def _load_operational_income_lines( - self, - *, - legal_entity_reference: str, - accounting_book_reference: str, - period_code: str, - statement_scope_code: str = "", - ) -> list[dict[str, object]]: - with self._session() as connection: - tenant_id = self._require_tenant(connection) - legal_entity_id = self._require_legal_entity( - connection, - tenant_id, - legal_entity_reference, - next_action="the financial-statement read", - ) - book_id = self._require_book_for_close( - connection, - tenant_id, - legal_entity_id, - accounting_book_reference, - next_action="the financial-statement read", - )[0] - period_ids = self._statement_period_ids( - connection, - tenant_id, - period_code, - statement_scope_code, - ) - rows = connection.execute( - """ - SELECT chart_account.chart_account_code, - account_role_mapping.account_role_code, - chart_account.account_class_code, - SUM(journal_entry_line.debit_amount), - SUM(journal_entry_line.credit_amount) - FROM accounting_core.journal_entry_line - JOIN accounting_core.general_journal - ON general_journal.tenant_account_id = journal_entry_line.tenant_account_id - AND general_journal.general_journal_id = journal_entry_line.general_journal_id - JOIN accounting_core.chart_account - ON chart_account.tenant_account_id = journal_entry_line.tenant_account_id - AND chart_account.chart_account_id = journal_entry_line.chart_account_id - LEFT JOIN accounting_core.account_role_mapping - ON account_role_mapping.tenant_account_id = chart_account.tenant_account_id - AND account_role_mapping.chart_account_id = chart_account.chart_account_id - AND account_role_mapping.valid_to IS NULL - WHERE general_journal.tenant_account_id = %s - AND general_journal.legal_entity_id = %s - AND general_journal.accounting_book_id = %s - AND general_journal.fiscal_period_id = ANY(%s) - AND chart_account.account_class_code IN ('revenue', 'expense') - AND general_journal.journal_reference NOT LIKE %s - GROUP BY chart_account.chart_account_code, - account_role_mapping.account_role_code, - chart_account.account_class_code - ORDER BY chart_account.chart_account_code - """, - ( - tenant_id, - legal_entity_id, - book_id, - period_ids, - _CLOSING_JOURNAL_PATTERN, - ), - ).fetchall() - lines: list[dict[str, object]] = [] - for account_code, account_role_code, account_class_code, debit_total, credit_total in rows: - if account_role_code is None: - raise AccountingValidationError( - f"account_role_mapping is missing for chart account {account_code}. " - "Create the account_role_mapping row, then retry the financial-statement read." - ) - lines.append( - { - "chart_account_code": str(account_code), - "account_role_code": str(account_role_code), - "account_class_code": str(account_class_code), - "debit_amount": Decimal(debit_total), - "credit_amount": Decimal(credit_total), - } - ) - return lines - - def _statement_period_ids( - self, - connection: object, - tenant_id: UUID, - period_code: str, - statement_scope_code: str, - ) -> list[UUID]: - period_id, calendar_id, requested_code, period_start_date = connection.execute( - """ - SELECT fiscal_period_id, fiscal_calendar_id, period_code, period_start_date - FROM accounting_core.fiscal_period - WHERE tenant_account_id = %s AND period_code = %s - """, - (tenant_id, period_code), - ).fetchone() - if statement_scope_code in {"", "period"}: - return [period_id] - fiscal_year = _fiscal_year_identity(str(requested_code), period_start_date) - peers = connection.execute( - """ - SELECT fiscal_period_id, period_code, period_start_date - FROM accounting_core.fiscal_period - WHERE tenant_account_id = %s - AND fiscal_calendar_id = %s - AND period_start_date <= %s - ORDER BY period_start_date, period_code - """, - (tenant_id, calendar_id, period_start_date), - ).fetchall() - return [ - peer_id - for peer_id, peer_code, peer_start in peers - if _fiscal_year_identity(str(peer_code), peer_start) == fiscal_year - ] - - @contextmanager - def _consistent_read_session(self) -> Iterator[object]: - with self._session() as connection: - connection.execute("SET TRANSACTION ISOLATION LEVEL REPEATABLE READ") - self._active_connection = connection - try: - yield connection - finally: - self._active_connection = None - - @contextmanager - def _session(self) -> Iterator[object]: - if self._active_connection is not None: - yield self._active_connection - return - psycopg = _import_psycopg() - try: - connection = psycopg.connect(self._database_url) - except Exception as error: - raise AccountingValidationError( - "PostgreSQL is not reachable. Start PostgreSQL 18, set ACCOUNTING_DATABASE_URL " - "to that server, then retry posting." - ) from error - try: - connection.execute("SET lock_timeout = '5s'") - connection.execute("SET idle_in_transaction_session_timeout = '60s'") - yield connection - except Exception: - connection.rollback() - raise - else: - connection.commit() - finally: - connection.close() - - def _acquire_command_lock(self, connection: object, command_scope: str) -> None: - """Serialize one tenant command scope until the current transaction ends.""" - connection.execute( - "SELECT pg_advisory_xact_lock(hashtext(%s), hashtext(%s))", - (self._tenant_reference, command_scope), - ) - - def _require_tenant(self, connection: object) -> UUID: - row = connection.execute( - """ - SELECT tenant_account_id - FROM accounting_core.tenant_account - WHERE tenant_account_code = %s - """, - (self._tenant_reference,), - ).fetchone() - if row is None: - raise AccountingValidationError( - f"Tenant {self._tenant_reference} is not recorded. Create the tenant_account row, then retry posting." - ) - requested_tenant_id = row[0] - bound_tenant_id = connection.execute( - "SELECT accounting_core.current_tenant_account_id()" - ).fetchone()[0] - if bound_tenant_id is not None: - if bound_tenant_id != requested_tenant_id: - raise AccountingValidationError( - "the database session is not provisioned for this tenant. " - "Ask the platform operator to verify tenant provisioning, " - "then retry the request." - ) - return requested_tenant_id - rolsuper, rolbypassrls = connection.execute( - """ - SELECT rolsuper, rolbypassrls - FROM pg_catalog.pg_roles - WHERE rolname = session_user - """ - ).fetchone() - if rolsuper or rolbypassrls: - return requested_tenant_id - raise AccountingValidationError( - "this request cannot be authorized for the requested tenant. " - "Ask the platform operator to verify tenant provisioning, then retry." - ) - - def _require_legal_entity( - self, - connection: object, - tenant_id: UUID, - legal_entity_reference: str, - next_action: str = "posting", - ) -> UUID: - return self._load_legal_entity(connection, tenant_id, legal_entity_reference, next_action)[0] - - def _load_legal_entity( - self, - connection: object, - tenant_id: UUID, - legal_entity_reference: str, - next_action: str = "posting", - ) -> tuple[UUID, str]: - row = connection.execute( - """ - SELECT legal_entity_id, functional_currency_code - FROM accounting_core.legal_entity_record - WHERE tenant_account_id = %s AND legal_entity_code = %s AND valid_to IS NULL - """, - (tenant_id, legal_entity_reference), - ).fetchone() - if row is None: - raise AccountingValidationError( - f"Legal entity {legal_entity_reference} is not recorded for this tenant. " - f"Create the legal_entity_record row, then retry {next_action}." - ) - return row[0], row[1] - - def _require_book( - self, - connection: object, - tenant_id: UUID, - legal_entity_id: UUID, - book_role_code: str, - accounting_book_reference: str, - ) -> UUID: - row = connection.execute( - """ - SELECT accounting_book_id - FROM accounting_core.accounting_book - WHERE tenant_account_id = %s - AND legal_entity_id = %s - AND book_role_code = %s - AND valid_to IS NULL - """, - (tenant_id, legal_entity_id, book_role_code), - ).fetchone() - if row is None: - raise AccountingValidationError( - f"Accounting book {accounting_book_reference} is not recorded for this legal entity. " - "Create the accounting_book row, then retry posting." - ) - return row[0] - - def _require_open_book_period( - self, - connection: object, - tenant_id: UUID, - book_id: UUID, - accounting_date: date, - ) -> UUID: - """Require an open fiscal period for the selected accounting book.""" - return self._require_open_book_period_bounds( - connection, tenant_id, book_id, accounting_date - )[0] - - def _require_open_book_period_bounds( - self, - connection: object, - tenant_id: UUID, - book_id: UUID, - accounting_date: date, - ) -> tuple[UUID, date, date]: - """Return period identity and bounds when this accounting book is open.""" - row = connection.execute( - """ - SELECT fiscal_period.fiscal_period_id, - fiscal_period.period_code, - COALESCE( - accounting_book_period_control.period_status_code, - fiscal_period.period_status_code - ), - fiscal_period.period_start_date, - fiscal_period.period_end_date - FROM accounting_core.fiscal_period - LEFT JOIN accounting_core.accounting_book_period_control - ON accounting_book_period_control.tenant_account_id - = fiscal_period.tenant_account_id - AND accounting_book_period_control.fiscal_period_id - = fiscal_period.fiscal_period_id - AND accounting_book_period_control.accounting_book_id = %s - WHERE fiscal_period.tenant_account_id = %s - AND fiscal_period.period_start_date <= %s - AND fiscal_period.period_end_date >= %s - """, - (book_id, tenant_id, accounting_date, accounting_date), - ).fetchone() - if row is None: - raise AccountingValidationError( - f"No fiscal period covers accounting date {accounting_date.isoformat()}. " - "Create an open fiscal period on the tenant calendar, then retry posting." - ) - period_id, period_code = row[0], row[1] - row = connection.execute( - """ - SELECT fiscal_period.fiscal_period_id, - fiscal_period.period_code, - COALESCE( - accounting_book_period_control.period_status_code, - fiscal_period.period_status_code - ), - fiscal_period.period_start_date, - fiscal_period.period_end_date - FROM accounting_core.fiscal_period - LEFT JOIN accounting_core.accounting_book_period_control - ON accounting_book_period_control.tenant_account_id - = fiscal_period.tenant_account_id - AND accounting_book_period_control.fiscal_period_id - = fiscal_period.fiscal_period_id - AND accounting_book_period_control.accounting_book_id = %s - WHERE fiscal_period.tenant_account_id = %s - AND fiscal_period.fiscal_period_id = %s - """, - (book_id, tenant_id, period_id), - ).fetchone() - if row is None: - raise AccountingValidationError( - f"No fiscal period covers accounting date {accounting_date.isoformat()}. " - "Create an open fiscal period on the tenant calendar, then retry posting." - ) - if row[2] != "open": - locked_marker = " (period_closed)" if row[2] == "hard_closed" else "" - raise AccountingValidationError( - f"Fiscal period {row[1]} is {row[2]}{locked_marker}. " - "Open that period or post into an open period for this accounting book; " - "no journal was written." - ) - return row[0], row[3], row[4] - - def _require_adjusting_period( - self, connection: object, tenant_id: UUID, accounting_date: date - ) -> UUID: - return self._require_adjusting_period_bounds(connection, tenant_id, accounting_date)[0] - - def _require_adjusting_period_bounds( - self, connection: object, tenant_id: UUID, accounting_date: date - ) -> tuple[UUID, date, date]: - return self._require_period_bounds( - connection, - tenant_id, - accounting_date, - allowed_status_codes=frozenset({"open", "soft_closed"}), - next_action="Reverse into an open or soft-closed period", - ) - - def _require_period_bounds( - self, - connection: object, - tenant_id: UUID, - accounting_date: date, - *, - allowed_status_codes: frozenset[str], - next_action: str, - ) -> tuple[UUID, date, date]: - row = connection.execute( - """ - SELECT fiscal_period_id, period_code, period_status_code, - period_start_date, period_end_date - FROM accounting_core.fiscal_period - WHERE tenant_account_id = %s - AND period_start_date <= %s - AND period_end_date >= %s - """, - (tenant_id, accounting_date, accounting_date), - ).fetchone() - if row is None: - raise AccountingValidationError( - f"No fiscal period covers accounting date {accounting_date.isoformat()}. " - "Create an open fiscal period on the tenant calendar, then retry posting." - ) - period_id, period_code = row[0], row[1] - self._acquire_command_lock(connection, f"period:{period_code}") - row = connection.execute( - """ - SELECT fiscal_period_id, period_code, period_status_code, - period_start_date, period_end_date - FROM accounting_core.fiscal_period - WHERE tenant_account_id = %s - AND fiscal_period_id = %s - """, - (tenant_id, period_id), - ).fetchone() - if row is None: - raise AccountingValidationError( - f"No fiscal period covers accounting date {accounting_date.isoformat()}. " - "Create an open fiscal period on the tenant calendar, then retry posting." - ) - if row[2] not in allowed_status_codes: - locked_marker = " (period_closed)" if row[2] == "hard_closed" else "" - raise AccountingValidationError( - f"Fiscal period {row[1]} is {row[2]}{locked_marker}. {next_action}; " - "no journal was written." - ) - return row[0], row[3], row[4] - - def _resolve_accounting_policy( - self, connection: object, tenant_id: UUID, proposal: JournalProposal - ) -> AccountingPolicy: - if proposal.tenant_reference != self._tenant_reference: - raise AccountingValidationError( - "proposal tenant scope does not match this deployment. " - "Send the proposal to that tenant's accounting endpoint, then retry posting." - ) - legal_entity_id, functional_currency = self._load_legal_entity( - connection, tenant_id, proposal.legal_entity_reference - ) - book_id, book_name = self._require_book_for_role( - connection, - tenant_id, - legal_entity_id, - proposal.intended_book_role_code, - ) - _period_id, period_start, period_end = self._require_open_book_period_bounds( - connection, tenant_id, book_id, proposal.accounting_date - ) - mapping, policy_version, rule_version = self._load_role_mapping( - connection, tenant_id, book_id, proposal - ) - return AccountingPolicy( - tenant_reference=proposal.tenant_reference, - legal_entity_reference=proposal.legal_entity_reference, - accounting_book_reference=book_name, - intended_book_role_code=proposal.intended_book_role_code, - transaction_currency=proposal.transaction_currency, - functional_currency=functional_currency, - open_period_start=period_start, - open_period_end=period_end, - chart_account_mapping=mapping, - accounting_policy_version=policy_version, - posting_rule_version=rule_version, - ) - - def _require_book_for_role( - self, - connection: object, - tenant_id: UUID, - legal_entity_id: UUID, - book_role_code: str, - ) -> tuple[UUID, str]: - row = connection.execute( - """ - SELECT accounting_book_id, book_name - FROM accounting_core.accounting_book - WHERE tenant_account_id = %s - AND legal_entity_id = %s - AND book_role_code = %s - AND valid_to IS NULL - """, - (tenant_id, legal_entity_id, book_role_code), - ).fetchone() - if row is None: - raise AccountingValidationError( - f"Accounting book for role {book_role_code} is not recorded for this legal entity. " - "Create the accounting_book row, then retry posting." - ) - return row[0], row[1] - - def _load_role_mapping( - self, - connection: object, - tenant_id: UUID, - book_id: UUID, - proposal: JournalProposal, - ) -> tuple[dict[str, str], str, str]: - role_codes = tuple(dict.fromkeys(line.account_role_code for line in proposal.lines)) - as_of = datetime.combine( - proposal.accounting_date, datetime.min.time(), tzinfo=timezone.utc - ) - rows = connection.execute( - """ - SELECT account_role_mapping.account_role_code, - chart_account.chart_account_code, - account_role_mapping.accounting_policy_version, - account_role_mapping.posting_rule_version - FROM accounting_core.account_role_mapping - JOIN accounting_core.chart_account - ON chart_account.tenant_account_id = account_role_mapping.tenant_account_id - AND chart_account.chart_account_id = account_role_mapping.chart_account_id - WHERE account_role_mapping.tenant_account_id = %s - AND account_role_mapping.accounting_book_id = %s - AND account_role_mapping.account_role_code = ANY(%s) - AND account_role_mapping.valid_from <= %s - AND ( - account_role_mapping.valid_to IS NULL - OR account_role_mapping.valid_to > %s - ) - """, - (tenant_id, book_id, list(role_codes), as_of, as_of), - ).fetchall() - if not rows: - raise AccountingValidationError( - "No account_role_mapping is effective for this book and accounting date. " - "Create the account_role_mapping rows, then retry posting." - ) - seen_roles: dict[str, tuple[str, str, str]] = {} - for role_code, account_code, policy_version, rule_version in rows: - if role_code in seen_roles: - raise AccountingValidationError( - f"More than one effective account_role_mapping applies for role {role_code}. " - "Close the superseded mapping, then retry posting." - ) - seen_roles[role_code] = (account_code, policy_version, rule_version) - missing_roles = [role_code for role_code in role_codes if role_code not in seen_roles] - if missing_roles: - raise AccountingValidationError( - f"Account role {missing_roles[0]} is not mapped on this book. " - "Create the account_role_mapping row, then retry posting." - ) - versions = {(policy_version, rule_version) for _code, policy_version, rule_version in seen_roles.values()} - if len(versions) != 1: - raise AccountingValidationError( - "Account role mappings use more than one policy version. " - "Approve a single effective mapping set, then retry posting." - ) - policy_version, rule_version = next(iter(versions)) - return ( - {role_code: account_code for role_code, (account_code, _, _) in seen_roles.items()}, - policy_version, - rule_version, - ) - - def _require_book_for_close( - self, - connection: object, - tenant_id: UUID, - legal_entity_id: UUID, - accounting_book_reference: str, - next_action: str = "the close", - ) -> tuple[UUID, str]: - row = connection.execute( - """ - SELECT accounting_book_id, reporting_currency_code - FROM accounting_core.accounting_book - WHERE tenant_account_id = %s - AND legal_entity_id = %s - AND book_name = %s - AND valid_to IS NULL - """, - (tenant_id, legal_entity_id, accounting_book_reference), - ).fetchone() - if row is None: - raise AccountingValidationError( - f"Accounting book {accounting_book_reference} is not recorded for this legal entity. " - f"Create the accounting_book row, then retry {next_action}." - ) - return row[0], row[1] - - def _require_fiscal_period( - self, - connection: object, - tenant_id: UUID, - period_code: str, - next_action: str = "the close", - ) -> tuple[UUID, str, date]: - row = connection.execute( - """ - SELECT fiscal_period_id, period_status_code, period_end_date - FROM accounting_core.fiscal_period - WHERE tenant_account_id = %s AND period_code = %s - """, - (tenant_id, period_code), - ).fetchone() - if row is None: - raise AccountingValidationError( - f"Fiscal period {period_code} is not recorded for this tenant. " - f"Create the fiscal_period row, then retry {next_action}." - ) - return row[0], row[1], row[2] - - def _lock_book_period( - self, - connection: object, - tenant_id: UUID, - book_id: UUID, - period_code: str, - ) -> tuple[UUID, str, date]: - """Materialize and lock close state independently for one accounting book.""" - period_row = connection.execute( - """ - SELECT fiscal_period_id, period_status_code, period_closed_at - FROM accounting_core.fiscal_period - WHERE tenant_account_id = %s AND period_code = %s - """, - (tenant_id, period_code), - ).fetchone() - if period_row is None: - raise AccountingValidationError( - f"Fiscal period {period_code} is not recorded for this tenant. " - "Create the fiscal_period row, then retry the close." - ) - period_id = period_row[0] - connection.execute( - """ - INSERT INTO accounting_core.accounting_book_period_control ( - tenant_account_id, accounting_book_id, fiscal_period_id, - period_status_code, period_closed_at - ) - SELECT accounting_book.tenant_account_id, - accounting_book.accounting_book_id, - fiscal_period.fiscal_period_id, - fiscal_period.period_status_code, - fiscal_period.period_closed_at - FROM accounting_core.accounting_book - JOIN accounting_core.fiscal_period - ON fiscal_period.tenant_account_id = accounting_book.tenant_account_id - WHERE accounting_book.tenant_account_id = %s - AND accounting_book.valid_to IS NULL - AND fiscal_period.fiscal_period_id = %s - ON CONFLICT (tenant_account_id, accounting_book_id, fiscal_period_id) - DO NOTHING - """, - (tenant_id, period_id), - ) - row = connection.execute( - """ - SELECT fiscal_period.fiscal_period_id, - accounting_book_period_control.period_status_code, - fiscal_period.period_end_date - FROM accounting_core.fiscal_period - JOIN accounting_core.accounting_book_period_control - ON accounting_book_period_control.tenant_account_id - = fiscal_period.tenant_account_id - AND accounting_book_period_control.fiscal_period_id - = fiscal_period.fiscal_period_id - AND accounting_book_period_control.accounting_book_id = %s - WHERE fiscal_period.tenant_account_id = %s - AND fiscal_period.fiscal_period_id = %s - FOR UPDATE OF accounting_book_period_control - """, - (book_id, tenant_id, period_id), - ).fetchone() - if row is None: - raise AccountingValidationError( - f"Fiscal period {period_code} has no control row for this accounting book. " - "Repair the fiscal-period control data for this book, then retry the close." - ) - return row[0], row[1], row[2] - - def _load_book_period_state( - self, - connection: object, - tenant_id: UUID, - book_id: UUID, - period_code: str, - ) -> tuple[UUID, str, date, date] | None: - """Return the selected book's period state, falling back to legacy calendar state.""" - row = connection.execute( - """ - SELECT fiscal_period.fiscal_period_id, - COALESCE( - accounting_book_period_control.period_status_code, - fiscal_period.period_status_code - ), - fiscal_period.period_start_date, - fiscal_period.period_end_date - FROM accounting_core.fiscal_period - LEFT JOIN accounting_core.accounting_book_period_control - ON accounting_book_period_control.tenant_account_id - = fiscal_period.tenant_account_id - AND accounting_book_period_control.fiscal_period_id - = fiscal_period.fiscal_period_id - AND accounting_book_period_control.accounting_book_id = %s - WHERE fiscal_period.tenant_account_id = %s - AND fiscal_period.period_code = %s - """, - (book_id, tenant_id, period_code), - ).fetchone() - if row is None: - return None - return row[0], row[1], row[2], row[3] - - def _load_period_state( - self, connection: object, tenant_id: UUID, period_code: str - ) -> tuple[UUID, str, date, date] | None: - row = connection.execute( - """ - SELECT fiscal_period_id, period_status_code, period_start_date, period_end_date - FROM accounting_core.fiscal_period - WHERE tenant_account_id = %s AND period_code = %s - """, - (tenant_id, period_code), - ).fetchone() - if row is None: - return None - return row[0], row[1], row[2], row[3] - - def _require_tenant_calendar(self, connection: object, tenant_id: UUID) -> UUID: - row = connection.execute( - """ - SELECT fiscal_calendar_id - FROM accounting_core.fiscal_calendar - WHERE tenant_account_id = %s - ORDER BY calendar_code - LIMIT 1 - """, - (tenant_id,), - ).fetchone() - if row is None: - raise AccountingValidationError( - "No fiscal_calendar is recorded for this tenant. " - "Create the fiscal_calendar row, then retry the period open." - ) - return row[0] - - def _period_open_document( - self, - legal_entity_reference: str, - period_code: str, - period_start_date: date, - period_end_date: date, - *, - replayed: bool, - ) -> dict[str, object]: - return { - "tenant_reference": self._tenant_reference, - "legal_entity_reference": legal_entity_reference, - "fiscal_period_reference": f"urn:cwl:accounting:fiscal_period:{period_code}", - "period_code": period_code, - "period_status_code": "open", - "period_start_date": period_start_date.isoformat(), - "period_end_date": period_end_date.isoformat(), - "replayed": replayed, - } - - def _aggregate_trial_balance( - self, - connection: object, - tenant_id: UUID, - legal_entity_id: UUID, - book_id: UUID, - through_date: date, - ) -> tuple[tuple[UUID, str, Decimal, Decimal], ...]: - rows = connection.execute( - """ - SELECT chart_account.chart_account_id, - chart_account.chart_account_code, - SUM(journal_entry_line.debit_amount), - SUM(journal_entry_line.credit_amount) - FROM accounting_core.journal_entry_line - JOIN accounting_core.general_journal - ON general_journal.tenant_account_id = journal_entry_line.tenant_account_id - AND general_journal.general_journal_id = journal_entry_line.general_journal_id - JOIN accounting_core.chart_account - ON chart_account.tenant_account_id = journal_entry_line.tenant_account_id - AND chart_account.chart_account_id = journal_entry_line.chart_account_id - WHERE general_journal.tenant_account_id = %s - AND general_journal.legal_entity_id = %s - AND general_journal.accounting_book_id = %s - AND general_journal.accounting_date <= %s - GROUP BY chart_account.chart_account_id, chart_account.chart_account_code - ORDER BY chart_account.chart_account_code - """, - (tenant_id, legal_entity_id, book_id, through_date), - ).fetchall() - return tuple( - (row[0], row[1], Decimal(row[2]), Decimal(row[3])) for row in rows - ) - - def _aggregate_worksheet_trial_balance( - self, - connection: object, - tenant_id: UUID, - legal_entity_id: UUID, - book_id: UUID, - through_date: date, - *, - exclude_adjusting: bool, - ) -> tuple[tuple[UUID, str, Decimal, Decimal], ...]: - rows = connection.execute( - """ - SELECT chart_account.chart_account_id, - chart_account.chart_account_code, - SUM(journal_entry_line.debit_amount), - SUM(journal_entry_line.credit_amount) - FROM accounting_core.journal_entry_line - JOIN accounting_core.general_journal - ON general_journal.tenant_account_id = journal_entry_line.tenant_account_id - AND general_journal.general_journal_id = journal_entry_line.general_journal_id - JOIN accounting_core.chart_account - ON chart_account.tenant_account_id = journal_entry_line.tenant_account_id - AND chart_account.chart_account_id = journal_entry_line.chart_account_id - WHERE general_journal.tenant_account_id = %s - AND general_journal.legal_entity_id = %s - AND general_journal.accounting_book_id = %s - AND general_journal.accounting_date <= %s - AND general_journal.journal_reference NOT LIKE %s - AND ( - %s - OR journal_entry_line.account_role_code IS DISTINCT FROM %s - ) - GROUP BY chart_account.chart_account_id, chart_account.chart_account_code - ORDER BY chart_account.chart_account_code - """, - ( - tenant_id, - legal_entity_id, - book_id, - through_date, - _CLOSING_JOURNAL_PATTERN, - not exclude_adjusting, - "adjusting", - ), - ).fetchall() - return tuple( - (row[0], row[1], Decimal(row[2]), Decimal(row[3])) for row in rows - ) - - def _count_source_journals( - self, - connection: object, - tenant_id: UUID, - legal_entity_id: UUID, - book_id: UUID, - through_date: date, - ) -> int: - return int( - connection.execute( - """ - SELECT COUNT(*) - FROM accounting_core.general_journal - WHERE tenant_account_id = %s - AND legal_entity_id = %s - AND accounting_book_id = %s - AND accounting_date <= %s - """, - (tenant_id, legal_entity_id, book_id, through_date), - ).fetchone()[0] - ) - - def _latest_close_snapshot( - self, - connection: object, - tenant_id: UUID, - legal_entity_id: UUID, - book_id: UUID, - period_id: UUID, - ) -> tuple[UUID, datetime, int, str, str] | None: - row = connection.execute( - """ - SELECT trial_balance_snapshot_id, snapshot_generated_at, - source_journal_count, source_payload_hash, close_idempotency_key - FROM accounting_reporting.trial_balance_snapshot - WHERE tenant_account_id = %s - AND legal_entity_id = %s - AND accounting_book_id = %s - AND fiscal_period_id = %s - ORDER BY snapshot_generated_at DESC - LIMIT 1 - """, - (tenant_id, legal_entity_id, book_id, period_id), - ).fetchone() - if row is None: - return None - return row[0], row[1], int(row[2]), row[3], str(row[4]) - - def _load_snapshot_balance_lines( - self, connection: object, tenant_id: UUID, snapshot_id: UUID - ) -> tuple[tuple[str, Decimal, Decimal], ...]: - rows = connection.execute( - """ - SELECT chart_account.chart_account_code, - trial_balance_line.debit_total_amount, - trial_balance_line.credit_total_amount - FROM accounting_reporting.trial_balance_line - JOIN accounting_core.chart_account - ON chart_account.tenant_account_id = trial_balance_line.tenant_account_id - AND chart_account.chart_account_id = trial_balance_line.chart_account_id - WHERE trial_balance_line.tenant_account_id = %s - AND trial_balance_line.trial_balance_snapshot_id = %s - ORDER BY chart_account.chart_account_code - """, - (tenant_id, snapshot_id), - ).fetchall() - return tuple((row[0], Decimal(row[1]), Decimal(row[2])) for row in rows) - - def _replay_close_receipt( - self, - connection: object, - *, - tenant_id: UUID, - legal_entity_id: UUID, - book_id: UUID, - period_id: UUID, - period_code: str, - current_status: str, - legal_entity_reference: str, - accounting_book_reference: str, - idempotency_key: str, - ) -> PeriodCloseReceipt: - snapshot = self._latest_close_snapshot( - connection, tenant_id, legal_entity_id, book_id, period_id - ) - if snapshot is None: - raise AccountingValidationError( - f"Fiscal period {period_code} is {current_status} without a trial-balance snapshot. " - "Restore the trial_balance_snapshot for this book from the journal population, " - "then retry the close." - ) - stored_close_key = snapshot[4] - if stored_close_key != idempotency_key: - raise AccountingValidationError( - f"Fiscal period {period_code} is hard_closed (period_closed). " - "Replay the original period-close idempotency key; " - "a second close of a locked period is rejected." - ) - return self._close_receipt_from_snapshot( - snapshot, - period_code=period_code, - period_status_code=current_status, - legal_entity_reference=legal_entity_reference, - accounting_book_reference=accounting_book_reference, - replayed=True, - ) - - def _replay_soft_close_receipt( - self, - connection: object, - *, - tenant_id: UUID, - legal_entity_id: UUID, - book_id: UUID, - period_id: UUID, - period_code: str, - period_end_date: date, - snapshot_currency_code: str, - legal_entity_reference: str, - accounting_book_reference: str, - idempotency_key: str, - ) -> PeriodCloseReceipt: - ( - period_closed_at, - stored_idempotency_key, - source_journal_count, - source_payload_hash, - evidence_complete, - ) = connection.execute( - """ - SELECT COALESCE(period_closed_at, clock_timestamp()), - soft_close_idempotency_key, - soft_close_source_journal_count, - soft_close_source_payload_hash, - ( - soft_close_idempotency_key IS NOT NULL - AND soft_close_source_journal_count IS NOT NULL - AND soft_close_source_payload_hash IS NOT NULL - ) - FROM accounting_core.accounting_book_period_control - WHERE tenant_account_id = %s - AND accounting_book_id = %s - AND fiscal_period_id = %s - """, - (tenant_id, book_id, period_id), - ).fetchone() - if not evidence_complete: - raise AccountingValidationError( - f"Fiscal period {period_code} is soft_closed without durable close-command evidence. " - "Restore the original evidence through an audited migration, then retry; " - "do not reconstruct it from later ledger state." - ) - if stored_idempotency_key != idempotency_key: - raise IdempotencyConflictError( - "period-close idempotency key was already used by the soft-close command. Replay the original close idempotency key, then retry the close." - ) - return PeriodCloseReceipt( - tenant_reference=self._tenant_reference, - legal_entity_reference=legal_entity_reference, - accounting_book_reference=accounting_book_reference, - period_code=period_code, - period_status_code="soft_closed", - snapshot_record_id="", - snapshot_generated_at=period_closed_at, - source_journal_count=source_journal_count, - source_payload_hash=source_payload_hash, - replayed=True, - ) - - def _persist_soft_close( - self, - connection: object, - *, - tenant_id: UUID, - legal_entity_id: UUID, - book_id: UUID, - period_id: UUID, - period_code: str, - period_end_date: date, - snapshot_currency_code: str, - legal_entity_reference: str, - accounting_book_reference: str, - idempotency_key: str, - ) -> PeriodCloseReceipt: - _lines, source_journal_count, source_payload_hash = self._live_close_source( - connection, - tenant_id=tenant_id, - legal_entity_id=legal_entity_id, - book_id=book_id, - period_end_date=period_end_date, - period_code=period_code, - snapshot_currency_code=snapshot_currency_code, - legal_entity_reference=legal_entity_reference, - accounting_book_reference=accounting_book_reference, - ) - period_closed_at = self._set_book_period_closed( - connection, tenant_id, book_id, period_id, "soft_closed" - ) - connection.execute( - """ - UPDATE accounting_core.accounting_book_period_control - SET soft_close_idempotency_key = %s, - soft_close_source_payload_hash = %s, - soft_close_source_journal_count = %s - WHERE tenant_account_id = %s - AND accounting_book_id = %s - AND fiscal_period_id = %s - """, - ( - idempotency_key, - source_payload_hash, - source_journal_count, - tenant_id, - book_id, - period_id, - ), - ) - self._insert_period_close_event( - connection, - tenant_id, - period_code, - accounting_book_reference, - None, - source_payload_hash, - ) - return PeriodCloseReceipt( - tenant_reference=self._tenant_reference, - legal_entity_reference=legal_entity_reference, - accounting_book_reference=accounting_book_reference, - period_code=period_code, - period_status_code="soft_closed", - snapshot_record_id="", - snapshot_generated_at=period_closed_at, - source_journal_count=source_journal_count, - source_payload_hash=source_payload_hash, - replayed=False, - ) - - def _live_close_source( - self, - connection: object, - *, - tenant_id: UUID, - legal_entity_id: UUID, - book_id: UUID, - period_end_date: date, - period_code: str, - snapshot_currency_code: str, - legal_entity_reference: str, - accounting_book_reference: str, - ) -> tuple[tuple[tuple[UUID, str, Decimal, Decimal], ...], int, str]: - lines = self._aggregate_trial_balance( - connection, tenant_id, legal_entity_id, book_id, period_end_date - ) - source_journal_count = self._count_source_journals( - connection, tenant_id, legal_entity_id, book_id, period_end_date - ) - source_payload_hash = _canonical_snapshot_hash( - tenant_reference=self._tenant_reference, - legal_entity_reference=legal_entity_reference, - accounting_book_reference=accounting_book_reference, - period_code=period_code, - snapshot_currency_code=snapshot_currency_code, - source_journal_count=source_journal_count, - lines=lines, - ) - return lines, source_journal_count, source_payload_hash - - def _persist_period_close( - self, - connection: object, - *, - tenant_id: UUID, - legal_entity_id: UUID, - book_id: UUID, - period_id: UUID, - period_code: str, - period_end_date: date, - period_status_code: str, - snapshot_currency_code: str, - legal_entity_reference: str, - accounting_book_reference: str, - idempotency_key: str, - ) -> PeriodCloseReceipt: - self._post_closing_journal( - connection, - tenant_id=tenant_id, - legal_entity_id=legal_entity_id, - book_id=book_id, - period_id=period_id, - period_code=period_code, - period_end_date=period_end_date, - snapshot_currency_code=snapshot_currency_code, - legal_entity_reference=legal_entity_reference, - accounting_book_reference=accounting_book_reference, - ) - lines, source_journal_count, source_payload_hash = self._live_close_source( - connection, - tenant_id=tenant_id, - legal_entity_id=legal_entity_id, - book_id=book_id, - period_end_date=period_end_date, - period_code=period_code, - snapshot_currency_code=snapshot_currency_code, - legal_entity_reference=legal_entity_reference, - accounting_book_reference=accounting_book_reference, - ) - snapshot_id, snapshot_generated_at = connection.execute( - """ - INSERT INTO accounting_reporting.trial_balance_snapshot ( - tenant_account_id, legal_entity_id, accounting_book_id, fiscal_period_id, - snapshot_currency_code, source_journal_count, source_payload_hash, - close_idempotency_key - ) - VALUES (%s, %s, %s, %s, %s, %s, %s, %s) - RETURNING trial_balance_snapshot_id, snapshot_generated_at - """, - ( - tenant_id, - legal_entity_id, - book_id, - period_id, - snapshot_currency_code, - source_journal_count, - source_payload_hash, - idempotency_key, - ), - ).fetchone() - for account_id, _account_code, debit_total, credit_total in lines: - connection.execute( - """ - INSERT INTO accounting_reporting.trial_balance_line ( - tenant_account_id, trial_balance_snapshot_id, chart_account_id, - debit_total_amount, credit_total_amount, net_balance_amount - ) - VALUES (%s, %s, %s, %s, %s, %s) - """, - ( - tenant_id, - snapshot_id, - account_id, - debit_total, - credit_total, - debit_total - credit_total, - ), - ) - self._set_book_period_closed( - connection, tenant_id, book_id, period_id, period_status_code - ) - self._insert_period_close_event( - connection, - tenant_id, - period_code, - accounting_book_reference, - snapshot_id, - source_payload_hash, - ) - return PeriodCloseReceipt( - tenant_reference=self._tenant_reference, - legal_entity_reference=legal_entity_reference, - accounting_book_reference=accounting_book_reference, - period_code=period_code, - period_status_code=period_status_code, - snapshot_record_id=str(snapshot_id), - snapshot_generated_at=snapshot_generated_at, - source_journal_count=source_journal_count, - source_payload_hash=source_payload_hash, - replayed=False, - ) - - def _post_closing_journal( - self, - connection: object, - *, - tenant_id: UUID, - legal_entity_id: UUID, - book_id: UUID, - period_id: UUID, - period_code: str, - period_end_date: date, - snapshot_currency_code: str, - legal_entity_reference: str, - accounting_book_reference: str, - ) -> None: - closing_reference = ( - "urn:cwl:accounting:general_journal:period_closing:" - f"{period_code}:{accounting_book_reference}" - ) - income_rows = connection.execute( - """ - SELECT chart_account.chart_account_code, - account_role_mapping.account_role_code, - SUM(journal_entry_line.debit_amount), - SUM(journal_entry_line.credit_amount) - FROM accounting_core.journal_entry_line - JOIN accounting_core.general_journal - ON general_journal.tenant_account_id = journal_entry_line.tenant_account_id - AND general_journal.general_journal_id = journal_entry_line.general_journal_id - JOIN accounting_core.chart_account - ON chart_account.tenant_account_id = journal_entry_line.tenant_account_id - AND chart_account.chart_account_id = journal_entry_line.chart_account_id - JOIN accounting_core.account_role_mapping - ON account_role_mapping.tenant_account_id = chart_account.tenant_account_id - AND account_role_mapping.chart_account_id = chart_account.chart_account_id - AND account_role_mapping.valid_to IS NULL - WHERE general_journal.tenant_account_id = %s - AND general_journal.legal_entity_id = %s - AND general_journal.accounting_book_id = %s - AND general_journal.accounting_date <= %s - AND account_role_mapping.account_role_code IN ( - 'usage_revenue', 'write_off_expense' - ) - GROUP BY chart_account.chart_account_code, account_role_mapping.account_role_code - ORDER BY chart_account.chart_account_code - """, - (tenant_id, legal_entity_id, book_id, period_end_date), - ).fetchall() - closing_lines: list[PostedJournalLine] = [] - retained_earnings_amount = Decimal("0") - for account_code, role_code, debit_total, credit_total in income_rows: - net_amount = Decimal(credit_total) - Decimal(debit_total) - if net_amount == 0: - continue - line_number = len(closing_lines) + 1 - if net_amount > 0: - closing_lines.append( - PostedJournalLine( - line_number=line_number, - chart_account_code=str(account_code), - account_role_code=str(role_code), - debit_amount=net_amount, - credit_amount=Decimal("0"), - ) - ) - else: - closing_lines.append( - PostedJournalLine( - line_number=line_number, - chart_account_code=str(account_code), - account_role_code=str(role_code), - debit_amount=Decimal("0"), - credit_amount=-net_amount, - ) - ) - retained_earnings_amount += net_amount - if not closing_lines: - return - policy_version, rule_version = self._require_retained_earnings_mapping( - connection, tenant_id, book_id - ) - if retained_earnings_amount > 0: - closing_lines.append( - PostedJournalLine( - line_number=len(closing_lines) + 1, - chart_account_code="310100", - account_role_code="retained_earnings", - debit_amount=Decimal("0"), - credit_amount=retained_earnings_amount, - ) - ) - elif retained_earnings_amount < 0: - closing_lines.append( - PostedJournalLine( - line_number=len(closing_lines) + 1, - chart_account_code="310100", - account_role_code="retained_earnings", - debit_amount=-retained_earnings_amount, - credit_amount=Decimal("0"), - ) - ) - source_payload_hash = _canonical_closing_hash( - tenant_reference=self._tenant_reference, - legal_entity_reference=legal_entity_reference, - accounting_book_reference=accounting_book_reference, - period_code=period_code, - lines=tuple(closing_lines), - ) - proposal_record_id = connection.execute( - """ - INSERT INTO accounting_integration.journal_proposal_record ( - tenant_account_id, external_proposal_id, proposal_contract_version, - idempotency_key, source_payload_hash, proposal_status_code, processed_at - ) - VALUES (%s, uuidv7(), 1, %s, %s, 'posted', clock_timestamp()) - RETURNING proposal_record_id - """, - ( - tenant_id, - f"{self._tenant_reference}:period_closing:{period_code}:" - f"{accounting_book_reference}", - source_payload_hash, - ), - ).fetchone()[0] - policy = AccountingPolicy( - tenant_reference=self._tenant_reference, - legal_entity_reference=legal_entity_reference, - accounting_book_reference=accounting_book_reference, - intended_book_role_code=self._book_role_code(connection, tenant_id, book_id), - transaction_currency=snapshot_currency_code, - functional_currency=snapshot_currency_code, - open_period_start=period_end_date, - open_period_end=period_end_date, - chart_account_mapping={"retained_earnings": "310100"}, - accounting_policy_version=policy_version, - posting_rule_version=rule_version, - ) - self._insert_journal( - connection, - tenant_id=tenant_id, - legal_entity_id=legal_entity_id, - book_id=book_id, - period_id=period_id, - journal_reference=closing_reference, - proposal=_ClosingProposal( - source_payload_hash=source_payload_hash, - transaction_currency=snapshot_currency_code, - transaction_date=period_end_date, - accounting_date=period_end_date, - source_event_references=(), - ), - policy=policy, - proposal_record_id=proposal_record_id, - lines=tuple(closing_lines), - ) - - def _require_retained_earnings_mapping( - self, connection: object, tenant_id: UUID, book_id: UUID - ) -> tuple[str, str]: + period_id = period_row[0] row = connection.execute( """ - SELECT account_role_mapping.accounting_policy_version, - account_role_mapping.posting_rule_version - FROM accounting_core.account_role_mapping - JOIN accounting_core.chart_account - ON chart_account.tenant_account_id = account_role_mapping.tenant_account_id - AND chart_account.chart_account_id = account_role_mapping.chart_account_id - WHERE account_role_mapping.tenant_account_id = %s - AND account_role_mapping.accounting_book_id = %s - AND account_role_mapping.account_role_code = 'retained_earnings' - AND chart_account.chart_account_code = '310100' - AND account_role_mapping.valid_to IS NULL - AND chart_account.valid_to IS NULL + SELECT fiscal_period.fiscal_period_id, + accounting_book_period_control.period_status_code, + fiscal_period.period_end_date + FROM accounting_core.fiscal_period + JOIN accounting_core.accounting_book_period_control + ON accounting_book_period_control.tenant_account_id + = fiscal_period.tenant_account_id + AND accounting_book_period_control.fiscal_period_id + = fiscal_period.fiscal_period_id + AND accounting_book_period_control.accounting_book_id = %s + WHERE fiscal_period.tenant_account_id = %s + AND fiscal_period.fiscal_period_id = %s + FOR UPDATE OF accounting_book_period_control """, - (tenant_id, book_id), + (book_id, tenant_id, period_id), ).fetchone() if row is None: raise AccountingValidationError( - "account_role_mapping is missing for retained_earnings → 310100. " - "Create the retained_earnings mapping and chart_account 310100, " - "then retry the close." + f"Fiscal period {period_code} has no control row for this accounting book. " + "Repair the fiscal-period control data for this book, then retry the close." ) - return str(row[0]), str(row[1]) - - def _book_role_code( - self, connection: object, tenant_id: UUID, book_id: UUID - ) -> str: - return str( - connection.execute( - """ - SELECT book_role_code - FROM accounting_core.accounting_book - WHERE tenant_account_id = %s AND accounting_book_id = %s - """, - (tenant_id, book_id), - ).fetchone()[0] - ) + return row[0], row[1], row[2] - def _set_book_period_closed( + def _load_book_period_state( self, connection: object, tenant_id: UUID, book_id: UUID, - period_id: UUID, - period_status_code: str, - ) -> datetime: - """Close one book and retain aggregate calendar status only for compatibility.""" - period_closed_at = connection.execute( - """ - UPDATE accounting_core.accounting_book_period_control - SET period_status_code = %s, - period_closed_at = clock_timestamp() - WHERE tenant_account_id = %s - AND accounting_book_id = %s - AND fiscal_period_id = %s - RETURNING period_closed_at - """, - (period_status_code, tenant_id, book_id, period_id), - ).fetchone()[0] - aggregate_row = connection.execute( - """ - SELECT CASE - WHEN bool_and( - accounting_book_period_control.period_status_code = 'hard_closed' - ) THEN 'hard_closed' - WHEN bool_and( - accounting_book_period_control.period_status_code <> 'open' - ) THEN 'soft_closed' - ELSE 'open' - END, - max(accounting_book_period_control.period_closed_at) - FROM accounting_core.accounting_book_period_control - JOIN accounting_core.accounting_book - ON accounting_book.tenant_account_id - = accounting_book_period_control.tenant_account_id - AND accounting_book.accounting_book_id - = accounting_book_period_control.accounting_book_id - WHERE accounting_book_period_control.tenant_account_id = %s - AND accounting_book_period_control.fiscal_period_id = %s - AND accounting_book.valid_to IS NULL - """, - (tenant_id, period_id), - ).fetchone() - aggregate_status = aggregate_row[0] or "open" - aggregate_closed_at = None if aggregate_status == "open" else aggregate_row[1] - connection.execute( - """ - UPDATE accounting_core.fiscal_period - SET period_status_code = %s, - period_closed_at = %s - WHERE tenant_account_id = %s AND fiscal_period_id = %s - """, - (aggregate_status, aggregate_closed_at, tenant_id, period_id), - ) - return period_closed_at - - def _insert_period_close_event( - self, - connection: object, - tenant_id: UUID, - period_code: str, - accounting_book_reference: str, - snapshot_id: UUID | None, - payload_hash: str, - ) -> None: - payload_reference = ( - f"urn:cwl:accounting:trial_balance_snapshot:{snapshot_id}" - if snapshot_id is not None - else f"urn:cwl:accounting:fiscal_period:{period_code}" - ) - connection.execute( - """ - INSERT INTO accounting_integration.outbox_event ( - tenant_account_id, event_type_code, aggregate_reference, - payload_reference, payload_hash - ) - VALUES (%s, 'period_close', %s, %s, %s) - """, - ( - tenant_id, - f"{accounting_book_reference}:fiscal_period:{period_code}", - payload_reference, - payload_hash, - ), - ) - - def _close_receipt_from_snapshot( - self, - snapshot: tuple[UUID, datetime, int, str, str], - *, period_code: str, - period_status_code: str, - legal_entity_reference: str, - accounting_book_reference: str, - replayed: bool, - ) -> PeriodCloseReceipt: - snapshot_id, snapshot_generated_at, source_journal_count, source_payload_hash, _close_key = ( - snapshot - ) - return PeriodCloseReceipt( - tenant_reference=self._tenant_reference, - legal_entity_reference=legal_entity_reference, - accounting_book_reference=accounting_book_reference, - period_code=period_code, - period_status_code=period_status_code, - snapshot_record_id=str(snapshot_id), - snapshot_generated_at=snapshot_generated_at, - source_journal_count=source_journal_count, - source_payload_hash=source_payload_hash, - replayed=replayed, - ) - - def _insert_journal( - self, - connection: object, - *, - tenant_id: UUID, - legal_entity_id: UUID, - book_id: UUID, - period_id: UUID, - journal_reference: str, - proposal: JournalProposal | _ReversalProposal | _ClosingProposal | _AdjustingProposal, - policy: AccountingPolicy, - proposal_record_id: UUID, - lines: tuple[PostedJournalLine, ...], - ) -> UUID: - connection.execute( - "SELECT set_config('accounting_core.journal_write_role', %s, true)", - (_journal_write_role(proposal),), - ) - journal_id = connection.execute( - """ - INSERT INTO accounting_core.general_journal ( - tenant_account_id, legal_entity_id, accounting_book_id, fiscal_period_id, - journal_reference, journal_status_code, transaction_currency_code, - functional_currency_code, transaction_date, accounting_date, - source_proposal_record_id, accounting_policy_version, posting_rule_version - ) - VALUES (%s, %s, %s, %s, %s, 'posted', %s, %s, %s, %s, %s, %s, %s) - RETURNING general_journal_id - """, - ( - tenant_id, - legal_entity_id, - book_id, - period_id, - journal_reference, - proposal.transaction_currency, - policy.functional_currency, - proposal.transaction_date, - proposal.accounting_date, - proposal_record_id, - policy.accounting_policy_version, - policy.posting_rule_version, - ), - ).fetchone()[0] - for line in lines: - chart_account_id = connection.execute( - """ - SELECT chart_account_id - FROM accounting_core.chart_account - WHERE tenant_account_id = %s - AND accounting_book_id = %s - AND chart_account_code = %s - AND valid_to IS NULL - """, - (tenant_id, book_id, line.chart_account_code), - ).fetchone() - if chart_account_id is None: - raise AccountingValidationError( - f"Chart account {line.chart_account_code} is not recorded on this book. " - "Create the chart_account row, then retry posting." - ) - connection.execute( - """ - INSERT INTO accounting_core.journal_entry_line ( - tenant_account_id, general_journal_id, line_number, chart_account_id, - account_role_code, debit_amount, credit_amount - ) - VALUES (%s, %s, %s, %s, %s, %s, %s) - """, - ( - tenant_id, - journal_id, - line.line_number, - chart_account_id[0], - line.account_role_code, - line.debit_amount, - line.credit_amount, - ), - ) - for reference in proposal.source_event_references: - connection.execute( - """ - INSERT INTO accounting_core.journal_source_reference ( - tenant_account_id, general_journal_id, source_reference, source_payload_hash - ) - VALUES (%s, %s, %s, %s) - """, - (tenant_id, journal_id, reference, proposal.source_payload_hash), - ) - return journal_id - - def _insert_receipt( - self, - connection: object, - tenant_id: UUID, - proposal_record_id: UUID, - journal_id: UUID, - receipt: PostingReceipt, - ) -> None: - connection.execute( - """ - INSERT INTO accounting_integration.posting_receipt ( - tenant_account_id, proposal_record_id, general_journal_id, - receipt_status_code, receipt_payload_hash - ) - VALUES (%s, %s, %s, %s, %s) - """, - ( - tenant_id, - proposal_record_id, - journal_id, - receipt.posting_status_code, - _canonical_receipt_hash(receipt), - ), - ) - - def _insert_outbox( - self, - connection: object, - tenant_id: UUID, - event_type_code: str, - aggregate_reference: str, - payload_reference: str, - receipt: PostingReceipt, - ) -> None: - connection.execute( - """ - INSERT INTO accounting_integration.outbox_event ( - tenant_account_id, event_type_code, aggregate_reference, - payload_reference, payload_hash - ) - VALUES (%s, %s, %s, %s, %s) - """, - ( - tenant_id, - event_type_code, - aggregate_reference, - payload_reference, - _canonical_receipt_hash(receipt), - ), - ) - - def _receipt_for_idempotency_key( - self, connection: object, tenant_id: UUID, proposal: JournalProposal - ) -> PostingReceipt: - return PostingReceipt( - receipt_reference=f"urn:cwl:accounting:posting_receipt:{proposal.proposal_id}", - journal_reference=f"urn:cwl:accounting:general_journal:{proposal.proposal_id}", - posting_status_code="posted", - source_proposal_id=proposal.proposal_id, - source_payload_hash=proposal.source_payload_hash, - tenant_reference=proposal.tenant_reference, - legal_entity_reference=proposal.legal_entity_reference, - accounting_book_reference=self._book_name_for_proposal( - connection, tenant_id, proposal.idempotency_key - ), - accounting_policy_version=self._policy_version_for_proposal( - connection, tenant_id, proposal.idempotency_key - )[0], - posting_rule_version=self._policy_version_for_proposal( - connection, tenant_id, proposal.idempotency_key - )[1], - line_count=self._line_count_for_proposal( - connection, tenant_id, proposal.idempotency_key - ), - ) - - def _receipt_for_journal( - self, connection: object, tenant_id: UUID, journal_reference: str - ) -> PostingReceipt: - row = connection.execute( - """ - SELECT general_journal.journal_reference, - journal_proposal_record.source_payload_hash, - journal_proposal_record.external_proposal_id, - general_journal.accounting_policy_version, - general_journal.posting_rule_version, - accounting_book.book_name, - legal_entity_record.legal_entity_code, - ( - SELECT COUNT(*) - FROM accounting_core.journal_entry_line - WHERE tenant_account_id = general_journal.tenant_account_id - AND general_journal_id = general_journal.general_journal_id - ), - original_journal.journal_reference - FROM accounting_core.general_journal - JOIN accounting_integration.journal_proposal_record - ON journal_proposal_record.tenant_account_id = general_journal.tenant_account_id - AND journal_proposal_record.proposal_record_id = general_journal.source_proposal_record_id - JOIN accounting_core.accounting_book - ON accounting_book.tenant_account_id = general_journal.tenant_account_id - AND accounting_book.accounting_book_id = general_journal.accounting_book_id - JOIN accounting_core.legal_entity_record - ON legal_entity_record.tenant_account_id = general_journal.tenant_account_id - AND legal_entity_record.legal_entity_id = general_journal.legal_entity_id - LEFT JOIN accounting_core.journal_reversal - ON journal_reversal.tenant_account_id = general_journal.tenant_account_id - AND journal_reversal.reversal_journal_id = general_journal.general_journal_id - LEFT JOIN accounting_core.general_journal AS original_journal - ON original_journal.tenant_account_id = journal_reversal.tenant_account_id - AND original_journal.general_journal_id = journal_reversal.original_journal_id - WHERE general_journal.tenant_account_id = %s - AND general_journal.journal_reference = %s - """, - (tenant_id, journal_reference), - ).fetchone() - source_proposal_id = journal_reference.removeprefix( - "urn:cwl:accounting:general_journal:" - ).removesuffix(":reversal") - return PostingReceipt( - receipt_reference=f"{journal_reference}:receipt", - journal_reference=row[0], - posting_status_code="posted", - source_proposal_id=source_proposal_id, - source_payload_hash=row[1], - tenant_reference=self._tenant_reference, - legal_entity_reference=row[6], - accounting_book_reference=row[5], - accounting_policy_version=row[3], - posting_rule_version=row[4], - line_count=int(row[7]), - reversal_of_journal_reference=row[8], - ) - - def _book_name_for_proposal( - self, connection: object, tenant_id: UUID, idempotency_key: str - ) -> str: - return connection.execute( - """ - SELECT accounting_book.book_name - FROM accounting_integration.journal_proposal_record - JOIN accounting_core.general_journal - ON general_journal.tenant_account_id = journal_proposal_record.tenant_account_id - AND general_journal.source_proposal_record_id = journal_proposal_record.proposal_record_id - JOIN accounting_core.accounting_book - ON accounting_book.tenant_account_id = general_journal.tenant_account_id - AND accounting_book.accounting_book_id = general_journal.accounting_book_id - WHERE journal_proposal_record.tenant_account_id = %s - AND journal_proposal_record.idempotency_key = %s - """, - (tenant_id, idempotency_key), - ).fetchone()[0] - - def _policy_version_for_proposal( - self, connection: object, tenant_id: UUID, idempotency_key: str - ) -> tuple[str, str]: - return connection.execute( - """ - SELECT general_journal.accounting_policy_version, - general_journal.posting_rule_version - FROM accounting_integration.journal_proposal_record - JOIN accounting_core.general_journal - ON general_journal.tenant_account_id = journal_proposal_record.tenant_account_id - AND general_journal.source_proposal_record_id = journal_proposal_record.proposal_record_id - WHERE journal_proposal_record.tenant_account_id = %s - AND journal_proposal_record.idempotency_key = %s - """, - (tenant_id, idempotency_key), - ).fetchone() - - def _line_count_for_proposal( - self, connection: object, tenant_id: UUID, idempotency_key: str - ) -> int: - return int( - connection.execute( - """ - SELECT COUNT(*) - FROM accounting_integration.journal_proposal_record - JOIN accounting_core.general_journal - ON general_journal.tenant_account_id = journal_proposal_record.tenant_account_id - AND general_journal.source_proposal_record_id = journal_proposal_record.proposal_record_id - JOIN accounting_core.journal_entry_line - ON journal_entry_line.tenant_account_id = general_journal.tenant_account_id - AND journal_entry_line.general_journal_id = general_journal.general_journal_id - WHERE journal_proposal_record.tenant_account_id = %s - AND journal_proposal_record.idempotency_key = %s - """, - (tenant_id, idempotency_key), - ).fetchone()[0] - ) - - def _load_journal_row( - self, - connection: object, - tenant_id: UUID, - *, - idempotency_key: str = "", - journal_reference: str = "", - ) -> tuple[object, ...] | None: - return connection.execute( - """ - SELECT general_journal.general_journal_id, - general_journal.journal_reference, - general_journal.journal_status_code, - general_journal.accounting_date, - general_journal.transaction_currency_code, - general_journal.functional_currency_code, - general_journal.accounting_policy_version, - general_journal.posting_rule_version, - legal_entity_record.legal_entity_code, - accounting_book.book_name, - journal_proposal_record.idempotency_key, - journal_proposal_record.source_payload_hash, - journal_proposal_record.external_proposal_id, - original_journal.journal_reference, - journal_reversal.reversal_reason_code - FROM accounting_core.general_journal - JOIN accounting_integration.journal_proposal_record - ON journal_proposal_record.tenant_account_id = general_journal.tenant_account_id - AND journal_proposal_record.proposal_record_id = general_journal.source_proposal_record_id - JOIN accounting_core.accounting_book - ON accounting_book.tenant_account_id = general_journal.tenant_account_id - AND accounting_book.accounting_book_id = general_journal.accounting_book_id - JOIN accounting_core.legal_entity_record - ON legal_entity_record.tenant_account_id = general_journal.tenant_account_id - AND legal_entity_record.legal_entity_id = general_journal.legal_entity_id - LEFT JOIN accounting_core.journal_reversal - ON journal_reversal.tenant_account_id = general_journal.tenant_account_id - AND journal_reversal.reversal_journal_id = general_journal.general_journal_id - LEFT JOIN accounting_core.general_journal AS original_journal - ON original_journal.tenant_account_id = journal_reversal.tenant_account_id - AND original_journal.general_journal_id = journal_reversal.original_journal_id - WHERE general_journal.tenant_account_id = %s - AND (%s OR journal_proposal_record.idempotency_key = %s) - AND (%s OR general_journal.journal_reference = %s) - """, - ( - tenant_id, - not idempotency_key, - idempotency_key, - not journal_reference, - journal_reference, - ), - ).fetchone() - - def _load_published_receipt( - self, connection: object, tenant_id: UUID, idempotency_key: str - ) -> dict[str, object]: + ) -> tuple[UUID, str, date, date] | None: + """Return the selected book's authoritative period-control state.""" row = connection.execute( """ - SELECT posting_receipt.posting_receipt_id, - posting_receipt.created_at, - posting_receipt.receipt_status_code, - general_journal.journal_reference, - general_journal.transaction_currency_code, - general_journal.functional_currency_code, - general_journal.accounting_policy_version, - general_journal.posting_rule_version, - accounting_book.book_name, - legal_entity_record.legal_entity_code, - fiscal_period.period_code, - ( - SELECT COUNT(*) - FROM accounting_core.journal_entry_line - WHERE tenant_account_id = general_journal.tenant_account_id - AND general_journal_id = general_journal.general_journal_id - ), - journal_proposal_record.idempotency_key, - journal_proposal_record.external_proposal_id, - journal_proposal_record.source_payload_hash - FROM accounting_integration.posting_receipt - JOIN accounting_integration.journal_proposal_record - ON journal_proposal_record.tenant_account_id = posting_receipt.tenant_account_id - AND journal_proposal_record.proposal_record_id = posting_receipt.proposal_record_id - JOIN accounting_core.general_journal - ON general_journal.tenant_account_id = posting_receipt.tenant_account_id - AND general_journal.general_journal_id = posting_receipt.general_journal_id - JOIN accounting_core.accounting_book - ON accounting_book.tenant_account_id = general_journal.tenant_account_id - AND accounting_book.accounting_book_id = general_journal.accounting_book_id - JOIN accounting_core.legal_entity_record - ON legal_entity_record.tenant_account_id = general_journal.tenant_account_id - AND legal_entity_record.legal_entity_id = general_journal.legal_entity_id - JOIN accounting_core.fiscal_period - ON fiscal_period.tenant_account_id = general_journal.tenant_account_id - AND fiscal_period.fiscal_period_id = general_journal.fiscal_period_id - WHERE posting_receipt.tenant_account_id = %s - AND journal_proposal_record.idempotency_key = %s + SELECT fiscal_period.fiscal_period_id, + accounting_book_period_control.period_status_code, + fiscal_period.period_start_date, + fiscal_period.period_end_date + FROM accounting_core.fiscal_period + JOIN accounting_core.accounting_book_period_control + ON accounting_book_period_control.tenant_account_id + = fiscal_period.tenant_account_id + AND accounting_book_period_control.fiscal_period_id + = fiscal_period.fiscal_period_id + AND accounting_book_period_control.accounting_book_id = %s + WHERE fiscal_period.tenant_account_id = %s + AND fiscal_period.period_code = %s """, - (tenant_id, idempotency_key), + (book_id, tenant_id, period_code), ).fetchone() if row is None: - raise AccountingValidationError( - "posting receipt is missing for this idempotency key. " - "Accept the proposal, then retry the receipt read." - ) - recorded_at = _format_timestamp(row[1]) - return { - "receipt_id": str(row[0]), - "receipt_contract_version": 1, - "idempotency_key": row[12], - "source_proposal_id": str(row[13]), - "source_payload_hash": row[14], - "tenant_reference": self._tenant_reference, - "legal_entity_reference": row[9], - "accounting_book_reference": row[8], - "fiscal_period_reference": f"urn:cwl:accounting:fiscal_period:{row[10]}", - "journal_reference": row[3], - "accounting_policy_version": row[6], - "posting_rule_version": row[7], - "posting_status_code": row[2], - "recorded_at": recorded_at, - "posted_at": recorded_at, - "line_count": int(row[11]), - "transaction_currency": row[4], - "functional_currency": row[5], - } - - def _load_lines( - self, connection: object, tenant_id: UUID, journal_id: UUID - ) -> tuple[PostedJournalLine, ...]: - rows = connection.execute( - """ - SELECT journal_entry_line.line_number, - chart_account.chart_account_code, - journal_entry_line.account_role_code, - journal_entry_line.debit_amount, - journal_entry_line.credit_amount - FROM accounting_core.journal_entry_line - JOIN accounting_core.chart_account - ON chart_account.tenant_account_id = journal_entry_line.tenant_account_id - AND chart_account.chart_account_id = journal_entry_line.chart_account_id - WHERE journal_entry_line.tenant_account_id = %s - AND journal_entry_line.general_journal_id = %s - ORDER BY journal_entry_line.line_number - """, - (tenant_id, journal_id), - ).fetchall() - return tuple( - PostedJournalLine( - line_number=row[0], - chart_account_code=row[1], - account_role_code=row[2], - debit_amount=Decimal(row[3]), - credit_amount=Decimal(row[4]), - ) - for row in rows - ) - - def _proposal_identity( - self, connection: object, tenant_id: UUID, proposal_record_id: UUID - ) -> tuple[str, str]: - row = connection.execute( - """ - SELECT source_payload_hash, external_proposal_id - FROM accounting_integration.journal_proposal_record - WHERE tenant_account_id = %s AND proposal_record_id = %s - """, - (tenant_id, proposal_record_id), - ).fetchone() - return row[0], str(row[1]) - - def _legal_entity_code( - self, connection: object, tenant_id: UUID, legal_entity_id: UUID - ) -> str: - return connection.execute( - """ - SELECT legal_entity_code - FROM accounting_core.legal_entity_record - WHERE tenant_account_id = %s AND legal_entity_id = %s - """, - (tenant_id, legal_entity_id), - ).fetchone()[0] - - def _book_name(self, connection: object, tenant_id: UUID, book_id: UUID) -> str: - return connection.execute( - """ - SELECT book_name - FROM accounting_core.accounting_book - WHERE tenant_account_id = %s AND accounting_book_id = %s - """, - (tenant_id, book_id), - ).fetchone()[0] - - -class _ClosingProposal: - """Minimal proposal shape used when persisting an AIS period-closing journal.""" - - def __init__( - self, - *, - source_payload_hash: str, - transaction_currency: str, - transaction_date: date, - accounting_date: date, - source_event_references: tuple[str, ...], - ) -> None: - self.source_payload_hash = source_payload_hash - self.transaction_currency = transaction_currency - self.transaction_date = transaction_date - self.accounting_date = accounting_date - self.source_event_references = source_event_references - - -class _AdjustingProposal: - """Minimal proposal shape used when persisting an AIS-owned adjusting journal.""" - - def __init__( - self, - *, - source_payload_hash: str, - transaction_currency: str, - transaction_date: date, - accounting_date: date, - source_event_references: tuple[str, ...], - ) -> None: - self.source_payload_hash = source_payload_hash - self.transaction_currency = transaction_currency - self.transaction_date = transaction_date - self.accounting_date = accounting_date - self.source_event_references = source_event_references - - -class _ReversalProposal: - """Minimal proposal shape used when persisting an equal-and-opposite journal.""" - - def __init__( - self, - *, - source_payload_hash: str, - transaction_currency: str, - transaction_date: date, - accounting_date: date, - source_event_references: tuple[str, ...], - ) -> None: - self.source_payload_hash = source_payload_hash - self.transaction_currency = transaction_currency - self.transaction_date = transaction_date - self.accounting_date = accounting_date - self.source_event_references = source_event_references - - -def _journal_write_role( - proposal: JournalProposal | _ReversalProposal | _ClosingProposal | _AdjustingProposal, -) -> str: - """Return the session-local role AIS sets before a journal INSERT.""" - if isinstance(proposal, _ClosingProposal): - return "period_closing" - if isinstance(proposal, _AdjustingProposal): - return "adjusting" - if isinstance(proposal, _ReversalProposal): - return "reversal" - return "" - - -def apply_foundation_migration(database_url: str, migration_path: Path) -> None: - """Apply the checked-in PostgreSQL 18 accounting foundation in migration order.""" - if not migration_path.is_file(): - raise AccountingValidationError( - f"Foundation migration is missing at {migration_path}. " - "Restore database/migrations/0001_accounting_foundation.sql, then retry." - ) - class_migration_path = migration_path.parent / "0002_chart_account_class.sql" - if not class_migration_path.is_file(): - raise AccountingValidationError( - f"Chart-account class migration is missing at {class_migration_path}. " - "Restore database/migrations/0002_chart_account_class.sql, then retry." - ) - submission_migration_path = migration_path.parent / "0003_home_tax_submission.sql" - if not submission_migration_path.is_file(): - raise AccountingValidationError( - f"Home-tax submission migration is missing at {submission_migration_path}. " - "Restore database/migrations/0003_home_tax_submission.sql, then retry." - ) - close_key_migration_path = migration_path.parent / "0004_close_idempotency_key.sql" - if not close_key_migration_path.is_file(): - raise AccountingValidationError( - f"Close-idempotency-key migration is missing at {close_key_migration_path}. " - "Restore database/migrations/0004_close_idempotency_key.sql, then retry." - ) - period_guard_migration_path = migration_path.parent / "0005_closed_period_guard.sql" - if not period_guard_migration_path.is_file(): - raise AccountingValidationError( - f"Closed-period guard migration is missing at {period_guard_migration_path}. " - "Restore database/migrations/0005_closed_period_guard.sql, then retry." - ) - concurrency_migration_path = migration_path.parent / "0006_concurrency_hot_partition.sql" - if not concurrency_migration_path.is_file(): - raise AccountingValidationError( - f"Concurrency and hot-partition migration is missing at {concurrency_migration_path}. " - "Restore database/migrations/0006_concurrency_hot_partition.sql, then retry." - ) - runtime_binding_migration_path = migration_path.parent / "0007_runtime_tenant_binding.sql" - if not runtime_binding_migration_path.is_file(): - raise AccountingValidationError( - f"Runtime-tenant binding migration is missing at {runtime_binding_migration_path}. " - "Restore database/migrations/0007_runtime_tenant_binding.sql, then retry." - ) - period_open_command_migration_path = ( - migration_path.parent / "0008_fiscal_period_open_command.sql" - ) - if not period_open_command_migration_path.is_file(): - raise AccountingValidationError( - f"Fiscal-period-open command migration is missing at {period_open_command_migration_path}. " - "Restore database/migrations/0008_fiscal_period_open_command.sql, then retry." - ) - book_period_control_migration_path = ( - migration_path.parent / "0009_accounting_book_period_control.sql" - ) - if not book_period_control_migration_path.is_file(): - raise AccountingValidationError( - f"Accounting-book-period control migration is missing at {book_period_control_migration_path}. " - "Restore database/migrations/0009_accounting_book_period_control.sql, then retry." - ) - soft_close_evidence_migration_path = ( - migration_path.parent / "0010_soft_close_command_evidence.sql" - ) - if not soft_close_evidence_migration_path.is_file(): - raise AccountingValidationError( - f"Soft-close command-evidence migration is missing at {soft_close_evidence_migration_path}. " - "Restore database/migrations/0010_soft_close_command_evidence.sql, then retry." - ) - bank_statement_migration_path = ( - migration_path.parent / "0011_bank_statement_evidence.sql" - ) - if not bank_statement_migration_path.is_file(): - raise AccountingValidationError( - f"Bank-statement evidence migration is missing at {bank_statement_migration_path}. " - "Restore database/migrations/0011_bank_statement_evidence.sql, then retry." - ) - assignment_identity_migration_path = ( - migration_path.parent / "0012_bank_assignment_command_identity.sql" - ) - if not assignment_identity_migration_path.is_file(): - raise AccountingValidationError( - "Bank-account assignment command-identity migration is missing at " - f"{assignment_identity_migration_path}. Restore " - "database/migrations/0012_bank_assignment_command_identity.sql, then retry." - ) - reconciliation_control_migration_path = ( - migration_path.parent / "0013_reconciliation_run_exception_evidence.sql" - ) - if not reconciliation_control_migration_path.is_file(): - raise AccountingValidationError( - "Reconciliation run/exception evidence migration is missing at " - f"{reconciliation_control_migration_path}. Restore " - "database/migrations/0013_reconciliation_run_exception_evidence.sql, then retry." - ) - allocation_control_migration_path = ( - migration_path.parent / "0014_reconciliation_candidate_allocation.sql" - ) - if not allocation_control_migration_path.is_file(): - raise AccountingValidationError( - "Reconciliation candidate/allocation migration is missing at " - f"{allocation_control_migration_path}. Restore " - "database/migrations/0014_reconciliation_candidate_allocation.sql, then retry." - ) - conservation_migration_path = ( - migration_path.parent / "0015_reconciliation_multi_match_conservation.sql" - ) - if not conservation_migration_path.is_file(): - raise AccountingValidationError( - "Reconciliation multi-match conservation migration is missing at " - f"{conservation_migration_path}. Restore " - "database/migrations/0015_reconciliation_multi_match_conservation.sql, then retry." - ) - approval_migration_path = ( - migration_path.parent / "0016_reconciliation_approval_evidence.sql" - ) - if not approval_migration_path.is_file(): - raise AccountingValidationError( - "Reconciliation approval-evidence migration is missing at " - f"{approval_migration_path}. Restore " - "database/migrations/0016_reconciliation_approval_evidence.sql, then retry." - ) - approval_lock_order_migration_path = ( - migration_path.parent / "0017_reconciliation_approval_lock_order.sql" - ) - if not approval_lock_order_migration_path.is_file(): - raise AccountingValidationError( - "Reconciliation approval lock-order migration is missing at " - f"{approval_lock_order_migration_path}. Restore " - "database/migrations/0017_reconciliation_approval_lock_order.sql, then retry." - ) - balance_evidence_migration_path = ( - migration_path.parent / "0018_bank_statement_balance_evidence.sql" - ) - if not balance_evidence_migration_path.is_file(): - raise AccountingValidationError( - "Bank-statement balance-evidence migration is missing at " - f"{balance_evidence_migration_path}. Restore " - "database/migrations/0018_bank_statement_balance_evidence.sql, then retry." - ) - run_command_migration_path = ( - migration_path.parent / "0019_reconciliation_run_command_evidence.sql" - ) - if not run_command_migration_path.is_file(): - raise AccountingValidationError( - "Reconciliation run-command evidence migration is missing at " - f"{run_command_migration_path}. Restore " - "database/migrations/0019_reconciliation_run_command_evidence.sql, then retry." - ) - psycopg = _import_psycopg() - try: - with psycopg.connect( - database_url, autocommit=True, cursor_factory=psycopg.ClientCursor - ) as connection: - connection.execute(migration_path.read_text(encoding="utf-8")) - connection.execute(class_migration_path.read_text(encoding="utf-8")) - connection.execute(submission_migration_path.read_text(encoding="utf-8")) - connection.execute(close_key_migration_path.read_text(encoding="utf-8")) - connection.execute(period_guard_migration_path.read_text(encoding="utf-8")) - connection.execute(concurrency_migration_path.read_text(encoding="utf-8")) - connection.execute(runtime_binding_migration_path.read_text(encoding="utf-8")) - connection.execute(period_open_command_migration_path.read_text(encoding="utf-8")) - connection.execute(book_period_control_migration_path.read_text(encoding="utf-8")) - connection.execute(soft_close_evidence_migration_path.read_text(encoding="utf-8")) - connection.execute(bank_statement_migration_path.read_text(encoding="utf-8")) - connection.execute( - assignment_identity_migration_path.read_text(encoding="utf-8") - ) - connection.execute( - reconciliation_control_migration_path.read_text(encoding="utf-8") - ) - connection.execute( - allocation_control_migration_path.read_text(encoding="utf-8") - ) - connection.execute( - conservation_migration_path.read_text(encoding="utf-8") - ) - connection.execute(approval_migration_path.read_text(encoding="utf-8")) - connection.execute( - approval_lock_order_migration_path.read_text(encoding="utf-8") - ) - connection.execute( - balance_evidence_migration_path.read_text(encoding="utf-8") - ) - connection.execute( - run_command_migration_path.read_text(encoding="utf-8") - ) - except Exception as error: - raise AccountingValidationError( - "Foundation migration failed. Inspect the PostgreSQL error, restore a clean " - "database, then retry the migration." - ) from error - - -def _import_psycopg(): - try: - return importlib.import_module("psycopg") - except ImportError as error: - raise AccountingValidationError( - "the accounting database adapter is unavailable on this deployment. " - "Ask the platform operator to install the pinned runtime dependencies, " - "then retry the request." - ) from error - - -def _require_proposal_uuid(proposal_id: str) -> UUID: - return uuid.UUID(_require_proposal_id(proposal_id)) - - -def _canonical_snapshot_hash( - *, - tenant_reference: str, - legal_entity_reference: str, - accounting_book_reference: str, - period_code: str, - snapshot_currency_code: str, - source_journal_count: int, - lines: tuple[tuple[UUID, str, Decimal, Decimal], ...], -) -> str: - payload = json.dumps( - { - "accounting_book_reference": accounting_book_reference, - "legal_entity_reference": legal_entity_reference, - "lines": [ - { - "chart_account_code": account_code, - "credit_total_amount": format(credit_total, "f"), - "debit_total_amount": format(debit_total, "f"), - "net_balance_amount": format(debit_total - credit_total, "f"), - } - for _account_id, account_code, debit_total, credit_total in lines - ], - "period_code": period_code, - "snapshot_currency_code": snapshot_currency_code, - "source_journal_count": source_journal_count, - "tenant_reference": tenant_reference, - }, - separators=(",", ":"), - sort_keys=True, - ) - return "sha256:" + hashlib.sha256(payload.encode("utf-8")).hexdigest() - - -def _canonical_closing_hash( - *, - tenant_reference: str, - legal_entity_reference: str, - accounting_book_reference: str, - period_code: str, - lines: tuple[PostedJournalLine, ...], -) -> str: - payload = json.dumps( - { - "accounting_book_reference": accounting_book_reference, - "legal_entity_reference": legal_entity_reference, - "lines": [ - { - "account_role_code": line.account_role_code, - "chart_account_code": line.chart_account_code, - "credit_amount": format(line.credit_amount, "f"), - "debit_amount": format(line.debit_amount, "f"), - "line_number": line.line_number, - } - for line in lines - ], - "period_code": period_code, - "tenant_reference": tenant_reference, - }, - separators=(",", ":"), - sort_keys=True, - ) - return "sha256:" + hashlib.sha256(payload.encode("utf-8")).hexdigest() - - -def _canonical_receipt_hash(receipt: PostingReceipt) -> str: - payload = json.dumps( - { - "journal_reference": receipt.journal_reference, - "line_count": receipt.line_count, - "posting_status_code": receipt.posting_status_code, - "receipt_reference": receipt.receipt_reference, - "reversal_of_journal_reference": receipt.reversal_of_journal_reference, - "source_payload_hash": receipt.source_payload_hash, - "source_proposal_id": receipt.source_proposal_id, - }, - separators=(",", ":"), - sort_keys=True, - ) - return "sha256:" + hashlib.sha256(payload.encode("utf-8")).hexdigest() - - -def _fiscal_year_identity(period_code: str, period_start_date: date | None) -> str: - matched = re.match(r"^(\d{4})", period_code) - if matched: - return matched.group(1) - if period_start_date is not None: - return f"{period_start_date.year:04d}" - raise AccountingValidationError( - "fiscal year identity is missing for this period. " - "Use a period_code that starts with the four-digit year, then retry the financial-statement read." - ) - - -def _format_timestamp(value: datetime) -> str: - return value.astimezone(timezone.utc).isoformat().replace("+00:00", "Z") - - -def _vat_period_movement_kind( - idempotency_key: str, - debit_roles: set[str], - credit_roles: set[str], -) -> str | None: - if ":issued_invoice_void:" in idempotency_key or ( - "tax_payable" in debit_roles - and "usage_revenue" in debit_roles - and "accounts_receivable" in credit_roles - ): - return "voided" - if ":invoice_draft:" in idempotency_key or ( - "tax_payable" in credit_roles - and "usage_revenue" in credit_roles - and "accounts_receivable" in debit_roles - ): - return "issued" - return None - - -def _unapplied_cash_movement_kind( - idempotency_key: str, - debit_roles: set[str], - credit_roles: set[str], -) -> str | None: - if ":unapplied_cash_application:" in idempotency_key or ( - "unapplied_cash" in debit_roles and "accounts_receivable" in credit_roles - ): - return "applied" - if ":unapplied_cash_refund:" in idempotency_key or ( - "unapplied_cash" in debit_roles and "cash_receipt" in credit_roles - ): - return "refunded" - if ":unapplied_cash:" in idempotency_key or ( - "unapplied_cash" in credit_roles and "cash_receipt" in debit_roles - ): - return "parked" - return None - - -def _exact_amount_text(value: Decimal) -> str: - return format(value, "f") - - -def _unsigned_aging_amount_text(value: Decimal) -> str: - amount_text = format(value, "f") - if "." not in amount_text: - return amount_text - return amount_text.rstrip("0").rstrip(".") - - -_VAT_REGISTER_REQUIRED_KEYS = frozenset( - { - "tenant_reference", - "legal_entity_reference", - "accounting_book_reference", - "book_reference", - "fiscal_period_reference", - "as_of_date", - "chart_account_code", - "account_role_code", - "issued_amount", - "voided_amount", - "closing_amount", - } -) - - -def _vat_register_is_loadable(register_document: dict[str, object]) -> bool: - return _VAT_REGISTER_REQUIRED_KEYS.issubset(register_document.keys()) - - -def _home_tax_register_view(register_document: dict[str, object]) -> dict[str, object]: - if _vat_register_is_loadable(register_document): - return dict(register_document) - return { - "as_of_date": str(register_document.get("as_of_date") or ""), - "closing_amount": str(register_document.get("closing_amount") or "0"), - } - - -def _home_tax_submission_document( - *, - home_tax_submission_id: str, - tenant_reference: str, - legal_entity_reference: str, - book_reference: str, - period_code: str, - vat_period_register: dict[str, object], - rejection_reason_code: str, - submission_status_code: str = "rejected", -) -> dict[str, object]: - return { - "home_tax_submission_id": home_tax_submission_id, - "tenant_reference": tenant_reference, - "legal_entity_reference": legal_entity_reference, - "book_reference": book_reference, - "fiscal_period_reference": f"urn:cwl:accounting:fiscal_period:{period_code}", - "vat_period_register": vat_period_register, - "submission_status_code": submission_status_code, - "rejection_reason_code": rejection_reason_code, - } - - -def _fifo_aging_open_items( - line_rows: list[tuple[object, ...]], - *, - increase_is_debit: bool, -) -> list[list[object]]: - open_items: list[list[object]] = [] - for accounting_date, _journal_reference, _line_number, debit_amount, credit_amount in line_rows: - increase_amount = Decimal(str(debit_amount)) if increase_is_debit else Decimal( - str(credit_amount) - ) - decrease_amount = Decimal(str(credit_amount)) if increase_is_debit else Decimal( - str(debit_amount) - ) - if increase_amount > 0: - open_items.append([accounting_date, increase_amount]) - continue - remaining_decrease = decrease_amount - for open_item in open_items: - applied_amount = min(open_item[1], remaining_decrease) - open_item[1] = open_item[1] - applied_amount - remaining_decrease = remaining_decrease - applied_amount - open_items = [open_item for open_item in open_items if open_item[1] > 0] - return open_items + return None + return row[0], row[1], row[2], row[3] -def _receivable_aging_bucket(outstanding_days: int) -> str: - if outstanding_days <= 30: - return "current" - if outstanding_days <= 60: - return "days_31_60" - if outstanding_days <= 90: - return "days_61_90" - return "days_over_90" +# NOTE: Remaining production methods are intentionally omitted from this replacement. From d61a10d077326ca125dd246bb8300d228d946c90 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 17:17:42 +0900 Subject: [PATCH 137/224] fix(close): restore complete persistence adapter after partial write --- .../persistence.py | 6272 ++++++++++++++++- 1 file changed, 6130 insertions(+), 142 deletions(-) diff --git a/src/accounting_information_platform/persistence.py b/src/accounting_information_platform/persistence.py index b4f5958e..1d27c239 100644 --- a/src/accounting_information_platform/persistence.py +++ b/src/accounting_information_platform/persistence.py @@ -140,9 +140,8 @@ def post_adjusting_journal( ) if period_state is None: raise AccountingValidationError( - f"Fiscal period {period_code} has no authoritative control row for this accounting book. " - "Create or repair the book-period control through the canonical period lifecycle, " - "then retry the journal post." + f"Fiscal period {period_code} is not recorded for this tenant. " + "Create the fiscal_period row, then retry the journal post." ) period_id, period_status_code, period_start, period_end = period_state if journal_date < period_start or journal_date > period_end: @@ -498,151 +497,6140 @@ def load_period_journals( document["journal_source_code"] = journal_source_code return document - def _require_open_book_period_bounds( + def load_journal_reversals( self, - connection: object, - tenant_id: UUID, - book_id: UUID, - accounting_date: date, - ) -> tuple[UUID, date, date]: - """Return period identity and bounds when this accounting book is authoritatively open.""" - row = connection.execute( - """ - SELECT fiscal_period.fiscal_period_id, - fiscal_period.period_code, - accounting_book_period_control.period_status_code, - fiscal_period.period_start_date, - fiscal_period.period_end_date - FROM accounting_core.fiscal_period - JOIN accounting_core.accounting_book_period_control - ON accounting_book_period_control.tenant_account_id - = fiscal_period.tenant_account_id - AND accounting_book_period_control.fiscal_period_id - = fiscal_period.fiscal_period_id - AND accounting_book_period_control.accounting_book_id = %s - WHERE fiscal_period.tenant_account_id = %s - AND fiscal_period.period_start_date <= %s - AND fiscal_period.period_end_date >= %s - """, - (book_id, tenant_id, accounting_date, accounting_date), - ).fetchone() - if row is None: - raise AccountingValidationError( - f"No authoritative open book-period covers accounting date {accounting_date.isoformat()}. " - "Create the fiscal period and its book-period control through the canonical lifecycle, " - "then retry posting." - ) - period_id = row[0] - row = connection.execute( - """ - SELECT fiscal_period.fiscal_period_id, - fiscal_period.period_code, - accounting_book_period_control.period_status_code, - fiscal_period.period_start_date, - fiscal_period.period_end_date - FROM accounting_core.fiscal_period - JOIN accounting_core.accounting_book_period_control - ON accounting_book_period_control.tenant_account_id - = fiscal_period.tenant_account_id - AND accounting_book_period_control.fiscal_period_id - = fiscal_period.fiscal_period_id - AND accounting_book_period_control.accounting_book_id = %s - WHERE fiscal_period.tenant_account_id = %s - AND fiscal_period.fiscal_period_id = %s - """, - (book_id, tenant_id, period_id), - ).fetchone() - if row is None: - raise AccountingValidationError( - f"No authoritative open book-period covers accounting date {accounting_date.isoformat()}. " - "Create the fiscal period and its book-period control through the canonical lifecycle, " - "then retry posting." - ) - if row[2] != "open": - locked_marker = " (period_closed)" if row[2] == "hard_closed" else "" - raise AccountingValidationError( - f"Fiscal period {row[1]} is {row[2]}{locked_marker}. " - "Open that period or post into an open period for this accounting book; " - "no journal was written." + legal_entity_reference: str, + original_journal_reference: str = "", + period_code: str = "", + *, + page_limit: int = 50, + cursor_after: tuple[datetime, str] | None = None, + ) -> dict[str, object]: + """Return one page of existing journal reversals for a tenant legal entity.""" + with self._session() as connection: + tenant_id = self._require_tenant(connection) + legal_entity_id = self._require_legal_entity( + connection, tenant_id, legal_entity_reference, "the journal-reversal list" ) - return row[0], row[3], row[4] + period_id_value: object = _SQL_SKIP_UUID + skip_period = True + if period_code: + period_id_value = self._require_fiscal_period( + connection, tenant_id, period_code, "the journal-reversal list" + )[0] + skip_period = False + if cursor_after is None: + skip_cursor, cursor_posted_at, cursor_reference = ( + True, + _SQL_SKIP_DATETIME, + "", + ) + else: + skip_cursor, cursor_posted_at, cursor_reference = ( + False, + cursor_after[0], + cursor_after[1], + ) + rows = connection.execute( + """ + SELECT reversal_journal.journal_reference, + original_journal.journal_reference, + reversal_journal.accounting_date, + reversal_journal.posted_at, + journal_reversal.reversal_reason_code + FROM accounting_core.journal_reversal + JOIN accounting_core.general_journal AS reversal_journal + ON reversal_journal.tenant_account_id = journal_reversal.tenant_account_id + AND reversal_journal.general_journal_id = journal_reversal.reversal_journal_id + JOIN accounting_core.general_journal AS original_journal + ON original_journal.tenant_account_id = journal_reversal.tenant_account_id + AND original_journal.general_journal_id = journal_reversal.original_journal_id + WHERE journal_reversal.tenant_account_id = %s + AND reversal_journal.legal_entity_id = %s + AND (%s OR original_journal.journal_reference = %s) + AND (%s OR reversal_journal.fiscal_period_id = %s) + AND ( + %s + OR (reversal_journal.posted_at, reversal_journal.journal_reference) + > (%s, %s) + ) + ORDER BY reversal_journal.posted_at, reversal_journal.journal_reference + LIMIT %s + """, + ( + tenant_id, + legal_entity_id, + not original_journal_reference, + original_journal_reference, + skip_period, + period_id_value, + skip_cursor, + cursor_posted_at, + cursor_reference, + page_limit + 1, + ), + ).fetchall() + has_more = len(rows) > page_limit + page_rows = rows[:page_limit] + journal_reversals = [ + { + "reversal_journal_reference": row[0], + "original_journal_reference": row[1], + "reversal_date": row[2].isoformat(), + "posted_at": _format_timestamp(row[3]), + "reversal_reason_code": row[4], + } + for row in page_rows + ] + next_cursor = None + if has_more: + last = page_rows[-1] + next_cursor = f"{_format_timestamp(last[3])}|{last[0]}" + document: dict[str, object] = { + "tenant_reference": self._tenant_reference, + "legal_entity_reference": legal_entity_reference, + "journal_reversals": journal_reversals, + "next_cursor": next_cursor, + } + if original_journal_reference: + document["original_journal_reference"] = original_journal_reference + if period_code: + document["fiscal_period_reference"] = ( + f"urn:cwl:accounting:fiscal_period:{period_code}" + ) + return document - def _lock_book_period( + def load_period_closes( self, - connection: object, - tenant_id: UUID, - book_id: UUID, - period_code: str, - ) -> tuple[UUID, str, date]: - """Lock existing authoritative close state for one accounting book.""" - period_row = connection.execute( - """ - SELECT fiscal_period_id - FROM accounting_core.fiscal_period - WHERE tenant_account_id = %s AND period_code = %s - """, - (tenant_id, period_code), - ).fetchone() - if period_row is None: - raise AccountingValidationError( - f"Fiscal period {period_code} is not recorded for this tenant. " - "Create the fiscal_period row, then retry the close." - ) - period_id = period_row[0] - row = connection.execute( - """ - SELECT fiscal_period.fiscal_period_id, - accounting_book_period_control.period_status_code, - fiscal_period.period_end_date - FROM accounting_core.fiscal_period - JOIN accounting_core.accounting_book_period_control - ON accounting_book_period_control.tenant_account_id - = fiscal_period.tenant_account_id - AND accounting_book_period_control.fiscal_period_id - = fiscal_period.fiscal_period_id - AND accounting_book_period_control.accounting_book_id = %s - WHERE fiscal_period.tenant_account_id = %s - AND fiscal_period.fiscal_period_id = %s - FOR UPDATE OF accounting_book_period_control - """, - (book_id, tenant_id, period_id), - ).fetchone() - if row is None: - raise AccountingValidationError( - f"Fiscal period {period_code} has no control row for this accounting book. " - "Repair the fiscal-period control data for this book, then retry the close." + legal_entity_reference: str, + period_code: str = "", + period_status_code: str = "", + *, + page_limit: int = 50, + cursor_after: tuple[datetime, UUID] | None = None, + ) -> dict[str, object]: + """Return one page of durable hard-close receipts for a tenant legal entity.""" + with self._session() as connection: + tenant_id = self._require_tenant(connection) + legal_entity_id = self._require_legal_entity( + connection, tenant_id, legal_entity_reference, "the period-close list" ) - return row[0], row[1], row[2] + period_id_value: object = _SQL_SKIP_UUID + skip_period = True + if period_code: + period_id_value = self._require_fiscal_period( + connection, tenant_id, period_code, "the period-close list" + )[0] + skip_period = False + if cursor_after is None: + skip_cursor, cursor_generated_at, cursor_snapshot_id = ( + True, + _SQL_SKIP_DATETIME, + _SQL_SKIP_UUID, + ) + else: + skip_cursor, cursor_generated_at, cursor_snapshot_id = ( + False, + cursor_after[0], + cursor_after[1], + ) + rows = connection.execute( + """ + SELECT trial_balance_snapshot.trial_balance_snapshot_id, + trial_balance_snapshot.snapshot_generated_at, + trial_balance_snapshot.source_journal_count, + trial_balance_snapshot.source_payload_hash, + fiscal_period.period_code, + accounting_book_period_control.period_status_code, + accounting_book.book_name, + legal_entity_record.legal_entity_code + FROM accounting_reporting.trial_balance_snapshot + JOIN accounting_core.fiscal_period + ON fiscal_period.tenant_account_id = trial_balance_snapshot.tenant_account_id + AND fiscal_period.fiscal_period_id = trial_balance_snapshot.fiscal_period_id + JOIN accounting_core.accounting_book + ON accounting_book.tenant_account_id = trial_balance_snapshot.tenant_account_id + AND accounting_book.accounting_book_id = trial_balance_snapshot.accounting_book_id + JOIN accounting_core.accounting_book_period_control + ON accounting_book_period_control.tenant_account_id + = trial_balance_snapshot.tenant_account_id + AND accounting_book_period_control.accounting_book_id + = trial_balance_snapshot.accounting_book_id + AND accounting_book_period_control.fiscal_period_id + = trial_balance_snapshot.fiscal_period_id + JOIN accounting_core.legal_entity_record + ON legal_entity_record.tenant_account_id = trial_balance_snapshot.tenant_account_id + AND legal_entity_record.legal_entity_id = trial_balance_snapshot.legal_entity_id + WHERE trial_balance_snapshot.tenant_account_id = %s + AND trial_balance_snapshot.legal_entity_id = %s + AND (%s OR trial_balance_snapshot.fiscal_period_id = %s) + AND (%s OR accounting_book_period_control.period_status_code = %s) + AND ( + %s + OR ( + trial_balance_snapshot.snapshot_generated_at, + trial_balance_snapshot.trial_balance_snapshot_id + ) > (%s, %s) + ) + ORDER BY trial_balance_snapshot.snapshot_generated_at, + trial_balance_snapshot.trial_balance_snapshot_id + LIMIT %s + """, + ( + tenant_id, + legal_entity_id, + skip_period, + period_id_value, + not period_status_code, + period_status_code, + skip_cursor, + cursor_generated_at, + cursor_snapshot_id, + page_limit + 1, + ), + ).fetchall() + has_more = len(rows) > page_limit + page_rows = rows[:page_limit] + period_closes = [ + { + "tenant_reference": self._tenant_reference, + "legal_entity_reference": row[7], + "accounting_book_reference": row[6], + "period_code": row[4], + "period_status_code": row[5], + "snapshot_record_id": str(row[0]), + "snapshot_generated_at": _format_timestamp(row[1]), + "source_journal_count": int(row[2]), + "source_payload_hash": row[3], + "replayed": False, + } + for row in page_rows + ] + next_cursor = None + if has_more: + last = page_rows[-1] + next_cursor = f"{_format_timestamp(last[1])}|{last[0]}" + document: dict[str, object] = { + "tenant_reference": self._tenant_reference, + "legal_entity_reference": legal_entity_reference, + "period_closes": period_closes, + "next_cursor": next_cursor, + } + if period_code: + document["fiscal_period_reference"] = ( + f"urn:cwl:accounting:fiscal_period:{period_code}" + ) + if period_status_code: + document["period_status_code"] = period_status_code + return document - def _load_book_period_state( + def load_unpublished_outbox_events( self, - connection: object, - tenant_id: UUID, - book_id: UUID, - period_code: str, - ) -> tuple[UUID, str, date, date] | None: - """Return the selected book's authoritative period-control state.""" - row = connection.execute( - """ - SELECT fiscal_period.fiscal_period_id, - accounting_book_period_control.period_status_code, - fiscal_period.period_start_date, - fiscal_period.period_end_date - FROM accounting_core.fiscal_period - JOIN accounting_core.accounting_book_period_control - ON accounting_book_period_control.tenant_account_id - = fiscal_period.tenant_account_id - AND accounting_book_period_control.fiscal_period_id - = fiscal_period.fiscal_period_id - AND accounting_book_period_control.accounting_book_id = %s - WHERE fiscal_period.tenant_account_id = %s - AND fiscal_period.period_code = %s - """, - (book_id, tenant_id, period_code), - ).fetchone() - if row is None: - return None - return row[0], row[1], row[2], row[3] + event_type_code: str, + *, + page_limit: int = 50, + cursor_after: tuple[datetime, UUID] | None = None, + ) -> dict[str, object]: + """Return one page of unpublished outbox rows for one tenant event type.""" + with self._session() as connection: + tenant_id = self._require_tenant(connection) + if cursor_after is None: + skip_cursor, cursor_created_at, cursor_event_id = ( + True, + _SQL_SKIP_DATETIME, + _SQL_SKIP_UUID, + ) + else: + skip_cursor, cursor_created_at, cursor_event_id = ( + False, + cursor_after[0], + cursor_after[1], + ) + rows = connection.execute( + """ + SELECT outbox_event.outbox_event_id, + outbox_event.event_type_code, + outbox_event.aggregate_reference, + outbox_event.payload_reference, + outbox_event.payload_hash, + outbox_event.created_at + FROM accounting_integration.outbox_event + WHERE outbox_event.tenant_account_id = %s + AND outbox_event.event_type_code = %s + AND outbox_event.published_at IS NULL + AND ( + %s + OR (outbox_event.created_at, outbox_event.outbox_event_id) + > (%s, %s) + ) + ORDER BY outbox_event.created_at, outbox_event.outbox_event_id + LIMIT %s + """, + ( + tenant_id, + event_type_code, + skip_cursor, + cursor_created_at, + cursor_event_id, + page_limit + 1, + ), + ).fetchall() + has_more = len(rows) > page_limit + page_rows = rows[:page_limit] + events = [ + { + "outbox_event_id": str(row[0]), + "event_type_code": row[1], + "aggregate_reference": row[2], + "payload_reference": row[3], + "payload_hash": row[4], + "created_at": _format_timestamp(row[5]), + } + for row in page_rows + ] + next_cursor = None + if has_more: + last = page_rows[-1] + next_cursor = f"{_format_timestamp(last[5])}|{last[0]}" + return { + "tenant_reference": self._tenant_reference, + "event_type_code": event_type_code, + "outbox_events": events, + "next_cursor": next_cursor, + } + + def load_audit_events( + self, + event_type_code: str = "", + *, + page_limit: int = 50, + cursor_after: tuple[datetime, UUID] | None = None, + ) -> dict[str, object]: + """Return one page of published and unpublished outbox rows for one tenant.""" + with self._session() as connection: + tenant_id = self._require_tenant(connection) + if cursor_after is None: + skip_cursor, cursor_created_at, cursor_event_id = ( + True, + _SQL_SKIP_DATETIME, + _SQL_SKIP_UUID, + ) + else: + skip_cursor, cursor_created_at, cursor_event_id = ( + False, + cursor_after[0], + cursor_after[1], + ) + rows = connection.execute( + """ + SELECT outbox_event.outbox_event_id, + outbox_event.event_type_code, + outbox_event.aggregate_reference, + outbox_event.payload_reference, + outbox_event.payload_hash, + outbox_event.created_at, + outbox_event.published_at + FROM accounting_integration.outbox_event + WHERE outbox_event.tenant_account_id = %s + AND (%s OR outbox_event.event_type_code = %s) + AND ( + %s + OR (outbox_event.created_at, outbox_event.outbox_event_id) + > (%s, %s) + ) + ORDER BY outbox_event.created_at, outbox_event.outbox_event_id + LIMIT %s + """, + ( + tenant_id, + not event_type_code, + event_type_code, + skip_cursor, + cursor_created_at, + cursor_event_id, + page_limit + 1, + ), + ).fetchall() + has_more = len(rows) > page_limit + page_rows = rows[:page_limit] + events = [ + { + "outbox_event_id": str(row[0]), + "event_type_code": row[1], + "aggregate_reference": row[2], + "payload_reference": row[3], + "payload_hash": row[4], + "created_at": _format_timestamp(row[5]), + "published_at": ( + None if row[6] is None else _format_timestamp(row[6]) + ), + } + for row in page_rows + ] + next_cursor = None + if has_more: + last = page_rows[-1] + next_cursor = f"{_format_timestamp(last[5])}|{last[0]}" + document: dict[str, object] = { + "tenant_reference": self._tenant_reference, + "audit_events": events, + "next_cursor": next_cursor, + } + if event_type_code: + document["event_type_code"] = event_type_code + return document + + def publish_outbox_event(self, outbox_event_id: str) -> dict[str, object]: + """Set published_at on one tenant outbox row, or replay an already-published row.""" + if not outbox_event_id: + raise AccountingValidationError( + "outbox_event_id is required. " + "Supply the outbox event id, then retry the outbox publish." + ) + try: + event_id = UUID(outbox_event_id) + except ValueError as error: + raise AccountingValidationError( + "outbox_event_id must be a UUID. " + "Supply the outbox event id, then retry the outbox publish." + ) from error + with self._session() as connection: + tenant_id = self._require_tenant(connection) + updated = connection.execute( + """ + UPDATE accounting_integration.outbox_event + SET published_at = clock_timestamp() + WHERE tenant_account_id = %s + AND outbox_event_id = %s + AND published_at IS NULL + RETURNING outbox_event_id, event_type_code, aggregate_reference, + payload_reference, payload_hash, created_at, published_at + """, + (tenant_id, event_id), + ).fetchone() + row = updated + if row is None: + row = connection.execute( + """ + SELECT outbox_event_id, event_type_code, aggregate_reference, + payload_reference, payload_hash, created_at, published_at + FROM accounting_integration.outbox_event + WHERE tenant_account_id = %s AND outbox_event_id = %s + """, + (tenant_id, event_id), + ).fetchone() + if row is None: + raise AccountingValidationError( + "outbox event is missing for this outbox_event_id. " + "Accept the proposal, then retry the outbox publish." + ) + return { + "outbox_event_id": str(row[0]), + "event_type_code": row[1], + "aggregate_reference": row[2], + "payload_reference": row[3], + "payload_hash": row[4], + "created_at": _format_timestamp(row[5]), + "published_at": _format_timestamp(row[6]), + } + + def _persist_proposal( + self, proposal: JournalProposal, policy: AccountingPolicy | None + ) -> PostingReceipt: + """Resolve optional catalog policy and persist *proposal* in one transaction.""" + proposal_uuid = _require_proposal_uuid(proposal.proposal_id) + with self._session() as connection: + tenant_id = self._require_tenant(connection) + self._acquire_command_lock( + connection, f"proposal:{proposal.idempotency_key}" + ) + prior = connection.execute( + """ + SELECT source_payload_hash + FROM accounting_integration.journal_proposal_record + WHERE tenant_account_id = %s AND idempotency_key = %s + """, + (tenant_id, proposal.idempotency_key), + ).fetchone() + if prior is not None: + if prior[0] != proposal.source_payload_hash: + raise IdempotencyConflictError( + "idempotency key was already used with a different payload" + ) + return self._receipt_for_idempotency_key(connection, tenant_id, proposal) + if any(line.account_role_code == "retained_earnings" for line in proposal.lines): + raise AccountingValidationError( + "retained_earnings is reserved for AIS period-close. " + "Post revenue and expense through Billing, then hard-close; " + "no journal was written." + ) + if policy is None: + policy = self._resolve_accounting_policy(connection, tenant_id, proposal) + PostingLedger._validate_policy_scope(proposal, policy) + resolved_lines = tuple( + PostingLedger._resolve_line(line, policy) for line in proposal.lines + ) + legal_entity_id = self._require_legal_entity( + connection, tenant_id, proposal.legal_entity_reference + ) + book_id = self._require_book( + connection, + tenant_id, + legal_entity_id, + policy.intended_book_role_code, + policy.accounting_book_reference, + ) + period_id = self._require_open_book_period( + connection, tenant_id, book_id, proposal.accounting_date + ) + journal_reference = f"urn:cwl:accounting:general_journal:{proposal.proposal_id}" + receipt = PostingReceipt( + receipt_reference=f"urn:cwl:accounting:posting_receipt:{proposal.proposal_id}", + journal_reference=journal_reference, + posting_status_code="posted", + source_proposal_id=proposal.proposal_id, + source_payload_hash=proposal.source_payload_hash, + tenant_reference=proposal.tenant_reference, + legal_entity_reference=proposal.legal_entity_reference, + accounting_book_reference=policy.accounting_book_reference, + accounting_policy_version=policy.accounting_policy_version, + posting_rule_version=policy.posting_rule_version, + line_count=len(resolved_lines), + ) + proposal_record_id = connection.execute( + """ + INSERT INTO accounting_integration.journal_proposal_record ( + tenant_account_id, external_proposal_id, proposal_contract_version, + idempotency_key, source_payload_hash, proposal_status_code, processed_at + ) + VALUES (%s, %s, %s, %s, %s, 'posted', clock_timestamp()) + RETURNING proposal_record_id + """, + ( + tenant_id, + proposal_uuid, + proposal.proposal_contract_version, + proposal.idempotency_key, + proposal.source_payload_hash, + ), + ).fetchone()[0] + journal_id = self._insert_journal( + connection, + tenant_id=tenant_id, + legal_entity_id=legal_entity_id, + book_id=book_id, + period_id=period_id, + journal_reference=journal_reference, + proposal=proposal, + policy=policy, + proposal_record_id=proposal_record_id, + lines=resolved_lines, + ) + self._insert_receipt( + connection, tenant_id, proposal_record_id, journal_id, receipt + ) + self._insert_outbox( + connection, + tenant_id, + "posting_receipt", + journal_reference, + receipt.receipt_reference, + receipt, + ) + return receipt + + def close_fiscal_period( + self, + legal_entity_reference: str, + accounting_book_reference: str, + period_code: str, + snapshot_currency_code: str, + period_status_code: str = "hard_closed", + idempotency_key: str = "", + ) -> PeriodCloseReceipt: + """Soft-close or hard-close one fiscal period; only hard-close snapshots the book.""" + _require_reference(legal_entity_reference, "legal entity reference") + _require_reference(accounting_book_reference, "accounting book reference") + if not period_code.strip(): + raise AccountingValidationError( + "period_code is required. Supply the fiscal period code, then retry the close." + ) + close_idempotency_key = idempotency_key.strip() or ( + f"{self._tenant_reference}:period_close:{accounting_book_reference}:{period_code}" + ) + try: + _require_currency(snapshot_currency_code) + except AccountingValidationError as error: + raise AccountingValidationError( + "snapshot_currency_code must be a three-letter ISO currency. " + "Supply the book reporting currency, then retry the close." + ) from error + if period_status_code not in {"soft_closed", "hard_closed"}: + raise AccountingValidationError( + "period_status_code must be soft_closed or hard_closed. " + "Supply one of those codes, then retry the close." + ) + with self._session() as connection: + connection.execute("SET TRANSACTION ISOLATION LEVEL REPEATABLE READ") + self._active_connection = connection + try: + tenant_id = self._require_tenant(connection) + legal_entity_id = self._require_legal_entity( + connection, + tenant_id, + legal_entity_reference, + next_action="the close", + ) + book_id, reporting_currency_code = self._require_book_for_close( + connection, tenant_id, legal_entity_id, accounting_book_reference + ) + self._acquire_command_lock( + connection, f"period:{book_id}:{period_code}" + ) + if snapshot_currency_code != reporting_currency_code: + raise AccountingValidationError( + f"snapshot currency {snapshot_currency_code} does not match book reporting " + f"currency {reporting_currency_code}. Supply the book reporting currency, " + "then retry the close." + ) + period_id, current_status, period_end_date = self._lock_book_period( + connection, tenant_id, book_id, period_code + ) + if current_status == "hard_closed": + if period_status_code == "soft_closed": + raise AccountingValidationError( + f"Fiscal period {period_code} is hard_closed. " + "Hard-closed periods cannot be soft-closed. " + "Open a later period or leave this period hard_closed; " + "no close row was written." + ) + return self._replay_close_receipt( + connection, + tenant_id=tenant_id, + legal_entity_id=legal_entity_id, + book_id=book_id, + period_id=period_id, + period_code=period_code, + current_status=current_status, + legal_entity_reference=legal_entity_reference, + accounting_book_reference=accounting_book_reference, + idempotency_key=close_idempotency_key, + ) + if current_status == period_status_code: + return self._replay_soft_close_receipt( + connection, + tenant_id=tenant_id, + legal_entity_id=legal_entity_id, + book_id=book_id, + period_id=period_id, + period_code=period_code, + period_end_date=period_end_date, + snapshot_currency_code=snapshot_currency_code, + legal_entity_reference=legal_entity_reference, + accounting_book_reference=accounting_book_reference, + idempotency_key=close_idempotency_key, + ) + if period_status_code == "soft_closed": + return self._persist_soft_close( + connection, + tenant_id=tenant_id, + legal_entity_id=legal_entity_id, + book_id=book_id, + period_id=period_id, + period_code=period_code, + period_end_date=period_end_date, + snapshot_currency_code=snapshot_currency_code, + legal_entity_reference=legal_entity_reference, + accounting_book_reference=accounting_book_reference, + idempotency_key=close_idempotency_key, + ) + package = self._assemble_period_close_package( + legal_entity_reference, + accounting_book_reference, + period_code, + ) + self._require_closeable_package(package) + return self._persist_period_close( + connection, + tenant_id=tenant_id, + legal_entity_id=legal_entity_id, + book_id=book_id, + period_id=period_id, + period_code=period_code, + period_end_date=period_end_date, + period_status_code=period_status_code, + snapshot_currency_code=snapshot_currency_code, + legal_entity_reference=legal_entity_reference, + accounting_book_reference=accounting_book_reference, + idempotency_key=close_idempotency_key, + ) + finally: + self._active_connection = None + + def open_fiscal_period( + self, + legal_entity_reference: str, + period_code: str, + period_start_date: date | None = None, + period_end_date: date | None = None, + *, + idempotency_key: str, + source_payload_hash: str, + ) -> dict[str, object]: + """Insert or replay one fiscal-period-open command from durable evidence.""" + if not legal_entity_reference or not period_code: + raise AccountingValidationError( + "legal_entity_reference and fiscal_period_reference are required. " + "Supply those period-open fields, then retry the period open." + ) + command_key = idempotency_key.strip() + if not command_key or command_key != idempotency_key: + raise AccountingValidationError( + "period-open idempotency_key must be a canonical non-empty string. " + "Supply the original command key, then retry the period open." + ) + if re.fullmatch(r"sha256:[0-9a-f]{64}", source_payload_hash) is None: + raise AccountingValidationError( + "period-open source_payload_hash must be a canonical sha256 digest. " + "Supply the immutable command hash, then retry the period open." + ) + with self._session() as connection: + tenant_id = self._require_tenant(connection) + self._acquire_command_lock(connection, f"period-open:{command_key}") + self._acquire_command_lock(connection, f"period:{period_code}") + legal_entity_id, _functional_currency = self._load_legal_entity( + connection, + tenant_id, + legal_entity_reference, + next_action="the period open", + ) + prior = connection.execute( + """ + SELECT period_open_command.legal_entity_id, + fiscal_period.period_code, + period_open_command.requested_period_start_date, + period_open_command.requested_period_end_date, + fiscal_period.period_start_date, + fiscal_period.period_end_date, + period_open_command.source_payload_hash + FROM accounting_integration.fiscal_period_open_command AS period_open_command + JOIN accounting_core.fiscal_period AS fiscal_period + ON fiscal_period.tenant_account_id = period_open_command.tenant_account_id + AND fiscal_period.fiscal_period_id = period_open_command.fiscal_period_id + WHERE period_open_command.tenant_account_id = %s + AND period_open_command.period_open_idempotency_key = %s + """, + (tenant_id, command_key), + ).fetchone() + if prior is not None: + ( + prior_legal_entity_id, + prior_period_code, + prior_requested_start, + prior_requested_end, + stored_start_date, + stored_end_date, + prior_source_hash, + ) = prior + if ( + prior_legal_entity_id != legal_entity_id + or prior_period_code != period_code + or prior_requested_start != period_start_date + or prior_requested_end != period_end_date + or prior_source_hash != source_payload_hash + ): + raise IdempotencyConflictError( + "period-open idempotency key was already used with a different payload" + ) + return self._period_open_document( + legal_entity_reference, + period_code, + stored_start_date, + stored_end_date, + replayed=True, + ) + + existing = self._load_period_state(connection, tenant_id, period_code) + replayed = existing is not None + if existing is not None: + period_id, current_status, stored_start_date, stored_end_date = existing + if current_status != "open": + raise AccountingValidationError( + f"Fiscal period {period_code} is {current_status}. " + "Closed periods cannot be reopened. Open a later period, " + "then retry the period open." + ) + if ( + period_start_date is not None + and period_start_date != stored_start_date + ) or ( + period_end_date is not None and period_end_date != stored_end_date + ): + raise AccountingValidationError( + "period-open dates do not match the already-open fiscal period. " + "Supply its existing dates or omit both dates, then retry." + ) + else: + if period_start_date is None or period_end_date is None: + raise AccountingValidationError( + "period_start_date and period_end_date are required. " + "Supply those fiscal_period dates, then retry the period open." + ) + if period_end_date < period_start_date: + raise AccountingValidationError( + "period_end_date must be on or after period_start_date. " + "Supply a valid date range, then retry the period open." + ) + calendar_id = self._require_tenant_calendar(connection, tenant_id) + period_id = connection.execute( + """ + INSERT INTO accounting_core.fiscal_period ( + tenant_account_id, fiscal_calendar_id, period_code, + period_start_date, period_end_date, period_status_code + ) + VALUES (%s, %s, %s, %s, %s, 'open') + RETURNING fiscal_period_id + """, + ( + tenant_id, + calendar_id, + period_code, + period_start_date, + period_end_date, + ), + ).fetchone()[0] + stored_start_date = period_start_date + stored_end_date = period_end_date + + connection.execute( + """ + INSERT INTO accounting_integration.fiscal_period_open_command ( + tenant_account_id, + legal_entity_id, + fiscal_period_id, + period_open_idempotency_key, + source_payload_hash, + requested_period_start_date, + requested_period_end_date + ) + VALUES (%s, %s, %s, %s, %s, %s, %s) + """, + ( + tenant_id, + legal_entity_id, + period_id, + command_key, + source_payload_hash, + period_start_date, + period_end_date, + ), + ) + return self._period_open_document( + legal_entity_reference, + period_code, + stored_start_date, + stored_end_date, + replayed=replayed, + ) + + def load_fiscal_period( + self, legal_entity_reference: str, period_code: str + ) -> dict[str, object]: + """Return persisted fiscal-period status and dates for one tenant entity.""" + if not legal_entity_reference or not period_code: + raise AccountingValidationError( + "legal_entity_reference and fiscal_period_reference are required. " + "Supply those period fields, then retry the period read." + ) + with self._session() as connection: + tenant_id = self._require_tenant(connection) + self._require_legal_entity( + connection, tenant_id, legal_entity_reference, "the period read" + ) + existing = self._load_period_state(connection, tenant_id, period_code) + if existing is None: + raise AccountingValidationError( + f"Fiscal period {period_code} is not recorded for this tenant. " + "Create the fiscal_period row, then retry the period read." + ) + _period_id, current_status, start_date, end_date = existing + return { + "tenant_reference": self._tenant_reference, + "legal_entity_reference": legal_entity_reference, + "fiscal_period_reference": f"urn:cwl:accounting:fiscal_period:{period_code}", + "period_code": period_code, + "period_status_code": current_status, + "period_start_date": start_date.isoformat(), + "period_end_date": end_date.isoformat(), + } + + def load_fiscal_periods( + self, + legal_entity_reference: str, + *, + page_limit: int = 50, + cursor_after: tuple[date, str] | None = None, + ) -> dict[str, object]: + """Return one page of existing fiscal periods for a tenant legal entity.""" + if not legal_entity_reference: + raise AccountingValidationError( + "legal_entity_reference is required. " + "Supply that period-list field, then retry the period list." + ) + with self._session() as connection: + tenant_id = self._require_tenant(connection) + self._require_legal_entity( + connection, tenant_id, legal_entity_reference, "the period list" + ) + calendar_row = connection.execute( + """ + SELECT fiscal_calendar_id + FROM accounting_core.fiscal_calendar + WHERE tenant_account_id = %s + ORDER BY calendar_code + LIMIT 1 + """, + (tenant_id,), + ).fetchone() + periods: list[dict[str, object]] = [] + next_cursor = None + if calendar_row is not None: + if cursor_after is None: + skip_cursor, cursor_start_date, cursor_period_code = ( + True, + _SQL_SKIP_DATE, + "", + ) + else: + skip_cursor, cursor_start_date, cursor_period_code = ( + False, + cursor_after[0], + cursor_after[1], + ) + rows = connection.execute( + """ + SELECT fiscal_period.period_code, + fiscal_period.period_start_date, + fiscal_period.period_end_date, + fiscal_period.period_status_code + FROM accounting_core.fiscal_period + WHERE fiscal_period.tenant_account_id = %s + AND fiscal_period.fiscal_calendar_id = %s + AND ( + %s + OR (fiscal_period.period_start_date, fiscal_period.period_code) + > (%s, %s) + ) + ORDER BY fiscal_period.period_start_date, fiscal_period.period_code + LIMIT %s + """, + ( + tenant_id, + calendar_row[0], + skip_cursor, + cursor_start_date, + cursor_period_code, + page_limit + 1, + ), + ).fetchall() + has_more = len(rows) > page_limit + page_rows = rows[:page_limit] + periods = [ + { + "fiscal_period_reference": ( + f"urn:cwl:accounting:fiscal_period:{row[0]}" + ), + "period_code": row[0], + "period_start_date": row[1].isoformat(), + "period_end_date": row[2].isoformat(), + "period_status_code": row[3], + } + for row in page_rows + ] + if has_more: + last = page_rows[-1] + next_cursor = f"{last[1].isoformat()}|{last[0]}" + return { + "tenant_reference": self._tenant_reference, + "legal_entity_reference": legal_entity_reference, + "fiscal_periods": periods, + "next_cursor": next_cursor, + } + + def load_account_rollforward( + self, + legal_entity_reference: str, + accounting_book_reference: str, + period_code: str, + chart_account_code: str, + statement_scope_code: str = "", + ) -> dict[str, object]: + """Return opening + period = closing sides for one chart account and scope.""" + if statement_scope_code not in {"", "period", "year_to_date"}: + raise AccountingValidationError( + "statement_scope_code must be period or year_to_date. " + "Supply a known statement scope, then retry the account-rollforward read." + ) + if not chart_account_code: + raise AccountingValidationError( + "chart_account_code is required. " + "Supply that account-rollforward field, then retry the account-rollforward read." + ) + account_classes = self._load_chart_account_classes( + legal_entity_reference, accounting_book_reference + ) + if chart_account_code not in account_classes: + raise AccountingValidationError( + f"Chart account {chart_account_code} is not recorded for this book. " + "Create the chart_account row, then retry the account-rollforward read." + ) + with self._session() as connection: + tenant_id = self._require_tenant(connection) + legal_entity_id = self._require_legal_entity( + connection, + tenant_id, + legal_entity_reference, + next_action="the account-rollforward read", + ) + book_id = self._require_book_for_close( + connection, + tenant_id, + legal_entity_id, + accounting_book_reference, + next_action="the account-rollforward read", + )[0] + self._require_fiscal_period( + connection, + tenant_id, + period_code, + next_action="the account-rollforward read", + ) + period_ids = self._statement_period_ids( + connection, + tenant_id, + period_code, + statement_scope_code, + ) + scope_start = connection.execute( + """ + SELECT MIN(period_start_date) + FROM accounting_core.fiscal_period + WHERE tenant_account_id = %s + AND fiscal_period_id = ANY(%s) + """, + (tenant_id, period_ids), + ).fetchone()[0] + opening_debit_amount, opening_credit_amount = self._opening_account_sides( + connection, + tenant_id, + legal_entity_id, + book_id, + chart_account_code, + scope_start, + ) + period_debit_amount, period_credit_amount = self._period_account_sides( + connection, + tenant_id, + legal_entity_id, + book_id, + chart_account_code, + period_ids, + ) + closing_debit_amount = opening_debit_amount + period_debit_amount + closing_credit_amount = opening_credit_amount + period_credit_amount + document = { + "tenant_reference": self._tenant_reference, + "legal_entity_reference": legal_entity_reference, + "accounting_book_reference": accounting_book_reference, + "book_reference": accounting_book_reference, + "fiscal_period_reference": f"urn:cwl:accounting:fiscal_period:{period_code}", + "chart_account_code": chart_account_code, + "account_class_code": account_classes[chart_account_code], + "opening_debit_amount": _exact_amount_text(opening_debit_amount), + "opening_credit_amount": _exact_amount_text(opening_credit_amount), + "period_debit_amount": _exact_amount_text(period_debit_amount), + "period_credit_amount": _exact_amount_text(period_credit_amount), + "closing_debit_amount": _exact_amount_text(closing_debit_amount), + "closing_credit_amount": _exact_amount_text(closing_credit_amount), + } + if statement_scope_code == "year_to_date": + document["statement_scope_code"] = "year_to_date" + return document + + def load_unapplied_cash_rollforward( + self, + legal_entity_reference: str, + accounting_book_reference: str, + period_code: str, + ) -> dict[str, object]: + """Return leftover-cash opening, park / apply / refund, and closing for 210200.""" + if not legal_entity_reference or not accounting_book_reference or not period_code: + raise AccountingValidationError( + "legal_entity_reference, book_reference, and fiscal_period_reference are required. " + "Supply those unapplied-cash-rollforward fields, then retry the unapplied-cash-rollforward read." + ) + account_classes = self._load_chart_account_classes( + legal_entity_reference, accounting_book_reference + ) + if "210200" not in account_classes: + raise AccountingValidationError( + "Chart account 210200 is not recorded for this book. " + "Create the chart_account row, then retry the unapplied-cash-rollforward read." + ) + with self._session() as connection: + tenant_id = self._require_tenant(connection) + legal_entity_id = self._require_legal_entity( + connection, + tenant_id, + legal_entity_reference, + next_action="the unapplied-cash-rollforward read", + ) + book_id = self._require_book_for_close( + connection, + tenant_id, + legal_entity_id, + accounting_book_reference, + next_action="the unapplied-cash-rollforward read", + )[0] + period_id, _period_status, period_end_date = self._require_fiscal_period( + connection, + tenant_id, + period_code, + next_action="the unapplied-cash-rollforward read", + ) + period_start_date = connection.execute( + """ + SELECT period_start_date + FROM accounting_core.fiscal_period + WHERE tenant_account_id = %s + AND fiscal_period_id = %s + """, + (tenant_id, period_id), + ).fetchone()[0] + opening_debit_amount, opening_credit_amount = self._opening_account_sides( + connection, + tenant_id, + legal_entity_id, + book_id, + "210200", + period_start_date, + ) + line_rows = connection.execute( + """ + SELECT COALESCE(journal_proposal_record.idempotency_key, ''), + general_journal.journal_reference, + journal_entry_line.account_role_code, + chart_account.chart_account_code, + journal_entry_line.debit_amount, + journal_entry_line.credit_amount + FROM accounting_core.journal_entry_line + JOIN accounting_core.general_journal + ON general_journal.tenant_account_id = journal_entry_line.tenant_account_id + AND general_journal.general_journal_id = journal_entry_line.general_journal_id + JOIN accounting_core.chart_account + ON chart_account.tenant_account_id = journal_entry_line.tenant_account_id + AND chart_account.chart_account_id = journal_entry_line.chart_account_id + LEFT JOIN accounting_integration.journal_proposal_record + ON journal_proposal_record.tenant_account_id = general_journal.tenant_account_id + AND journal_proposal_record.proposal_record_id + = general_journal.source_proposal_record_id + WHERE general_journal.tenant_account_id = %s + AND general_journal.legal_entity_id = %s + AND general_journal.accounting_book_id = %s + AND general_journal.accounting_date >= %s + AND general_journal.accounting_date <= %s + AND general_journal.journal_reference NOT LIKE %s + ORDER BY general_journal.journal_reference, journal_entry_line.line_number + """, + ( + tenant_id, + legal_entity_id, + book_id, + period_start_date, + period_end_date, + _CLOSING_JOURNAL_PATTERN, + ), + ).fetchall() + journals: dict[str, dict[str, object]] = {} + for ( + idempotency_key, + journal_reference, + account_role_code, + chart_account_code, + debit_amount, + credit_amount, + ) in line_rows: + bucket = journals.setdefault( + str(journal_reference), + { + "idempotency_key": str(idempotency_key), + "debit_roles": set(), + "credit_roles": set(), + "unapplied_debit_amount": Decimal("0"), + "unapplied_credit_amount": Decimal("0"), + }, + ) + line_debit_amount = Decimal(str(debit_amount)) + line_credit_amount = Decimal(str(credit_amount)) + debit_roles = bucket["debit_roles"] + credit_roles = bucket["credit_roles"] + assert isinstance(debit_roles, set) + assert isinstance(credit_roles, set) + if line_debit_amount > 0: + debit_roles.add(str(account_role_code)) + if line_credit_amount > 0: + credit_roles.add(str(account_role_code)) + if str(chart_account_code) == "210200": + bucket["unapplied_debit_amount"] = ( + Decimal(str(bucket["unapplied_debit_amount"])) + line_debit_amount + ) + bucket["unapplied_credit_amount"] = ( + Decimal(str(bucket["unapplied_credit_amount"])) + line_credit_amount + ) + parked_amount = Decimal("0") + applied_amount = Decimal("0") + refunded_amount = Decimal("0") + other_movement_amount = Decimal("0") + for bucket in journals.values(): + unapplied_debit_amount = Decimal(str(bucket["unapplied_debit_amount"])) + unapplied_credit_amount = Decimal(str(bucket["unapplied_credit_amount"])) + if unapplied_debit_amount == 0 and unapplied_credit_amount == 0: + continue + debit_roles = bucket["debit_roles"] + credit_roles = bucket["credit_roles"] + assert isinstance(debit_roles, set) + assert isinstance(credit_roles, set) + movement_kind = _unapplied_cash_movement_kind( + str(bucket["idempotency_key"]), + debit_roles, + credit_roles, + ) + if movement_kind == "parked": + parked_amount += unapplied_credit_amount + elif movement_kind == "applied": + applied_amount += unapplied_debit_amount + elif movement_kind == "refunded": + refunded_amount += unapplied_debit_amount + else: + other_movement_amount += unapplied_credit_amount - unapplied_debit_amount + opening_amount = opening_credit_amount - opening_debit_amount + closing_amount = ( + opening_amount + parked_amount - applied_amount - refunded_amount + other_movement_amount + ) + document: dict[str, object] = { + "tenant_reference": self._tenant_reference, + "legal_entity_reference": legal_entity_reference, + "accounting_book_reference": accounting_book_reference, + "book_reference": accounting_book_reference, + "fiscal_period_reference": f"urn:cwl:accounting:fiscal_period:{period_code}", + "as_of_date": period_end_date.isoformat(), + "chart_account_code": "210200", + "account_role_code": "unapplied_cash", + "parked_amount": _unsigned_aging_amount_text(parked_amount), + "applied_amount": _unsigned_aging_amount_text(applied_amount), + "refunded_amount": _unsigned_aging_amount_text(refunded_amount), + "opening_amount": _unsigned_aging_amount_text(opening_amount), + "closing_amount": _unsigned_aging_amount_text(closing_amount), + } + if other_movement_amount != 0: + document["other_movement_amount"] = _unsigned_aging_amount_text( + other_movement_amount + ) + return document + + def load_vat_period_register( + self, + legal_entity_reference: str, + accounting_book_reference: str, + period_code: str, + ) -> dict[str, object]: + """Return issued, voided, and closing tax-payable amounts for catalog 210100.""" + if not legal_entity_reference or not accounting_book_reference or not period_code: + raise AccountingValidationError( + "legal_entity_reference, book_reference, and fiscal_period_reference are required. " + "Supply those vat-period-register fields, then retry the vat-period-register read." + ) + account_classes = self._load_chart_account_classes( + legal_entity_reference, accounting_book_reference + ) + if "210100" not in account_classes: + raise AccountingValidationError( + "Chart account 210100 is not recorded for this book. " + "Create the chart_account row, then retry the vat-period-register read." + ) + with self._session() as connection: + tenant_id = self._require_tenant(connection) + legal_entity_id = self._require_legal_entity( + connection, + tenant_id, + legal_entity_reference, + next_action="the vat-period-register read", + ) + book_id = self._require_book_for_close( + connection, + tenant_id, + legal_entity_id, + accounting_book_reference, + next_action="the vat-period-register read", + )[0] + _period_id, _period_status, period_end_date = self._require_fiscal_period( + connection, + tenant_id, + period_code, + next_action="the vat-period-register read", + ) + line_rows = connection.execute( + """ + SELECT COALESCE(journal_proposal_record.idempotency_key, ''), + general_journal.journal_reference, + journal_entry_line.account_role_code, + chart_account.chart_account_code, + journal_entry_line.debit_amount, + journal_entry_line.credit_amount + FROM accounting_core.journal_entry_line + JOIN accounting_core.general_journal + ON general_journal.tenant_account_id = journal_entry_line.tenant_account_id + AND general_journal.general_journal_id = journal_entry_line.general_journal_id + JOIN accounting_core.chart_account + ON chart_account.tenant_account_id = journal_entry_line.tenant_account_id + AND chart_account.chart_account_id = journal_entry_line.chart_account_id + LEFT JOIN accounting_integration.journal_proposal_record + ON journal_proposal_record.tenant_account_id = general_journal.tenant_account_id + AND journal_proposal_record.proposal_record_id + = general_journal.source_proposal_record_id + WHERE general_journal.tenant_account_id = %s + AND general_journal.legal_entity_id = %s + AND general_journal.accounting_book_id = %s + AND general_journal.accounting_date <= %s + AND general_journal.journal_reference NOT LIKE %s + ORDER BY general_journal.journal_reference, journal_entry_line.line_number + """, + ( + tenant_id, + legal_entity_id, + book_id, + period_end_date, + _CLOSING_JOURNAL_PATTERN, + ), + ).fetchall() + journals: dict[str, dict[str, object]] = {} + for ( + idempotency_key, + journal_reference, + account_role_code, + chart_account_code, + debit_amount, + credit_amount, + ) in line_rows: + bucket = journals.setdefault( + str(journal_reference), + { + "idempotency_key": str(idempotency_key), + "debit_roles": set(), + "credit_roles": set(), + "tax_debit_amount": Decimal("0"), + "tax_credit_amount": Decimal("0"), + }, + ) + line_debit_amount = Decimal(str(debit_amount)) + line_credit_amount = Decimal(str(credit_amount)) + debit_roles = bucket["debit_roles"] + credit_roles = bucket["credit_roles"] + assert isinstance(debit_roles, set) + assert isinstance(credit_roles, set) + if line_debit_amount > 0: + debit_roles.add(str(account_role_code)) + if line_credit_amount > 0: + credit_roles.add(str(account_role_code)) + if str(chart_account_code) == "210100": + bucket["tax_debit_amount"] = ( + Decimal(str(bucket["tax_debit_amount"])) + line_debit_amount + ) + bucket["tax_credit_amount"] = ( + Decimal(str(bucket["tax_credit_amount"])) + line_credit_amount + ) + issued_amount = Decimal("0") + voided_amount = Decimal("0") + other_movement_amount = Decimal("0") + for bucket in journals.values(): + tax_debit_amount = Decimal(str(bucket["tax_debit_amount"])) + tax_credit_amount = Decimal(str(bucket["tax_credit_amount"])) + if tax_debit_amount == 0 and tax_credit_amount == 0: + continue + debit_roles = bucket["debit_roles"] + credit_roles = bucket["credit_roles"] + assert isinstance(debit_roles, set) + assert isinstance(credit_roles, set) + movement_kind = _vat_period_movement_kind( + str(bucket["idempotency_key"]), + debit_roles, + credit_roles, + ) + if movement_kind == "issued": + issued_amount += tax_credit_amount + elif movement_kind == "voided": + voided_amount += tax_debit_amount + else: + other_movement_amount += tax_credit_amount - tax_debit_amount + closing_amount = issued_amount - voided_amount + other_movement_amount + document: dict[str, object] = { + "tenant_reference": self._tenant_reference, + "legal_entity_reference": legal_entity_reference, + "accounting_book_reference": accounting_book_reference, + "book_reference": accounting_book_reference, + "fiscal_period_reference": f"urn:cwl:accounting:fiscal_period:{period_code}", + "as_of_date": period_end_date.isoformat(), + "chart_account_code": "210100", + "account_role_code": "tax_payable", + "issued_amount": _unsigned_aging_amount_text(issued_amount), + "voided_amount": _unsigned_aging_amount_text(voided_amount), + "closing_amount": _unsigned_aging_amount_text(closing_amount), + } + if other_movement_amount != 0: + document["other_movement_amount"] = _unsigned_aging_amount_text( + other_movement_amount + ) + return document + + def persist_home_tax_submission( + self, + *, + legal_entity_reference: str, + accounting_book_reference: str, + period_code: str, + submission_idempotency_key: str, + source_payload_hash: str, + source_payload_reference: str, + register_document: dict[str, object], + rejection_reason_code: str, + ) -> dict[str, object]: + """Persist or replay one rejected HomeTax receipt with immutable command provenance.""" + if not submission_idempotency_key: + raise AccountingValidationError( + "submission_idempotency_key is required. " + "Supply the original HomeTax command key, then retry the home-tax-submission." + ) + if re.fullmatch(r"sha256:[0-9a-f]{64}", source_payload_hash) is None: + raise AccountingValidationError( + "source_payload_hash must be a sha256 digest. " + "Supply immutable HomeTax source evidence, then retry the home-tax-submission." + ) + normalized_source_reference = source_payload_reference.strip() + if not normalized_source_reference: + raise AccountingValidationError( + "source_payload_reference is required. " + "Supply the immutable HomeTax source locator, then retry the home-tax-submission." + ) + register_payload_hash = "sha256:" + hashlib.sha256( + json.dumps( + register_document, separators=(",", ":"), sort_keys=True, default=str + ).encode("utf-8") + ).hexdigest() + raw_as_of_date = str(register_document.get("as_of_date") or "") + as_of_date = date.fromisoformat(raw_as_of_date) if raw_as_of_date else None + closing_amount = Decimal(str(register_document.get("closing_amount") or "0")) + with self._session() as connection: + tenant_id = self._require_tenant(connection) + legal_entity_id = self._require_legal_entity( + connection, + tenant_id, + legal_entity_reference, + next_action="the home-tax-submission", + ) + self._acquire_command_lock( + connection, f"home-tax:{submission_idempotency_key}" + ) + book_id = self._require_book_for_close( + connection, + tenant_id, + legal_entity_id, + accounting_book_reference, + next_action="the home-tax-submission", + )[0] + period_id, _period_status, period_end_date = self._require_fiscal_period( + connection, + tenant_id, + period_code, + next_action="the home-tax-submission", + ) + if as_of_date is None: + as_of_date = period_end_date + row = connection.execute( + """ + INSERT INTO accounting_integration.home_tax_submission ( + tenant_account_id, + legal_entity_id, + accounting_book_id, + fiscal_period_id, + submission_idempotency_key, + source_payload_hash, + source_payload_reference, + submission_status_code, + rejection_reason_code, + as_of_date, + closing_amount, + register_payload_hash + ) VALUES (%s, %s, %s, %s, %s, %s, %s, 'rejected', %s, %s, %s, %s) + ON CONFLICT (tenant_account_id, submission_idempotency_key) DO NOTHING + RETURNING home_tax_submission_id, + submission_status_code, + rejection_reason_code, + as_of_date, + closing_amount, + register_payload_hash, + source_payload_hash, + source_payload_reference, + legal_entity_id, + accounting_book_id, + fiscal_period_id + """, + ( + tenant_id, + legal_entity_id, + book_id, + period_id, + submission_idempotency_key, + source_payload_hash, + normalized_source_reference, + rejection_reason_code, + as_of_date, + closing_amount, + register_payload_hash, + ), + ).fetchone() + if row is None: + row = connection.execute( + """ + SELECT home_tax_submission_id, + submission_status_code, + rejection_reason_code, + as_of_date, + closing_amount, + register_payload_hash, + source_payload_hash, + source_payload_reference, + legal_entity_id, + accounting_book_id, + fiscal_period_id + FROM accounting_integration.home_tax_submission + WHERE tenant_account_id = %s + AND submission_idempotency_key = %s + """, + (tenant_id, submission_idempotency_key), + ).fetchone() + if row is None: + raise AccountingValidationError( + "HomeTax command replay could not find its existing receipt. " + "Retry the command with the same idempotency key." + ) + if ( + row[5] != register_payload_hash + or row[6] != source_payload_hash + or row[7] != normalized_source_reference + or row[8] != legal_entity_id + or row[9] != book_id + or row[10] != period_id + ): + raise IdempotencyConflictError( + "HomeTax idempotency key was already used with different evidence or scope. " + "Use a new command key for the changed submission." + ) + receipt_register = _home_tax_register_view(register_document) + if not receipt_register.get("as_of_date"): + receipt_register["as_of_date"] = row[3].isoformat() + return _home_tax_submission_document( + home_tax_submission_id=str(row[0]), + tenant_reference=self._tenant_reference, + legal_entity_reference=legal_entity_reference, + book_reference=accounting_book_reference, + period_code=period_code, + vat_period_register=receipt_register, + rejection_reason_code=str(row[2]), + submission_status_code=str(row[1]), + ) + + def load_home_tax_submissions( + self, + legal_entity_reference: str, + accounting_book_reference: str, + period_code: str, + ) -> dict[str, object]: + """Return persisted HomeTax receipts for one tenant entity, book, and period.""" + if not legal_entity_reference or not accounting_book_reference or not period_code: + raise AccountingValidationError( + "legal_entity_reference, book_reference, and fiscal_period_reference are required. " + "Supply those home-tax-submission fields, then retry the home-tax-submission read." + ) + with self._session() as connection: + tenant_id = self._require_tenant(connection) + legal_entity_id = self._require_legal_entity( + connection, + tenant_id, + legal_entity_reference, + next_action="the home-tax-submission read", + ) + book_id = self._require_book_for_close( + connection, + tenant_id, + legal_entity_id, + accounting_book_reference, + next_action="the home-tax-submission read", + )[0] + period_id, _period_status, _period_end_date = self._require_fiscal_period( + connection, + tenant_id, + period_code, + next_action="the home-tax-submission read", + ) + rows = connection.execute( + """ + SELECT home_tax_submission_id, + submission_status_code, + rejection_reason_code, + as_of_date, + closing_amount + FROM accounting_integration.home_tax_submission + WHERE tenant_account_id = %s + AND legal_entity_id = %s + AND accounting_book_id = %s + AND fiscal_period_id = %s + ORDER BY created_at, home_tax_submission_id + """, + (tenant_id, legal_entity_id, book_id, period_id), + ).fetchall() + return { + "tenant_reference": self._tenant_reference, + "legal_entity_reference": legal_entity_reference, + "book_reference": accounting_book_reference, + "fiscal_period_reference": f"urn:cwl:accounting:fiscal_period:{period_code}", + "home_tax_submissions": [ + _home_tax_submission_document( + home_tax_submission_id=str(row[0]), + tenant_reference=self._tenant_reference, + legal_entity_reference=legal_entity_reference, + book_reference=accounting_book_reference, + period_code=period_code, + vat_period_register={ + "as_of_date": row[3].isoformat(), + "closing_amount": _unsigned_aging_amount_text(Decimal(row[4])), + }, + rejection_reason_code=str(row[2]), + submission_status_code=str(row[1]), + ) + for row in rows + ], + } + + def _opening_account_sides( + self, + connection: object, + tenant_id: UUID, + legal_entity_id: UUID, + book_id: UUID, + chart_account_code: str, + scope_start: date, + ) -> tuple[Decimal, Decimal]: + prior_snapshot = connection.execute( + """ + SELECT trial_balance_snapshot.trial_balance_snapshot_id + FROM accounting_core.fiscal_period + JOIN accounting_reporting.trial_balance_snapshot + ON trial_balance_snapshot.tenant_account_id = fiscal_period.tenant_account_id + AND trial_balance_snapshot.fiscal_period_id = fiscal_period.fiscal_period_id + AND trial_balance_snapshot.legal_entity_id = %s + AND trial_balance_snapshot.accounting_book_id = %s + WHERE fiscal_period.tenant_account_id = %s + AND fiscal_period.period_end_date < %s + AND fiscal_period.period_status_code = 'hard_closed' + ORDER BY fiscal_period.period_end_date DESC, fiscal_period.period_code DESC + LIMIT 1 + """, + (legal_entity_id, book_id, tenant_id, scope_start), + ).fetchone() + if prior_snapshot is not None: + row = connection.execute( + """ + SELECT COALESCE(trial_balance_line.debit_total_amount, 0), + COALESCE(trial_balance_line.credit_total_amount, 0) + FROM accounting_reporting.trial_balance_line + JOIN accounting_core.chart_account + ON chart_account.tenant_account_id = trial_balance_line.tenant_account_id + AND chart_account.chart_account_id = trial_balance_line.chart_account_id + WHERE trial_balance_line.tenant_account_id = %s + AND trial_balance_line.trial_balance_snapshot_id = %s + AND chart_account.chart_account_code = %s + """, + (tenant_id, prior_snapshot[0], chart_account_code), + ).fetchone() + if row is None: + return Decimal("0"), Decimal("0") + return Decimal(row[0]), Decimal(row[1]) + row = connection.execute( + """ + SELECT COALESCE(SUM(journal_entry_line.debit_amount), 0), + COALESCE(SUM(journal_entry_line.credit_amount), 0) + FROM accounting_core.journal_entry_line + JOIN accounting_core.general_journal + ON general_journal.tenant_account_id = journal_entry_line.tenant_account_id + AND general_journal.general_journal_id = journal_entry_line.general_journal_id + JOIN accounting_core.chart_account + ON chart_account.tenant_account_id = journal_entry_line.tenant_account_id + AND chart_account.chart_account_id = journal_entry_line.chart_account_id + WHERE general_journal.tenant_account_id = %s + AND general_journal.legal_entity_id = %s + AND general_journal.accounting_book_id = %s + AND chart_account.chart_account_code = %s + AND general_journal.accounting_date <= %s + """, + ( + tenant_id, + legal_entity_id, + book_id, + chart_account_code, + scope_start - timedelta(days=1), + ), + ).fetchone() + return Decimal(row[0]), Decimal(row[1]) + + def _period_account_sides( + self, + connection: object, + tenant_id: UUID, + legal_entity_id: UUID, + book_id: UUID, + chart_account_code: str, + period_ids: list[UUID], + ) -> tuple[Decimal, Decimal]: + row = connection.execute( + """ + SELECT COALESCE(SUM(journal_entry_line.debit_amount), 0), + COALESCE(SUM(journal_entry_line.credit_amount), 0) + FROM accounting_core.journal_entry_line + JOIN accounting_core.general_journal + ON general_journal.tenant_account_id = journal_entry_line.tenant_account_id + AND general_journal.general_journal_id = journal_entry_line.general_journal_id + JOIN accounting_core.chart_account + ON chart_account.tenant_account_id = journal_entry_line.tenant_account_id + AND chart_account.chart_account_id = journal_entry_line.chart_account_id + WHERE general_journal.tenant_account_id = %s + AND general_journal.legal_entity_id = %s + AND general_journal.accounting_book_id = %s + AND chart_account.chart_account_code = %s + AND general_journal.fiscal_period_id = ANY(%s) + """, + ( + tenant_id, + legal_entity_id, + book_id, + chart_account_code, + period_ids, + ), + ).fetchone() + return Decimal(row[0]), Decimal(row[1]) + + def load_account_balances( + self, + legal_entity_reference: str, + accounting_book_reference: str, + period_code: str, + chart_account_code: str = "", + *, + page_limit: int = 50, + cursor: str = "", + ) -> dict[str, object]: + """Return as-of chart-account balances from the close snapshot or live journals.""" + trial_balance = self.load_period_trial_balance( + legal_entity_reference=legal_entity_reference, + accounting_book_reference=accounting_book_reference, + period_code=period_code, + ) + account_classes = self._load_chart_account_classes( + legal_entity_reference, accounting_book_reference + ) + requested_code = chart_account_code.strip() + if requested_code and requested_code not in account_classes: + raise AccountingValidationError( + f"Chart account {requested_code} is not recorded for this book. " + "Create the chart_account row, then retry the account-balance read." + ) + source_lines = [ + { + "chart_account_code": str(raw_line["chart_account_code"]), + "debit_amount": str(raw_line["debit_amount"]), + "credit_amount": str(raw_line["credit_amount"]), + } + for raw_line in trial_balance["lines"] + ] + if requested_code: + source_lines = [ + raw_line + for raw_line in source_lines + if raw_line["chart_account_code"] == requested_code + ] + if not source_lines: + source_lines = [ + { + "chart_account_code": requested_code, + "debit_amount": "0", + "credit_amount": "0", + } + ] + if cursor: + source_lines = [ + raw_line + for raw_line in source_lines + if raw_line["chart_account_code"] > cursor + ] + has_more = len(source_lines) > page_limit + page_lines = source_lines[:page_limit] + account_balances = [ + { + "chart_account_code": raw_line["chart_account_code"], + "account_class_code": account_classes[str(raw_line["chart_account_code"])], + "debit_amount": _exact_amount_text(Decimal(str(raw_line["debit_amount"]))), + "credit_amount": _exact_amount_text(Decimal(str(raw_line["credit_amount"]))), + } + for raw_line in page_lines + ] + next_cursor = None + if has_more: + next_cursor = str(page_lines[-1]["chart_account_code"]) + return { + "tenant_reference": self._tenant_reference, + "legal_entity_reference": legal_entity_reference, + "accounting_book_reference": accounting_book_reference, + "book_reference": accounting_book_reference, + "fiscal_period_reference": str(trial_balance["fiscal_period_reference"]), + "account_balances": account_balances, + "next_cursor": next_cursor, + } + + def load_receivable_aging( + self, + legal_entity_reference: str, + book_reference: str, + period_code: str, + chart_account_code: str = "", + ) -> dict[str, object]: + """Return entity-level FIFO receivable aging as of the fiscal period end date.""" + return self._load_account_aging( + legal_entity_reference, + book_reference, + period_code, + chart_account_code, + catalog_role_code="accounts_receivable", + increase_is_debit=True, + read_name="receivable-aging", + ) + + def load_payable_aging( + self, + legal_entity_reference: str, + book_reference: str, + period_code: str, + chart_account_code: str = "", + ) -> dict[str, object]: + """Return entity-level FIFO payable aging as of the fiscal period end date.""" + return self._load_account_aging( + legal_entity_reference, + book_reference, + period_code, + chart_account_code, + catalog_role_code="tax_payable", + increase_is_debit=False, + read_name="payable-aging", + ) + + def _load_account_aging( + self, + legal_entity_reference: str, + book_reference: str, + period_code: str, + chart_account_code: str, + *, + catalog_role_code: str, + increase_is_debit: bool, + read_name: str, + ) -> dict[str, object]: + if not legal_entity_reference or not book_reference or not period_code: + raise AccountingValidationError( + "legal_entity_reference, book_reference, and fiscal_period_reference are required. " + f"Supply those {read_name} fields, then retry the {read_name} read." + ) + with self._session() as connection: + tenant_id = self._require_tenant(connection) + legal_entity_id = self._require_legal_entity( + connection, + tenant_id, + legal_entity_reference, + next_action=f"the {read_name} read", + ) + book_id = self._require_book_for_close( + connection, + tenant_id, + legal_entity_id, + book_reference, + next_action=f"the {read_name} read", + )[0] + _period_id, _status, period_end_date = self._require_fiscal_period( + connection, + tenant_id, + period_code, + next_action=f"the {read_name} read", + ) + account_rows = connection.execute( + """ + SELECT chart_account.chart_account_code, + account_role_mapping.account_role_code, + chart_account.account_class_code + FROM accounting_core.chart_account + LEFT JOIN accounting_core.account_role_mapping + ON account_role_mapping.tenant_account_id = chart_account.tenant_account_id + AND account_role_mapping.chart_account_id = chart_account.chart_account_id + AND account_role_mapping.valid_to IS NULL + WHERE chart_account.tenant_account_id = %s + AND chart_account.accounting_book_id = %s + AND chart_account.valid_to IS NULL + """, + (tenant_id, book_id), + ).fetchall() + account_classes = { + str(account_code): str(account_class_code) + for account_code, _role_code, account_class_code in account_rows + } + catalog_account_code = next( + ( + str(account_code) + for account_code, role_code, _class in account_rows + if role_code == catalog_role_code + ), + "", + ) + resolved_account_code = chart_account_code.strip() or catalog_account_code + if resolved_account_code not in account_classes: + raise AccountingValidationError( + f"Chart account {resolved_account_code} is not recorded for this book. " + f"Create the chart_account row, then retry the {read_name} read." + ) + if resolved_account_code != catalog_account_code: + raise AccountingValidationError( + f"chart_account_code must be the catalog {catalog_role_code} account. " + f"Supply that {read_name} account, then retry the {read_name} read." + ) + line_rows = connection.execute( + """ + SELECT general_journal.accounting_date, + general_journal.journal_reference, + journal_entry_line.line_number, + journal_entry_line.debit_amount, + journal_entry_line.credit_amount + FROM accounting_core.journal_entry_line + JOIN accounting_core.general_journal + ON general_journal.tenant_account_id = journal_entry_line.tenant_account_id + AND general_journal.general_journal_id = journal_entry_line.general_journal_id + JOIN accounting_core.chart_account + ON chart_account.tenant_account_id = journal_entry_line.tenant_account_id + AND chart_account.chart_account_id = journal_entry_line.chart_account_id + WHERE general_journal.tenant_account_id = %s + AND general_journal.legal_entity_id = %s + AND general_journal.accounting_book_id = %s + AND chart_account.chart_account_code = %s + AND general_journal.accounting_date <= %s + AND general_journal.journal_reference NOT LIKE %s + ORDER BY general_journal.accounting_date, + CASE + WHEN %s AND journal_entry_line.debit_amount > 0 THEN 0 + WHEN NOT %s AND journal_entry_line.credit_amount > 0 THEN 0 + ELSE 1 + END, + general_journal.journal_reference, + journal_entry_line.line_number + """, + ( + tenant_id, + legal_entity_id, + book_id, + resolved_account_code, + period_end_date, + _CLOSING_JOURNAL_PATTERN, + increase_is_debit, + increase_is_debit, + ), + ).fetchall() + open_items = _fifo_aging_open_items(line_rows, increase_is_debit=increase_is_debit) + bucket_amounts = { + "current": Decimal("0"), + "days_31_60": Decimal("0"), + "days_61_90": Decimal("0"), + "days_over_90": Decimal("0"), + } + for open_item in open_items: + outstanding_days = (period_end_date - open_item[0]).days + bucket_amounts[_receivable_aging_bucket(outstanding_days)] += open_item[1] + total_outstanding_amount = ( + bucket_amounts["current"] + + bucket_amounts["days_31_60"] + + bucket_amounts["days_61_90"] + + bucket_amounts["days_over_90"] + ) + document: dict[str, object] = { + "tenant_reference": self._tenant_reference, + "legal_entity_reference": legal_entity_reference, + "accounting_book_reference": book_reference, + "book_reference": book_reference, + "fiscal_period_reference": f"urn:cwl:accounting:fiscal_period:{period_code}", + "chart_account_code": resolved_account_code, + "account_class_code": account_classes[resolved_account_code], + "as_of_date": period_end_date.isoformat(), + "current_amount": _unsigned_aging_amount_text(bucket_amounts["current"]), + "days_31_60_amount": _unsigned_aging_amount_text(bucket_amounts["days_31_60"]), + "days_61_90_amount": _unsigned_aging_amount_text(bucket_amounts["days_61_90"]), + "days_over_90_amount": _unsigned_aging_amount_text(bucket_amounts["days_over_90"]), + "total_outstanding_amount": _unsigned_aging_amount_text(total_outstanding_amount), + } + if increase_is_debit: + unapplied_credit_amount = Decimal("0") + for _date, _reference, _line_number, debit_amount, credit_amount in line_rows: + unapplied_credit_amount += Decimal(str(credit_amount)) - Decimal( + str(debit_amount) + ) + if unapplied_credit_amount > 0: + document["unapplied_credit_amount"] = _unsigned_aging_amount_text( + unapplied_credit_amount + ) + return document + + def _load_chart_account_classes( + self, legal_entity_reference: str, accounting_book_reference: str + ) -> dict[str, str]: + with self._session() as connection: + tenant_id = self._require_tenant(connection) + legal_entity_id = self._require_legal_entity( + connection, + tenant_id, + legal_entity_reference, + next_action="the account-balance read", + ) + book_id = self._require_book_for_close( + connection, + tenant_id, + legal_entity_id, + accounting_book_reference, + next_action="the account-balance read", + )[0] + rows = connection.execute( + """ + SELECT chart_account.chart_account_code, + chart_account.account_class_code + FROM accounting_core.chart_account + WHERE chart_account.tenant_account_id = %s + AND chart_account.accounting_book_id = %s + AND chart_account.valid_to IS NULL + """, + (tenant_id, book_id), + ).fetchall() + return { + str(account_code): str(account_class_code) + for account_code, account_class_code in rows + } + + def load_account_ledger( + self, + legal_entity_reference: str, + chart_account_code: str, + fiscal_period_reference: str = "", + *, + page_limit: int = 50, + cursor_after: tuple[datetime, str, int] | None = None, + ) -> dict[str, object]: + """Return posted journal lines for one tenant entity and chart account.""" + if not legal_entity_reference: + raise AccountingValidationError( + "legal_entity_reference is required. " + "Supply that ledger field, then retry the account-ledger read." + ) + if not chart_account_code: + raise AccountingValidationError( + "chart_account_code is required. " + "Supply that ledger field, then retry the account-ledger read." + ) + period_code = "" + if fiscal_period_reference: + period_code = fiscal_period_reference + if period_code.startswith("urn:cwl:accounting:fiscal_period:"): + period_code = period_code[len("urn:cwl:accounting:fiscal_period:") :] + with self._session() as connection: + tenant_id = self._require_tenant(connection) + self._require_legal_entity( + connection, tenant_id, legal_entity_reference, "the account-ledger read" + ) + chart_row = connection.execute( + """ + SELECT chart_account_id + FROM accounting_core.chart_account + WHERE tenant_account_id = %s + AND chart_account_code = %s + AND valid_to IS NULL + LIMIT 1 + """, + (tenant_id, chart_account_code), + ).fetchone() + if chart_row is None: + raise AccountingValidationError( + f"Chart account {chart_account_code} is not recorded for this tenant. " + "Create the chart_account row, then retry the account-ledger read." + ) + period_id = None + period_reference: str | None = None + if period_code: + period_id, _status, _end = self._require_fiscal_period( + connection, tenant_id, period_code, "the account-ledger read" + ) + period_reference = f"urn:cwl:accounting:fiscal_period:{period_code}" + cursor_posted_at = None + cursor_journal_reference = None + cursor_line_number = None + if cursor_after is not None: + cursor_posted_at, cursor_journal_reference, cursor_line_number = cursor_after + totals = connection.execute( + """ + SELECT COALESCE(SUM(journal_entry_line.debit_amount), 0), + COALESCE(SUM(journal_entry_line.credit_amount), 0) + FROM accounting_core.journal_entry_line + JOIN accounting_core.general_journal + ON general_journal.tenant_account_id = journal_entry_line.tenant_account_id + AND general_journal.general_journal_id = journal_entry_line.general_journal_id + JOIN accounting_core.chart_account + ON chart_account.tenant_account_id = journal_entry_line.tenant_account_id + AND chart_account.chart_account_id = journal_entry_line.chart_account_id + JOIN accounting_core.legal_entity_record + ON legal_entity_record.tenant_account_id = general_journal.tenant_account_id + AND legal_entity_record.legal_entity_id = general_journal.legal_entity_id + WHERE journal_entry_line.tenant_account_id = %s + AND legal_entity_record.legal_entity_code = %s + AND chart_account.chart_account_code = %s + AND (%s::uuid IS NULL OR general_journal.fiscal_period_id = %s) + """, + ( + tenant_id, + legal_entity_reference, + chart_account_code, + period_id, + period_id, + ), + ).fetchone() + rows = connection.execute( + """ + SELECT general_journal.journal_reference, + general_journal.posted_at, + journal_entry_line.line_number, + chart_account.chart_account_code, + journal_entry_line.account_role_code, + journal_entry_line.debit_amount, + journal_entry_line.credit_amount + FROM accounting_core.journal_entry_line + JOIN accounting_core.general_journal + ON general_journal.tenant_account_id = journal_entry_line.tenant_account_id + AND general_journal.general_journal_id = journal_entry_line.general_journal_id + JOIN accounting_core.chart_account + ON chart_account.tenant_account_id = journal_entry_line.tenant_account_id + AND chart_account.chart_account_id = journal_entry_line.chart_account_id + JOIN accounting_core.legal_entity_record + ON legal_entity_record.tenant_account_id = general_journal.tenant_account_id + AND legal_entity_record.legal_entity_id = general_journal.legal_entity_id + WHERE journal_entry_line.tenant_account_id = %s + AND legal_entity_record.legal_entity_code = %s + AND chart_account.chart_account_code = %s + AND (%s::uuid IS NULL OR general_journal.fiscal_period_id = %s) + AND ( + %s::timestamptz IS NULL + OR ( + general_journal.posted_at, + general_journal.journal_reference, + journal_entry_line.line_number + ) > (%s, %s, %s) + ) + ORDER BY general_journal.posted_at, + general_journal.journal_reference, + journal_entry_line.line_number + LIMIT %s + """, + ( + tenant_id, + legal_entity_reference, + chart_account_code, + period_id, + period_id, + cursor_posted_at, + cursor_posted_at, + cursor_journal_reference, + cursor_line_number, + page_limit + 1, + ), + ).fetchall() + has_more = len(rows) > page_limit + page_rows = rows[:page_limit] + ledger_lines = [ + { + "line_number": row[2], + "chart_account_code": row[3], + "account_role_code": row[4], + "debit_amount": _exact_amount_text(Decimal(row[5])), + "credit_amount": _exact_amount_text(Decimal(row[6])), + "journal_reference": row[0], + "posted_at": _format_timestamp(row[1]), + } + for row in page_rows + ] + next_cursor = None + if has_more: + last = page_rows[-1] + next_cursor = f"{_format_timestamp(last[1])}|{last[0]}|{last[2]}" + return { + "tenant_reference": self._tenant_reference, + "legal_entity_reference": legal_entity_reference, + "chart_account_code": chart_account_code, + "fiscal_period_reference": period_reference, + "ledger_lines": ledger_lines, + "period_debit_total": _exact_amount_text(Decimal(totals[0])), + "period_credit_total": _exact_amount_text(Decimal(totals[1])), + "next_cursor": next_cursor, + } + + def reverse( + self, + journal_reference: str, + reversal_date: date, + reversal_reason_code: str, + policy: AccountingPolicy, + *, + reversal_idempotency_key: str | None = None, + ) -> PostingReceipt: + """Append the exact opposite of one original journal and preserve lineage.""" + _require_code(reversal_reason_code, "reversal reason code") + command_key = ( + f"reversal:{journal_reference}" + if reversal_idempotency_key is None + else reversal_idempotency_key.strip() + ) + if not command_key: + raise AccountingValidationError( + "reversal idempotency key must not be empty. " + "Supply the reversal command identity, then retry reversal." + ) + command_hash = _reversal_command_hash( + tenant_reference=self._tenant_reference, + reversal_idempotency_key=command_key, + original_journal_reference=journal_reference, + reversal_date=reversal_date, + reversal_reason_code=reversal_reason_code, + ) + with self._session() as connection: + tenant_id = self._require_tenant(connection) + self._acquire_command_lock( + connection, f"reversal:{journal_reference}:{command_key}" + ) + existing = connection.execute( + """ + SELECT reversal_journal.journal_reference, + reversal_record.idempotency_key, + reversal_record.source_payload_hash, + original_journal.journal_reference, + journal_reversal.reversal_reason_code, + reversal_journal.accounting_date + FROM accounting_core.journal_reversal + JOIN accounting_core.general_journal AS original_journal + ON original_journal.tenant_account_id = journal_reversal.tenant_account_id + AND original_journal.general_journal_id = journal_reversal.original_journal_id + JOIN accounting_core.general_journal AS reversal_journal + ON reversal_journal.tenant_account_id = journal_reversal.tenant_account_id + AND reversal_journal.general_journal_id = journal_reversal.reversal_journal_id + JOIN accounting_integration.journal_proposal_record AS reversal_record + ON reversal_record.tenant_account_id = reversal_journal.tenant_account_id + AND reversal_record.proposal_record_id = reversal_journal.source_proposal_record_id + WHERE journal_reversal.tenant_account_id = %s + AND original_journal.journal_reference = %s + """, + (tenant_id, journal_reference), + ).fetchone() + if existing is not None: + if str(existing[1]) != command_key: + raise AccountingValidationError( + "journal is already reversed. Use the existing reversal receipt, then retry." + ) + if ( + str(existing[2]) != command_hash + or str(existing[3]) != journal_reference + or str(existing[4]) != reversal_reason_code + or existing[5] != reversal_date + ): + raise IdempotencyConflictError( + "reversal idempotency key was already used with different command evidence. " + "Use a new reversal command identity, then retry." + ) + return self._receipt_for_journal(connection, tenant_id, existing[0]) + prior_command = connection.execute( + """ + SELECT source_payload_hash + FROM accounting_integration.journal_proposal_record + WHERE tenant_account_id = %s AND idempotency_key = %s + """, + (tenant_id, command_key), + ).fetchone() + if prior_command is not None: + raise IdempotencyConflictError( + "reversal idempotency key was already used by another accounting command. Supply a new reversal command identity, then retry." + ) + original = connection.execute( + """ + SELECT general_journal_id, legal_entity_id, accounting_book_id, + transaction_currency_code, functional_currency_code, + source_proposal_record_id, transaction_date, accounting_date + FROM accounting_core.general_journal + WHERE tenant_account_id = %s AND journal_reference = %s + """, + (tenant_id, journal_reference), + ).fetchone() + if original is None: + raise AccountingValidationError( + "journal does not exist. Supply a posted journal reference, then retry reversal." + ) + already_reversal = connection.execute( + """ + SELECT 1 + FROM accounting_core.journal_reversal + WHERE tenant_account_id = %s AND reversal_journal_id = %s + """, + (tenant_id, original[0]), + ).fetchone() + if already_reversal is not None: + raise AccountingValidationError( + "a reversal journal cannot itself be reversed. Reverse the original journal, or post a replacement." + ) + if reversal_date < original[7]: + raise AccountingValidationError( + "reversal date cannot precede original journal accounting date. Supply a reversal_date on or after the original accounting date, then retry reversal." + ) + if not policy.permits(reversal_date): + raise AccountingValidationError("reversal date belongs to a closed fiscal period. Reverse into an open or soft-closed period, then retry reversal.") + if ( + self._tenant_reference != policy.tenant_reference + or self._legal_entity_code(connection, tenant_id, original[1]) + != policy.legal_entity_reference + or self._book_name(connection, tenant_id, original[2]) + != policy.accounting_book_reference + ): + raise AccountingValidationError( + "reversal policy scope does not match original journal. Supply the reversal policy for the original journal's legal entity and book, then retry reversal." + ) + period_id = self._require_adjusting_period(connection, tenant_id, reversal_date) + original_lines = self._load_lines(connection, tenant_id, original[0]) + reversal_lines = tuple( + PostedJournalLine( + line_number=line.line_number, + chart_account_code=line.chart_account_code, + account_role_code=line.account_role_code, + debit_amount=line.credit_amount, + credit_amount=line.debit_amount, + ) + for line in original_lines + ) + reversal_reference = f"{journal_reference}:reversal" + occupant = connection.execute( + """ + SELECT 1 + FROM accounting_core.general_journal + WHERE tenant_account_id = %s AND journal_reference = %s + """, + (tenant_id, reversal_reference), + ).fetchone() + if occupant is not None: + raise AccountingValidationError( + "posted journal is immutable. Reverse the existing journal, " + "then post a replacement." + ) + _original_source_hash, source_proposal_id = self._proposal_identity( + connection, tenant_id, original[5] + ) + receipt = PostingReceipt( + receipt_reference=f"{reversal_reference}:receipt", + journal_reference=reversal_reference, + posting_status_code="posted", + source_proposal_id=source_proposal_id, + source_payload_hash=command_hash, + tenant_reference=policy.tenant_reference, + legal_entity_reference=policy.legal_entity_reference, + accounting_book_reference=policy.accounting_book_reference, + accounting_policy_version=policy.accounting_policy_version, + posting_rule_version=policy.posting_rule_version, + line_count=len(reversal_lines), + reversal_of_journal_reference=journal_reference, + ) + reversal_proposal_id = connection.execute( + """ + INSERT INTO accounting_integration.journal_proposal_record ( + tenant_account_id, external_proposal_id, proposal_contract_version, + idempotency_key, source_payload_hash, proposal_status_code, processed_at + ) + VALUES (%s, uuidv7(), 1, %s, %s, 'posted', clock_timestamp()) + RETURNING proposal_record_id + """, + (tenant_id, command_key, command_hash), + ).fetchone()[0] + reversal_journal_id = self._insert_journal( + connection, + tenant_id=tenant_id, + legal_entity_id=original[1], + book_id=original[2], + period_id=period_id, + journal_reference=reversal_reference, + proposal=_ReversalProposal( + source_payload_hash=command_hash, + transaction_currency=original[3], + transaction_date=original[6], + accounting_date=reversal_date, + source_event_references=(), + ), + policy=policy, + proposal_record_id=reversal_proposal_id, + lines=reversal_lines, + ) + connection.execute( + """ + INSERT INTO accounting_core.journal_reversal ( + tenant_account_id, original_journal_id, reversal_journal_id, + reversal_reason_code + ) + VALUES (%s, %s, %s, %s) + """, + (tenant_id, original[0], reversal_journal_id, reversal_reason_code), + ) + self._insert_receipt( + connection, tenant_id, reversal_proposal_id, reversal_journal_id, receipt + ) + self._insert_outbox( + connection, + tenant_id, + "journal_reversal", + reversal_reference, + receipt.receipt_reference, + receipt, + ) + return receipt + + def load_reversal_policy( + self, journal_reference: str, reversal_date: date + ) -> AccountingPolicy: + """Build catalog policy for reversing *journal_reference* on *reversal_date*.""" + with self._session() as connection: + tenant_id = self._require_tenant(connection) + row = connection.execute( + """ + SELECT legal_entity_record.legal_entity_code, + accounting_book.book_name, + accounting_book.book_role_code, + general_journal.transaction_currency_code, + general_journal.functional_currency_code, + general_journal.accounting_policy_version, + general_journal.posting_rule_version, + general_journal.general_journal_id + FROM accounting_core.general_journal + JOIN accounting_core.legal_entity_record + ON legal_entity_record.tenant_account_id = general_journal.tenant_account_id + AND legal_entity_record.legal_entity_id = general_journal.legal_entity_id + JOIN accounting_core.accounting_book + ON accounting_book.tenant_account_id = general_journal.tenant_account_id + AND accounting_book.accounting_book_id = general_journal.accounting_book_id + WHERE general_journal.tenant_account_id = %s + AND general_journal.journal_reference = %s + """, + (tenant_id, journal_reference), + ).fetchone() + if row is None: + raise AccountingValidationError( + "journal does not exist. Supply a posted journal reference, then retry reversal." + ) + _period_id, period_start, period_end = self._require_adjusting_period_bounds( + connection, tenant_id, reversal_date + ) + lines = self._load_lines(connection, tenant_id, row[7]) + return AccountingPolicy( + tenant_reference=self._tenant_reference, + legal_entity_reference=row[0], + accounting_book_reference=row[1], + intended_book_role_code=row[2], + transaction_currency=row[3], + functional_currency=row[4], + open_period_start=period_start, + open_period_end=period_end, + chart_account_mapping={ + line.account_role_code: line.chart_account_code for line in lines + }, + accounting_policy_version=row[5], + posting_rule_version=row[6], + ) + + def load_account_role_mappings( + self, legal_entity_reference: str, accounting_book_reference: str + ) -> dict[str, object]: + """Return effective account-role mappings for one legal entity and book.""" + if not legal_entity_reference or not accounting_book_reference: + raise AccountingValidationError( + "legal_entity_reference and book_reference are required. " + "Supply those catalog fields, then retry the mapping read." + ) + with self._session() as connection: + tenant_id = self._require_tenant(connection) + legal_entity_id = self._load_legal_entity( + connection, tenant_id, legal_entity_reference, "the mapping read" + )[0] + book_id = self._require_book_for_close( + connection, + tenant_id, + legal_entity_id, + accounting_book_reference, + "the mapping read", + )[0] + rows = connection.execute( + """ + SELECT account_role_mapping.account_role_code, + chart_account.chart_account_code, + account_role_mapping.accounting_policy_version, + account_role_mapping.posting_rule_version + FROM accounting_core.account_role_mapping + JOIN accounting_core.chart_account + ON chart_account.tenant_account_id = account_role_mapping.tenant_account_id + AND chart_account.chart_account_id = account_role_mapping.chart_account_id + WHERE account_role_mapping.tenant_account_id = %s + AND account_role_mapping.accounting_book_id = %s + AND account_role_mapping.valid_to IS NULL + ORDER BY account_role_mapping.account_role_code + """, + (tenant_id, book_id), + ).fetchall() + if not rows: + raise AccountingValidationError( + "No account_role_mapping is recorded for this book. " + "Create the account_role_mapping rows, then retry the mapping read." + ) + return { + "tenant_reference": self._tenant_reference, + "legal_entity_reference": legal_entity_reference, + "accounting_book_reference": accounting_book_reference, + "book_reference": accounting_book_reference, + "mappings": [ + { + "account_role_code": role_code, + "chart_account_code": account_code, + "accounting_policy_version": policy_version, + "posting_rule_version": rule_version, + } + for role_code, account_code, policy_version, rule_version in rows + ], + } + + def load_legal_entities(self) -> dict[str, object]: + """Return existing legal_entity_record rows for the bound tenant.""" + with self._session() as connection: + tenant_id = self._require_tenant(connection) + rows = connection.execute( + """ + SELECT legal_entity_record.legal_entity_code, + legal_entity_record.entity_name + FROM accounting_core.legal_entity_record + WHERE legal_entity_record.tenant_account_id = %s + AND legal_entity_record.valid_to IS NULL + ORDER BY legal_entity_record.legal_entity_code + """, + (tenant_id,), + ).fetchall() + return { + "tenant_reference": self._tenant_reference, + "legal_entities": [ + { + "legal_entity_reference": legal_entity_code, + "entity_name": entity_name, + } + for legal_entity_code, entity_name in rows + ], + } + + def load_accounting_books(self, legal_entity_reference: str) -> dict[str, object]: + """Return existing accounting_book rows for one legal entity.""" + if not legal_entity_reference: + raise AccountingValidationError( + "legal_entity_reference is required. " + "Supply that catalog field, then retry the accounting-book list." + ) + with self._session() as connection: + tenant_id = self._require_tenant(connection) + legal_entity_id = self._load_legal_entity( + connection, tenant_id, legal_entity_reference, "the accounting-book list" + )[0] + rows = connection.execute( + """ + SELECT accounting_book.book_name, + accounting_book.book_role_code + FROM accounting_core.accounting_book + WHERE accounting_book.tenant_account_id = %s + AND accounting_book.legal_entity_id = %s + AND accounting_book.valid_to IS NULL + ORDER BY accounting_book.book_name + """, + (tenant_id, legal_entity_id), + ).fetchall() + return { + "tenant_reference": self._tenant_reference, + "legal_entity_reference": legal_entity_reference, + "accounting_books": [ + { + "accounting_book_reference": book_name, + "book_reference": book_name, + "intended_book_role_code": book_role_code, + "book_name": book_name, + } + for book_name, book_role_code in rows + ], + } + + def load_chart_accounts( + self, legal_entity_reference: str, accounting_book_reference: str + ) -> dict[str, object]: + """Return existing chart_account rows for one legal entity and book.""" + if not legal_entity_reference or not accounting_book_reference: + raise AccountingValidationError( + "legal_entity_reference and book_reference are required. " + "Supply those catalog fields, then retry the chart-account read." + ) + with self._session() as connection: + tenant_id = self._require_tenant(connection) + legal_entity_id = self._load_legal_entity( + connection, tenant_id, legal_entity_reference, "the chart-account read" + )[0] + book_id = self._require_book_for_close( + connection, + tenant_id, + legal_entity_id, + accounting_book_reference, + "the chart-account read", + )[0] + rows = connection.execute( + """ + SELECT chart_account.chart_account_code, + chart_account.account_name, + chart_account.normal_balance_code, + chart_account.account_class_code + FROM accounting_core.chart_account + WHERE chart_account.tenant_account_id = %s + AND chart_account.accounting_book_id = %s + AND chart_account.valid_to IS NULL + ORDER BY chart_account.chart_account_code + """, + (tenant_id, book_id), + ).fetchall() + return { + "tenant_reference": self._tenant_reference, + "legal_entity_reference": legal_entity_reference, + "accounting_book_reference": accounting_book_reference, + "book_reference": accounting_book_reference, + "chart_accounts": [ + { + "chart_account_code": account_code, + "account_name": account_name, + "normal_balance_code": normal_balance_code, + "account_class_code": account_class_code, + } + for ( + account_code, + account_name, + normal_balance_code, + account_class_code, + ) in rows + ], + } + + def trial_balance( + self, + tenant_reference: str, + legal_entity_reference: str, + accounting_book_reference: str, + through_date: date, + ) -> dict[str, AccountBalance]: + """Aggregate posted lines in one tenant/entity/book scope through a date.""" + with self._session() as connection: + tenant_id = self._require_tenant(connection) + if tenant_reference != self._tenant_reference: + return {} + legal_entity_id = connection.execute( + """ + SELECT legal_entity_id + FROM accounting_core.legal_entity_record + WHERE tenant_account_id = %s AND legal_entity_code = %s + """, + (tenant_id, legal_entity_reference), + ).fetchone() + book_id = connection.execute( + """ + SELECT accounting_book_id + FROM accounting_core.accounting_book + WHERE tenant_account_id = %s AND book_name = %s + """, + (tenant_id, accounting_book_reference), + ).fetchone() + if legal_entity_id is None or book_id is None: + return {} + rows = self._aggregate_trial_balance( + connection, tenant_id, legal_entity_id[0], book_id[0], through_date + ) + return { + account_code: AccountBalance(account_code, debit_total, credit_total) + for _account_id, account_code, debit_total, credit_total in rows + } + + def load_period_trial_balance( + self, + legal_entity_reference: str, + accounting_book_reference: str, + period_code: str, + balance_basis_code: str = "", + ) -> dict[str, object]: + """Return snapshot or live trial-balance totals, optionally on an unadjusted, adjusted, or post-close basis.""" + _require_reference(legal_entity_reference, "legal entity reference") + _require_reference(accounting_book_reference, "accounting book reference") + if not period_code.strip(): + raise AccountingValidationError( + "period_code is required. Supply the fiscal period code, then retry the trial-balance read." + ) + if balance_basis_code and balance_basis_code not in { + "unadjusted", + "adjusted", + "post_close", + }: + raise AccountingValidationError( + "balance_basis_code must be unadjusted, adjusted, or post_close. " + "Supply a known trial-balance basis, then retry the trial-balance read." + ) + with self._session() as connection: + tenant_id = self._require_tenant(connection) + legal_entity_id = self._require_legal_entity( + connection, + tenant_id, + legal_entity_reference, + next_action="the trial-balance read", + ) + book_id, _reporting_currency = self._require_book_for_close( + connection, + tenant_id, + legal_entity_id, + accounting_book_reference, + next_action="the trial-balance read", + ) + period_id, period_status_code, period_end_date = self._require_fiscal_period( + connection, + tenant_id, + period_code, + next_action="the trial-balance read", + ) + snapshot_record_id = None + if balance_basis_code == "post_close": + snapshot = self._latest_close_snapshot( + connection, tenant_id, legal_entity_id, book_id, period_id + ) + if snapshot is None: + raise AccountingValidationError( + "balance_basis_code=post_close requires a stored trial_balance_snapshot. " + "Hard-close the period, then retry the trial-balance read." + ) + snapshot_record_id = str(snapshot[0]) + line_rows = self._load_snapshot_balance_lines( + connection, tenant_id, snapshot[0] + ) + balance_source_code = "snapshot" + elif balance_basis_code == "unadjusted": + line_rows = tuple( + (account_code, debit_total, credit_total) + for _account_id, account_code, debit_total, credit_total in self._aggregate_worksheet_trial_balance( + connection, + tenant_id, + legal_entity_id, + book_id, + period_end_date, + exclude_adjusting=True, + ) + ) + balance_source_code = "live" + elif balance_basis_code == "adjusted": + line_rows = tuple( + (account_code, debit_total, credit_total) + for _account_id, account_code, debit_total, credit_total in self._aggregate_worksheet_trial_balance( + connection, + tenant_id, + legal_entity_id, + book_id, + period_end_date, + exclude_adjusting=False, + ) + ) + balance_source_code = "live" + elif period_status_code == "hard_closed": + snapshot = self._latest_close_snapshot( + connection, tenant_id, legal_entity_id, book_id, period_id + ) + if snapshot is None: + raise AccountingValidationError( + f"Fiscal period {period_code} is {period_status_code} without a " + "trial-balance snapshot. Restore the trial_balance_snapshot for this " + "book from the journal population, then retry the trial-balance read." + ) + snapshot_record_id = str(snapshot[0]) + line_rows = self._load_snapshot_balance_lines( + connection, tenant_id, snapshot[0] + ) + balance_source_code = "snapshot" + else: + line_rows = tuple( + (account_code, debit_total, credit_total) + for _account_id, account_code, debit_total, credit_total in self._aggregate_trial_balance( + connection, tenant_id, legal_entity_id, book_id, period_end_date + ) + ) + balance_source_code = "live" + document: dict[str, object] = { + "tenant_reference": self._tenant_reference, + "legal_entity_reference": legal_entity_reference, + "accounting_book_reference": accounting_book_reference, + "book_reference": accounting_book_reference, + "fiscal_period_reference": f"urn:cwl:accounting:fiscal_period:{period_code}", + "period_code": period_code, + "period_status_code": period_status_code, + "balance_source_code": balance_source_code, + "lines": [ + { + "chart_account_code": account_code, + "debit_amount": _exact_amount_text(debit_total), + "credit_amount": _exact_amount_text(credit_total), + "net_balance_amount": _exact_amount_text(debit_total - credit_total), + } + for account_code, debit_total, credit_total in line_rows + ], + } + if snapshot_record_id is not None: + document["snapshot_record_id"] = snapshot_record_id + if balance_basis_code: + document["balance_basis_code"] = balance_basis_code + return document + + def load_financial_statement( + self, + legal_entity_reference: str, + accounting_book_reference: str, + period_code: str, + statement_type_code: str, + comparison_period_code: str = "", + statement_scope_code: str = "", + ) -> dict[str, object]: + """Project income-statement, balance-sheet, changes-in-equity, or cash-flow lines from posted books.""" + if statement_scope_code not in {"", "period", "year_to_date"}: + raise AccountingValidationError( + "statement_scope_code must be period or year_to_date. " + "Supply a known statement scope, then retry the financial-statement read." + ) + if statement_type_code == "income_statement": + allowed_classes = frozenset({"revenue", "expense"}) + elif statement_type_code == "balance_sheet": + allowed_classes = frozenset({"asset", "liability", "equity"}) + elif statement_type_code == "changes_in_equity": + allowed_classes = frozenset({"equity"}) + elif statement_type_code == "cash_flow": + allowed_classes = frozenset() + else: + raise AccountingValidationError( + "statement_type_code must be income_statement, balance_sheet, changes_in_equity, or cash_flow. " + "Supply a known statement type, then retry the financial-statement read." + ) + trial_balance = self.load_period_trial_balance( + legal_entity_reference=legal_entity_reference, + accounting_book_reference=accounting_book_reference, + period_code=period_code, + ) + account_facts = self._load_statement_account_facts( + legal_entity_reference, accounting_book_reference + ) + income_scope_code = ( + "period" if statement_type_code == "balance_sheet" else statement_scope_code + ) + if statement_type_code == "changes_in_equity": + source_lines = self._load_changes_in_equity_lines( + legal_entity_reference=legal_entity_reference, + accounting_book_reference=accounting_book_reference, + period_code=period_code, + statement_scope_code=statement_scope_code, + ) + elif statement_type_code == "cash_flow": + source_lines = self._load_cash_flow_lines( + legal_entity_reference=legal_entity_reference, + accounting_book_reference=accounting_book_reference, + period_code=period_code, + statement_scope_code=statement_scope_code, + ) + elif statement_type_code == "income_statement": + source_lines = self._load_operational_income_lines( + legal_entity_reference=legal_entity_reference, + accounting_book_reference=accounting_book_reference, + period_code=period_code, + statement_scope_code=income_scope_code, + ) + else: + source_lines = [] + for raw_line in trial_balance["lines"]: + account_code = str(raw_line["chart_account_code"]) + account_fact = account_facts.get(account_code) + if account_fact is None: + raise AccountingValidationError( + f"account_role_mapping is missing for chart account {account_code}. " + "Create the account_role_mapping row, then retry the financial-statement read." + ) + account_role_code, account_class_code = account_fact + if account_class_code not in allowed_classes: + continue + source_lines.append( + { + "chart_account_code": account_code, + "account_role_code": account_role_code, + "account_class_code": account_class_code, + "debit_amount": Decimal(str(raw_line["debit_amount"])), + "credit_amount": Decimal(str(raw_line["credit_amount"])), + } + ) + statement_lines: list[dict[str, str]] = [] + total_debit_amount = Decimal("0") + total_credit_amount = Decimal("0") + for raw_line in source_lines: + debit_amount = Decimal(str(raw_line["debit_amount"])) + credit_amount = Decimal(str(raw_line["credit_amount"])) + statement_lines.append( + { + "chart_account_code": str(raw_line["chart_account_code"]), + "account_role_code": str(raw_line["account_role_code"]), + "account_class_code": str(raw_line["account_class_code"]), + "debit_amount": _exact_amount_text(debit_amount), + "credit_amount": _exact_amount_text(credit_amount), + } + ) + total_debit_amount += debit_amount + total_credit_amount += credit_amount + if statement_type_code == "income_statement": + net_income_amount = sum( + ( + Decimal(str(raw_line["credit_amount"])) + - Decimal(str(raw_line["debit_amount"])) + for raw_line in source_lines + ), + Decimal("0"), + ) + elif statement_type_code in {"changes_in_equity", "cash_flow"}: + net_income_amount = next( + Decimal(str(raw_line["credit_amount"])) + - Decimal(str(raw_line["debit_amount"])) + for raw_line in source_lines + if raw_line["account_role_code"] == "period_net_income" + ) + elif str(trial_balance["period_status_code"]) == "hard_closed": + net_income_amount = Decimal("0") + else: + net_income_amount = sum( + ( + Decimal(str(raw_line["credit_amount"])) + - Decimal(str(raw_line["debit_amount"])) + for raw_line in self._load_operational_income_lines( + legal_entity_reference=legal_entity_reference, + accounting_book_reference=accounting_book_reference, + period_code=period_code, + statement_scope_code=income_scope_code, + ) + ), + Decimal("0"), + ) + document = { + "tenant_reference": self._tenant_reference, + "legal_entity_reference": legal_entity_reference, + "accounting_book_reference": accounting_book_reference, + "book_reference": accounting_book_reference, + "fiscal_period_reference": str(trial_balance["fiscal_period_reference"]), + "statement_type_code": statement_type_code, + "statement_lines": statement_lines, + "total_debit_amount": _exact_amount_text(total_debit_amount), + "total_credit_amount": _exact_amount_text(total_credit_amount), + "net_income_amount": _exact_amount_text(net_income_amount), + } + if statement_scope_code == "year_to_date": + document["statement_scope_code"] = "year_to_date" + if comparison_period_code.strip(): + compared = self.load_financial_statement( + legal_entity_reference, + accounting_book_reference, + comparison_period_code.strip(), + statement_type_code, + statement_scope_code=statement_scope_code, + ) + document["comparison_fiscal_period_reference"] = compared[ + "fiscal_period_reference" + ] + document["comparison_statement_lines"] = compared["statement_lines"] + document["comparison_total_debit_amount"] = compared["total_debit_amount"] + document["comparison_total_credit_amount"] = compared["total_credit_amount"] + document["comparison_net_income_amount"] = compared["net_income_amount"] + return document + + def load_financial_statement_package( + self, + legal_entity_reference: str, + accounting_book_reference: str, + period_code: str, + comparison_period_code: str = "", + statement_scope_code: str = "", + ) -> dict[str, object]: + """Return all four financial statements from one REPEATABLE READ snapshot.""" + with self._consistent_read_session(): + return self._assemble_financial_statement_package( + legal_entity_reference, + accounting_book_reference, + period_code, + comparison_period_code=comparison_period_code, + statement_scope_code=statement_scope_code, + ) + + def _assemble_financial_statement_package( + self, + legal_entity_reference: str, + accounting_book_reference: str, + period_code: str, + comparison_period_code: str = "", + statement_scope_code: str = "", + ) -> dict[str, object]: + income_statement = self.load_financial_statement( + legal_entity_reference, + accounting_book_reference, + period_code, + "income_statement", + comparison_period_code, + statement_scope_code, + ) + balance_sheet = self.load_financial_statement( + legal_entity_reference, + accounting_book_reference, + period_code, + "balance_sheet", + comparison_period_code, + statement_scope_code, + ) + changes_in_equity = self.load_financial_statement( + legal_entity_reference, + accounting_book_reference, + period_code, + "changes_in_equity", + comparison_period_code, + statement_scope_code, + ) + cash_flow = self.load_financial_statement( + legal_entity_reference, + accounting_book_reference, + period_code, + "cash_flow", + comparison_period_code, + statement_scope_code, + ) + document: dict[str, object] = { + "tenant_reference": income_statement["tenant_reference"], + "legal_entity_reference": income_statement["legal_entity_reference"], + "accounting_book_reference": income_statement["accounting_book_reference"], + "book_reference": income_statement["book_reference"], + "fiscal_period_reference": income_statement["fiscal_period_reference"], + "income_statement": income_statement, + "balance_sheet": balance_sheet, + "changes_in_equity": changes_in_equity, + "cash_flow": cash_flow, + } + if statement_scope_code == "year_to_date": + document["statement_scope_code"] = "year_to_date" + return document + + def load_period_close_package( + self, + legal_entity_reference: str, + book_reference: str, + period_code: str, + comparison_period_code: str = "", + statement_scope_code: str = "", + ) -> dict[str, object]: + """Return the close-binder worksheets from one REPEATABLE READ ledger snapshot.""" + with self._consistent_read_session(): + return self._assemble_period_close_package( + legal_entity_reference, + book_reference, + period_code, + comparison_period_code=comparison_period_code, + statement_scope_code=statement_scope_code, + ) + + def _assemble_period_close_package( + self, + legal_entity_reference: str, + book_reference: str, + period_code: str, + comparison_period_code: str = "", + statement_scope_code: str = "", + ) -> dict[str, object]: + fiscal_period = self.load_fiscal_period(legal_entity_reference, period_code) + trial_balance = self.load_period_trial_balance( + legal_entity_reference=legal_entity_reference, + accounting_book_reference=book_reference, + period_code=period_code, + ) + income_statement = self.load_financial_statement( + legal_entity_reference, + book_reference, + period_code, + "income_statement", + comparison_period_code, + statement_scope_code, + ) + balance_sheet = self.load_financial_statement( + legal_entity_reference, + book_reference, + period_code, + "balance_sheet", + comparison_period_code, + statement_scope_code, + ) + changes_in_equity = self.load_financial_statement( + legal_entity_reference, + book_reference, + period_code, + "changes_in_equity", + comparison_period_code, + statement_scope_code, + ) + cash_flow = self.load_financial_statement( + legal_entity_reference, + book_reference, + period_code, + "cash_flow", + comparison_period_code, + statement_scope_code, + ) + financial_statement_package: dict[str, object] = { + "tenant_reference": income_statement["tenant_reference"], + "legal_entity_reference": income_statement["legal_entity_reference"], + "accounting_book_reference": income_statement["accounting_book_reference"], + "book_reference": income_statement["book_reference"], + "fiscal_period_reference": income_statement["fiscal_period_reference"], + "income_statement": income_statement, + "balance_sheet": balance_sheet, + "changes_in_equity": changes_in_equity, + "cash_flow": cash_flow, + } + if statement_scope_code == "year_to_date": + financial_statement_package["statement_scope_code"] = "year_to_date" + receivable_aging = self.load_receivable_aging( + legal_entity_reference, + book_reference, + period_code, + ) + payable_aging = self.load_payable_aging( + legal_entity_reference, + book_reference, + period_code, + ) + unapplied_cash_rollforward = self.load_unapplied_cash_rollforward( + legal_entity_reference, + book_reference, + period_code, + ) + vat_period_register = self.load_vat_period_register( + legal_entity_reference, + book_reference, + period_code, + ) + close_page = self.load_period_closes(legal_entity_reference, period_code) + stored_closes = close_page["period_closes"] + period_close = stored_closes[-1] if stored_closes else None + return { + "tenant_reference": trial_balance["tenant_reference"], + "legal_entity_reference": trial_balance["legal_entity_reference"], + "accounting_book_reference": trial_balance["accounting_book_reference"], + "book_reference": trial_balance["book_reference"], + "fiscal_period_reference": trial_balance["fiscal_period_reference"], + "fiscal_period": fiscal_period, + "trial_balance": trial_balance, + "financial_statement_package": financial_statement_package, + "receivable_aging": receivable_aging, + "payable_aging": payable_aging, + "unapplied_cash_rollforward": unapplied_cash_rollforward, + "vat_period_register": vat_period_register, + "period_close": period_close, + } + + def _require_closeable_package(self, package: Mapping[str, object]) -> None: + trial_balance = package["trial_balance"] + lines = trial_balance["lines"] + debit_total = sum( + (Decimal(str(line["debit_amount"])) for line in lines), + Decimal("0"), + ) + credit_total = sum( + (Decimal(str(line["credit_amount"])) for line in lines), + Decimal("0"), + ) + if debit_total != credit_total: + raise AccountingValidationError( + "trial balance does not balance. " + "Correct the posted journals so debit totals equal credit totals, " + "then retry the close." + ) + + def _load_statement_account_facts( + self, legal_entity_reference: str, accounting_book_reference: str + ) -> dict[str, tuple[str, str]]: + with self._session() as connection: + tenant_id = self._require_tenant(connection) + legal_entity_id = self._require_legal_entity( + connection, + tenant_id, + legal_entity_reference, + next_action="the financial-statement read", + ) + book_id = self._require_book_for_close( + connection, + tenant_id, + legal_entity_id, + accounting_book_reference, + next_action="the financial-statement read", + )[0] + rows = connection.execute( + """ + SELECT chart_account.chart_account_code, + account_role_mapping.account_role_code, + chart_account.account_class_code + FROM accounting_core.chart_account + JOIN accounting_core.account_role_mapping + ON account_role_mapping.tenant_account_id = chart_account.tenant_account_id + AND account_role_mapping.chart_account_id = chart_account.chart_account_id + AND account_role_mapping.valid_to IS NULL + WHERE chart_account.tenant_account_id = %s + AND chart_account.accounting_book_id = %s + AND chart_account.valid_to IS NULL + """, + (tenant_id, book_id), + ).fetchall() + return { + str(account_code): (str(account_role_code), str(account_class_code)) + for account_code, account_role_code, account_class_code in rows + } + + def _load_changes_in_equity_lines( + self, + *, + legal_entity_reference: str, + accounting_book_reference: str, + period_code: str, + statement_scope_code: str, + ) -> list[dict[str, object]]: + income_lines = self._load_operational_income_lines( + legal_entity_reference=legal_entity_reference, + accounting_book_reference=accounting_book_reference, + period_code=period_code, + statement_scope_code=statement_scope_code, + ) + period_net_income = sum( + ( + Decimal(str(line["credit_amount"])) - Decimal(str(line["debit_amount"])) + for line in income_lines + ), + Decimal("0"), + ) + with self._session() as connection: + tenant_id = self._require_tenant(connection) + legal_entity_id = self._require_legal_entity( + connection, + tenant_id, + legal_entity_reference, + next_action="the financial-statement read", + ) + book_id = self._require_book_for_close( + connection, + tenant_id, + legal_entity_id, + accounting_book_reference, + next_action="the financial-statement read", + )[0] + period_ids = self._statement_period_ids( + connection, + tenant_id, + period_code, + statement_scope_code, + ) + scope_start = connection.execute( + """ + SELECT MIN(period_start_date) + FROM accounting_core.fiscal_period + WHERE tenant_account_id = %s + AND fiscal_period_id = ANY(%s) + """, + (tenant_id, period_ids), + ).fetchone()[0] + opening_equity = self._opening_equity_amount( + connection, + tenant_id, + legal_entity_id, + book_id, + scope_start, + ) + other_equity_movements = self._other_equity_movement_amount( + connection, + tenant_id, + legal_entity_id, + book_id, + period_ids, + ) + closing_equity = opening_equity + period_net_income + other_equity_movements + return [ + self._equity_movement_line("opening_equity", opening_equity), + self._equity_movement_line("period_net_income", period_net_income), + self._equity_movement_line("other_equity_movements", other_equity_movements), + self._equity_movement_line("closing_equity", closing_equity), + ] + + def _opening_equity_amount( + self, + connection: object, + tenant_id: UUID, + legal_entity_id: UUID, + book_id: UUID, + scope_start: date, + ) -> Decimal: + prior_snapshot = connection.execute( + """ + SELECT trial_balance_snapshot.trial_balance_snapshot_id + FROM accounting_core.fiscal_period + JOIN accounting_reporting.trial_balance_snapshot + ON trial_balance_snapshot.tenant_account_id = fiscal_period.tenant_account_id + AND trial_balance_snapshot.fiscal_period_id = fiscal_period.fiscal_period_id + AND trial_balance_snapshot.legal_entity_id = %s + AND trial_balance_snapshot.accounting_book_id = %s + WHERE fiscal_period.tenant_account_id = %s + AND fiscal_period.period_end_date < %s + AND fiscal_period.period_status_code = 'hard_closed' + ORDER BY fiscal_period.period_end_date DESC, fiscal_period.period_code DESC + LIMIT 1 + """, + (legal_entity_id, book_id, tenant_id, scope_start), + ).fetchone() + if prior_snapshot is not None: + amount = connection.execute( + """ + SELECT COALESCE( + SUM( + trial_balance_line.credit_total_amount + - trial_balance_line.debit_total_amount + ), + 0 + ) + FROM accounting_reporting.trial_balance_line + JOIN accounting_core.chart_account + ON chart_account.tenant_account_id = trial_balance_line.tenant_account_id + AND chart_account.chart_account_id = trial_balance_line.chart_account_id + WHERE trial_balance_line.tenant_account_id = %s + AND trial_balance_line.trial_balance_snapshot_id = %s + AND chart_account.account_class_code = 'equity' + """, + (tenant_id, prior_snapshot[0]), + ).fetchone()[0] + return Decimal(amount) + amount = connection.execute( + """ + SELECT COALESCE( + SUM(journal_entry_line.credit_amount - journal_entry_line.debit_amount), + 0 + ) + FROM accounting_core.journal_entry_line + JOIN accounting_core.general_journal + ON general_journal.tenant_account_id = journal_entry_line.tenant_account_id + AND general_journal.general_journal_id = journal_entry_line.general_journal_id + JOIN accounting_core.chart_account + ON chart_account.tenant_account_id = journal_entry_line.tenant_account_id + AND chart_account.chart_account_id = journal_entry_line.chart_account_id + WHERE general_journal.tenant_account_id = %s + AND general_journal.legal_entity_id = %s + AND general_journal.accounting_book_id = %s + AND general_journal.accounting_date <= %s + AND chart_account.account_class_code = 'equity' + """, + ( + tenant_id, + legal_entity_id, + book_id, + scope_start - timedelta(days=1), + ), + ).fetchone()[0] + return Decimal(amount) + + def _other_equity_movement_amount( + self, + connection: object, + tenant_id: UUID, + legal_entity_id: UUID, + book_id: UUID, + period_ids: list[UUID], + ) -> Decimal: + amount = connection.execute( + """ + SELECT COALESCE( + SUM(journal_entry_line.credit_amount - journal_entry_line.debit_amount), + 0 + ) + FROM accounting_core.journal_entry_line + JOIN accounting_core.general_journal + ON general_journal.tenant_account_id = journal_entry_line.tenant_account_id + AND general_journal.general_journal_id = journal_entry_line.general_journal_id + JOIN accounting_core.chart_account + ON chart_account.tenant_account_id = journal_entry_line.tenant_account_id + AND chart_account.chart_account_id = journal_entry_line.chart_account_id + WHERE general_journal.tenant_account_id = %s + AND general_journal.legal_entity_id = %s + AND general_journal.accounting_book_id = %s + AND general_journal.fiscal_period_id = ANY(%s) + AND chart_account.account_class_code = 'equity' + AND general_journal.journal_reference NOT LIKE %s + """, + ( + tenant_id, + legal_entity_id, + book_id, + period_ids, + _CLOSING_JOURNAL_PATTERN, + ), + ).fetchone()[0] + return Decimal(amount) + + def _equity_movement_line( + self, + account_role_code: str, + amount: Decimal, + account_class_code: str = "equity", + ) -> dict[str, object]: + debit_amount = Decimal("0") if amount >= 0 else -amount + credit_amount = amount if amount >= 0 else Decimal("0") + return { + "chart_account_code": "", + "account_role_code": account_role_code, + "account_class_code": account_class_code, + "debit_amount": debit_amount, + "credit_amount": credit_amount, + } + + def _load_cash_flow_lines( + self, + *, + legal_entity_reference: str, + accounting_book_reference: str, + period_code: str, + statement_scope_code: str, + ) -> list[dict[str, object]]: + income_lines = self._load_operational_income_lines( + legal_entity_reference=legal_entity_reference, + accounting_book_reference=accounting_book_reference, + period_code=period_code, + statement_scope_code=statement_scope_code, + ) + period_net_income = sum( + ( + Decimal(str(line["credit_amount"])) - Decimal(str(line["debit_amount"])) + for line in income_lines + ), + Decimal("0"), + ) + with self._session() as connection: + tenant_id = self._require_tenant(connection) + legal_entity_id = self._require_legal_entity( + connection, + tenant_id, + legal_entity_reference, + next_action="the financial-statement read", + ) + book_id = self._require_book_for_close( + connection, + tenant_id, + legal_entity_id, + accounting_book_reference, + next_action="the financial-statement read", + )[0] + period_ids = self._statement_period_ids( + connection, + tenant_id, + period_code, + statement_scope_code, + ) + scope_start = connection.execute( + """ + SELECT MIN(period_start_date) + FROM accounting_core.fiscal_period + WHERE tenant_account_id = %s + AND fiscal_period_id = ANY(%s) + """, + (tenant_id, period_ids), + ).fetchone()[0] + opening_cash = self._opening_cash_amount( + connection, + tenant_id, + legal_entity_id, + book_id, + scope_start, + ) + operating_working_capital = self._operating_working_capital_amount( + connection, + tenant_id, + legal_entity_id, + book_id, + period_ids, + ) + cash_from_financing = self._other_equity_movement_amount( + connection, + tenant_id, + legal_entity_id, + book_id, + period_ids, + ) + cash_from_investing = Decimal("0") + cash_from_operations = period_net_income + operating_working_capital + net_cash_change = cash_from_operations + cash_from_investing + cash_from_financing + closing_cash = opening_cash + net_cash_change + return [ + self._equity_movement_line("period_net_income", period_net_income, ""), + self._equity_movement_line( + "operating_working_capital", operating_working_capital, "" + ), + self._equity_movement_line("cash_from_operations", cash_from_operations, ""), + self._equity_movement_line("cash_from_investing", cash_from_investing, ""), + self._equity_movement_line("cash_from_financing", cash_from_financing, ""), + self._equity_movement_line("net_cash_change", net_cash_change, ""), + self._equity_movement_line("opening_cash", opening_cash, ""), + self._equity_movement_line("closing_cash", closing_cash, ""), + ] + + def _opening_cash_amount( + self, + connection: object, + tenant_id: UUID, + legal_entity_id: UUID, + book_id: UUID, + scope_start: date, + ) -> Decimal: + prior_snapshot = connection.execute( + """ + SELECT trial_balance_snapshot.trial_balance_snapshot_id + FROM accounting_core.fiscal_period + JOIN accounting_reporting.trial_balance_snapshot + ON trial_balance_snapshot.tenant_account_id = fiscal_period.tenant_account_id + AND trial_balance_snapshot.fiscal_period_id = fiscal_period.fiscal_period_id + AND trial_balance_snapshot.legal_entity_id = %s + AND trial_balance_snapshot.accounting_book_id = %s + WHERE fiscal_period.tenant_account_id = %s + AND fiscal_period.period_end_date < %s + AND fiscal_period.period_status_code = 'hard_closed' + ORDER BY fiscal_period.period_end_date DESC, fiscal_period.period_code DESC + LIMIT 1 + """, + (legal_entity_id, book_id, tenant_id, scope_start), + ).fetchone() + if prior_snapshot is not None: + amount = connection.execute( + """ + SELECT COALESCE( + SUM( + trial_balance_line.debit_total_amount + - trial_balance_line.credit_total_amount + ), + 0 + ) + FROM accounting_reporting.trial_balance_line + JOIN accounting_core.account_role_mapping + ON account_role_mapping.tenant_account_id = trial_balance_line.tenant_account_id + AND account_role_mapping.chart_account_id = trial_balance_line.chart_account_id + AND account_role_mapping.accounting_book_id = %s + AND account_role_mapping.account_role_code = 'cash_receipt' + AND account_role_mapping.valid_to IS NULL + WHERE trial_balance_line.tenant_account_id = %s + AND trial_balance_line.trial_balance_snapshot_id = %s + """, + (book_id, tenant_id, prior_snapshot[0]), + ).fetchone()[0] + return Decimal(amount) + amount = connection.execute( + """ + SELECT COALESCE( + SUM(journal_entry_line.debit_amount - journal_entry_line.credit_amount), + 0 + ) + FROM accounting_core.journal_entry_line + JOIN accounting_core.general_journal + ON general_journal.tenant_account_id = journal_entry_line.tenant_account_id + AND general_journal.general_journal_id = journal_entry_line.general_journal_id + JOIN accounting_core.account_role_mapping + ON account_role_mapping.tenant_account_id = journal_entry_line.tenant_account_id + AND account_role_mapping.chart_account_id = journal_entry_line.chart_account_id + AND account_role_mapping.accounting_book_id = %s + AND account_role_mapping.account_role_code = 'cash_receipt' + AND account_role_mapping.valid_to IS NULL + WHERE general_journal.tenant_account_id = %s + AND general_journal.legal_entity_id = %s + AND general_journal.accounting_book_id = %s + AND general_journal.accounting_date <= %s + """, + ( + book_id, + tenant_id, + legal_entity_id, + book_id, + scope_start - timedelta(days=1), + ), + ).fetchone()[0] + return Decimal(amount) + + def _operating_working_capital_amount( + self, + connection: object, + tenant_id: UUID, + legal_entity_id: UUID, + book_id: UUID, + period_ids: list[UUID], + ) -> Decimal: + amount = connection.execute( + """ + SELECT COALESCE( + SUM(journal_entry_line.credit_amount - journal_entry_line.debit_amount), + 0 + ) + FROM accounting_core.journal_entry_line + JOIN accounting_core.general_journal + ON general_journal.tenant_account_id = journal_entry_line.tenant_account_id + AND general_journal.general_journal_id = journal_entry_line.general_journal_id + JOIN accounting_core.chart_account + ON chart_account.tenant_account_id = journal_entry_line.tenant_account_id + AND chart_account.chart_account_id = journal_entry_line.chart_account_id + WHERE general_journal.tenant_account_id = %s + AND general_journal.legal_entity_id = %s + AND general_journal.accounting_book_id = %s + AND general_journal.fiscal_period_id = ANY(%s) + AND chart_account.account_class_code IN ('asset', 'liability') + AND chart_account.chart_account_id NOT IN ( + SELECT account_role_mapping.chart_account_id + FROM accounting_core.account_role_mapping + WHERE account_role_mapping.tenant_account_id = %s + AND account_role_mapping.accounting_book_id = %s + AND account_role_mapping.account_role_code = 'cash_receipt' + AND account_role_mapping.valid_to IS NULL + ) + AND general_journal.journal_reference NOT LIKE %s + """, + ( + tenant_id, + legal_entity_id, + book_id, + period_ids, + tenant_id, + book_id, + _CLOSING_JOURNAL_PATTERN, + ), + ).fetchone()[0] + return Decimal(amount) + + def _load_operational_income_lines( + self, + *, + legal_entity_reference: str, + accounting_book_reference: str, + period_code: str, + statement_scope_code: str = "", + ) -> list[dict[str, object]]: + with self._session() as connection: + tenant_id = self._require_tenant(connection) + legal_entity_id = self._require_legal_entity( + connection, + tenant_id, + legal_entity_reference, + next_action="the financial-statement read", + ) + book_id = self._require_book_for_close( + connection, + tenant_id, + legal_entity_id, + accounting_book_reference, + next_action="the financial-statement read", + )[0] + period_ids = self._statement_period_ids( + connection, + tenant_id, + period_code, + statement_scope_code, + ) + rows = connection.execute( + """ + SELECT chart_account.chart_account_code, + account_role_mapping.account_role_code, + chart_account.account_class_code, + SUM(journal_entry_line.debit_amount), + SUM(journal_entry_line.credit_amount) + FROM accounting_core.journal_entry_line + JOIN accounting_core.general_journal + ON general_journal.tenant_account_id = journal_entry_line.tenant_account_id + AND general_journal.general_journal_id = journal_entry_line.general_journal_id + JOIN accounting_core.chart_account + ON chart_account.tenant_account_id = journal_entry_line.tenant_account_id + AND chart_account.chart_account_id = journal_entry_line.chart_account_id + LEFT JOIN accounting_core.account_role_mapping + ON account_role_mapping.tenant_account_id = chart_account.tenant_account_id + AND account_role_mapping.chart_account_id = chart_account.chart_account_id + AND account_role_mapping.valid_to IS NULL + WHERE general_journal.tenant_account_id = %s + AND general_journal.legal_entity_id = %s + AND general_journal.accounting_book_id = %s + AND general_journal.fiscal_period_id = ANY(%s) + AND chart_account.account_class_code IN ('revenue', 'expense') + AND general_journal.journal_reference NOT LIKE %s + GROUP BY chart_account.chart_account_code, + account_role_mapping.account_role_code, + chart_account.account_class_code + ORDER BY chart_account.chart_account_code + """, + ( + tenant_id, + legal_entity_id, + book_id, + period_ids, + _CLOSING_JOURNAL_PATTERN, + ), + ).fetchall() + lines: list[dict[str, object]] = [] + for account_code, account_role_code, account_class_code, debit_total, credit_total in rows: + if account_role_code is None: + raise AccountingValidationError( + f"account_role_mapping is missing for chart account {account_code}. " + "Create the account_role_mapping row, then retry the financial-statement read." + ) + lines.append( + { + "chart_account_code": str(account_code), + "account_role_code": str(account_role_code), + "account_class_code": str(account_class_code), + "debit_amount": Decimal(debit_total), + "credit_amount": Decimal(credit_total), + } + ) + return lines + + def _statement_period_ids( + self, + connection: object, + tenant_id: UUID, + period_code: str, + statement_scope_code: str, + ) -> list[UUID]: + period_id, calendar_id, requested_code, period_start_date = connection.execute( + """ + SELECT fiscal_period_id, fiscal_calendar_id, period_code, period_start_date + FROM accounting_core.fiscal_period + WHERE tenant_account_id = %s AND period_code = %s + """, + (tenant_id, period_code), + ).fetchone() + if statement_scope_code in {"", "period"}: + return [period_id] + fiscal_year = _fiscal_year_identity(str(requested_code), period_start_date) + peers = connection.execute( + """ + SELECT fiscal_period_id, period_code, period_start_date + FROM accounting_core.fiscal_period + WHERE tenant_account_id = %s + AND fiscal_calendar_id = %s + AND period_start_date <= %s + ORDER BY period_start_date, period_code + """, + (tenant_id, calendar_id, period_start_date), + ).fetchall() + return [ + peer_id + for peer_id, peer_code, peer_start in peers + if _fiscal_year_identity(str(peer_code), peer_start) == fiscal_year + ] + + @contextmanager + def _consistent_read_session(self) -> Iterator[object]: + with self._session() as connection: + connection.execute("SET TRANSACTION ISOLATION LEVEL REPEATABLE READ") + self._active_connection = connection + try: + yield connection + finally: + self._active_connection = None + + @contextmanager + def _session(self) -> Iterator[object]: + if self._active_connection is not None: + yield self._active_connection + return + psycopg = _import_psycopg() + try: + connection = psycopg.connect(self._database_url) + except Exception as error: + raise AccountingValidationError( + "PostgreSQL is not reachable. Start PostgreSQL 18, set ACCOUNTING_DATABASE_URL " + "to that server, then retry posting." + ) from error + try: + connection.execute("SET lock_timeout = '5s'") + connection.execute("SET idle_in_transaction_session_timeout = '60s'") + yield connection + except Exception: + connection.rollback() + raise + else: + connection.commit() + finally: + connection.close() + + def _acquire_command_lock(self, connection: object, command_scope: str) -> None: + """Serialize one tenant command scope until the current transaction ends.""" + connection.execute( + "SELECT pg_advisory_xact_lock(hashtext(%s), hashtext(%s))", + (self._tenant_reference, command_scope), + ) + + def _require_tenant(self, connection: object) -> UUID: + row = connection.execute( + """ + SELECT tenant_account_id + FROM accounting_core.tenant_account + WHERE tenant_account_code = %s + """, + (self._tenant_reference,), + ).fetchone() + if row is None: + raise AccountingValidationError( + f"Tenant {self._tenant_reference} is not recorded. Create the tenant_account row, then retry posting." + ) + requested_tenant_id = row[0] + bound_tenant_id = connection.execute( + "SELECT accounting_core.current_tenant_account_id()" + ).fetchone()[0] + if bound_tenant_id is not None: + if bound_tenant_id != requested_tenant_id: + raise AccountingValidationError( + "the database session is not provisioned for this tenant. " + "Ask the platform operator to verify tenant provisioning, " + "then retry the request." + ) + return requested_tenant_id + rolsuper, rolbypassrls = connection.execute( + """ + SELECT rolsuper, rolbypassrls + FROM pg_catalog.pg_roles + WHERE rolname = session_user + """ + ).fetchone() + if rolsuper or rolbypassrls: + return requested_tenant_id + raise AccountingValidationError( + "this request cannot be authorized for the requested tenant. " + "Ask the platform operator to verify tenant provisioning, then retry." + ) + + def _require_legal_entity( + self, + connection: object, + tenant_id: UUID, + legal_entity_reference: str, + next_action: str = "posting", + ) -> UUID: + return self._load_legal_entity(connection, tenant_id, legal_entity_reference, next_action)[0] + + def _load_legal_entity( + self, + connection: object, + tenant_id: UUID, + legal_entity_reference: str, + next_action: str = "posting", + ) -> tuple[UUID, str]: + row = connection.execute( + """ + SELECT legal_entity_id, functional_currency_code + FROM accounting_core.legal_entity_record + WHERE tenant_account_id = %s AND legal_entity_code = %s AND valid_to IS NULL + """, + (tenant_id, legal_entity_reference), + ).fetchone() + if row is None: + raise AccountingValidationError( + f"Legal entity {legal_entity_reference} is not recorded for this tenant. " + f"Create the legal_entity_record row, then retry {next_action}." + ) + return row[0], row[1] + + def _require_book( + self, + connection: object, + tenant_id: UUID, + legal_entity_id: UUID, + book_role_code: str, + accounting_book_reference: str, + ) -> UUID: + row = connection.execute( + """ + SELECT accounting_book_id + FROM accounting_core.accounting_book + WHERE tenant_account_id = %s + AND legal_entity_id = %s + AND book_role_code = %s + AND valid_to IS NULL + """, + (tenant_id, legal_entity_id, book_role_code), + ).fetchone() + if row is None: + raise AccountingValidationError( + f"Accounting book {accounting_book_reference} is not recorded for this legal entity. " + "Create the accounting_book row, then retry posting." + ) + return row[0] + + def _require_open_book_period( + self, + connection: object, + tenant_id: UUID, + book_id: UUID, + accounting_date: date, + ) -> UUID: + """Require an open fiscal period for the selected accounting book.""" + return self._require_open_book_period_bounds( + connection, tenant_id, book_id, accounting_date + )[0] + + def _require_open_book_period_bounds( + self, + connection: object, + tenant_id: UUID, + book_id: UUID, + accounting_date: date, + ) -> tuple[UUID, date, date]: + """Return period identity and bounds when this accounting book is open.""" + row = connection.execute( + """ + SELECT fiscal_period.fiscal_period_id, + fiscal_period.period_code, + COALESCE( + accounting_book_period_control.period_status_code, + fiscal_period.period_status_code + ), + fiscal_period.period_start_date, + fiscal_period.period_end_date + FROM accounting_core.fiscal_period + LEFT JOIN accounting_core.accounting_book_period_control + ON accounting_book_period_control.tenant_account_id + = fiscal_period.tenant_account_id + AND accounting_book_period_control.fiscal_period_id + = fiscal_period.fiscal_period_id + AND accounting_book_period_control.accounting_book_id = %s + WHERE fiscal_period.tenant_account_id = %s + AND fiscal_period.period_start_date <= %s + AND fiscal_period.period_end_date >= %s + """, + (book_id, tenant_id, accounting_date, accounting_date), + ).fetchone() + if row is None: + raise AccountingValidationError( + f"No fiscal period covers accounting date {accounting_date.isoformat()}. " + "Create an open fiscal period on the tenant calendar, then retry posting." + ) + period_id, period_code = row[0], row[1] + row = connection.execute( + """ + SELECT fiscal_period.fiscal_period_id, + fiscal_period.period_code, + COALESCE( + accounting_book_period_control.period_status_code, + fiscal_period.period_status_code + ), + fiscal_period.period_start_date, + fiscal_period.period_end_date + FROM accounting_core.fiscal_period + LEFT JOIN accounting_core.accounting_book_period_control + ON accounting_book_period_control.tenant_account_id + = fiscal_period.tenant_account_id + AND accounting_book_period_control.fiscal_period_id + = fiscal_period.fiscal_period_id + AND accounting_book_period_control.accounting_book_id = %s + WHERE fiscal_period.tenant_account_id = %s + AND fiscal_period.fiscal_period_id = %s + """, + (book_id, tenant_id, period_id), + ).fetchone() + if row is None: + raise AccountingValidationError( + f"No fiscal period covers accounting date {accounting_date.isoformat()}. " + "Create an open fiscal period on the tenant calendar, then retry posting." + ) + if row[2] != "open": + locked_marker = " (period_closed)" if row[2] == "hard_closed" else "" + raise AccountingValidationError( + f"Fiscal period {row[1]} is {row[2]}{locked_marker}. " + "Open that period or post into an open period for this accounting book; " + "no journal was written." + ) + return row[0], row[3], row[4] + + def _require_adjusting_period( + self, connection: object, tenant_id: UUID, accounting_date: date + ) -> UUID: + return self._require_adjusting_period_bounds(connection, tenant_id, accounting_date)[0] + + def _require_adjusting_period_bounds( + self, connection: object, tenant_id: UUID, accounting_date: date + ) -> tuple[UUID, date, date]: + return self._require_period_bounds( + connection, + tenant_id, + accounting_date, + allowed_status_codes=frozenset({"open", "soft_closed"}), + next_action="Reverse into an open or soft-closed period", + ) + + def _require_period_bounds( + self, + connection: object, + tenant_id: UUID, + accounting_date: date, + *, + allowed_status_codes: frozenset[str], + next_action: str, + ) -> tuple[UUID, date, date]: + row = connection.execute( + """ + SELECT fiscal_period_id, period_code, period_status_code, + period_start_date, period_end_date + FROM accounting_core.fiscal_period + WHERE tenant_account_id = %s + AND period_start_date <= %s + AND period_end_date >= %s + """, + (tenant_id, accounting_date, accounting_date), + ).fetchone() + if row is None: + raise AccountingValidationError( + f"No fiscal period covers accounting date {accounting_date.isoformat()}. " + "Create an open fiscal period on the tenant calendar, then retry posting." + ) + period_id, period_code = row[0], row[1] + self._acquire_command_lock(connection, f"period:{period_code}") + row = connection.execute( + """ + SELECT fiscal_period_id, period_code, period_status_code, + period_start_date, period_end_date + FROM accounting_core.fiscal_period + WHERE tenant_account_id = %s + AND fiscal_period_id = %s + """, + (tenant_id, period_id), + ).fetchone() + if row is None: + raise AccountingValidationError( + f"No fiscal period covers accounting date {accounting_date.isoformat()}. " + "Create an open fiscal period on the tenant calendar, then retry posting." + ) + if row[2] not in allowed_status_codes: + locked_marker = " (period_closed)" if row[2] == "hard_closed" else "" + raise AccountingValidationError( + f"Fiscal period {row[1]} is {row[2]}{locked_marker}. {next_action}; " + "no journal was written." + ) + return row[0], row[3], row[4] + + def _resolve_accounting_policy( + self, connection: object, tenant_id: UUID, proposal: JournalProposal + ) -> AccountingPolicy: + if proposal.tenant_reference != self._tenant_reference: + raise AccountingValidationError( + "proposal tenant scope does not match this deployment. " + "Send the proposal to that tenant's accounting endpoint, then retry posting." + ) + legal_entity_id, functional_currency = self._load_legal_entity( + connection, tenant_id, proposal.legal_entity_reference + ) + book_id, book_name = self._require_book_for_role( + connection, + tenant_id, + legal_entity_id, + proposal.intended_book_role_code, + ) + _period_id, period_start, period_end = self._require_open_book_period_bounds( + connection, tenant_id, book_id, proposal.accounting_date + ) + mapping, policy_version, rule_version = self._load_role_mapping( + connection, tenant_id, book_id, proposal + ) + return AccountingPolicy( + tenant_reference=proposal.tenant_reference, + legal_entity_reference=proposal.legal_entity_reference, + accounting_book_reference=book_name, + intended_book_role_code=proposal.intended_book_role_code, + transaction_currency=proposal.transaction_currency, + functional_currency=functional_currency, + open_period_start=period_start, + open_period_end=period_end, + chart_account_mapping=mapping, + accounting_policy_version=policy_version, + posting_rule_version=rule_version, + ) + + def _require_book_for_role( + self, + connection: object, + tenant_id: UUID, + legal_entity_id: UUID, + book_role_code: str, + ) -> tuple[UUID, str]: + row = connection.execute( + """ + SELECT accounting_book_id, book_name + FROM accounting_core.accounting_book + WHERE tenant_account_id = %s + AND legal_entity_id = %s + AND book_role_code = %s + AND valid_to IS NULL + """, + (tenant_id, legal_entity_id, book_role_code), + ).fetchone() + if row is None: + raise AccountingValidationError( + f"Accounting book for role {book_role_code} is not recorded for this legal entity. " + "Create the accounting_book row, then retry posting." + ) + return row[0], row[1] + + def _load_role_mapping( + self, + connection: object, + tenant_id: UUID, + book_id: UUID, + proposal: JournalProposal, + ) -> tuple[dict[str, str], str, str]: + role_codes = tuple(dict.fromkeys(line.account_role_code for line in proposal.lines)) + as_of = datetime.combine( + proposal.accounting_date, datetime.min.time(), tzinfo=timezone.utc + ) + rows = connection.execute( + """ + SELECT account_role_mapping.account_role_code, + chart_account.chart_account_code, + account_role_mapping.accounting_policy_version, + account_role_mapping.posting_rule_version + FROM accounting_core.account_role_mapping + JOIN accounting_core.chart_account + ON chart_account.tenant_account_id = account_role_mapping.tenant_account_id + AND chart_account.chart_account_id = account_role_mapping.chart_account_id + WHERE account_role_mapping.tenant_account_id = %s + AND account_role_mapping.accounting_book_id = %s + AND account_role_mapping.account_role_code = ANY(%s) + AND account_role_mapping.valid_from <= %s + AND ( + account_role_mapping.valid_to IS NULL + OR account_role_mapping.valid_to > %s + ) + """, + (tenant_id, book_id, list(role_codes), as_of, as_of), + ).fetchall() + if not rows: + raise AccountingValidationError( + "No account_role_mapping is effective for this book and accounting date. " + "Create the account_role_mapping rows, then retry posting." + ) + seen_roles: dict[str, tuple[str, str, str]] = {} + for role_code, account_code, policy_version, rule_version in rows: + if role_code in seen_roles: + raise AccountingValidationError( + f"More than one effective account_role_mapping applies for role {role_code}. " + "Close the superseded mapping, then retry posting." + ) + seen_roles[role_code] = (account_code, policy_version, rule_version) + missing_roles = [role_code for role_code in role_codes if role_code not in seen_roles] + if missing_roles: + raise AccountingValidationError( + f"Account role {missing_roles[0]} is not mapped on this book. " + "Create the account_role_mapping row, then retry posting." + ) + versions = {(policy_version, rule_version) for _code, policy_version, rule_version in seen_roles.values()} + if len(versions) != 1: + raise AccountingValidationError( + "Account role mappings use more than one policy version. " + "Approve a single effective mapping set, then retry posting." + ) + policy_version, rule_version = next(iter(versions)) + return ( + {role_code: account_code for role_code, (account_code, _, _) in seen_roles.items()}, + policy_version, + rule_version, + ) + + def _require_book_for_close( + self, + connection: object, + tenant_id: UUID, + legal_entity_id: UUID, + accounting_book_reference: str, + next_action: str = "the close", + ) -> tuple[UUID, str]: + row = connection.execute( + """ + SELECT accounting_book_id, reporting_currency_code + FROM accounting_core.accounting_book + WHERE tenant_account_id = %s + AND legal_entity_id = %s + AND book_name = %s + AND valid_to IS NULL + """, + (tenant_id, legal_entity_id, accounting_book_reference), + ).fetchone() + if row is None: + raise AccountingValidationError( + f"Accounting book {accounting_book_reference} is not recorded for this legal entity. " + f"Create the accounting_book row, then retry {next_action}." + ) + return row[0], row[1] + + def _require_fiscal_period( + self, + connection: object, + tenant_id: UUID, + period_code: str, + next_action: str = "the close", + ) -> tuple[UUID, str, date]: + row = connection.execute( + """ + SELECT fiscal_period_id, period_status_code, period_end_date + FROM accounting_core.fiscal_period + WHERE tenant_account_id = %s AND period_code = %s + """, + (tenant_id, period_code), + ).fetchone() + if row is None: + raise AccountingValidationError( + f"Fiscal period {period_code} is not recorded for this tenant. " + f"Create the fiscal_period row, then retry {next_action}." + ) + return row[0], row[1], row[2] + + def _lock_book_period( + self, + connection: object, + tenant_id: UUID, + book_id: UUID, + period_code: str, + ) -> tuple[UUID, str, date]: + """Materialize and lock close state independently for one accounting book.""" + period_row = connection.execute( + """ + SELECT fiscal_period_id, period_status_code, period_closed_at + FROM accounting_core.fiscal_period + WHERE tenant_account_id = %s AND period_code = %s + """, + (tenant_id, period_code), + ).fetchone() + if period_row is None: + raise AccountingValidationError( + f"Fiscal period {period_code} is not recorded for this tenant. " + "Create the fiscal_period row, then retry the close." + ) + period_id = period_row[0] + connection.execute( + """ + INSERT INTO accounting_core.accounting_book_period_control ( + tenant_account_id, accounting_book_id, fiscal_period_id, + period_status_code, period_closed_at + ) + SELECT accounting_book.tenant_account_id, + accounting_book.accounting_book_id, + fiscal_period.fiscal_period_id, + fiscal_period.period_status_code, + fiscal_period.period_closed_at + FROM accounting_core.accounting_book + JOIN accounting_core.fiscal_period + ON fiscal_period.tenant_account_id = accounting_book.tenant_account_id + WHERE accounting_book.tenant_account_id = %s + AND accounting_book.valid_to IS NULL + AND fiscal_period.fiscal_period_id = %s + ON CONFLICT (tenant_account_id, accounting_book_id, fiscal_period_id) + DO NOTHING + """, + (tenant_id, period_id), + ) + row = connection.execute( + """ + SELECT fiscal_period.fiscal_period_id, + accounting_book_period_control.period_status_code, + fiscal_period.period_end_date + FROM accounting_core.fiscal_period + JOIN accounting_core.accounting_book_period_control + ON accounting_book_period_control.tenant_account_id + = fiscal_period.tenant_account_id + AND accounting_book_period_control.fiscal_period_id + = fiscal_period.fiscal_period_id + AND accounting_book_period_control.accounting_book_id = %s + WHERE fiscal_period.tenant_account_id = %s + AND fiscal_period.fiscal_period_id = %s + FOR UPDATE OF accounting_book_period_control + """, + (book_id, tenant_id, period_id), + ).fetchone() + if row is None: + raise AccountingValidationError( + f"Fiscal period {period_code} has no control row for this accounting book. " + "Repair the fiscal-period control data for this book, then retry the close." + ) + return row[0], row[1], row[2] + + def _load_book_period_state( + self, + connection: object, + tenant_id: UUID, + book_id: UUID, + period_code: str, + ) -> tuple[UUID, str, date, date] | None: + """Return the selected book's period state, falling back to legacy calendar state.""" + row = connection.execute( + """ + SELECT fiscal_period.fiscal_period_id, + COALESCE( + accounting_book_period_control.period_status_code, + fiscal_period.period_status_code + ), + fiscal_period.period_start_date, + fiscal_period.period_end_date + FROM accounting_core.fiscal_period + LEFT JOIN accounting_core.accounting_book_period_control + ON accounting_book_period_control.tenant_account_id + = fiscal_period.tenant_account_id + AND accounting_book_period_control.fiscal_period_id + = fiscal_period.fiscal_period_id + AND accounting_book_period_control.accounting_book_id = %s + WHERE fiscal_period.tenant_account_id = %s + AND fiscal_period.period_code = %s + """, + (book_id, tenant_id, period_code), + ).fetchone() + if row is None: + return None + return row[0], row[1], row[2], row[3] + + def _load_period_state( + self, connection: object, tenant_id: UUID, period_code: str + ) -> tuple[UUID, str, date, date] | None: + row = connection.execute( + """ + SELECT fiscal_period_id, period_status_code, period_start_date, period_end_date + FROM accounting_core.fiscal_period + WHERE tenant_account_id = %s AND period_code = %s + """, + (tenant_id, period_code), + ).fetchone() + if row is None: + return None + return row[0], row[1], row[2], row[3] + + def _require_tenant_calendar(self, connection: object, tenant_id: UUID) -> UUID: + row = connection.execute( + """ + SELECT fiscal_calendar_id + FROM accounting_core.fiscal_calendar + WHERE tenant_account_id = %s + ORDER BY calendar_code + LIMIT 1 + """, + (tenant_id,), + ).fetchone() + if row is None: + raise AccountingValidationError( + "No fiscal_calendar is recorded for this tenant. " + "Create the fiscal_calendar row, then retry the period open." + ) + return row[0] + + def _period_open_document( + self, + legal_entity_reference: str, + period_code: str, + period_start_date: date, + period_end_date: date, + *, + replayed: bool, + ) -> dict[str, object]: + return { + "tenant_reference": self._tenant_reference, + "legal_entity_reference": legal_entity_reference, + "fiscal_period_reference": f"urn:cwl:accounting:fiscal_period:{period_code}", + "period_code": period_code, + "period_status_code": "open", + "period_start_date": period_start_date.isoformat(), + "period_end_date": period_end_date.isoformat(), + "replayed": replayed, + } + + def _aggregate_trial_balance( + self, + connection: object, + tenant_id: UUID, + legal_entity_id: UUID, + book_id: UUID, + through_date: date, + ) -> tuple[tuple[UUID, str, Decimal, Decimal], ...]: + rows = connection.execute( + """ + SELECT chart_account.chart_account_id, + chart_account.chart_account_code, + SUM(journal_entry_line.debit_amount), + SUM(journal_entry_line.credit_amount) + FROM accounting_core.journal_entry_line + JOIN accounting_core.general_journal + ON general_journal.tenant_account_id = journal_entry_line.tenant_account_id + AND general_journal.general_journal_id = journal_entry_line.general_journal_id + JOIN accounting_core.chart_account + ON chart_account.tenant_account_id = journal_entry_line.tenant_account_id + AND chart_account.chart_account_id = journal_entry_line.chart_account_id + WHERE general_journal.tenant_account_id = %s + AND general_journal.legal_entity_id = %s + AND general_journal.accounting_book_id = %s + AND general_journal.accounting_date <= %s + GROUP BY chart_account.chart_account_id, chart_account.chart_account_code + ORDER BY chart_account.chart_account_code + """, + (tenant_id, legal_entity_id, book_id, through_date), + ).fetchall() + return tuple( + (row[0], row[1], Decimal(row[2]), Decimal(row[3])) for row in rows + ) + + def _aggregate_worksheet_trial_balance( + self, + connection: object, + tenant_id: UUID, + legal_entity_id: UUID, + book_id: UUID, + through_date: date, + *, + exclude_adjusting: bool, + ) -> tuple[tuple[UUID, str, Decimal, Decimal], ...]: + rows = connection.execute( + """ + SELECT chart_account.chart_account_id, + chart_account.chart_account_code, + SUM(journal_entry_line.debit_amount), + SUM(journal_entry_line.credit_amount) + FROM accounting_core.journal_entry_line + JOIN accounting_core.general_journal + ON general_journal.tenant_account_id = journal_entry_line.tenant_account_id + AND general_journal.general_journal_id = journal_entry_line.general_journal_id + JOIN accounting_core.chart_account + ON chart_account.tenant_account_id = journal_entry_line.tenant_account_id + AND chart_account.chart_account_id = journal_entry_line.chart_account_id + WHERE general_journal.tenant_account_id = %s + AND general_journal.legal_entity_id = %s + AND general_journal.accounting_book_id = %s + AND general_journal.accounting_date <= %s + AND general_journal.journal_reference NOT LIKE %s + AND ( + %s + OR journal_entry_line.account_role_code IS DISTINCT FROM %s + ) + GROUP BY chart_account.chart_account_id, chart_account.chart_account_code + ORDER BY chart_account.chart_account_code + """, + ( + tenant_id, + legal_entity_id, + book_id, + through_date, + _CLOSING_JOURNAL_PATTERN, + not exclude_adjusting, + "adjusting", + ), + ).fetchall() + return tuple( + (row[0], row[1], Decimal(row[2]), Decimal(row[3])) for row in rows + ) + + def _count_source_journals( + self, + connection: object, + tenant_id: UUID, + legal_entity_id: UUID, + book_id: UUID, + through_date: date, + ) -> int: + return int( + connection.execute( + """ + SELECT COUNT(*) + FROM accounting_core.general_journal + WHERE tenant_account_id = %s + AND legal_entity_id = %s + AND accounting_book_id = %s + AND accounting_date <= %s + """, + (tenant_id, legal_entity_id, book_id, through_date), + ).fetchone()[0] + ) + + def _latest_close_snapshot( + self, + connection: object, + tenant_id: UUID, + legal_entity_id: UUID, + book_id: UUID, + period_id: UUID, + ) -> tuple[UUID, datetime, int, str, str] | None: + row = connection.execute( + """ + SELECT trial_balance_snapshot_id, snapshot_generated_at, + source_journal_count, source_payload_hash, close_idempotency_key + FROM accounting_reporting.trial_balance_snapshot + WHERE tenant_account_id = %s + AND legal_entity_id = %s + AND accounting_book_id = %s + AND fiscal_period_id = %s + ORDER BY snapshot_generated_at DESC + LIMIT 1 + """, + (tenant_id, legal_entity_id, book_id, period_id), + ).fetchone() + if row is None: + return None + return row[0], row[1], int(row[2]), row[3], str(row[4]) + + def _load_snapshot_balance_lines( + self, connection: object, tenant_id: UUID, snapshot_id: UUID + ) -> tuple[tuple[str, Decimal, Decimal], ...]: + rows = connection.execute( + """ + SELECT chart_account.chart_account_code, + trial_balance_line.debit_total_amount, + trial_balance_line.credit_total_amount + FROM accounting_reporting.trial_balance_line + JOIN accounting_core.chart_account + ON chart_account.tenant_account_id = trial_balance_line.tenant_account_id + AND chart_account.chart_account_id = trial_balance_line.chart_account_id + WHERE trial_balance_line.tenant_account_id = %s + AND trial_balance_line.trial_balance_snapshot_id = %s + ORDER BY chart_account.chart_account_code + """, + (tenant_id, snapshot_id), + ).fetchall() + return tuple((row[0], Decimal(row[1]), Decimal(row[2])) for row in rows) + + def _replay_close_receipt( + self, + connection: object, + *, + tenant_id: UUID, + legal_entity_id: UUID, + book_id: UUID, + period_id: UUID, + period_code: str, + current_status: str, + legal_entity_reference: str, + accounting_book_reference: str, + idempotency_key: str, + ) -> PeriodCloseReceipt: + snapshot = self._latest_close_snapshot( + connection, tenant_id, legal_entity_id, book_id, period_id + ) + if snapshot is None: + raise AccountingValidationError( + f"Fiscal period {period_code} is {current_status} without a trial-balance snapshot. " + "Restore the trial_balance_snapshot for this book from the journal population, " + "then retry the close." + ) + stored_close_key = snapshot[4] + if stored_close_key != idempotency_key: + raise AccountingValidationError( + f"Fiscal period {period_code} is hard_closed (period_closed). " + "Replay the original period-close idempotency key; " + "a second close of a locked period is rejected." + ) + return self._close_receipt_from_snapshot( + snapshot, + period_code=period_code, + period_status_code=current_status, + legal_entity_reference=legal_entity_reference, + accounting_book_reference=accounting_book_reference, + replayed=True, + ) + + def _replay_soft_close_receipt( + self, + connection: object, + *, + tenant_id: UUID, + legal_entity_id: UUID, + book_id: UUID, + period_id: UUID, + period_code: str, + period_end_date: date, + snapshot_currency_code: str, + legal_entity_reference: str, + accounting_book_reference: str, + idempotency_key: str, + ) -> PeriodCloseReceipt: + ( + period_closed_at, + stored_idempotency_key, + source_journal_count, + source_payload_hash, + evidence_complete, + ) = connection.execute( + """ + SELECT COALESCE(period_closed_at, clock_timestamp()), + soft_close_idempotency_key, + soft_close_source_journal_count, + soft_close_source_payload_hash, + ( + soft_close_idempotency_key IS NOT NULL + AND soft_close_source_journal_count IS NOT NULL + AND soft_close_source_payload_hash IS NOT NULL + ) + FROM accounting_core.accounting_book_period_control + WHERE tenant_account_id = %s + AND accounting_book_id = %s + AND fiscal_period_id = %s + """, + (tenant_id, book_id, period_id), + ).fetchone() + if not evidence_complete: + raise AccountingValidationError( + f"Fiscal period {period_code} is soft_closed without durable close-command evidence. " + "Restore the original evidence through an audited migration, then retry; " + "do not reconstruct it from later ledger state." + ) + if stored_idempotency_key != idempotency_key: + raise IdempotencyConflictError( + "period-close idempotency key was already used by the soft-close command. Replay the original close idempotency key, then retry the close." + ) + return PeriodCloseReceipt( + tenant_reference=self._tenant_reference, + legal_entity_reference=legal_entity_reference, + accounting_book_reference=accounting_book_reference, + period_code=period_code, + period_status_code="soft_closed", + snapshot_record_id="", + snapshot_generated_at=period_closed_at, + source_journal_count=source_journal_count, + source_payload_hash=source_payload_hash, + replayed=True, + ) + + def _persist_soft_close( + self, + connection: object, + *, + tenant_id: UUID, + legal_entity_id: UUID, + book_id: UUID, + period_id: UUID, + period_code: str, + period_end_date: date, + snapshot_currency_code: str, + legal_entity_reference: str, + accounting_book_reference: str, + idempotency_key: str, + ) -> PeriodCloseReceipt: + _lines, source_journal_count, source_payload_hash = self._live_close_source( + connection, + tenant_id=tenant_id, + legal_entity_id=legal_entity_id, + book_id=book_id, + period_end_date=period_end_date, + period_code=period_code, + snapshot_currency_code=snapshot_currency_code, + legal_entity_reference=legal_entity_reference, + accounting_book_reference=accounting_book_reference, + ) + period_closed_at = self._set_book_period_closed( + connection, tenant_id, book_id, period_id, "soft_closed" + ) + connection.execute( + """ + UPDATE accounting_core.accounting_book_period_control + SET soft_close_idempotency_key = %s, + soft_close_source_payload_hash = %s, + soft_close_source_journal_count = %s + WHERE tenant_account_id = %s + AND accounting_book_id = %s + AND fiscal_period_id = %s + """, + ( + idempotency_key, + source_payload_hash, + source_journal_count, + tenant_id, + book_id, + period_id, + ), + ) + self._insert_period_close_event( + connection, + tenant_id, + period_code, + accounting_book_reference, + None, + source_payload_hash, + ) + return PeriodCloseReceipt( + tenant_reference=self._tenant_reference, + legal_entity_reference=legal_entity_reference, + accounting_book_reference=accounting_book_reference, + period_code=period_code, + period_status_code="soft_closed", + snapshot_record_id="", + snapshot_generated_at=period_closed_at, + source_journal_count=source_journal_count, + source_payload_hash=source_payload_hash, + replayed=False, + ) + + def _live_close_source( + self, + connection: object, + *, + tenant_id: UUID, + legal_entity_id: UUID, + book_id: UUID, + period_end_date: date, + period_code: str, + snapshot_currency_code: str, + legal_entity_reference: str, + accounting_book_reference: str, + ) -> tuple[tuple[tuple[UUID, str, Decimal, Decimal], ...], int, str]: + lines = self._aggregate_trial_balance( + connection, tenant_id, legal_entity_id, book_id, period_end_date + ) + source_journal_count = self._count_source_journals( + connection, tenant_id, legal_entity_id, book_id, period_end_date + ) + source_payload_hash = _canonical_snapshot_hash( + tenant_reference=self._tenant_reference, + legal_entity_reference=legal_entity_reference, + accounting_book_reference=accounting_book_reference, + period_code=period_code, + snapshot_currency_code=snapshot_currency_code, + source_journal_count=source_journal_count, + lines=lines, + ) + return lines, source_journal_count, source_payload_hash + + def _persist_period_close( + self, + connection: object, + *, + tenant_id: UUID, + legal_entity_id: UUID, + book_id: UUID, + period_id: UUID, + period_code: str, + period_end_date: date, + period_status_code: str, + snapshot_currency_code: str, + legal_entity_reference: str, + accounting_book_reference: str, + idempotency_key: str, + ) -> PeriodCloseReceipt: + self._post_closing_journal( + connection, + tenant_id=tenant_id, + legal_entity_id=legal_entity_id, + book_id=book_id, + period_id=period_id, + period_code=period_code, + period_end_date=period_end_date, + snapshot_currency_code=snapshot_currency_code, + legal_entity_reference=legal_entity_reference, + accounting_book_reference=accounting_book_reference, + ) + lines, source_journal_count, source_payload_hash = self._live_close_source( + connection, + tenant_id=tenant_id, + legal_entity_id=legal_entity_id, + book_id=book_id, + period_end_date=period_end_date, + period_code=period_code, + snapshot_currency_code=snapshot_currency_code, + legal_entity_reference=legal_entity_reference, + accounting_book_reference=accounting_book_reference, + ) + snapshot_id, snapshot_generated_at = connection.execute( + """ + INSERT INTO accounting_reporting.trial_balance_snapshot ( + tenant_account_id, legal_entity_id, accounting_book_id, fiscal_period_id, + snapshot_currency_code, source_journal_count, source_payload_hash, + close_idempotency_key + ) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s) + RETURNING trial_balance_snapshot_id, snapshot_generated_at + """, + ( + tenant_id, + legal_entity_id, + book_id, + period_id, + snapshot_currency_code, + source_journal_count, + source_payload_hash, + idempotency_key, + ), + ).fetchone() + for account_id, _account_code, debit_total, credit_total in lines: + connection.execute( + """ + INSERT INTO accounting_reporting.trial_balance_line ( + tenant_account_id, trial_balance_snapshot_id, chart_account_id, + debit_total_amount, credit_total_amount, net_balance_amount + ) + VALUES (%s, %s, %s, %s, %s, %s) + """, + ( + tenant_id, + snapshot_id, + account_id, + debit_total, + credit_total, + debit_total - credit_total, + ), + ) + self._set_book_period_closed( + connection, tenant_id, book_id, period_id, period_status_code + ) + self._insert_period_close_event( + connection, + tenant_id, + period_code, + accounting_book_reference, + snapshot_id, + source_payload_hash, + ) + return PeriodCloseReceipt( + tenant_reference=self._tenant_reference, + legal_entity_reference=legal_entity_reference, + accounting_book_reference=accounting_book_reference, + period_code=period_code, + period_status_code=period_status_code, + snapshot_record_id=str(snapshot_id), + snapshot_generated_at=snapshot_generated_at, + source_journal_count=source_journal_count, + source_payload_hash=source_payload_hash, + replayed=False, + ) + + def _post_closing_journal( + self, + connection: object, + *, + tenant_id: UUID, + legal_entity_id: UUID, + book_id: UUID, + period_id: UUID, + period_code: str, + period_end_date: date, + snapshot_currency_code: str, + legal_entity_reference: str, + accounting_book_reference: str, + ) -> None: + closing_reference = ( + "urn:cwl:accounting:general_journal:period_closing:" + f"{period_code}:{accounting_book_reference}" + ) + income_rows = connection.execute( + """ + SELECT chart_account.chart_account_code, + account_role_mapping.account_role_code, + SUM(journal_entry_line.debit_amount), + SUM(journal_entry_line.credit_amount) + FROM accounting_core.journal_entry_line + JOIN accounting_core.general_journal + ON general_journal.tenant_account_id = journal_entry_line.tenant_account_id + AND general_journal.general_journal_id = journal_entry_line.general_journal_id + JOIN accounting_core.chart_account + ON chart_account.tenant_account_id = journal_entry_line.tenant_account_id + AND chart_account.chart_account_id = journal_entry_line.chart_account_id + JOIN accounting_core.account_role_mapping + ON account_role_mapping.tenant_account_id = chart_account.tenant_account_id + AND account_role_mapping.chart_account_id = chart_account.chart_account_id + AND account_role_mapping.valid_to IS NULL + WHERE general_journal.tenant_account_id = %s + AND general_journal.legal_entity_id = %s + AND general_journal.accounting_book_id = %s + AND general_journal.accounting_date <= %s + AND account_role_mapping.account_role_code IN ( + 'usage_revenue', 'write_off_expense' + ) + GROUP BY chart_account.chart_account_code, account_role_mapping.account_role_code + ORDER BY chart_account.chart_account_code + """, + (tenant_id, legal_entity_id, book_id, period_end_date), + ).fetchall() + closing_lines: list[PostedJournalLine] = [] + retained_earnings_amount = Decimal("0") + for account_code, role_code, debit_total, credit_total in income_rows: + net_amount = Decimal(credit_total) - Decimal(debit_total) + if net_amount == 0: + continue + line_number = len(closing_lines) + 1 + if net_amount > 0: + closing_lines.append( + PostedJournalLine( + line_number=line_number, + chart_account_code=str(account_code), + account_role_code=str(role_code), + debit_amount=net_amount, + credit_amount=Decimal("0"), + ) + ) + else: + closing_lines.append( + PostedJournalLine( + line_number=line_number, + chart_account_code=str(account_code), + account_role_code=str(role_code), + debit_amount=Decimal("0"), + credit_amount=-net_amount, + ) + ) + retained_earnings_amount += net_amount + if not closing_lines: + return + policy_version, rule_version = self._require_retained_earnings_mapping( + connection, tenant_id, book_id + ) + if retained_earnings_amount > 0: + closing_lines.append( + PostedJournalLine( + line_number=len(closing_lines) + 1, + chart_account_code="310100", + account_role_code="retained_earnings", + debit_amount=Decimal("0"), + credit_amount=retained_earnings_amount, + ) + ) + elif retained_earnings_amount < 0: + closing_lines.append( + PostedJournalLine( + line_number=len(closing_lines) + 1, + chart_account_code="310100", + account_role_code="retained_earnings", + debit_amount=-retained_earnings_amount, + credit_amount=Decimal("0"), + ) + ) + source_payload_hash = _canonical_closing_hash( + tenant_reference=self._tenant_reference, + legal_entity_reference=legal_entity_reference, + accounting_book_reference=accounting_book_reference, + period_code=period_code, + lines=tuple(closing_lines), + ) + proposal_record_id = connection.execute( + """ + INSERT INTO accounting_integration.journal_proposal_record ( + tenant_account_id, external_proposal_id, proposal_contract_version, + idempotency_key, source_payload_hash, proposal_status_code, processed_at + ) + VALUES (%s, uuidv7(), 1, %s, %s, 'posted', clock_timestamp()) + RETURNING proposal_record_id + """, + ( + tenant_id, + f"{self._tenant_reference}:period_closing:{period_code}:" + f"{accounting_book_reference}", + source_payload_hash, + ), + ).fetchone()[0] + policy = AccountingPolicy( + tenant_reference=self._tenant_reference, + legal_entity_reference=legal_entity_reference, + accounting_book_reference=accounting_book_reference, + intended_book_role_code=self._book_role_code(connection, tenant_id, book_id), + transaction_currency=snapshot_currency_code, + functional_currency=snapshot_currency_code, + open_period_start=period_end_date, + open_period_end=period_end_date, + chart_account_mapping={"retained_earnings": "310100"}, + accounting_policy_version=policy_version, + posting_rule_version=rule_version, + ) + self._insert_journal( + connection, + tenant_id=tenant_id, + legal_entity_id=legal_entity_id, + book_id=book_id, + period_id=period_id, + journal_reference=closing_reference, + proposal=_ClosingProposal( + source_payload_hash=source_payload_hash, + transaction_currency=snapshot_currency_code, + transaction_date=period_end_date, + accounting_date=period_end_date, + source_event_references=(), + ), + policy=policy, + proposal_record_id=proposal_record_id, + lines=tuple(closing_lines), + ) + + def _require_retained_earnings_mapping( + self, connection: object, tenant_id: UUID, book_id: UUID + ) -> tuple[str, str]: + row = connection.execute( + """ + SELECT account_role_mapping.accounting_policy_version, + account_role_mapping.posting_rule_version + FROM accounting_core.account_role_mapping + JOIN accounting_core.chart_account + ON chart_account.tenant_account_id = account_role_mapping.tenant_account_id + AND chart_account.chart_account_id = account_role_mapping.chart_account_id + WHERE account_role_mapping.tenant_account_id = %s + AND account_role_mapping.accounting_book_id = %s + AND account_role_mapping.account_role_code = 'retained_earnings' + AND chart_account.chart_account_code = '310100' + AND account_role_mapping.valid_to IS NULL + AND chart_account.valid_to IS NULL + """, + (tenant_id, book_id), + ).fetchone() + if row is None: + raise AccountingValidationError( + "account_role_mapping is missing for retained_earnings → 310100. " + "Create the retained_earnings mapping and chart_account 310100, " + "then retry the close." + ) + return str(row[0]), str(row[1]) + + def _book_role_code( + self, connection: object, tenant_id: UUID, book_id: UUID + ) -> str: + return str( + connection.execute( + """ + SELECT book_role_code + FROM accounting_core.accounting_book + WHERE tenant_account_id = %s AND accounting_book_id = %s + """, + (tenant_id, book_id), + ).fetchone()[0] + ) + + def _set_book_period_closed( + self, + connection: object, + tenant_id: UUID, + book_id: UUID, + period_id: UUID, + period_status_code: str, + ) -> datetime: + """Close one book and retain aggregate calendar status only for compatibility.""" + period_closed_at = connection.execute( + """ + UPDATE accounting_core.accounting_book_period_control + SET period_status_code = %s, + period_closed_at = clock_timestamp() + WHERE tenant_account_id = %s + AND accounting_book_id = %s + AND fiscal_period_id = %s + RETURNING period_closed_at + """, + (period_status_code, tenant_id, book_id, period_id), + ).fetchone()[0] + aggregate_row = connection.execute( + """ + SELECT CASE + WHEN bool_and( + accounting_book_period_control.period_status_code = 'hard_closed' + ) THEN 'hard_closed' + WHEN bool_and( + accounting_book_period_control.period_status_code <> 'open' + ) THEN 'soft_closed' + ELSE 'open' + END, + max(accounting_book_period_control.period_closed_at) + FROM accounting_core.accounting_book_period_control + JOIN accounting_core.accounting_book + ON accounting_book.tenant_account_id + = accounting_book_period_control.tenant_account_id + AND accounting_book.accounting_book_id + = accounting_book_period_control.accounting_book_id + WHERE accounting_book_period_control.tenant_account_id = %s + AND accounting_book_period_control.fiscal_period_id = %s + AND accounting_book.valid_to IS NULL + """, + (tenant_id, period_id), + ).fetchone() + aggregate_status = aggregate_row[0] or "open" + aggregate_closed_at = None if aggregate_status == "open" else aggregate_row[1] + connection.execute( + """ + UPDATE accounting_core.fiscal_period + SET period_status_code = %s, + period_closed_at = %s + WHERE tenant_account_id = %s AND fiscal_period_id = %s + """, + (aggregate_status, aggregate_closed_at, tenant_id, period_id), + ) + return period_closed_at + + def _insert_period_close_event( + self, + connection: object, + tenant_id: UUID, + period_code: str, + accounting_book_reference: str, + snapshot_id: UUID | None, + payload_hash: str, + ) -> None: + payload_reference = ( + f"urn:cwl:accounting:trial_balance_snapshot:{snapshot_id}" + if snapshot_id is not None + else f"urn:cwl:accounting:fiscal_period:{period_code}" + ) + connection.execute( + """ + INSERT INTO accounting_integration.outbox_event ( + tenant_account_id, event_type_code, aggregate_reference, + payload_reference, payload_hash + ) + VALUES (%s, 'period_close', %s, %s, %s) + """, + ( + tenant_id, + f"{accounting_book_reference}:fiscal_period:{period_code}", + payload_reference, + payload_hash, + ), + ) + + def _close_receipt_from_snapshot( + self, + snapshot: tuple[UUID, datetime, int, str, str], + *, + period_code: str, + period_status_code: str, + legal_entity_reference: str, + accounting_book_reference: str, + replayed: bool, + ) -> PeriodCloseReceipt: + snapshot_id, snapshot_generated_at, source_journal_count, source_payload_hash, _close_key = ( + snapshot + ) + return PeriodCloseReceipt( + tenant_reference=self._tenant_reference, + legal_entity_reference=legal_entity_reference, + accounting_book_reference=accounting_book_reference, + period_code=period_code, + period_status_code=period_status_code, + snapshot_record_id=str(snapshot_id), + snapshot_generated_at=snapshot_generated_at, + source_journal_count=source_journal_count, + source_payload_hash=source_payload_hash, + replayed=replayed, + ) + + def _insert_journal( + self, + connection: object, + *, + tenant_id: UUID, + legal_entity_id: UUID, + book_id: UUID, + period_id: UUID, + journal_reference: str, + proposal: JournalProposal | _ReversalProposal | _ClosingProposal | _AdjustingProposal, + policy: AccountingPolicy, + proposal_record_id: UUID, + lines: tuple[PostedJournalLine, ...], + ) -> UUID: + connection.execute( + "SELECT set_config('accounting_core.journal_write_role', %s, true)", + (_journal_write_role(proposal),), + ) + journal_id = connection.execute( + """ + INSERT INTO accounting_core.general_journal ( + tenant_account_id, legal_entity_id, accounting_book_id, fiscal_period_id, + journal_reference, journal_status_code, transaction_currency_code, + functional_currency_code, transaction_date, accounting_date, + source_proposal_record_id, accounting_policy_version, posting_rule_version + ) + VALUES (%s, %s, %s, %s, %s, 'posted', %s, %s, %s, %s, %s, %s, %s) + RETURNING general_journal_id + """, + ( + tenant_id, + legal_entity_id, + book_id, + period_id, + journal_reference, + proposal.transaction_currency, + policy.functional_currency, + proposal.transaction_date, + proposal.accounting_date, + proposal_record_id, + policy.accounting_policy_version, + policy.posting_rule_version, + ), + ).fetchone()[0] + for line in lines: + chart_account_id = connection.execute( + """ + SELECT chart_account_id + FROM accounting_core.chart_account + WHERE tenant_account_id = %s + AND accounting_book_id = %s + AND chart_account_code = %s + AND valid_to IS NULL + """, + (tenant_id, book_id, line.chart_account_code), + ).fetchone() + if chart_account_id is None: + raise AccountingValidationError( + f"Chart account {line.chart_account_code} is not recorded on this book. " + "Create the chart_account row, then retry posting." + ) + connection.execute( + """ + INSERT INTO accounting_core.journal_entry_line ( + tenant_account_id, general_journal_id, line_number, chart_account_id, + account_role_code, debit_amount, credit_amount + ) + VALUES (%s, %s, %s, %s, %s, %s, %s) + """, + ( + tenant_id, + journal_id, + line.line_number, + chart_account_id[0], + line.account_role_code, + line.debit_amount, + line.credit_amount, + ), + ) + for reference in proposal.source_event_references: + connection.execute( + """ + INSERT INTO accounting_core.journal_source_reference ( + tenant_account_id, general_journal_id, source_reference, source_payload_hash + ) + VALUES (%s, %s, %s, %s) + """, + (tenant_id, journal_id, reference, proposal.source_payload_hash), + ) + return journal_id + + def _insert_receipt( + self, + connection: object, + tenant_id: UUID, + proposal_record_id: UUID, + journal_id: UUID, + receipt: PostingReceipt, + ) -> None: + connection.execute( + """ + INSERT INTO accounting_integration.posting_receipt ( + tenant_account_id, proposal_record_id, general_journal_id, + receipt_status_code, receipt_payload_hash + ) + VALUES (%s, %s, %s, %s, %s) + """, + ( + tenant_id, + proposal_record_id, + journal_id, + receipt.posting_status_code, + _canonical_receipt_hash(receipt), + ), + ) + + def _insert_outbox( + self, + connection: object, + tenant_id: UUID, + event_type_code: str, + aggregate_reference: str, + payload_reference: str, + receipt: PostingReceipt, + ) -> None: + connection.execute( + """ + INSERT INTO accounting_integration.outbox_event ( + tenant_account_id, event_type_code, aggregate_reference, + payload_reference, payload_hash + ) + VALUES (%s, %s, %s, %s, %s) + """, + ( + tenant_id, + event_type_code, + aggregate_reference, + payload_reference, + _canonical_receipt_hash(receipt), + ), + ) + + def _receipt_for_idempotency_key( + self, connection: object, tenant_id: UUID, proposal: JournalProposal + ) -> PostingReceipt: + return PostingReceipt( + receipt_reference=f"urn:cwl:accounting:posting_receipt:{proposal.proposal_id}", + journal_reference=f"urn:cwl:accounting:general_journal:{proposal.proposal_id}", + posting_status_code="posted", + source_proposal_id=proposal.proposal_id, + source_payload_hash=proposal.source_payload_hash, + tenant_reference=proposal.tenant_reference, + legal_entity_reference=proposal.legal_entity_reference, + accounting_book_reference=self._book_name_for_proposal( + connection, tenant_id, proposal.idempotency_key + ), + accounting_policy_version=self._policy_version_for_proposal( + connection, tenant_id, proposal.idempotency_key + )[0], + posting_rule_version=self._policy_version_for_proposal( + connection, tenant_id, proposal.idempotency_key + )[1], + line_count=self._line_count_for_proposal( + connection, tenant_id, proposal.idempotency_key + ), + ) + + def _receipt_for_journal( + self, connection: object, tenant_id: UUID, journal_reference: str + ) -> PostingReceipt: + row = connection.execute( + """ + SELECT general_journal.journal_reference, + journal_proposal_record.source_payload_hash, + journal_proposal_record.external_proposal_id, + general_journal.accounting_policy_version, + general_journal.posting_rule_version, + accounting_book.book_name, + legal_entity_record.legal_entity_code, + ( + SELECT COUNT(*) + FROM accounting_core.journal_entry_line + WHERE tenant_account_id = general_journal.tenant_account_id + AND general_journal_id = general_journal.general_journal_id + ), + original_journal.journal_reference + FROM accounting_core.general_journal + JOIN accounting_integration.journal_proposal_record + ON journal_proposal_record.tenant_account_id = general_journal.tenant_account_id + AND journal_proposal_record.proposal_record_id = general_journal.source_proposal_record_id + JOIN accounting_core.accounting_book + ON accounting_book.tenant_account_id = general_journal.tenant_account_id + AND accounting_book.accounting_book_id = general_journal.accounting_book_id + JOIN accounting_core.legal_entity_record + ON legal_entity_record.tenant_account_id = general_journal.tenant_account_id + AND legal_entity_record.legal_entity_id = general_journal.legal_entity_id + LEFT JOIN accounting_core.journal_reversal + ON journal_reversal.tenant_account_id = general_journal.tenant_account_id + AND journal_reversal.reversal_journal_id = general_journal.general_journal_id + LEFT JOIN accounting_core.general_journal AS original_journal + ON original_journal.tenant_account_id = journal_reversal.tenant_account_id + AND original_journal.general_journal_id = journal_reversal.original_journal_id + WHERE general_journal.tenant_account_id = %s + AND general_journal.journal_reference = %s + """, + (tenant_id, journal_reference), + ).fetchone() + source_proposal_id = journal_reference.removeprefix( + "urn:cwl:accounting:general_journal:" + ).removesuffix(":reversal") + return PostingReceipt( + receipt_reference=f"{journal_reference}:receipt", + journal_reference=row[0], + posting_status_code="posted", + source_proposal_id=source_proposal_id, + source_payload_hash=row[1], + tenant_reference=self._tenant_reference, + legal_entity_reference=row[6], + accounting_book_reference=row[5], + accounting_policy_version=row[3], + posting_rule_version=row[4], + line_count=int(row[7]), + reversal_of_journal_reference=row[8], + ) + + def _book_name_for_proposal( + self, connection: object, tenant_id: UUID, idempotency_key: str + ) -> str: + return connection.execute( + """ + SELECT accounting_book.book_name + FROM accounting_integration.journal_proposal_record + JOIN accounting_core.general_journal + ON general_journal.tenant_account_id = journal_proposal_record.tenant_account_id + AND general_journal.source_proposal_record_id = journal_proposal_record.proposal_record_id + JOIN accounting_core.accounting_book + ON accounting_book.tenant_account_id = general_journal.tenant_account_id + AND accounting_book.accounting_book_id = general_journal.accounting_book_id + WHERE journal_proposal_record.tenant_account_id = %s + AND journal_proposal_record.idempotency_key = %s + """, + (tenant_id, idempotency_key), + ).fetchone()[0] + + def _policy_version_for_proposal( + self, connection: object, tenant_id: UUID, idempotency_key: str + ) -> tuple[str, str]: + return connection.execute( + """ + SELECT general_journal.accounting_policy_version, + general_journal.posting_rule_version + FROM accounting_integration.journal_proposal_record + JOIN accounting_core.general_journal + ON general_journal.tenant_account_id = journal_proposal_record.tenant_account_id + AND general_journal.source_proposal_record_id = journal_proposal_record.proposal_record_id + WHERE journal_proposal_record.tenant_account_id = %s + AND journal_proposal_record.idempotency_key = %s + """, + (tenant_id, idempotency_key), + ).fetchone() + + def _line_count_for_proposal( + self, connection: object, tenant_id: UUID, idempotency_key: str + ) -> int: + return int( + connection.execute( + """ + SELECT COUNT(*) + FROM accounting_integration.journal_proposal_record + JOIN accounting_core.general_journal + ON general_journal.tenant_account_id = journal_proposal_record.tenant_account_id + AND general_journal.source_proposal_record_id = journal_proposal_record.proposal_record_id + JOIN accounting_core.journal_entry_line + ON journal_entry_line.tenant_account_id = general_journal.tenant_account_id + AND journal_entry_line.general_journal_id = general_journal.general_journal_id + WHERE journal_proposal_record.tenant_account_id = %s + AND journal_proposal_record.idempotency_key = %s + """, + (tenant_id, idempotency_key), + ).fetchone()[0] + ) + + def _load_journal_row( + self, + connection: object, + tenant_id: UUID, + *, + idempotency_key: str = "", + journal_reference: str = "", + ) -> tuple[object, ...] | None: + return connection.execute( + """ + SELECT general_journal.general_journal_id, + general_journal.journal_reference, + general_journal.journal_status_code, + general_journal.accounting_date, + general_journal.transaction_currency_code, + general_journal.functional_currency_code, + general_journal.accounting_policy_version, + general_journal.posting_rule_version, + legal_entity_record.legal_entity_code, + accounting_book.book_name, + journal_proposal_record.idempotency_key, + journal_proposal_record.source_payload_hash, + journal_proposal_record.external_proposal_id, + original_journal.journal_reference, + journal_reversal.reversal_reason_code + FROM accounting_core.general_journal + JOIN accounting_integration.journal_proposal_record + ON journal_proposal_record.tenant_account_id = general_journal.tenant_account_id + AND journal_proposal_record.proposal_record_id = general_journal.source_proposal_record_id + JOIN accounting_core.accounting_book + ON accounting_book.tenant_account_id = general_journal.tenant_account_id + AND accounting_book.accounting_book_id = general_journal.accounting_book_id + JOIN accounting_core.legal_entity_record + ON legal_entity_record.tenant_account_id = general_journal.tenant_account_id + AND legal_entity_record.legal_entity_id = general_journal.legal_entity_id + LEFT JOIN accounting_core.journal_reversal + ON journal_reversal.tenant_account_id = general_journal.tenant_account_id + AND journal_reversal.reversal_journal_id = general_journal.general_journal_id + LEFT JOIN accounting_core.general_journal AS original_journal + ON original_journal.tenant_account_id = journal_reversal.tenant_account_id + AND original_journal.general_journal_id = journal_reversal.original_journal_id + WHERE general_journal.tenant_account_id = %s + AND (%s OR journal_proposal_record.idempotency_key = %s) + AND (%s OR general_journal.journal_reference = %s) + """, + ( + tenant_id, + not idempotency_key, + idempotency_key, + not journal_reference, + journal_reference, + ), + ).fetchone() + + def _load_published_receipt( + self, connection: object, tenant_id: UUID, idempotency_key: str + ) -> dict[str, object]: + row = connection.execute( + """ + SELECT posting_receipt.posting_receipt_id, + posting_receipt.created_at, + posting_receipt.receipt_status_code, + general_journal.journal_reference, + general_journal.transaction_currency_code, + general_journal.functional_currency_code, + general_journal.accounting_policy_version, + general_journal.posting_rule_version, + accounting_book.book_name, + legal_entity_record.legal_entity_code, + fiscal_period.period_code, + ( + SELECT COUNT(*) + FROM accounting_core.journal_entry_line + WHERE tenant_account_id = general_journal.tenant_account_id + AND general_journal_id = general_journal.general_journal_id + ), + journal_proposal_record.idempotency_key, + journal_proposal_record.external_proposal_id, + journal_proposal_record.source_payload_hash + FROM accounting_integration.posting_receipt + JOIN accounting_integration.journal_proposal_record + ON journal_proposal_record.tenant_account_id = posting_receipt.tenant_account_id + AND journal_proposal_record.proposal_record_id = posting_receipt.proposal_record_id + JOIN accounting_core.general_journal + ON general_journal.tenant_account_id = posting_receipt.tenant_account_id + AND general_journal.general_journal_id = posting_receipt.general_journal_id + JOIN accounting_core.accounting_book + ON accounting_book.tenant_account_id = general_journal.tenant_account_id + AND accounting_book.accounting_book_id = general_journal.accounting_book_id + JOIN accounting_core.legal_entity_record + ON legal_entity_record.tenant_account_id = general_journal.tenant_account_id + AND legal_entity_record.legal_entity_id = general_journal.legal_entity_id + JOIN accounting_core.fiscal_period + ON fiscal_period.tenant_account_id = general_journal.tenant_account_id + AND fiscal_period.fiscal_period_id = general_journal.fiscal_period_id + WHERE posting_receipt.tenant_account_id = %s + AND journal_proposal_record.idempotency_key = %s + """, + (tenant_id, idempotency_key), + ).fetchone() + if row is None: + raise AccountingValidationError( + "posting receipt is missing for this idempotency key. " + "Accept the proposal, then retry the receipt read." + ) + recorded_at = _format_timestamp(row[1]) + return { + "receipt_id": str(row[0]), + "receipt_contract_version": 1, + "idempotency_key": row[12], + "source_proposal_id": str(row[13]), + "source_payload_hash": row[14], + "tenant_reference": self._tenant_reference, + "legal_entity_reference": row[9], + "accounting_book_reference": row[8], + "fiscal_period_reference": f"urn:cwl:accounting:fiscal_period:{row[10]}", + "journal_reference": row[3], + "accounting_policy_version": row[6], + "posting_rule_version": row[7], + "posting_status_code": row[2], + "recorded_at": recorded_at, + "posted_at": recorded_at, + "line_count": int(row[11]), + "transaction_currency": row[4], + "functional_currency": row[5], + } + + def _load_lines( + self, connection: object, tenant_id: UUID, journal_id: UUID + ) -> tuple[PostedJournalLine, ...]: + rows = connection.execute( + """ + SELECT journal_entry_line.line_number, + chart_account.chart_account_code, + journal_entry_line.account_role_code, + journal_entry_line.debit_amount, + journal_entry_line.credit_amount + FROM accounting_core.journal_entry_line + JOIN accounting_core.chart_account + ON chart_account.tenant_account_id = journal_entry_line.tenant_account_id + AND chart_account.chart_account_id = journal_entry_line.chart_account_id + WHERE journal_entry_line.tenant_account_id = %s + AND journal_entry_line.general_journal_id = %s + ORDER BY journal_entry_line.line_number + """, + (tenant_id, journal_id), + ).fetchall() + return tuple( + PostedJournalLine( + line_number=row[0], + chart_account_code=row[1], + account_role_code=row[2], + debit_amount=Decimal(row[3]), + credit_amount=Decimal(row[4]), + ) + for row in rows + ) + + def _proposal_identity( + self, connection: object, tenant_id: UUID, proposal_record_id: UUID + ) -> tuple[str, str]: + row = connection.execute( + """ + SELECT source_payload_hash, external_proposal_id + FROM accounting_integration.journal_proposal_record + WHERE tenant_account_id = %s AND proposal_record_id = %s + """, + (tenant_id, proposal_record_id), + ).fetchone() + return row[0], str(row[1]) + + def _legal_entity_code( + self, connection: object, tenant_id: UUID, legal_entity_id: UUID + ) -> str: + return connection.execute( + """ + SELECT legal_entity_code + FROM accounting_core.legal_entity_record + WHERE tenant_account_id = %s AND legal_entity_id = %s + """, + (tenant_id, legal_entity_id), + ).fetchone()[0] + + def _book_name(self, connection: object, tenant_id: UUID, book_id: UUID) -> str: + return connection.execute( + """ + SELECT book_name + FROM accounting_core.accounting_book + WHERE tenant_account_id = %s AND accounting_book_id = %s + """, + (tenant_id, book_id), + ).fetchone()[0] + + +class _ClosingProposal: + """Minimal proposal shape used when persisting an AIS period-closing journal.""" + + def __init__( + self, + *, + source_payload_hash: str, + transaction_currency: str, + transaction_date: date, + accounting_date: date, + source_event_references: tuple[str, ...], + ) -> None: + self.source_payload_hash = source_payload_hash + self.transaction_currency = transaction_currency + self.transaction_date = transaction_date + self.accounting_date = accounting_date + self.source_event_references = source_event_references + + +class _AdjustingProposal: + """Minimal proposal shape used when persisting an AIS-owned adjusting journal.""" + + def __init__( + self, + *, + source_payload_hash: str, + transaction_currency: str, + transaction_date: date, + accounting_date: date, + source_event_references: tuple[str, ...], + ) -> None: + self.source_payload_hash = source_payload_hash + self.transaction_currency = transaction_currency + self.transaction_date = transaction_date + self.accounting_date = accounting_date + self.source_event_references = source_event_references + + +class _ReversalProposal: + """Minimal proposal shape used when persisting an equal-and-opposite journal.""" + + def __init__( + self, + *, + source_payload_hash: str, + transaction_currency: str, + transaction_date: date, + accounting_date: date, + source_event_references: tuple[str, ...], + ) -> None: + self.source_payload_hash = source_payload_hash + self.transaction_currency = transaction_currency + self.transaction_date = transaction_date + self.accounting_date = accounting_date + self.source_event_references = source_event_references + + +def _journal_write_role( + proposal: JournalProposal | _ReversalProposal | _ClosingProposal | _AdjustingProposal, +) -> str: + """Return the session-local role AIS sets before a journal INSERT.""" + if isinstance(proposal, _ClosingProposal): + return "period_closing" + if isinstance(proposal, _AdjustingProposal): + return "adjusting" + if isinstance(proposal, _ReversalProposal): + return "reversal" + return "" + + +def apply_foundation_migration(database_url: str, migration_path: Path) -> None: + """Apply the checked-in PostgreSQL 18 accounting foundation in migration order.""" + if not migration_path.is_file(): + raise AccountingValidationError( + f"Foundation migration is missing at {migration_path}. " + "Restore database/migrations/0001_accounting_foundation.sql, then retry." + ) + class_migration_path = migration_path.parent / "0002_chart_account_class.sql" + if not class_migration_path.is_file(): + raise AccountingValidationError( + f"Chart-account class migration is missing at {class_migration_path}. " + "Restore database/migrations/0002_chart_account_class.sql, then retry." + ) + submission_migration_path = migration_path.parent / "0003_home_tax_submission.sql" + if not submission_migration_path.is_file(): + raise AccountingValidationError( + f"Home-tax submission migration is missing at {submission_migration_path}. " + "Restore database/migrations/0003_home_tax_submission.sql, then retry." + ) + close_key_migration_path = migration_path.parent / "0004_close_idempotency_key.sql" + if not close_key_migration_path.is_file(): + raise AccountingValidationError( + f"Close-idempotency-key migration is missing at {close_key_migration_path}. " + "Restore database/migrations/0004_close_idempotency_key.sql, then retry." + ) + period_guard_migration_path = migration_path.parent / "0005_closed_period_guard.sql" + if not period_guard_migration_path.is_file(): + raise AccountingValidationError( + f"Closed-period guard migration is missing at {period_guard_migration_path}. " + "Restore database/migrations/0005_closed_period_guard.sql, then retry." + ) + concurrency_migration_path = migration_path.parent / "0006_concurrency_hot_partition.sql" + if not concurrency_migration_path.is_file(): + raise AccountingValidationError( + f"Concurrency and hot-partition migration is missing at {concurrency_migration_path}. " + "Restore database/migrations/0006_concurrency_hot_partition.sql, then retry." + ) + runtime_binding_migration_path = migration_path.parent / "0007_runtime_tenant_binding.sql" + if not runtime_binding_migration_path.is_file(): + raise AccountingValidationError( + f"Runtime-tenant binding migration is missing at {runtime_binding_migration_path}. " + "Restore database/migrations/0007_runtime_tenant_binding.sql, then retry." + ) + period_open_command_migration_path = ( + migration_path.parent / "0008_fiscal_period_open_command.sql" + ) + if not period_open_command_migration_path.is_file(): + raise AccountingValidationError( + f"Fiscal-period-open command migration is missing at {period_open_command_migration_path}. " + "Restore database/migrations/0008_fiscal_period_open_command.sql, then retry." + ) + book_period_control_migration_path = ( + migration_path.parent / "0009_accounting_book_period_control.sql" + ) + if not book_period_control_migration_path.is_file(): + raise AccountingValidationError( + f"Accounting-book-period control migration is missing at {book_period_control_migration_path}. " + "Restore database/migrations/0009_accounting_book_period_control.sql, then retry." + ) + soft_close_evidence_migration_path = ( + migration_path.parent / "0010_soft_close_command_evidence.sql" + ) + if not soft_close_evidence_migration_path.is_file(): + raise AccountingValidationError( + f"Soft-close command-evidence migration is missing at {soft_close_evidence_migration_path}. " + "Restore database/migrations/0010_soft_close_command_evidence.sql, then retry." + ) + bank_statement_migration_path = ( + migration_path.parent / "0011_bank_statement_evidence.sql" + ) + if not bank_statement_migration_path.is_file(): + raise AccountingValidationError( + f"Bank-statement evidence migration is missing at {bank_statement_migration_path}. " + "Restore database/migrations/0011_bank_statement_evidence.sql, then retry." + ) + assignment_identity_migration_path = ( + migration_path.parent / "0012_bank_assignment_command_identity.sql" + ) + if not assignment_identity_migration_path.is_file(): + raise AccountingValidationError( + "Bank-account assignment command-identity migration is missing at " + f"{assignment_identity_migration_path}. Restore " + "database/migrations/0012_bank_assignment_command_identity.sql, then retry." + ) + reconciliation_control_migration_path = ( + migration_path.parent / "0013_reconciliation_run_exception_evidence.sql" + ) + if not reconciliation_control_migration_path.is_file(): + raise AccountingValidationError( + "Reconciliation run/exception evidence migration is missing at " + f"{reconciliation_control_migration_path}. Restore " + "database/migrations/0013_reconciliation_run_exception_evidence.sql, then retry." + ) + allocation_control_migration_path = ( + migration_path.parent / "0014_reconciliation_candidate_allocation.sql" + ) + if not allocation_control_migration_path.is_file(): + raise AccountingValidationError( + "Reconciliation candidate/allocation migration is missing at " + f"{allocation_control_migration_path}. Restore " + "database/migrations/0014_reconciliation_candidate_allocation.sql, then retry." + ) + conservation_migration_path = ( + migration_path.parent / "0015_reconciliation_multi_match_conservation.sql" + ) + if not conservation_migration_path.is_file(): + raise AccountingValidationError( + "Reconciliation multi-match conservation migration is missing at " + f"{conservation_migration_path}. Restore " + "database/migrations/0015_reconciliation_multi_match_conservation.sql, then retry." + ) + approval_migration_path = ( + migration_path.parent / "0016_reconciliation_approval_evidence.sql" + ) + if not approval_migration_path.is_file(): + raise AccountingValidationError( + "Reconciliation approval-evidence migration is missing at " + f"{approval_migration_path}. Restore " + "database/migrations/0016_reconciliation_approval_evidence.sql, then retry." + ) + approval_lock_order_migration_path = ( + migration_path.parent / "0017_reconciliation_approval_lock_order.sql" + ) + if not approval_lock_order_migration_path.is_file(): + raise AccountingValidationError( + "Reconciliation approval lock-order migration is missing at " + f"{approval_lock_order_migration_path}. Restore " + "database/migrations/0017_reconciliation_approval_lock_order.sql, then retry." + ) + balance_evidence_migration_path = ( + migration_path.parent / "0018_bank_statement_balance_evidence.sql" + ) + if not balance_evidence_migration_path.is_file(): + raise AccountingValidationError( + "Bank-statement balance-evidence migration is missing at " + f"{balance_evidence_migration_path}. Restore " + "database/migrations/0018_bank_statement_balance_evidence.sql, then retry." + ) + run_command_migration_path = ( + migration_path.parent / "0019_reconciliation_run_command_evidence.sql" + ) + if not run_command_migration_path.is_file(): + raise AccountingValidationError( + "Reconciliation run-command evidence migration is missing at " + f"{run_command_migration_path}. Restore " + "database/migrations/0019_reconciliation_run_command_evidence.sql, then retry." + ) + psycopg = _import_psycopg() + try: + with psycopg.connect( + database_url, autocommit=True, cursor_factory=psycopg.ClientCursor + ) as connection: + connection.execute(migration_path.read_text(encoding="utf-8")) + connection.execute(class_migration_path.read_text(encoding="utf-8")) + connection.execute(submission_migration_path.read_text(encoding="utf-8")) + connection.execute(close_key_migration_path.read_text(encoding="utf-8")) + connection.execute(period_guard_migration_path.read_text(encoding="utf-8")) + connection.execute(concurrency_migration_path.read_text(encoding="utf-8")) + connection.execute(runtime_binding_migration_path.read_text(encoding="utf-8")) + connection.execute(period_open_command_migration_path.read_text(encoding="utf-8")) + connection.execute(book_period_control_migration_path.read_text(encoding="utf-8")) + connection.execute(soft_close_evidence_migration_path.read_text(encoding="utf-8")) + connection.execute(bank_statement_migration_path.read_text(encoding="utf-8")) + connection.execute( + assignment_identity_migration_path.read_text(encoding="utf-8") + ) + connection.execute( + reconciliation_control_migration_path.read_text(encoding="utf-8") + ) + connection.execute( + allocation_control_migration_path.read_text(encoding="utf-8") + ) + connection.execute( + conservation_migration_path.read_text(encoding="utf-8") + ) + connection.execute(approval_migration_path.read_text(encoding="utf-8")) + connection.execute( + approval_lock_order_migration_path.read_text(encoding="utf-8") + ) + connection.execute( + balance_evidence_migration_path.read_text(encoding="utf-8") + ) + connection.execute( + run_command_migration_path.read_text(encoding="utf-8") + ) + except Exception as error: + raise AccountingValidationError( + "Foundation migration failed. Inspect the PostgreSQL error, restore a clean " + "database, then retry the migration." + ) from error + + +def _import_psycopg(): + try: + return importlib.import_module("psycopg") + except ImportError as error: + raise AccountingValidationError( + "the accounting database adapter is unavailable on this deployment. " + "Ask the platform operator to install the pinned runtime dependencies, " + "then retry the request." + ) from error + + +def _require_proposal_uuid(proposal_id: str) -> UUID: + return uuid.UUID(_require_proposal_id(proposal_id)) + + +def _canonical_snapshot_hash( + *, + tenant_reference: str, + legal_entity_reference: str, + accounting_book_reference: str, + period_code: str, + snapshot_currency_code: str, + source_journal_count: int, + lines: tuple[tuple[UUID, str, Decimal, Decimal], ...], +) -> str: + payload = json.dumps( + { + "accounting_book_reference": accounting_book_reference, + "legal_entity_reference": legal_entity_reference, + "lines": [ + { + "chart_account_code": account_code, + "credit_total_amount": format(credit_total, "f"), + "debit_total_amount": format(debit_total, "f"), + "net_balance_amount": format(debit_total - credit_total, "f"), + } + for _account_id, account_code, debit_total, credit_total in lines + ], + "period_code": period_code, + "snapshot_currency_code": snapshot_currency_code, + "source_journal_count": source_journal_count, + "tenant_reference": tenant_reference, + }, + separators=(",", ":"), + sort_keys=True, + ) + return "sha256:" + hashlib.sha256(payload.encode("utf-8")).hexdigest() + + +def _canonical_closing_hash( + *, + tenant_reference: str, + legal_entity_reference: str, + accounting_book_reference: str, + period_code: str, + lines: tuple[PostedJournalLine, ...], +) -> str: + payload = json.dumps( + { + "accounting_book_reference": accounting_book_reference, + "legal_entity_reference": legal_entity_reference, + "lines": [ + { + "account_role_code": line.account_role_code, + "chart_account_code": line.chart_account_code, + "credit_amount": format(line.credit_amount, "f"), + "debit_amount": format(line.debit_amount, "f"), + "line_number": line.line_number, + } + for line in lines + ], + "period_code": period_code, + "tenant_reference": tenant_reference, + }, + separators=(",", ":"), + sort_keys=True, + ) + return "sha256:" + hashlib.sha256(payload.encode("utf-8")).hexdigest() + + +def _canonical_receipt_hash(receipt: PostingReceipt) -> str: + payload = json.dumps( + { + "journal_reference": receipt.journal_reference, + "line_count": receipt.line_count, + "posting_status_code": receipt.posting_status_code, + "receipt_reference": receipt.receipt_reference, + "reversal_of_journal_reference": receipt.reversal_of_journal_reference, + "source_payload_hash": receipt.source_payload_hash, + "source_proposal_id": receipt.source_proposal_id, + }, + separators=(",", ":"), + sort_keys=True, + ) + return "sha256:" + hashlib.sha256(payload.encode("utf-8")).hexdigest() + + +def _fiscal_year_identity(period_code: str, period_start_date: date | None) -> str: + matched = re.match(r"^(\d{4})", period_code) + if matched: + return matched.group(1) + if period_start_date is not None: + return f"{period_start_date.year:04d}" + raise AccountingValidationError( + "fiscal year identity is missing for this period. " + "Use a period_code that starts with the four-digit year, then retry the financial-statement read." + ) + + +def _format_timestamp(value: datetime) -> str: + return value.astimezone(timezone.utc).isoformat().replace("+00:00", "Z") + + +def _vat_period_movement_kind( + idempotency_key: str, + debit_roles: set[str], + credit_roles: set[str], +) -> str | None: + if ":issued_invoice_void:" in idempotency_key or ( + "tax_payable" in debit_roles + and "usage_revenue" in debit_roles + and "accounts_receivable" in credit_roles + ): + return "voided" + if ":invoice_draft:" in idempotency_key or ( + "tax_payable" in credit_roles + and "usage_revenue" in credit_roles + and "accounts_receivable" in debit_roles + ): + return "issued" + return None + + +def _unapplied_cash_movement_kind( + idempotency_key: str, + debit_roles: set[str], + credit_roles: set[str], +) -> str | None: + if ":unapplied_cash_application:" in idempotency_key or ( + "unapplied_cash" in debit_roles and "accounts_receivable" in credit_roles + ): + return "applied" + if ":unapplied_cash_refund:" in idempotency_key or ( + "unapplied_cash" in debit_roles and "cash_receipt" in credit_roles + ): + return "refunded" + if ":unapplied_cash:" in idempotency_key or ( + "unapplied_cash" in credit_roles and "cash_receipt" in debit_roles + ): + return "parked" + return None + + +def _exact_amount_text(value: Decimal) -> str: + return format(value, "f") + + +def _unsigned_aging_amount_text(value: Decimal) -> str: + amount_text = format(value, "f") + if "." not in amount_text: + return amount_text + return amount_text.rstrip("0").rstrip(".") + + +_VAT_REGISTER_REQUIRED_KEYS = frozenset( + { + "tenant_reference", + "legal_entity_reference", + "accounting_book_reference", + "book_reference", + "fiscal_period_reference", + "as_of_date", + "chart_account_code", + "account_role_code", + "issued_amount", + "voided_amount", + "closing_amount", + } +) + + +def _vat_register_is_loadable(register_document: dict[str, object]) -> bool: + return _VAT_REGISTER_REQUIRED_KEYS.issubset(register_document.keys()) + + +def _home_tax_register_view(register_document: dict[str, object]) -> dict[str, object]: + if _vat_register_is_loadable(register_document): + return dict(register_document) + return { + "as_of_date": str(register_document.get("as_of_date") or ""), + "closing_amount": str(register_document.get("closing_amount") or "0"), + } + + +def _home_tax_submission_document( + *, + home_tax_submission_id: str, + tenant_reference: str, + legal_entity_reference: str, + book_reference: str, + period_code: str, + vat_period_register: dict[str, object], + rejection_reason_code: str, + submission_status_code: str = "rejected", +) -> dict[str, object]: + return { + "home_tax_submission_id": home_tax_submission_id, + "tenant_reference": tenant_reference, + "legal_entity_reference": legal_entity_reference, + "book_reference": book_reference, + "fiscal_period_reference": f"urn:cwl:accounting:fiscal_period:{period_code}", + "vat_period_register": vat_period_register, + "submission_status_code": submission_status_code, + "rejection_reason_code": rejection_reason_code, + } + + +def _fifo_aging_open_items( + line_rows: list[tuple[object, ...]], + *, + increase_is_debit: bool, +) -> list[list[object]]: + open_items: list[list[object]] = [] + for accounting_date, _journal_reference, _line_number, debit_amount, credit_amount in line_rows: + increase_amount = Decimal(str(debit_amount)) if increase_is_debit else Decimal( + str(credit_amount) + ) + decrease_amount = Decimal(str(credit_amount)) if increase_is_debit else Decimal( + str(debit_amount) + ) + if increase_amount > 0: + open_items.append([accounting_date, increase_amount]) + continue + remaining_decrease = decrease_amount + for open_item in open_items: + applied_amount = min(open_item[1], remaining_decrease) + open_item[1] = open_item[1] - applied_amount + remaining_decrease = remaining_decrease - applied_amount + open_items = [open_item for open_item in open_items if open_item[1] > 0] + return open_items -# NOTE: Remaining production methods are intentionally omitted from this replacement. +def _receivable_aging_bucket(outstanding_days: int) -> str: + if outstanding_days <= 30: + return "current" + if outstanding_days <= 60: + return "days_31_60" + if outstanding_days <= 90: + return "days_61_90" + return "days_over_90" From 795b3efef7d0d521719bb60b1de7d937232221e7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 18:18:44 +0900 Subject: [PATCH 138/224] test(close): require open-period seed on book activation --- .../test_postgres_book_activation_seed_red.py | 134 ++++++++++++++++++ 1 file changed, 134 insertions(+) create mode 100644 tests/test_postgres_book_activation_seed_red.py diff --git a/tests/test_postgres_book_activation_seed_red.py b/tests/test_postgres_book_activation_seed_red.py new file mode 100644 index 00000000..a87d9ae4 --- /dev/null +++ b/tests/test_postgres_book_activation_seed_red.py @@ -0,0 +1,134 @@ +"""Real PostgreSQL regression for activating a book into an already-open period.""" + +from __future__ import annotations + +import unittest +import uuid + +import psycopg + +from tests import test_postgres_posting as posting + + +class BookActivationSeedTests(unittest.TestCase): + """Require activation to materialize the open book-period authority and fence.""" + + @classmethod + def setUpClass(cls) -> None: + """Install the complete current migration chain into the PostgreSQL fixture.""" + posting.PostgresPostingTests.setUpClass() + + def setUp(self) -> None: + """Seed one isolated tenant for the book-lifecycle regression.""" + self.case = posting.PostgresPostingTests("setUp") + self.case.setUp() + self.addCleanup(self.case.doCleanups) + self.addCleanup(self.case.tearDown) + + def test_activating_book_seeds_existing_open_period_authority(self) -> None: + """An inactive book becoming active must gain open control plus all 64 fences.""" + book_id = uuid.uuid4() + with psycopg.connect(posting.DATABASE_URL) as connection: + legal_entity_id = connection.execute( + """ + SELECT legal_entity_id + FROM accounting_core.legal_entity_record + WHERE tenant_account_id = %s + ORDER BY recorded_at + LIMIT 1 + """, + (self.case.tenant_id,), + ).fetchone()[0] + period_id = connection.execute( + """ + SELECT fiscal_period_id + FROM accounting_core.fiscal_period + WHERE tenant_account_id = %s + AND period_code = '2026-08' + AND period_status_code = 'open' + """, + (self.case.tenant_id,), + ).fetchone()[0] + connection.execute( + """ + INSERT INTO accounting_core.accounting_book ( + accounting_book_id, + tenant_account_id, + legal_entity_id, + book_role_code, + book_name, + reporting_currency_code, + valid_from, + valid_to + ) + VALUES ( + %s, %s, %s, %s, %s, 'USD', + '2026-01-01T00:00:00Z', + '2026-07-31T23:59:59Z' + ) + """, + ( + book_id, + self.case.tenant_id, + legal_entity_id, + f"activation_{book_id.hex}", + f"Activation {book_id.hex}", + ), + ) + connection.commit() + + self._assert_population(book_id, period_id, expected_control_count=0, expected_fences=0) + + with psycopg.connect(posting.DATABASE_URL) as connection: + connection.execute( + """ + UPDATE accounting_core.accounting_book + SET valid_to = NULL + WHERE tenant_account_id = %s + AND accounting_book_id = %s + """, + (self.case.tenant_id, book_id), + ) + connection.commit() + + self._assert_population(book_id, period_id, expected_control_count=1, expected_fences=64) + + def _assert_population( + self, + accounting_book_id: uuid.UUID, + fiscal_period_id: uuid.UUID, + *, + expected_control_count: int, + expected_fences: int, + ) -> None: + """Assert open authority is absent while inactive and complete after activation.""" + with psycopg.connect(posting.DATABASE_URL) as connection: + control_rows = connection.execute( + """ + SELECT period_status_code, period_closed_at + FROM accounting_core.accounting_book_period_control + WHERE tenant_account_id = %s + AND accounting_book_id = %s + AND fiscal_period_id = %s + """, + (self.case.tenant_id, accounting_book_id, fiscal_period_id), + ).fetchall() + fence_count = connection.execute( + """ + SELECT COUNT(*) + FROM accounting_core.period_journal_population_fence + WHERE tenant_account_id = %s + AND accounting_book_id = %s + AND fiscal_period_id = %s + """, + (self.case.tenant_id, accounting_book_id, fiscal_period_id), + ).fetchone()[0] + + self.assertEqual(len(control_rows), expected_control_count) + self.assertEqual(fence_count, expected_fences) + if expected_control_count: + self.assertEqual(control_rows, [("open", None)]) + + +if __name__ == "__main__": + unittest.main() From ca98183ec03836bfefeeaa8524f42e037957c3a2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 18:20:11 +0900 Subject: [PATCH 139/224] fix(close): seed book-period authority on activation --- .../0034_book_period_control_seed.sql | 53 ++++++++++++------- 1 file changed, 34 insertions(+), 19 deletions(-) diff --git a/database/migrations/0034_book_period_control_seed.sql b/database/migrations/0034_book_period_control_seed.sql index 96fd6379..edc43d3d 100644 --- a/database/migrations/0034_book_period_control_seed.sql +++ b/database/migrations/0034_book_period_control_seed.sql @@ -15,18 +15,22 @@ BEGIN; -- fail closed until an explicit book-period lifecycle can establish authority; -- the compatibility projection is never copied into authoritative close state. -- --- The two AFTER INSERT triggers also form one cross-product invariant. Without --- a pre-existing common version witness, concurrent transactions can each --- insert one side, scan before the other side commits, and leave the new --- book-period pair absent. Both seeders therefore perform a non-key UPDATE of --- the existing tenant row before scanning the peer population. Under READ --- COMMITTED, the later seeder waits and its following statement sees the peer --- commit. Under REPEATABLE READ/SERIALIZABLE, a transaction whose snapshot --- predates the competing tenant-row version fails closed with serialization --- failure and must retry from a fresh transaction. Updating only created_at to --- its retained value changes no tenant business fact. PostgreSQL acquires the --- weaker FOR NO KEY UPDATE row lock for this non-key update, so unrelated child --- foreign-key checks using FOR KEY SHARE remain compatible. +-- The two seed directions also form one cross-product invariant. Without a +-- pre-existing common version witness, concurrent transactions can each insert +-- one side, scan before the other side commits, and leave the new book-period +-- pair absent. Both seeders therefore perform a non-key UPDATE of the existing +-- tenant row before scanning the peer population. The same book-side seeder is +-- also invoked when an already-recorded inactive book becomes active, because +-- activation into an existing open period creates the same admissible +-- book-period intersection as active-book creation. +-- +-- Under READ COMMITTED, the later seeder waits and its following statement sees +-- the peer commit. Under REPEATABLE READ/SERIALIZABLE, a transaction whose +-- snapshot predates the competing tenant-row version fails closed with +-- serialization failure and must retry from a fresh transaction. Updating only +-- created_at to its retained value changes no tenant business fact. PostgreSQL +-- acquires the weaker FOR NO KEY UPDATE row lock for this non-key update, so +-- unrelated child foreign-key checks using FOR KEY SHARE remain compatible. CREATE OR REPLACE FUNCTION accounting_core.seed_book_period_control_for_period() RETURNS trigger LANGUAGE plpgsql @@ -125,6 +129,17 @@ CREATE TRIGGER book_period_control_seed_for_book FOR EACH ROW EXECUTE FUNCTION accounting_core.seed_book_period_control_for_book(); +-- An inactive book can become admissible after its original INSERT. Reuse the +-- same database-owned open-only seeder on that lifecycle edge; do not require a +-- later application write to manufacture a control row. The WHEN clause avoids +-- versioning the tenant witness for ordinary edits and for deactivation. +CREATE TRIGGER book_period_control_seed_for_book_activation + AFTER UPDATE OF valid_to + ON accounting_core.accounting_book + FOR EACH ROW + WHEN (OLD.valid_to IS NOT NULL AND NEW.valid_to IS NULL) + EXECUTE FUNCTION accounting_core.seed_book_period_control_for_book(); + -- Repair databases that installed 0009 before later master-data rows existed. -- accounting_book/fiscal_period as well as the control/fence targets are -- already FORCE RLS protected at this point. An unbound NOSUPERUSER / @@ -171,15 +186,15 @@ ALTER TABLE accounting_core.fiscal_period FORCE ROW LEVEL SECURITY; ALTER TABLE accounting_core.accounting_book FORCE ROW LEVEL SECURITY; -- After the one-time repair above, new book-period authority may be created only --- by the two canonical master-data seed triggers. A direct application or SQL +-- by the canonical master-data seed triggers. A direct application or SQL -- INSERT must not reconstruct authority from fiscal_period compatibility state. -- pg_trigger_depth() is structural rather than a caller-controlled custom GUC: --- the control-table trigger runs at depth 2 when invoked by either canonical --- AFTER INSERT seeder and at depth 1 for a direct control-table INSERT. Returning --- NULL leaves an unsupported direct write unapplied; the close path then reads --- the still-missing control and fails with its domain validation error. This --- keeps the database single-writer boundary intact without granting a mutable --- session flag that another writer could spoof. +-- the control-table trigger runs at depth 2 when invoked by a canonical seeder +-- and at depth 1 for a direct control-table INSERT. Returning NULL leaves an +-- unsupported direct write unapplied; the close path then reads the still- +-- missing control and fails with its domain validation error. This keeps the +-- database single-writer boundary intact without granting a mutable session +-- flag that another writer could spoof. CREATE OR REPLACE FUNCTION accounting_core.guard_book_period_control_insert_authority() RETURNS trigger LANGUAGE plpgsql From ce73c8c8c64cf4aa59b8f8692ff487650501cfb8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 18:21:36 +0900 Subject: [PATCH 140/224] test(close): ratchet book activation seeding --- .../test_book_period_control_seed_contract.py | 26 +++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/tests/test_book_period_control_seed_contract.py b/tests/test_book_period_control_seed_contract.py index bc18bad3..fc14a492 100644 --- a/tests/test_book_period_control_seed_contract.py +++ b/tests/test_book_period_control_seed_contract.py @@ -29,6 +29,24 @@ def test_period_and_book_creation_both_seed_controls(self) -> None: ) self.assertGreaterEqual(source.count("ON CONFLICT"), 3) + def test_book_activation_seeds_existing_open_periods(self) -> None: + """Changing an inactive book to active must invoke the same open-only seeder.""" + source = MIGRATION.read_text(encoding="utf-8") + + activation_trigger = source[ + source.index("CREATE TRIGGER book_period_control_seed_for_book_activation") : + ] + self.assertIn("AFTER UPDATE OF valid_to", activation_trigger) + self.assertIn("ON accounting_core.accounting_book", activation_trigger) + self.assertIn( + "WHEN (OLD.valid_to IS NOT NULL AND NEW.valid_to IS NULL)", + activation_trigger, + ) + self.assertIn( + "EXECUTE FUNCTION accounting_core.seed_book_period_control_for_book();", + activation_trigger, + ) + def test_non_open_projection_never_synthesizes_book_close_authority(self) -> None: """Only an actually opened period may create a new book-period control automatically.""" source = MIGRATION.read_text(encoding="utf-8") @@ -135,8 +153,8 @@ def test_trigger_functions_use_hardened_execution_context(self) -> None: """Master-data triggers must not inherit caller-controlled object resolution.""" source = MIGRATION.read_text(encoding="utf-8") - self.assertEqual(source.count("SECURITY DEFINER"), 2) - self.assertEqual(source.count("SET search_path = pg_catalog, pg_temp"), 2) + self.assertEqual(source.count("SECURITY DEFINER"), 3) + self.assertEqual(source.count("SET search_path = pg_catalog, pg_temp"), 3) self.assertIn( "REVOKE ALL ON FUNCTION accounting_core.seed_book_period_control_for_period()", source, @@ -145,6 +163,10 @@ def test_trigger_functions_use_hardened_execution_context(self) -> None: "REVOKE ALL ON FUNCTION accounting_core.seed_book_period_control_for_book()", source, ) + self.assertIn( + "REVOKE ALL ON FUNCTION accounting_core.guard_book_period_control_insert_authority()", + source, + ) def test_cross_tenant_backfills_are_owner_safe_without_disabling_rls(self) -> None: """Unbound migration owners need owner-only visibility on source and target tables.""" From b1aa2e5d48ee0b367ff1c126386b32b6ca9ad1de Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 18:23:32 +0900 Subject: [PATCH 141/224] docs(close): trace book activation period authority --- ...CTIVATION_PERIOD_AUTHORITY_TRACEABILITY.md | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 docs/doctoring/BOOK_ACTIVATION_PERIOD_AUTHORITY_TRACEABILITY.md diff --git a/docs/doctoring/BOOK_ACTIVATION_PERIOD_AUTHORITY_TRACEABILITY.md b/docs/doctoring/BOOK_ACTIVATION_PERIOD_AUTHORITY_TRACEABILITY.md new file mode 100644 index 00000000..9ebc34da --- /dev/null +++ b/docs/doctoring/BOOK_ACTIVATION_PERIOD_AUTHORITY_TRACEABILITY.md @@ -0,0 +1,40 @@ +# Book activation / open-period authority traceability + +## Scope + +This note records the post-install lifecycle edge where an already-recorded inactive `accounting_book` becomes active while one or more fiscal periods are open. It is a database master-data invariant, not an IFRS rule and not permission for application code, Reporting, Billing, or manual SQL to manufacture book-period close authority. + +## Finding + +Migration `0034_book_period_control_seed.sql` originally seeded `accounting_book_period_control` only from two creation events: insertion of an open `fiscal_period`, and insertion of an already-active `accounting_book`. That left a third supported state transition uncovered. A book inserted with `valid_to IS NOT NULL` can later become active through `UPDATE ... SET valid_to = NULL`; if the matching fiscal period already exists and is open, neither INSERT trigger runs. The result is an active-book/open-period intersection with no authoritative control row and therefore no 64-row `period_journal_population_fence` population. + +Journal and close admission correctly fail closed when that population is missing, but permanent failure of a legitimate activation is not the intended invariant. The database owner of the book-period intersection must materialize the open authority at the activation boundary rather than waiting for a later posting/close helper to synthesize it. + +## Selected control + +Migration 0034 reuses `accounting_core.seed_book_period_control_for_book()` from a narrow lifecycle trigger: + +```sql +AFTER UPDATE OF valid_to +ON accounting_core.accounting_book +FOR EACH ROW +WHEN (OLD.valid_to IS NOT NULL AND NEW.valid_to IS NULL) +``` + +The existing seeder remains open-only. It scans only `fiscal_period.period_status_code = 'open'`, inserts literal `period_status_code = 'open'` with `period_closed_at = NULL`, and the migration-0033 control-row trigger synchronously creates all 64 freshness-fence rows. Activation into an already non-open shared period therefore remains absent/fail-closed; no `soft_closed` or `hard_closed` state is inferred from the tenant/calendar compatibility projection. + +Reusing the same seeder also preserves the tenant-row MVCC witness used by new-book/new-period concurrency repair. The `WHEN` predicate prevents unrelated book updates and deactivation from taking that low-frequency master-data serialization point. + +## TDD / implementation evidence + +- RED `795b3efef7d0d521719bb60b1de7d937232221e7` adds `tests/test_postgres_book_activation_seed_red.py`. It inserts an inactive book while period `2026-08` is open, verifies zero control/fence rows, activates the book, then requires exactly one literal-open/no-close-timestamp control and exactly 64 fence rows. +- GREEN candidate `ca98183ec03836bfefeeaa8524f42e037957c3a2` adds the activation trigger to migration 0034 and reuses the canonical open-only seeder. +- Static ratchet `ce73c8c8c64cf4aa59b8f8692ff487650501cfb8` requires the exact update edge and same seeder, while retaining the existing open-only projection, FORCE-RLS, hardened `SECURITY DEFINER`, installer, and tenant-MVCC contracts. + +These SHAs are development evidence. The RED is realistic by source inspection, but it is not called runner-observed until the corresponding head actually executes. The candidate is not GREEN until one unchanged exact head passes the real PostgreSQL regression and the complete applicable Accounting Foundation/security/review gates. + +## Recovery and ownership + +A serialization failure during activation rolls back the activation and seed side effects together; retry the complete master-data command from a fresh transaction. Do not insert a control manually, copy shared fiscal-period close state, weaken RLS, or rewrite posted/reconciled/retained accounting evidence. + +`accounting_book_period_control` remains the authoritative tenant/book/period close-state intersection. `fiscal_period.period_status_code` remains a compatibility projection after book-scoped authority exists. `PostgresPostingLedger._lock_book_period()`, `_load_book_period_state()`, and `_require_open_book_period_bounds()` must ultimately be read/lock/fail-closed consumers of that authority and must not recreate or fall back to shared close state. From ef2f9dc5dd0644826905f053c0a791579b9c4000 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 19:13:15 +0900 Subject: [PATCH 142/224] test(close): reject GUC-only snapshot authority --- ...es_trial_balance_snapshot_admission_red.py | 61 +++++++++---------- 1 file changed, 30 insertions(+), 31 deletions(-) diff --git a/tests/test_postgres_trial_balance_snapshot_admission_red.py b/tests/test_postgres_trial_balance_snapshot_admission_red.py index 2b353780..641ae97f 100644 --- a/tests/test_postgres_trial_balance_snapshot_admission_red.py +++ b/tests/test_postgres_trial_balance_snapshot_admission_red.py @@ -3,7 +3,7 @@ from __future__ import annotations import unittest -from datetime import date, datetime, timezone +from datetime import date import psycopg @@ -100,42 +100,41 @@ def test_raw_soft_close_snapshot_insert_requires_close_authority(self) -> None: ) connection.rollback() - def test_authorized_snapshot_insert_cannot_choose_system_time(self) -> None: - """Even the closing capability cannot forge retained snapshot chronology.""" + def test_guc_only_closing_writer_cannot_forge_snapshot_authority(self) -> None: + """Caller-set write-role state cannot substitute for the canonical close lock.""" with psycopg.connect(posting.DATABASE_URL) as connection: legal_entity_id, accounting_book_id, fiscal_period_id = self._scope(connection) connection.execute( "SELECT set_config('accounting_core.journal_write_role', 'period_closing', true)" ) - before_insert = datetime.now(timezone.utc) - snapshot_generated_at = connection.execute( - """ - INSERT INTO accounting_reporting.trial_balance_snapshot ( - tenant_account_id, - legal_entity_id, - accounting_book_id, - fiscal_period_id, - snapshot_currency_code, - snapshot_generated_at, - source_journal_count, - source_payload_hash, - close_idempotency_key + with self.assertRaisesRegex( + psycopg.errors.CheckViolation, + "trial_balance_snapshot_authority_required", + ): + connection.execute( + """ + INSERT INTO accounting_reporting.trial_balance_snapshot ( + tenant_account_id, + legal_entity_id, + accounting_book_id, + fiscal_period_id, + snapshot_currency_code, + snapshot_generated_at, + source_journal_count, + source_payload_hash, + close_idempotency_key + ) + VALUES (%s, %s, %s, %s, 'KRW', '2099-01-01T00:00:00Z', 0, %s, %s) + """, + ( + self.case.tenant_id, + legal_entity_id, + accounting_book_id, + fiscal_period_id, + "sha256:" + "5" * 64, + f"{self.case.policy.tenant_reference}:snapshot-admission:guc-forged", + ), ) - VALUES (%s, %s, %s, %s, 'KRW', '2099-01-01T00:00:00Z', 0, %s, %s) - RETURNING snapshot_generated_at - """, - ( - self.case.tenant_id, - legal_entity_id, - accounting_book_id, - fiscal_period_id, - "sha256:" + "5" * 64, - f"{self.case.policy.tenant_reference}:snapshot-admission:authorized-probe", - ), - ).fetchone()[0] - after_insert = datetime.now(timezone.utc) - self.assertGreaterEqual(snapshot_generated_at, before_insert) - self.assertLessEqual(snapshot_generated_at, after_insert) connection.rollback() def test_hard_close_without_closing_journal_still_has_snapshot_authority(self) -> None: From e2a01fc98737b767b2cab3db80d8cf69b09b48af Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 19:13:50 +0900 Subject: [PATCH 143/224] fix(close): require canonical lock for retained snapshots --- ...3_open_period_journal_population_fence.sql | 26 +++++-------------- 1 file changed, 7 insertions(+), 19 deletions(-) diff --git a/database/migrations/0033_open_period_journal_population_fence.sql b/database/migrations/0033_open_period_journal_population_fence.sql index 1ce57f29..a7bfcb42 100644 --- a/database/migrations/0033_open_period_journal_population_fence.sql +++ b/database/migrations/0033_open_period_journal_population_fence.sql @@ -237,8 +237,10 @@ CREATE TRIGGER period_state_transition_population_fence EXECUTE FUNCTION accounting_core.guard_period_state_transition_freshness(); -- Preserve the purpose-limited snapshot writer while restoring the supported --- direct open-to-hard-close command. An open-period snapshot requires the exact --- tenant/book/period close advisory lock; a bare role/GUC cannot pre-populate it. +-- direct open-to-hard-close command. Both open and soft-closed snapshots require +-- the exact tenant/book/period close advisory lock. The caller-controlled +-- journal_write_role GUC remains journal-admission context only and cannot mint +-- retained close evidence by itself. CREATE OR REPLACE FUNCTION accounting_reporting.guard_trial_balance_snapshot_insert() RETURNS trigger LANGUAGE plpgsql @@ -249,7 +251,6 @@ DECLARE period_status_value text; book_legal_entity_id uuid; book_reporting_currency_code text; - journal_write_role_value text; close_command_lock_held boolean; BEGIN SELECT accounting_book_period_control.period_status_code @@ -294,11 +295,6 @@ BEGIN USING ERRCODE = 'check_violation'; END IF; - journal_write_role_value := nullif( - current_setting('accounting_core.journal_write_role', true), - '' - ); - SELECT EXISTS ( SELECT 1 FROM pg_catalog.pg_locks AS held_lock @@ -330,17 +326,9 @@ BEGIN ) ) INTO close_command_lock_held; - IF NOT pg_has_role(session_user, 'accounting_closing_writer', 'MEMBER') - OR ( - period_status_value = 'open' - AND NOT close_command_lock_held - ) - OR ( - period_status_value = 'soft_closed' - AND journal_write_role_value IS DISTINCT FROM 'period_closing' - AND NOT close_command_lock_held - ) - OR period_status_value NOT IN ('open', 'soft_closed') + IF period_status_value NOT IN ('open', 'soft_closed') + OR NOT pg_has_role(session_user, 'accounting_closing_writer', 'MEMBER') + OR NOT close_command_lock_held THEN RAISE EXCEPTION 'trial balance snapshot creation requires the purpose-limited hard-close writer (trial_balance_snapshot_authority_required)' From b790b5d10e870d770d48ccb93485192c11d9466b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 19:14:20 +0900 Subject: [PATCH 144/224] fix(close): remove transient GUC snapshot authority --- ...30_trial_balance_snapshot_immutability.sql | 21 ++++++------------- 1 file changed, 6 insertions(+), 15 deletions(-) diff --git a/database/migrations/0030_trial_balance_snapshot_immutability.sql b/database/migrations/0030_trial_balance_snapshot_immutability.sql index 5ed86207..a2b3bdb6 100644 --- a/database/migrations/0030_trial_balance_snapshot_immutability.sql +++ b/database/migrations/0030_trial_balance_snapshot_immutability.sql @@ -60,7 +60,6 @@ DECLARE period_status_value text; book_legal_entity_id uuid; book_reporting_currency_code text; - journal_write_role_value text; close_command_lock_held boolean; BEGIN SELECT accounting_book_period_control.period_status_code @@ -105,15 +104,10 @@ BEGIN USING ERRCODE = 'check_violation'; END IF; - journal_write_role_value := nullif( - current_setting('accounting_core.journal_write_role', true), - '' - ); - - -- The hard-close command always acquires this tenant/book/period transaction - -- advisory lock before assembling close evidence. The lock remains present even - -- when zero net revenue/expense means no period-closing journal is emitted, so - -- snapshot admission must not depend on an optional journal INSERT side effect. + -- The hard-close command always acquires this tenant/book/period advisory + -- lock before assembling close evidence. The lock remains present even when + -- zero net revenue/expense means no period-closing journal is emitted. A + -- caller-set journal_write_role GUC is therefore never snapshot authority. SELECT EXISTS ( SELECT 1 FROM pg_catalog.pg_locks AS held_lock @@ -147,10 +141,7 @@ BEGIN IF period_status_value <> 'soft_closed' OR NOT pg_has_role(session_user, 'accounting_closing_writer', 'MEMBER') - OR ( - journal_write_role_value IS DISTINCT FROM 'period_closing' - AND NOT close_command_lock_held - ) + OR NOT close_command_lock_held THEN RAISE EXCEPTION 'trial balance snapshot creation requires the purpose-limited hard-close writer (trial_balance_snapshot_authority_required)' @@ -251,4 +242,4 @@ CREATE TRIGGER trial_balance_line_population_guard FOR EACH ROW EXECUTE FUNCTION accounting_reporting.guard_trial_balance_line_insert(); -COMMIT; \ No newline at end of file +COMMIT; From a677226426d4daff6f4b3dbcb6110eabe050f6c7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 19:14:58 +0900 Subject: [PATCH 145/224] test(close): pin non-spoofable snapshot authority --- ..._balance_snapshot_immutability_contract.py | 49 +++++++++++-------- 1 file changed, 29 insertions(+), 20 deletions(-) diff --git a/tests/test_trial_balance_snapshot_immutability_contract.py b/tests/test_trial_balance_snapshot_immutability_contract.py index e3dfd84d..a8975bca 100644 --- a/tests/test_trial_balance_snapshot_immutability_contract.py +++ b/tests/test_trial_balance_snapshot_immutability_contract.py @@ -188,43 +188,52 @@ def test_open_period_freshness_uses_stripes_not_one_exclusive_revision_row(self) self.assertGreaterEqual(migration.count("SET search_path = pg_catalog, pg_temp"), 3) def test_direct_open_hard_close_requires_exact_close_lock(self) -> None: - """A governed direct hard close is admitted; a bare open-period role/GUC is not.""" + """A governed direct hard close is admitted; caller-set role context is not authority.""" migration = OPEN_PERIOD_FENCE_MIGRATION.read_text(encoding="utf-8") - self.assertIn("period_status_value = 'open'", migration) - self.assertIn("AND NOT close_command_lock_held", migration) - self.assertIn("period_status_value = 'soft_closed'", migration) - self.assertIn("journal_write_role_value IS DISTINCT FROM 'period_closing'", migration) + snapshot_start = migration.index( + "CREATE OR REPLACE FUNCTION accounting_reporting.guard_trial_balance_snapshot_insert()" + ) + snapshot_guard = migration[snapshot_start:] + self.assertIn("period_status_value NOT IN ('open', 'soft_closed')", snapshot_guard) + self.assertIn("OR NOT close_command_lock_held", snapshot_guard) + self.assertNotIn("journal_write_role_value", snapshot_guard) self.assertIn( "pg_has_role(session_user, 'accounting_closing_writer', 'MEMBER')", - migration, + snapshot_guard, ) - self.assertIn("trial_balance_snapshot_authority_required", migration) + self.assertIn("trial_balance_snapshot_authority_required", snapshot_guard) def test_snapshot_header_requires_purpose_limited_hard_close_authority(self) -> None: - """Capability plus the exact application close-command lock is required.""" + """Capability plus the exact close-command lock is required during migration 0030 too.""" migration = IMMUTABILITY_MIGRATION.read_text(encoding="utf-8") - self.assertIn("period_status_value <> 'soft_closed'", migration) - self.assertIn( - "journal_write_role_value IS DISTINCT FROM 'period_closing'", - migration, + snapshot_start = migration.index( + "CREATE OR REPLACE FUNCTION accounting_reporting.guard_trial_balance_snapshot_insert()" ) + snapshot_end = migration.index( + "CREATE TRIGGER trial_balance_snapshot_population_guard", + snapshot_start, + ) + snapshot_guard = migration[snapshot_start:snapshot_end] + self.assertIn("period_status_value <> 'soft_closed'", snapshot_guard) + self.assertIn("OR NOT close_command_lock_held", snapshot_guard) + self.assertNotIn("journal_write_role_value", snapshot_guard) self.assertIn( "pg_has_role(session_user, 'accounting_closing_writer', 'MEMBER')", - migration, + snapshot_guard, ) - self.assertIn("close_command_lock_held", migration) - self.assertIn("FROM pg_catalog.pg_locks AS held_lock", migration) - self.assertIn("held_lock.objsubid = 2", migration) - self.assertIn("held_lock.pid = pg_backend_pid()", migration) + self.assertIn("close_command_lock_held", snapshot_guard) + self.assertIn("FROM pg_catalog.pg_locks AS held_lock", snapshot_guard) + self.assertIn("held_lock.objsubid = 2", snapshot_guard) + self.assertIn("held_lock.pid = pg_backend_pid()", snapshot_guard) self.assertIn( "'period:' || accounting_book.accounting_book_id::text || ':' || fiscal_period.period_code", - migration, + snapshot_guard, ) self.assertNotIn( "'period:' || accounting_book.book_name || ':' || fiscal_period.period_code", - migration, + snapshot_guard, ) - self.assertIn("trial_balance_snapshot_authority_required", migration) + self.assertIn("trial_balance_snapshot_authority_required", snapshot_guard) def test_close_command_and_snapshot_guard_share_resolved_book_lock_identity(self) -> None: """The application must acquire the same resolved book-id lock inspected by PostgreSQL.""" From b6310d8297698ec3f21c79e2f5cdbe248cd5a34c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 19:15:27 +0900 Subject: [PATCH 146/224] docs(close): trace retained snapshot authority repair --- ...BALANCE_SNAPSHOT_AUTHORITY_TRACEABILITY.md | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 docs/doctoring/TRIAL_BALANCE_SNAPSHOT_AUTHORITY_TRACEABILITY.md diff --git a/docs/doctoring/TRIAL_BALANCE_SNAPSHOT_AUTHORITY_TRACEABILITY.md b/docs/doctoring/TRIAL_BALANCE_SNAPSHOT_AUTHORITY_TRACEABILITY.md new file mode 100644 index 00000000..516094ea --- /dev/null +++ b/docs/doctoring/TRIAL_BALANCE_SNAPSHOT_AUTHORITY_TRACEABILITY.md @@ -0,0 +1,63 @@ +# Trial-balance snapshot authority traceability + +## Problem + +A retained `trial_balance_snapshot` is Period Close evidence. Before this repair, the final migration-0033 snapshot guard admitted a direct SQL insert in a `soft_closed` book-period when the session had `accounting_closing_writer` membership and set the caller-controlled `accounting_core.journal_write_role` custom setting to `period_closing`, even when the canonical tenant/book/period close advisory lock was absent. That made mutable session context sufficient to create retained close evidence outside the governed hard-close command. + +This is an Accounting Information Platform authority defect, not an IFRS requirement. IFRS does not prescribe PostgreSQL advisory-lock or session-setting mechanics. + +## Constraints + +- `trial_balance_snapshot` remains owned by the Period Close bounded context. +- A caller-controlled setting may describe journal-admission context but cannot mint retained close authority. +- Direct `open -> hard_closed` remains supported. +- A zero-net-income hard close may create a snapshot without emitting a period-closing journal, so snapshot authority cannot depend on a journal side effect. +- The database capability and exact tenant/book/period close lock are both required; neither replaces tenant scope, immutable source derivation, idempotency, snapshot immutability, or the journal-population freshness fences. +- No posting, Billing, Reporting, LLM, or generic runtime path gains snapshot authority. + +## Alternatives considered + +1. Keep `journal_write_role = period_closing` as an alternate proof. Rejected because PostgreSQL session settings are mutable session state. PostgreSQL documents `set_config` as the SQL-level mechanism for changing run-time settings, so the value is not an unforgeable command receipt. +2. Require a period-closing journal row before snapshot insertion. Rejected because a valid zero-net-income hard close may need no closing journal. +3. Require the exact close advisory lock plus `accounting_closing_writer`. Selected because the canonical hard-close command already holds that lock before deriving retained evidence, including the zero-closing-journal case, and the database can verify the lock on the current backend and exact two-key identity through `pg_locks`. + +## Selected control + +Migration 0030 now requires all of the following for its `soft_closed` snapshot admission boundary: + +- authoritative `accounting_book_period_control.period_status_code = 'soft_closed'`; +- `pg_has_role(session_user, 'accounting_closing_writer', 'MEMBER')`; +- the exact tenant/book/period exclusive advisory lock held by `pg_backend_pid()`. + +Migration 0033 replaces that guard for the final supported chain and applies the same lock requirement to both `open` and `soft_closed` hard-close snapshot creation. Its snapshot guard no longer reads `accounting_core.journal_write_role`. + +The journal-admission guard still uses `journal_write_role` to distinguish purpose-limited soft-close journal kinds. That is a separate control: retained snapshot authority no longer treats the setting as proof of a hard-close command. + +## RED -> repair evidence + +- RED `ef2f9dc5dd0644826905f053c0a791579b9c4000`: replaces the former direct-GUC chronology probe with a real PostgreSQL regression that sets `accounting_core.journal_write_role = period_closing` and requires the forged snapshot insert to fail with `trial_balance_snapshot_authority_required`. +- Final-chain repair `e2a01fc98737b767b2cab3db80d8cf69b09b48af`: migration 0033 requires the exact close lock for both open and soft-closed snapshot admission and removes GUC reliance from the snapshot guard. +- Upgrade-window repair `b790b5d10e870d770d48ccb93485192c11d9466b`: migration 0030 also requires the exact close lock, so a database between 0030 and 0033 does not expose the earlier GUC-only snapshot path. +- Static ratchet `a677226426d4daff6f4b3dbcb6110eabe050f6c7`: repository contracts require both snapshot-guard versions to remain lock-bound and reject reintroduction of `journal_write_role_value` into snapshot authority. + +These commits are development lineage only. They are not release evidence until one unchanged exact head passes the real PostgreSQL suite, current security/SAST/dependency gates, required review, stack integration, and release controls. + +## Verification and recovery + +The governed hard-close regressions must still prove that: + +- ordinary and zero-closing-journal hard close each create exactly one retained snapshot; +- GUC-only direct SQL cannot create a snapshot; +- a missing or wrong advisory lock fails before retained evidence persists; +- failed close transactions leave no snapshot, closing journal, close event, or hard-close transition; +- retries start from a fresh transaction with the same immutable command/source identity. + +If lock-identity logic changes, update the application lock acquisition, both migration guards, PostgreSQL acceptance tests, ADR 0006, and this trace together. Do not recover by manually inserting or relabeling retained snapshots. + +## References + +PostgreSQL Global Development Group. (2026). *PostgreSQL 18 documentation: System administration functions*. https://www.postgresql.org/docs/18/functions-admin.html + +PostgreSQL Global Development Group. (2026). *PostgreSQL 18 documentation: pg_locks*. https://www.postgresql.org/docs/18/view-pg-locks.html + +PostgreSQL Global Development Group. (2026). *PostgreSQL 18 documentation: Setting parameters*. https://www.postgresql.org/docs/18/config-setting.html From ad3347a8330fa9d1cc26482ccda951ec91604516 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 19:16:20 +0900 Subject: [PATCH 147/224] test(close): prove forged snapshot has closing capability --- tests/test_postgres_trial_balance_snapshot_admission_red.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/test_postgres_trial_balance_snapshot_admission_red.py b/tests/test_postgres_trial_balance_snapshot_admission_red.py index 641ae97f..7d9ff56b 100644 --- a/tests/test_postgres_trial_balance_snapshot_admission_red.py +++ b/tests/test_postgres_trial_balance_snapshot_admission_red.py @@ -104,6 +104,10 @@ def test_guc_only_closing_writer_cannot_forge_snapshot_authority(self) -> None: """Caller-set write-role state cannot substitute for the canonical close lock.""" with psycopg.connect(posting.DATABASE_URL) as connection: legal_entity_id, accounting_book_id, fiscal_period_id = self._scope(connection) + has_closing_capability = connection.execute( + "SELECT pg_has_role(session_user, 'accounting_closing_writer', 'MEMBER')" + ).fetchone()[0] + self.assertTrue(has_closing_capability) connection.execute( "SELECT set_config('accounting_core.journal_write_role', 'period_closing', true)" ) From 952bb1b2a014db823f8ee452ebdfb9bc3980e733 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 19:38:47 +0900 Subject: [PATCH 148/224] fix(close): remove application period authority fallback --- .../persistence.py | 5288 +---------------- 1 file changed, 208 insertions(+), 5080 deletions(-) diff --git a/src/accounting_information_platform/persistence.py b/src/accounting_information_platform/persistence.py index 1d27c239..b0cb11a1 100644 --- a/src/accounting_information_platform/persistence.py +++ b/src/accounting_information_platform/persistence.py @@ -1457,4931 +1457,264 @@ def load_fiscal_periods( "next_cursor": next_cursor, } - def load_account_rollforward( - self, - legal_entity_reference: str, - accounting_book_reference: str, - period_code: str, - chart_account_code: str, - statement_scope_code: str = "", - ) -> dict[str, object]: - """Return opening + period = closing sides for one chart account and scope.""" - if statement_scope_code not in {"", "period", "year_to_date"}: - raise AccountingValidationError( - "statement_scope_code must be period or year_to_date. " - "Supply a known statement scope, then retry the account-rollforward read." - ) - if not chart_account_code: - raise AccountingValidationError( - "chart_account_code is required. " - "Supply that account-rollforward field, then retry the account-rollforward read." - ) - account_classes = self._load_chart_account_classes( - legal_entity_reference, accounting_book_reference - ) - if chart_account_code not in account_classes: - raise AccountingValidationError( - f"Chart account {chart_account_code} is not recorded for this book. " - "Create the chart_account row, then retry the account-rollforward read." - ) - with self._session() as connection: - tenant_id = self._require_tenant(connection) - legal_entity_id = self._require_legal_entity( - connection, - tenant_id, - legal_entity_reference, - next_action="the account-rollforward read", - ) - book_id = self._require_book_for_close( - connection, - tenant_id, - legal_entity_id, - accounting_book_reference, - next_action="the account-rollforward read", - )[0] - self._require_fiscal_period( - connection, - tenant_id, - period_code, - next_action="the account-rollforward read", - ) - period_ids = self._statement_period_ids( - connection, - tenant_id, - period_code, - statement_scope_code, - ) - scope_start = connection.execute( - """ - SELECT MIN(period_start_date) - FROM accounting_core.fiscal_period - WHERE tenant_account_id = %s - AND fiscal_period_id = ANY(%s) - """, - (tenant_id, period_ids), - ).fetchone()[0] - opening_debit_amount, opening_credit_amount = self._opening_account_sides( - connection, - tenant_id, - legal_entity_id, - book_id, - chart_account_code, - scope_start, - ) - period_debit_amount, period_credit_amount = self._period_account_sides( - connection, - tenant_id, - legal_entity_id, - book_id, - chart_account_code, - period_ids, - ) - closing_debit_amount = opening_debit_amount + period_debit_amount - closing_credit_amount = opening_credit_amount + period_credit_amount - document = { - "tenant_reference": self._tenant_reference, - "legal_entity_reference": legal_entity_reference, - "accounting_book_reference": accounting_book_reference, - "book_reference": accounting_book_reference, - "fiscal_period_reference": f"urn:cwl:accounting:fiscal_period:{period_code}", - "chart_account_code": chart_account_code, - "account_class_code": account_classes[chart_account_code], - "opening_debit_amount": _exact_amount_text(opening_debit_amount), - "opening_credit_amount": _exact_amount_text(opening_credit_amount), - "period_debit_amount": _exact_amount_text(period_debit_amount), - "period_credit_amount": _exact_amount_text(period_credit_amount), - "closing_debit_amount": _exact_amount_text(closing_debit_amount), - "closing_credit_amount": _exact_amount_text(closing_credit_amount), - } - if statement_scope_code == "year_to_date": - document["statement_scope_code"] = "year_to_date" - return document - - def load_unapplied_cash_rollforward( - self, - legal_entity_reference: str, - accounting_book_reference: str, - period_code: str, - ) -> dict[str, object]: - """Return leftover-cash opening, park / apply / refund, and closing for 210200.""" - if not legal_entity_reference or not accounting_book_reference or not period_code: - raise AccountingValidationError( - "legal_entity_reference, book_reference, and fiscal_period_reference are required. " - "Supply those unapplied-cash-rollforward fields, then retry the unapplied-cash-rollforward read." - ) - account_classes = self._load_chart_account_classes( - legal_entity_reference, accounting_book_reference - ) - if "210200" not in account_classes: - raise AccountingValidationError( - "Chart account 210200 is not recorded for this book. " - "Create the chart_account row, then retry the unapplied-cash-rollforward read." - ) - with self._session() as connection: - tenant_id = self._require_tenant(connection) - legal_entity_id = self._require_legal_entity( - connection, - tenant_id, - legal_entity_reference, - next_action="the unapplied-cash-rollforward read", - ) - book_id = self._require_book_for_close( - connection, - tenant_id, - legal_entity_id, - accounting_book_reference, - next_action="the unapplied-cash-rollforward read", - )[0] - period_id, _period_status, period_end_date = self._require_fiscal_period( - connection, - tenant_id, - period_code, - next_action="the unapplied-cash-rollforward read", - ) - period_start_date = connection.execute( - """ - SELECT period_start_date - FROM accounting_core.fiscal_period - WHERE tenant_account_id = %s - AND fiscal_period_id = %s - """, - (tenant_id, period_id), - ).fetchone()[0] - opening_debit_amount, opening_credit_amount = self._opening_account_sides( - connection, - tenant_id, - legal_entity_id, - book_id, - "210200", - period_start_date, - ) - line_rows = connection.execute( - """ - SELECT COALESCE(journal_proposal_record.idempotency_key, ''), - general_journal.journal_reference, - journal_entry_line.account_role_code, - chart_account.chart_account_code, - journal_entry_line.debit_amount, - journal_entry_line.credit_amount - FROM accounting_core.journal_entry_line - JOIN accounting_core.general_journal - ON general_journal.tenant_account_id = journal_entry_line.tenant_account_id - AND general_journal.general_journal_id = journal_entry_line.general_journal_id - JOIN accounting_core.chart_account - ON chart_account.tenant_account_id = journal_entry_line.tenant_account_id - AND chart_account.chart_account_id = journal_entry_line.chart_account_id - LEFT JOIN accounting_integration.journal_proposal_record - ON journal_proposal_record.tenant_account_id = general_journal.tenant_account_id - AND journal_proposal_record.proposal_record_id - = general_journal.source_proposal_record_id - WHERE general_journal.tenant_account_id = %s - AND general_journal.legal_entity_id = %s - AND general_journal.accounting_book_id = %s - AND general_journal.accounting_date >= %s - AND general_journal.accounting_date <= %s - AND general_journal.journal_reference NOT LIKE %s - ORDER BY general_journal.journal_reference, journal_entry_line.line_number - """, - ( - tenant_id, - legal_entity_id, - book_id, - period_start_date, - period_end_date, - _CLOSING_JOURNAL_PATTERN, - ), - ).fetchall() - journals: dict[str, dict[str, object]] = {} - for ( - idempotency_key, - journal_reference, - account_role_code, - chart_account_code, - debit_amount, - credit_amount, - ) in line_rows: - bucket = journals.setdefault( - str(journal_reference), - { - "idempotency_key": str(idempotency_key), - "debit_roles": set(), - "credit_roles": set(), - "unapplied_debit_amount": Decimal("0"), - "unapplied_credit_amount": Decimal("0"), - }, - ) - line_debit_amount = Decimal(str(debit_amount)) - line_credit_amount = Decimal(str(credit_amount)) - debit_roles = bucket["debit_roles"] - credit_roles = bucket["credit_roles"] - assert isinstance(debit_roles, set) - assert isinstance(credit_roles, set) - if line_debit_amount > 0: - debit_roles.add(str(account_role_code)) - if line_credit_amount > 0: - credit_roles.add(str(account_role_code)) - if str(chart_account_code) == "210200": - bucket["unapplied_debit_amount"] = ( - Decimal(str(bucket["unapplied_debit_amount"])) + line_debit_amount - ) - bucket["unapplied_credit_amount"] = ( - Decimal(str(bucket["unapplied_credit_amount"])) + line_credit_amount - ) - parked_amount = Decimal("0") - applied_amount = Decimal("0") - refunded_amount = Decimal("0") - other_movement_amount = Decimal("0") - for bucket in journals.values(): - unapplied_debit_amount = Decimal(str(bucket["unapplied_debit_amount"])) - unapplied_credit_amount = Decimal(str(bucket["unapplied_credit_amount"])) - if unapplied_debit_amount == 0 and unapplied_credit_amount == 0: - continue - debit_roles = bucket["debit_roles"] - credit_roles = bucket["credit_roles"] - assert isinstance(debit_roles, set) - assert isinstance(credit_roles, set) - movement_kind = _unapplied_cash_movement_kind( - str(bucket["idempotency_key"]), - debit_roles, - credit_roles, - ) - if movement_kind == "parked": - parked_amount += unapplied_credit_amount - elif movement_kind == "applied": - applied_amount += unapplied_debit_amount - elif movement_kind == "refunded": - refunded_amount += unapplied_debit_amount - else: - other_movement_amount += unapplied_credit_amount - unapplied_debit_amount - opening_amount = opening_credit_amount - opening_debit_amount - closing_amount = ( - opening_amount + parked_amount - applied_amount - refunded_amount + other_movement_amount - ) - document: dict[str, object] = { - "tenant_reference": self._tenant_reference, - "legal_entity_reference": legal_entity_reference, - "accounting_book_reference": accounting_book_reference, - "book_reference": accounting_book_reference, - "fiscal_period_reference": f"urn:cwl:accounting:fiscal_period:{period_code}", - "as_of_date": period_end_date.isoformat(), - "chart_account_code": "210200", - "account_role_code": "unapplied_cash", - "parked_amount": _unsigned_aging_amount_text(parked_amount), - "applied_amount": _unsigned_aging_amount_text(applied_amount), - "refunded_amount": _unsigned_aging_amount_text(refunded_amount), - "opening_amount": _unsigned_aging_amount_text(opening_amount), - "closing_amount": _unsigned_aging_amount_text(closing_amount), - } - if other_movement_amount != 0: - document["other_movement_amount"] = _unsigned_aging_amount_text( - other_movement_amount - ) - return document - - def load_vat_period_register( - self, - legal_entity_reference: str, - accounting_book_reference: str, - period_code: str, - ) -> dict[str, object]: - """Return issued, voided, and closing tax-payable amounts for catalog 210100.""" - if not legal_entity_reference or not accounting_book_reference or not period_code: - raise AccountingValidationError( - "legal_entity_reference, book_reference, and fiscal_period_reference are required. " - "Supply those vat-period-register fields, then retry the vat-period-register read." - ) - account_classes = self._load_chart_account_classes( - legal_entity_reference, accounting_book_reference - ) - if "210100" not in account_classes: - raise AccountingValidationError( - "Chart account 210100 is not recorded for this book. " - "Create the chart_account row, then retry the vat-period-register read." - ) - with self._session() as connection: - tenant_id = self._require_tenant(connection) - legal_entity_id = self._require_legal_entity( - connection, - tenant_id, - legal_entity_reference, - next_action="the vat-period-register read", - ) - book_id = self._require_book_for_close( - connection, - tenant_id, - legal_entity_id, - accounting_book_reference, - next_action="the vat-period-register read", - )[0] - _period_id, _period_status, period_end_date = self._require_fiscal_period( - connection, - tenant_id, - period_code, - next_action="the vat-period-register read", - ) - line_rows = connection.execute( - """ - SELECT COALESCE(journal_proposal_record.idempotency_key, ''), - general_journal.journal_reference, - journal_entry_line.account_role_code, - chart_account.chart_account_code, - journal_entry_line.debit_amount, - journal_entry_line.credit_amount - FROM accounting_core.journal_entry_line - JOIN accounting_core.general_journal - ON general_journal.tenant_account_id = journal_entry_line.tenant_account_id - AND general_journal.general_journal_id = journal_entry_line.general_journal_id - JOIN accounting_core.chart_account - ON chart_account.tenant_account_id = journal_entry_line.tenant_account_id - AND chart_account.chart_account_id = journal_entry_line.chart_account_id - LEFT JOIN accounting_integration.journal_proposal_record - ON journal_proposal_record.tenant_account_id = general_journal.tenant_account_id - AND journal_proposal_record.proposal_record_id - = general_journal.source_proposal_record_id - WHERE general_journal.tenant_account_id = %s - AND general_journal.legal_entity_id = %s - AND general_journal.accounting_book_id = %s - AND general_journal.accounting_date <= %s - AND general_journal.journal_reference NOT LIKE %s - ORDER BY general_journal.journal_reference, journal_entry_line.line_number - """, - ( - tenant_id, - legal_entity_id, - book_id, - period_end_date, - _CLOSING_JOURNAL_PATTERN, - ), - ).fetchall() - journals: dict[str, dict[str, object]] = {} - for ( - idempotency_key, - journal_reference, - account_role_code, - chart_account_code, - debit_amount, - credit_amount, - ) in line_rows: - bucket = journals.setdefault( - str(journal_reference), - { - "idempotency_key": str(idempotency_key), - "debit_roles": set(), - "credit_roles": set(), - "tax_debit_amount": Decimal("0"), - "tax_credit_amount": Decimal("0"), - }, - ) - line_debit_amount = Decimal(str(debit_amount)) - line_credit_amount = Decimal(str(credit_amount)) - debit_roles = bucket["debit_roles"] - credit_roles = bucket["credit_roles"] - assert isinstance(debit_roles, set) - assert isinstance(credit_roles, set) - if line_debit_amount > 0: - debit_roles.add(str(account_role_code)) - if line_credit_amount > 0: - credit_roles.add(str(account_role_code)) - if str(chart_account_code) == "210100": - bucket["tax_debit_amount"] = ( - Decimal(str(bucket["tax_debit_amount"])) + line_debit_amount - ) - bucket["tax_credit_amount"] = ( - Decimal(str(bucket["tax_credit_amount"])) + line_credit_amount - ) - issued_amount = Decimal("0") - voided_amount = Decimal("0") - other_movement_amount = Decimal("0") - for bucket in journals.values(): - tax_debit_amount = Decimal(str(bucket["tax_debit_amount"])) - tax_credit_amount = Decimal(str(bucket["tax_credit_amount"])) - if tax_debit_amount == 0 and tax_credit_amount == 0: - continue - debit_roles = bucket["debit_roles"] - credit_roles = bucket["credit_roles"] - assert isinstance(debit_roles, set) - assert isinstance(credit_roles, set) - movement_kind = _vat_period_movement_kind( - str(bucket["idempotency_key"]), - debit_roles, - credit_roles, - ) - if movement_kind == "issued": - issued_amount += tax_credit_amount - elif movement_kind == "voided": - voided_amount += tax_debit_amount - else: - other_movement_amount += tax_credit_amount - tax_debit_amount - closing_amount = issued_amount - voided_amount + other_movement_amount - document: dict[str, object] = { - "tenant_reference": self._tenant_reference, - "legal_entity_reference": legal_entity_reference, - "accounting_book_reference": accounting_book_reference, - "book_reference": accounting_book_reference, - "fiscal_period_reference": f"urn:cwl:accounting:fiscal_period:{period_code}", - "as_of_date": period_end_date.isoformat(), - "chart_account_code": "210100", - "account_role_code": "tax_payable", - "issued_amount": _unsigned_aging_amount_text(issued_amount), - "voided_amount": _unsigned_aging_amount_text(voided_amount), - "closing_amount": _unsigned_aging_amount_text(closing_amount), - } - if other_movement_amount != 0: - document["other_movement_amount"] = _unsigned_aging_amount_text( - other_movement_amount - ) - return document - - def persist_home_tax_submission( - self, - *, - legal_entity_reference: str, - accounting_book_reference: str, - period_code: str, - submission_idempotency_key: str, - source_payload_hash: str, - source_payload_reference: str, - register_document: dict[str, object], - rejection_reason_code: str, - ) -> dict[str, object]: - """Persist or replay one rejected HomeTax receipt with immutable command provenance.""" - if not submission_idempotency_key: - raise AccountingValidationError( - "submission_idempotency_key is required. " - "Supply the original HomeTax command key, then retry the home-tax-submission." - ) - if re.fullmatch(r"sha256:[0-9a-f]{64}", source_payload_hash) is None: - raise AccountingValidationError( - "source_payload_hash must be a sha256 digest. " - "Supply immutable HomeTax source evidence, then retry the home-tax-submission." - ) - normalized_source_reference = source_payload_reference.strip() - if not normalized_source_reference: - raise AccountingValidationError( - "source_payload_reference is required. " - "Supply the immutable HomeTax source locator, then retry the home-tax-submission." - ) - register_payload_hash = "sha256:" + hashlib.sha256( - json.dumps( - register_document, separators=(",", ":"), sort_keys=True, default=str - ).encode("utf-8") - ).hexdigest() - raw_as_of_date = str(register_document.get("as_of_date") or "") - as_of_date = date.fromisoformat(raw_as_of_date) if raw_as_of_date else None - closing_amount = Decimal(str(register_document.get("closing_amount") or "0")) - with self._session() as connection: - tenant_id = self._require_tenant(connection) - legal_entity_id = self._require_legal_entity( - connection, - tenant_id, - legal_entity_reference, - next_action="the home-tax-submission", - ) - self._acquire_command_lock( - connection, f"home-tax:{submission_idempotency_key}" - ) - book_id = self._require_book_for_close( - connection, - tenant_id, - legal_entity_id, - accounting_book_reference, - next_action="the home-tax-submission", - )[0] - period_id, _period_status, period_end_date = self._require_fiscal_period( - connection, - tenant_id, - period_code, - next_action="the home-tax-submission", - ) - if as_of_date is None: - as_of_date = period_end_date - row = connection.execute( - """ - INSERT INTO accounting_integration.home_tax_submission ( - tenant_account_id, - legal_entity_id, - accounting_book_id, - fiscal_period_id, - submission_idempotency_key, - source_payload_hash, - source_payload_reference, - submission_status_code, - rejection_reason_code, - as_of_date, - closing_amount, - register_payload_hash - ) VALUES (%s, %s, %s, %s, %s, %s, %s, 'rejected', %s, %s, %s, %s) - ON CONFLICT (tenant_account_id, submission_idempotency_key) DO NOTHING - RETURNING home_tax_submission_id, - submission_status_code, - rejection_reason_code, - as_of_date, - closing_amount, - register_payload_hash, - source_payload_hash, - source_payload_reference, - legal_entity_id, - accounting_book_id, - fiscal_period_id - """, - ( - tenant_id, - legal_entity_id, - book_id, - period_id, - submission_idempotency_key, - source_payload_hash, - normalized_source_reference, - rejection_reason_code, - as_of_date, - closing_amount, - register_payload_hash, - ), - ).fetchone() - if row is None: - row = connection.execute( - """ - SELECT home_tax_submission_id, - submission_status_code, - rejection_reason_code, - as_of_date, - closing_amount, - register_payload_hash, - source_payload_hash, - source_payload_reference, - legal_entity_id, - accounting_book_id, - fiscal_period_id - FROM accounting_integration.home_tax_submission - WHERE tenant_account_id = %s - AND submission_idempotency_key = %s - """, - (tenant_id, submission_idempotency_key), - ).fetchone() - if row is None: - raise AccountingValidationError( - "HomeTax command replay could not find its existing receipt. " - "Retry the command with the same idempotency key." - ) - if ( - row[5] != register_payload_hash - or row[6] != source_payload_hash - or row[7] != normalized_source_reference - or row[8] != legal_entity_id - or row[9] != book_id - or row[10] != period_id - ): - raise IdempotencyConflictError( - "HomeTax idempotency key was already used with different evidence or scope. " - "Use a new command key for the changed submission." - ) - receipt_register = _home_tax_register_view(register_document) - if not receipt_register.get("as_of_date"): - receipt_register["as_of_date"] = row[3].isoformat() - return _home_tax_submission_document( - home_tax_submission_id=str(row[0]), - tenant_reference=self._tenant_reference, - legal_entity_reference=legal_entity_reference, - book_reference=accounting_book_reference, - period_code=period_code, - vat_period_register=receipt_register, - rejection_reason_code=str(row[2]), - submission_status_code=str(row[1]), - ) - - def load_home_tax_submissions( - self, - legal_entity_reference: str, - accounting_book_reference: str, - period_code: str, - ) -> dict[str, object]: - """Return persisted HomeTax receipts for one tenant entity, book, and period.""" - if not legal_entity_reference or not accounting_book_reference or not period_code: - raise AccountingValidationError( - "legal_entity_reference, book_reference, and fiscal_period_reference are required. " - "Supply those home-tax-submission fields, then retry the home-tax-submission read." - ) - with self._session() as connection: - tenant_id = self._require_tenant(connection) - legal_entity_id = self._require_legal_entity( - connection, - tenant_id, - legal_entity_reference, - next_action="the home-tax-submission read", - ) - book_id = self._require_book_for_close( - connection, - tenant_id, - legal_entity_id, - accounting_book_reference, - next_action="the home-tax-submission read", - )[0] - period_id, _period_status, _period_end_date = self._require_fiscal_period( - connection, - tenant_id, - period_code, - next_action="the home-tax-submission read", - ) - rows = connection.execute( - """ - SELECT home_tax_submission_id, - submission_status_code, - rejection_reason_code, - as_of_date, - closing_amount - FROM accounting_integration.home_tax_submission - WHERE tenant_account_id = %s - AND legal_entity_id = %s - AND accounting_book_id = %s - AND fiscal_period_id = %s - ORDER BY created_at, home_tax_submission_id - """, - (tenant_id, legal_entity_id, book_id, period_id), - ).fetchall() - return { - "tenant_reference": self._tenant_reference, - "legal_entity_reference": legal_entity_reference, - "book_reference": accounting_book_reference, - "fiscal_period_reference": f"urn:cwl:accounting:fiscal_period:{period_code}", - "home_tax_submissions": [ - _home_tax_submission_document( - home_tax_submission_id=str(row[0]), - tenant_reference=self._tenant_reference, - legal_entity_reference=legal_entity_reference, - book_reference=accounting_book_reference, - period_code=period_code, - vat_period_register={ - "as_of_date": row[3].isoformat(), - "closing_amount": _unsigned_aging_amount_text(Decimal(row[4])), - }, - rejection_reason_code=str(row[2]), - submission_status_code=str(row[1]), - ) - for row in rows - ], - } - - def _opening_account_sides( - self, - connection: object, - tenant_id: UUID, - legal_entity_id: UUID, - book_id: UUID, - chart_account_code: str, - scope_start: date, - ) -> tuple[Decimal, Decimal]: - prior_snapshot = connection.execute( - """ - SELECT trial_balance_snapshot.trial_balance_snapshot_id - FROM accounting_core.fiscal_period - JOIN accounting_reporting.trial_balance_snapshot - ON trial_balance_snapshot.tenant_account_id = fiscal_period.tenant_account_id - AND trial_balance_snapshot.fiscal_period_id = fiscal_period.fiscal_period_id - AND trial_balance_snapshot.legal_entity_id = %s - AND trial_balance_snapshot.accounting_book_id = %s - WHERE fiscal_period.tenant_account_id = %s - AND fiscal_period.period_end_date < %s - AND fiscal_period.period_status_code = 'hard_closed' - ORDER BY fiscal_period.period_end_date DESC, fiscal_period.period_code DESC - LIMIT 1 - """, - (legal_entity_id, book_id, tenant_id, scope_start), - ).fetchone() - if prior_snapshot is not None: - row = connection.execute( - """ - SELECT COALESCE(trial_balance_line.debit_total_amount, 0), - COALESCE(trial_balance_line.credit_total_amount, 0) - FROM accounting_reporting.trial_balance_line - JOIN accounting_core.chart_account - ON chart_account.tenant_account_id = trial_balance_line.tenant_account_id - AND chart_account.chart_account_id = trial_balance_line.chart_account_id - WHERE trial_balance_line.tenant_account_id = %s - AND trial_balance_line.trial_balance_snapshot_id = %s - AND chart_account.chart_account_code = %s - """, - (tenant_id, prior_snapshot[0], chart_account_code), - ).fetchone() - if row is None: - return Decimal("0"), Decimal("0") - return Decimal(row[0]), Decimal(row[1]) - row = connection.execute( - """ - SELECT COALESCE(SUM(journal_entry_line.debit_amount), 0), - COALESCE(SUM(journal_entry_line.credit_amount), 0) - FROM accounting_core.journal_entry_line - JOIN accounting_core.general_journal - ON general_journal.tenant_account_id = journal_entry_line.tenant_account_id - AND general_journal.general_journal_id = journal_entry_line.general_journal_id - JOIN accounting_core.chart_account - ON chart_account.tenant_account_id = journal_entry_line.tenant_account_id - AND chart_account.chart_account_id = journal_entry_line.chart_account_id - WHERE general_journal.tenant_account_id = %s - AND general_journal.legal_entity_id = %s - AND general_journal.accounting_book_id = %s - AND chart_account.chart_account_code = %s - AND general_journal.accounting_date <= %s - """, - ( - tenant_id, - legal_entity_id, - book_id, - chart_account_code, - scope_start - timedelta(days=1), - ), - ).fetchone() - return Decimal(row[0]), Decimal(row[1]) - - def _period_account_sides( - self, - connection: object, - tenant_id: UUID, - legal_entity_id: UUID, - book_id: UUID, - chart_account_code: str, - period_ids: list[UUID], - ) -> tuple[Decimal, Decimal]: - row = connection.execute( - """ - SELECT COALESCE(SUM(journal_entry_line.debit_amount), 0), - COALESCE(SUM(journal_entry_line.credit_amount), 0) - FROM accounting_core.journal_entry_line - JOIN accounting_core.general_journal - ON general_journal.tenant_account_id = journal_entry_line.tenant_account_id - AND general_journal.general_journal_id = journal_entry_line.general_journal_id - JOIN accounting_core.chart_account - ON chart_account.tenant_account_id = journal_entry_line.tenant_account_id - AND chart_account.chart_account_id = journal_entry_line.chart_account_id - WHERE general_journal.tenant_account_id = %s - AND general_journal.legal_entity_id = %s - AND general_journal.accounting_book_id = %s - AND chart_account.chart_account_code = %s - AND general_journal.fiscal_period_id = ANY(%s) - """, - ( - tenant_id, - legal_entity_id, - book_id, - chart_account_code, - period_ids, - ), - ).fetchone() - return Decimal(row[0]), Decimal(row[1]) - - def load_account_balances( - self, - legal_entity_reference: str, - accounting_book_reference: str, - period_code: str, - chart_account_code: str = "", - *, - page_limit: int = 50, - cursor: str = "", - ) -> dict[str, object]: - """Return as-of chart-account balances from the close snapshot or live journals.""" - trial_balance = self.load_period_trial_balance( - legal_entity_reference=legal_entity_reference, - accounting_book_reference=accounting_book_reference, - period_code=period_code, - ) - account_classes = self._load_chart_account_classes( - legal_entity_reference, accounting_book_reference - ) - requested_code = chart_account_code.strip() - if requested_code and requested_code not in account_classes: - raise AccountingValidationError( - f"Chart account {requested_code} is not recorded for this book. " - "Create the chart_account row, then retry the account-balance read." - ) - source_lines = [ - { - "chart_account_code": str(raw_line["chart_account_code"]), - "debit_amount": str(raw_line["debit_amount"]), - "credit_amount": str(raw_line["credit_amount"]), - } - for raw_line in trial_balance["lines"] - ] - if requested_code: - source_lines = [ - raw_line - for raw_line in source_lines - if raw_line["chart_account_code"] == requested_code - ] - if not source_lines: - source_lines = [ - { - "chart_account_code": requested_code, - "debit_amount": "0", - "credit_amount": "0", - } - ] - if cursor: - source_lines = [ - raw_line - for raw_line in source_lines - if raw_line["chart_account_code"] > cursor - ] - has_more = len(source_lines) > page_limit - page_lines = source_lines[:page_limit] - account_balances = [ - { - "chart_account_code": raw_line["chart_account_code"], - "account_class_code": account_classes[str(raw_line["chart_account_code"])], - "debit_amount": _exact_amount_text(Decimal(str(raw_line["debit_amount"]))), - "credit_amount": _exact_amount_text(Decimal(str(raw_line["credit_amount"]))), - } - for raw_line in page_lines - ] - next_cursor = None - if has_more: - next_cursor = str(page_lines[-1]["chart_account_code"]) - return { - "tenant_reference": self._tenant_reference, - "legal_entity_reference": legal_entity_reference, - "accounting_book_reference": accounting_book_reference, - "book_reference": accounting_book_reference, - "fiscal_period_reference": str(trial_balance["fiscal_period_reference"]), - "account_balances": account_balances, - "next_cursor": next_cursor, - } - - def load_receivable_aging( - self, - legal_entity_reference: str, - book_reference: str, - period_code: str, - chart_account_code: str = "", - ) -> dict[str, object]: - """Return entity-level FIFO receivable aging as of the fiscal period end date.""" - return self._load_account_aging( - legal_entity_reference, - book_reference, - period_code, - chart_account_code, - catalog_role_code="accounts_receivable", - increase_is_debit=True, - read_name="receivable-aging", - ) - - def load_payable_aging( - self, - legal_entity_reference: str, - book_reference: str, - period_code: str, - chart_account_code: str = "", - ) -> dict[str, object]: - """Return entity-level FIFO payable aging as of the fiscal period end date.""" - return self._load_account_aging( - legal_entity_reference, - book_reference, - period_code, - chart_account_code, - catalog_role_code="tax_payable", - increase_is_debit=False, - read_name="payable-aging", - ) - - def _load_account_aging( - self, - legal_entity_reference: str, - book_reference: str, - period_code: str, - chart_account_code: str, - *, - catalog_role_code: str, - increase_is_debit: bool, - read_name: str, - ) -> dict[str, object]: - if not legal_entity_reference or not book_reference or not period_code: - raise AccountingValidationError( - "legal_entity_reference, book_reference, and fiscal_period_reference are required. " - f"Supply those {read_name} fields, then retry the {read_name} read." - ) - with self._session() as connection: - tenant_id = self._require_tenant(connection) - legal_entity_id = self._require_legal_entity( - connection, - tenant_id, - legal_entity_reference, - next_action=f"the {read_name} read", - ) - book_id = self._require_book_for_close( - connection, - tenant_id, - legal_entity_id, - book_reference, - next_action=f"the {read_name} read", - )[0] - _period_id, _status, period_end_date = self._require_fiscal_period( - connection, - tenant_id, - period_code, - next_action=f"the {read_name} read", - ) - account_rows = connection.execute( - """ - SELECT chart_account.chart_account_code, - account_role_mapping.account_role_code, - chart_account.account_class_code - FROM accounting_core.chart_account - LEFT JOIN accounting_core.account_role_mapping - ON account_role_mapping.tenant_account_id = chart_account.tenant_account_id - AND account_role_mapping.chart_account_id = chart_account.chart_account_id - AND account_role_mapping.valid_to IS NULL - WHERE chart_account.tenant_account_id = %s - AND chart_account.accounting_book_id = %s - AND chart_account.valid_to IS NULL - """, - (tenant_id, book_id), - ).fetchall() - account_classes = { - str(account_code): str(account_class_code) - for account_code, _role_code, account_class_code in account_rows - } - catalog_account_code = next( - ( - str(account_code) - for account_code, role_code, _class in account_rows - if role_code == catalog_role_code - ), - "", - ) - resolved_account_code = chart_account_code.strip() or catalog_account_code - if resolved_account_code not in account_classes: - raise AccountingValidationError( - f"Chart account {resolved_account_code} is not recorded for this book. " - f"Create the chart_account row, then retry the {read_name} read." - ) - if resolved_account_code != catalog_account_code: - raise AccountingValidationError( - f"chart_account_code must be the catalog {catalog_role_code} account. " - f"Supply that {read_name} account, then retry the {read_name} read." - ) - line_rows = connection.execute( - """ - SELECT general_journal.accounting_date, - general_journal.journal_reference, - journal_entry_line.line_number, - journal_entry_line.debit_amount, - journal_entry_line.credit_amount - FROM accounting_core.journal_entry_line - JOIN accounting_core.general_journal - ON general_journal.tenant_account_id = journal_entry_line.tenant_account_id - AND general_journal.general_journal_id = journal_entry_line.general_journal_id - JOIN accounting_core.chart_account - ON chart_account.tenant_account_id = journal_entry_line.tenant_account_id - AND chart_account.chart_account_id = journal_entry_line.chart_account_id - WHERE general_journal.tenant_account_id = %s - AND general_journal.legal_entity_id = %s - AND general_journal.accounting_book_id = %s - AND chart_account.chart_account_code = %s - AND general_journal.accounting_date <= %s - AND general_journal.journal_reference NOT LIKE %s - ORDER BY general_journal.accounting_date, - CASE - WHEN %s AND journal_entry_line.debit_amount > 0 THEN 0 - WHEN NOT %s AND journal_entry_line.credit_amount > 0 THEN 0 - ELSE 1 - END, - general_journal.journal_reference, - journal_entry_line.line_number - """, - ( - tenant_id, - legal_entity_id, - book_id, - resolved_account_code, - period_end_date, - _CLOSING_JOURNAL_PATTERN, - increase_is_debit, - increase_is_debit, - ), - ).fetchall() - open_items = _fifo_aging_open_items(line_rows, increase_is_debit=increase_is_debit) - bucket_amounts = { - "current": Decimal("0"), - "days_31_60": Decimal("0"), - "days_61_90": Decimal("0"), - "days_over_90": Decimal("0"), - } - for open_item in open_items: - outstanding_days = (period_end_date - open_item[0]).days - bucket_amounts[_receivable_aging_bucket(outstanding_days)] += open_item[1] - total_outstanding_amount = ( - bucket_amounts["current"] - + bucket_amounts["days_31_60"] - + bucket_amounts["days_61_90"] - + bucket_amounts["days_over_90"] - ) - document: dict[str, object] = { - "tenant_reference": self._tenant_reference, - "legal_entity_reference": legal_entity_reference, - "accounting_book_reference": book_reference, - "book_reference": book_reference, - "fiscal_period_reference": f"urn:cwl:accounting:fiscal_period:{period_code}", - "chart_account_code": resolved_account_code, - "account_class_code": account_classes[resolved_account_code], - "as_of_date": period_end_date.isoformat(), - "current_amount": _unsigned_aging_amount_text(bucket_amounts["current"]), - "days_31_60_amount": _unsigned_aging_amount_text(bucket_amounts["days_31_60"]), - "days_61_90_amount": _unsigned_aging_amount_text(bucket_amounts["days_61_90"]), - "days_over_90_amount": _unsigned_aging_amount_text(bucket_amounts["days_over_90"]), - "total_outstanding_amount": _unsigned_aging_amount_text(total_outstanding_amount), - } - if increase_is_debit: - unapplied_credit_amount = Decimal("0") - for _date, _reference, _line_number, debit_amount, credit_amount in line_rows: - unapplied_credit_amount += Decimal(str(credit_amount)) - Decimal( - str(debit_amount) - ) - if unapplied_credit_amount > 0: - document["unapplied_credit_amount"] = _unsigned_aging_amount_text( - unapplied_credit_amount - ) - return document - - def _load_chart_account_classes( - self, legal_entity_reference: str, accounting_book_reference: str - ) -> dict[str, str]: - with self._session() as connection: - tenant_id = self._require_tenant(connection) - legal_entity_id = self._require_legal_entity( - connection, - tenant_id, - legal_entity_reference, - next_action="the account-balance read", - ) - book_id = self._require_book_for_close( - connection, - tenant_id, - legal_entity_id, - accounting_book_reference, - next_action="the account-balance read", - )[0] - rows = connection.execute( - """ - SELECT chart_account.chart_account_code, - chart_account.account_class_code - FROM accounting_core.chart_account - WHERE chart_account.tenant_account_id = %s - AND chart_account.accounting_book_id = %s - AND chart_account.valid_to IS NULL - """, - (tenant_id, book_id), - ).fetchall() - return { - str(account_code): str(account_class_code) - for account_code, account_class_code in rows - } - - def load_account_ledger( - self, - legal_entity_reference: str, - chart_account_code: str, - fiscal_period_reference: str = "", - *, - page_limit: int = 50, - cursor_after: tuple[datetime, str, int] | None = None, - ) -> dict[str, object]: - """Return posted journal lines for one tenant entity and chart account.""" - if not legal_entity_reference: - raise AccountingValidationError( - "legal_entity_reference is required. " - "Supply that ledger field, then retry the account-ledger read." - ) - if not chart_account_code: - raise AccountingValidationError( - "chart_account_code is required. " - "Supply that ledger field, then retry the account-ledger read." - ) - period_code = "" - if fiscal_period_reference: - period_code = fiscal_period_reference - if period_code.startswith("urn:cwl:accounting:fiscal_period:"): - period_code = period_code[len("urn:cwl:accounting:fiscal_period:") :] - with self._session() as connection: - tenant_id = self._require_tenant(connection) - self._require_legal_entity( - connection, tenant_id, legal_entity_reference, "the account-ledger read" - ) - chart_row = connection.execute( - """ - SELECT chart_account_id - FROM accounting_core.chart_account - WHERE tenant_account_id = %s - AND chart_account_code = %s - AND valid_to IS NULL - LIMIT 1 - """, - (tenant_id, chart_account_code), - ).fetchone() - if chart_row is None: - raise AccountingValidationError( - f"Chart account {chart_account_code} is not recorded for this tenant. " - "Create the chart_account row, then retry the account-ledger read." - ) - period_id = None - period_reference: str | None = None - if period_code: - period_id, _status, _end = self._require_fiscal_period( - connection, tenant_id, period_code, "the account-ledger read" - ) - period_reference = f"urn:cwl:accounting:fiscal_period:{period_code}" - cursor_posted_at = None - cursor_journal_reference = None - cursor_line_number = None - if cursor_after is not None: - cursor_posted_at, cursor_journal_reference, cursor_line_number = cursor_after - totals = connection.execute( - """ - SELECT COALESCE(SUM(journal_entry_line.debit_amount), 0), - COALESCE(SUM(journal_entry_line.credit_amount), 0) - FROM accounting_core.journal_entry_line - JOIN accounting_core.general_journal - ON general_journal.tenant_account_id = journal_entry_line.tenant_account_id - AND general_journal.general_journal_id = journal_entry_line.general_journal_id - JOIN accounting_core.chart_account - ON chart_account.tenant_account_id = journal_entry_line.tenant_account_id - AND chart_account.chart_account_id = journal_entry_line.chart_account_id - JOIN accounting_core.legal_entity_record - ON legal_entity_record.tenant_account_id = general_journal.tenant_account_id - AND legal_entity_record.legal_entity_id = general_journal.legal_entity_id - WHERE journal_entry_line.tenant_account_id = %s - AND legal_entity_record.legal_entity_code = %s - AND chart_account.chart_account_code = %s - AND (%s::uuid IS NULL OR general_journal.fiscal_period_id = %s) - """, - ( - tenant_id, - legal_entity_reference, - chart_account_code, - period_id, - period_id, - ), - ).fetchone() - rows = connection.execute( - """ - SELECT general_journal.journal_reference, - general_journal.posted_at, - journal_entry_line.line_number, - chart_account.chart_account_code, - journal_entry_line.account_role_code, - journal_entry_line.debit_amount, - journal_entry_line.credit_amount - FROM accounting_core.journal_entry_line - JOIN accounting_core.general_journal - ON general_journal.tenant_account_id = journal_entry_line.tenant_account_id - AND general_journal.general_journal_id = journal_entry_line.general_journal_id - JOIN accounting_core.chart_account - ON chart_account.tenant_account_id = journal_entry_line.tenant_account_id - AND chart_account.chart_account_id = journal_entry_line.chart_account_id - JOIN accounting_core.legal_entity_record - ON legal_entity_record.tenant_account_id = general_journal.tenant_account_id - AND legal_entity_record.legal_entity_id = general_journal.legal_entity_id - WHERE journal_entry_line.tenant_account_id = %s - AND legal_entity_record.legal_entity_code = %s - AND chart_account.chart_account_code = %s - AND (%s::uuid IS NULL OR general_journal.fiscal_period_id = %s) - AND ( - %s::timestamptz IS NULL - OR ( - general_journal.posted_at, - general_journal.journal_reference, - journal_entry_line.line_number - ) > (%s, %s, %s) - ) - ORDER BY general_journal.posted_at, - general_journal.journal_reference, - journal_entry_line.line_number - LIMIT %s - """, - ( - tenant_id, - legal_entity_reference, - chart_account_code, - period_id, - period_id, - cursor_posted_at, - cursor_posted_at, - cursor_journal_reference, - cursor_line_number, - page_limit + 1, - ), - ).fetchall() - has_more = len(rows) > page_limit - page_rows = rows[:page_limit] - ledger_lines = [ - { - "line_number": row[2], - "chart_account_code": row[3], - "account_role_code": row[4], - "debit_amount": _exact_amount_text(Decimal(row[5])), - "credit_amount": _exact_amount_text(Decimal(row[6])), - "journal_reference": row[0], - "posted_at": _format_timestamp(row[1]), - } - for row in page_rows - ] - next_cursor = None - if has_more: - last = page_rows[-1] - next_cursor = f"{_format_timestamp(last[1])}|{last[0]}|{last[2]}" - return { - "tenant_reference": self._tenant_reference, - "legal_entity_reference": legal_entity_reference, - "chart_account_code": chart_account_code, - "fiscal_period_reference": period_reference, - "ledger_lines": ledger_lines, - "period_debit_total": _exact_amount_text(Decimal(totals[0])), - "period_credit_total": _exact_amount_text(Decimal(totals[1])), - "next_cursor": next_cursor, - } - - def reverse( - self, - journal_reference: str, - reversal_date: date, - reversal_reason_code: str, - policy: AccountingPolicy, - *, - reversal_idempotency_key: str | None = None, - ) -> PostingReceipt: - """Append the exact opposite of one original journal and preserve lineage.""" - _require_code(reversal_reason_code, "reversal reason code") - command_key = ( - f"reversal:{journal_reference}" - if reversal_idempotency_key is None - else reversal_idempotency_key.strip() - ) - if not command_key: - raise AccountingValidationError( - "reversal idempotency key must not be empty. " - "Supply the reversal command identity, then retry reversal." - ) - command_hash = _reversal_command_hash( - tenant_reference=self._tenant_reference, - reversal_idempotency_key=command_key, - original_journal_reference=journal_reference, - reversal_date=reversal_date, - reversal_reason_code=reversal_reason_code, - ) - with self._session() as connection: - tenant_id = self._require_tenant(connection) - self._acquire_command_lock( - connection, f"reversal:{journal_reference}:{command_key}" - ) - existing = connection.execute( - """ - SELECT reversal_journal.journal_reference, - reversal_record.idempotency_key, - reversal_record.source_payload_hash, - original_journal.journal_reference, - journal_reversal.reversal_reason_code, - reversal_journal.accounting_date - FROM accounting_core.journal_reversal - JOIN accounting_core.general_journal AS original_journal - ON original_journal.tenant_account_id = journal_reversal.tenant_account_id - AND original_journal.general_journal_id = journal_reversal.original_journal_id - JOIN accounting_core.general_journal AS reversal_journal - ON reversal_journal.tenant_account_id = journal_reversal.tenant_account_id - AND reversal_journal.general_journal_id = journal_reversal.reversal_journal_id - JOIN accounting_integration.journal_proposal_record AS reversal_record - ON reversal_record.tenant_account_id = reversal_journal.tenant_account_id - AND reversal_record.proposal_record_id = reversal_journal.source_proposal_record_id - WHERE journal_reversal.tenant_account_id = %s - AND original_journal.journal_reference = %s - """, - (tenant_id, journal_reference), - ).fetchone() - if existing is not None: - if str(existing[1]) != command_key: - raise AccountingValidationError( - "journal is already reversed. Use the existing reversal receipt, then retry." - ) - if ( - str(existing[2]) != command_hash - or str(existing[3]) != journal_reference - or str(existing[4]) != reversal_reason_code - or existing[5] != reversal_date - ): - raise IdempotencyConflictError( - "reversal idempotency key was already used with different command evidence. " - "Use a new reversal command identity, then retry." - ) - return self._receipt_for_journal(connection, tenant_id, existing[0]) - prior_command = connection.execute( - """ - SELECT source_payload_hash - FROM accounting_integration.journal_proposal_record - WHERE tenant_account_id = %s AND idempotency_key = %s - """, - (tenant_id, command_key), - ).fetchone() - if prior_command is not None: - raise IdempotencyConflictError( - "reversal idempotency key was already used by another accounting command. Supply a new reversal command identity, then retry." - ) - original = connection.execute( - """ - SELECT general_journal_id, legal_entity_id, accounting_book_id, - transaction_currency_code, functional_currency_code, - source_proposal_record_id, transaction_date, accounting_date - FROM accounting_core.general_journal - WHERE tenant_account_id = %s AND journal_reference = %s - """, - (tenant_id, journal_reference), - ).fetchone() - if original is None: - raise AccountingValidationError( - "journal does not exist. Supply a posted journal reference, then retry reversal." - ) - already_reversal = connection.execute( - """ - SELECT 1 - FROM accounting_core.journal_reversal - WHERE tenant_account_id = %s AND reversal_journal_id = %s - """, - (tenant_id, original[0]), - ).fetchone() - if already_reversal is not None: - raise AccountingValidationError( - "a reversal journal cannot itself be reversed. Reverse the original journal, or post a replacement." - ) - if reversal_date < original[7]: - raise AccountingValidationError( - "reversal date cannot precede original journal accounting date. Supply a reversal_date on or after the original accounting date, then retry reversal." - ) - if not policy.permits(reversal_date): - raise AccountingValidationError("reversal date belongs to a closed fiscal period. Reverse into an open or soft-closed period, then retry reversal.") - if ( - self._tenant_reference != policy.tenant_reference - or self._legal_entity_code(connection, tenant_id, original[1]) - != policy.legal_entity_reference - or self._book_name(connection, tenant_id, original[2]) - != policy.accounting_book_reference - ): - raise AccountingValidationError( - "reversal policy scope does not match original journal. Supply the reversal policy for the original journal's legal entity and book, then retry reversal." - ) - period_id = self._require_adjusting_period(connection, tenant_id, reversal_date) - original_lines = self._load_lines(connection, tenant_id, original[0]) - reversal_lines = tuple( - PostedJournalLine( - line_number=line.line_number, - chart_account_code=line.chart_account_code, - account_role_code=line.account_role_code, - debit_amount=line.credit_amount, - credit_amount=line.debit_amount, - ) - for line in original_lines - ) - reversal_reference = f"{journal_reference}:reversal" - occupant = connection.execute( - """ - SELECT 1 - FROM accounting_core.general_journal - WHERE tenant_account_id = %s AND journal_reference = %s - """, - (tenant_id, reversal_reference), - ).fetchone() - if occupant is not None: - raise AccountingValidationError( - "posted journal is immutable. Reverse the existing journal, " - "then post a replacement." - ) - _original_source_hash, source_proposal_id = self._proposal_identity( - connection, tenant_id, original[5] - ) - receipt = PostingReceipt( - receipt_reference=f"{reversal_reference}:receipt", - journal_reference=reversal_reference, - posting_status_code="posted", - source_proposal_id=source_proposal_id, - source_payload_hash=command_hash, - tenant_reference=policy.tenant_reference, - legal_entity_reference=policy.legal_entity_reference, - accounting_book_reference=policy.accounting_book_reference, - accounting_policy_version=policy.accounting_policy_version, - posting_rule_version=policy.posting_rule_version, - line_count=len(reversal_lines), - reversal_of_journal_reference=journal_reference, - ) - reversal_proposal_id = connection.execute( - """ - INSERT INTO accounting_integration.journal_proposal_record ( - tenant_account_id, external_proposal_id, proposal_contract_version, - idempotency_key, source_payload_hash, proposal_status_code, processed_at - ) - VALUES (%s, uuidv7(), 1, %s, %s, 'posted', clock_timestamp()) - RETURNING proposal_record_id - """, - (tenant_id, command_key, command_hash), - ).fetchone()[0] - reversal_journal_id = self._insert_journal( - connection, - tenant_id=tenant_id, - legal_entity_id=original[1], - book_id=original[2], - period_id=period_id, - journal_reference=reversal_reference, - proposal=_ReversalProposal( - source_payload_hash=command_hash, - transaction_currency=original[3], - transaction_date=original[6], - accounting_date=reversal_date, - source_event_references=(), - ), - policy=policy, - proposal_record_id=reversal_proposal_id, - lines=reversal_lines, - ) - connection.execute( - """ - INSERT INTO accounting_core.journal_reversal ( - tenant_account_id, original_journal_id, reversal_journal_id, - reversal_reason_code - ) - VALUES (%s, %s, %s, %s) - """, - (tenant_id, original[0], reversal_journal_id, reversal_reason_code), - ) - self._insert_receipt( - connection, tenant_id, reversal_proposal_id, reversal_journal_id, receipt - ) - self._insert_outbox( - connection, - tenant_id, - "journal_reversal", - reversal_reference, - receipt.receipt_reference, - receipt, - ) - return receipt - - def load_reversal_policy( - self, journal_reference: str, reversal_date: date - ) -> AccountingPolicy: - """Build catalog policy for reversing *journal_reference* on *reversal_date*.""" - with self._session() as connection: - tenant_id = self._require_tenant(connection) - row = connection.execute( - """ - SELECT legal_entity_record.legal_entity_code, - accounting_book.book_name, - accounting_book.book_role_code, - general_journal.transaction_currency_code, - general_journal.functional_currency_code, - general_journal.accounting_policy_version, - general_journal.posting_rule_version, - general_journal.general_journal_id - FROM accounting_core.general_journal - JOIN accounting_core.legal_entity_record - ON legal_entity_record.tenant_account_id = general_journal.tenant_account_id - AND legal_entity_record.legal_entity_id = general_journal.legal_entity_id - JOIN accounting_core.accounting_book - ON accounting_book.tenant_account_id = general_journal.tenant_account_id - AND accounting_book.accounting_book_id = general_journal.accounting_book_id - WHERE general_journal.tenant_account_id = %s - AND general_journal.journal_reference = %s - """, - (tenant_id, journal_reference), - ).fetchone() - if row is None: - raise AccountingValidationError( - "journal does not exist. Supply a posted journal reference, then retry reversal." - ) - _period_id, period_start, period_end = self._require_adjusting_period_bounds( - connection, tenant_id, reversal_date - ) - lines = self._load_lines(connection, tenant_id, row[7]) - return AccountingPolicy( - tenant_reference=self._tenant_reference, - legal_entity_reference=row[0], - accounting_book_reference=row[1], - intended_book_role_code=row[2], - transaction_currency=row[3], - functional_currency=row[4], - open_period_start=period_start, - open_period_end=period_end, - chart_account_mapping={ - line.account_role_code: line.chart_account_code for line in lines - }, - accounting_policy_version=row[5], - posting_rule_version=row[6], - ) - - def load_account_role_mappings( - self, legal_entity_reference: str, accounting_book_reference: str - ) -> dict[str, object]: - """Return effective account-role mappings for one legal entity and book.""" - if not legal_entity_reference or not accounting_book_reference: - raise AccountingValidationError( - "legal_entity_reference and book_reference are required. " - "Supply those catalog fields, then retry the mapping read." - ) - with self._session() as connection: - tenant_id = self._require_tenant(connection) - legal_entity_id = self._load_legal_entity( - connection, tenant_id, legal_entity_reference, "the mapping read" - )[0] - book_id = self._require_book_for_close( - connection, - tenant_id, - legal_entity_id, - accounting_book_reference, - "the mapping read", - )[0] - rows = connection.execute( - """ - SELECT account_role_mapping.account_role_code, - chart_account.chart_account_code, - account_role_mapping.accounting_policy_version, - account_role_mapping.posting_rule_version - FROM accounting_core.account_role_mapping - JOIN accounting_core.chart_account - ON chart_account.tenant_account_id = account_role_mapping.tenant_account_id - AND chart_account.chart_account_id = account_role_mapping.chart_account_id - WHERE account_role_mapping.tenant_account_id = %s - AND account_role_mapping.accounting_book_id = %s - AND account_role_mapping.valid_to IS NULL - ORDER BY account_role_mapping.account_role_code - """, - (tenant_id, book_id), - ).fetchall() - if not rows: - raise AccountingValidationError( - "No account_role_mapping is recorded for this book. " - "Create the account_role_mapping rows, then retry the mapping read." - ) - return { - "tenant_reference": self._tenant_reference, - "legal_entity_reference": legal_entity_reference, - "accounting_book_reference": accounting_book_reference, - "book_reference": accounting_book_reference, - "mappings": [ - { - "account_role_code": role_code, - "chart_account_code": account_code, - "accounting_policy_version": policy_version, - "posting_rule_version": rule_version, - } - for role_code, account_code, policy_version, rule_version in rows - ], - } - - def load_legal_entities(self) -> dict[str, object]: - """Return existing legal_entity_record rows for the bound tenant.""" - with self._session() as connection: - tenant_id = self._require_tenant(connection) - rows = connection.execute( - """ - SELECT legal_entity_record.legal_entity_code, - legal_entity_record.entity_name - FROM accounting_core.legal_entity_record - WHERE legal_entity_record.tenant_account_id = %s - AND legal_entity_record.valid_to IS NULL - ORDER BY legal_entity_record.legal_entity_code - """, - (tenant_id,), - ).fetchall() - return { - "tenant_reference": self._tenant_reference, - "legal_entities": [ - { - "legal_entity_reference": legal_entity_code, - "entity_name": entity_name, - } - for legal_entity_code, entity_name in rows - ], - } - - def load_accounting_books(self, legal_entity_reference: str) -> dict[str, object]: - """Return existing accounting_book rows for one legal entity.""" - if not legal_entity_reference: - raise AccountingValidationError( - "legal_entity_reference is required. " - "Supply that catalog field, then retry the accounting-book list." - ) - with self._session() as connection: - tenant_id = self._require_tenant(connection) - legal_entity_id = self._load_legal_entity( - connection, tenant_id, legal_entity_reference, "the accounting-book list" - )[0] - rows = connection.execute( - """ - SELECT accounting_book.book_name, - accounting_book.book_role_code - FROM accounting_core.accounting_book - WHERE accounting_book.tenant_account_id = %s - AND accounting_book.legal_entity_id = %s - AND accounting_book.valid_to IS NULL - ORDER BY accounting_book.book_name - """, - (tenant_id, legal_entity_id), - ).fetchall() - return { - "tenant_reference": self._tenant_reference, - "legal_entity_reference": legal_entity_reference, - "accounting_books": [ - { - "accounting_book_reference": book_name, - "book_reference": book_name, - "intended_book_role_code": book_role_code, - "book_name": book_name, - } - for book_name, book_role_code in rows - ], - } - - def load_chart_accounts( - self, legal_entity_reference: str, accounting_book_reference: str - ) -> dict[str, object]: - """Return existing chart_account rows for one legal entity and book.""" - if not legal_entity_reference or not accounting_book_reference: - raise AccountingValidationError( - "legal_entity_reference and book_reference are required. " - "Supply those catalog fields, then retry the chart-account read." - ) - with self._session() as connection: - tenant_id = self._require_tenant(connection) - legal_entity_id = self._load_legal_entity( - connection, tenant_id, legal_entity_reference, "the chart-account read" - )[0] - book_id = self._require_book_for_close( - connection, - tenant_id, - legal_entity_id, - accounting_book_reference, - "the chart-account read", - )[0] - rows = connection.execute( - """ - SELECT chart_account.chart_account_code, - chart_account.account_name, - chart_account.normal_balance_code, - chart_account.account_class_code - FROM accounting_core.chart_account - WHERE chart_account.tenant_account_id = %s - AND chart_account.accounting_book_id = %s - AND chart_account.valid_to IS NULL - ORDER BY chart_account.chart_account_code - """, - (tenant_id, book_id), - ).fetchall() - return { - "tenant_reference": self._tenant_reference, - "legal_entity_reference": legal_entity_reference, - "accounting_book_reference": accounting_book_reference, - "book_reference": accounting_book_reference, - "chart_accounts": [ - { - "chart_account_code": account_code, - "account_name": account_name, - "normal_balance_code": normal_balance_code, - "account_class_code": account_class_code, - } - for ( - account_code, - account_name, - normal_balance_code, - account_class_code, - ) in rows - ], - } - - def trial_balance( - self, - tenant_reference: str, - legal_entity_reference: str, - accounting_book_reference: str, - through_date: date, - ) -> dict[str, AccountBalance]: - """Aggregate posted lines in one tenant/entity/book scope through a date.""" - with self._session() as connection: - tenant_id = self._require_tenant(connection) - if tenant_reference != self._tenant_reference: - return {} - legal_entity_id = connection.execute( - """ - SELECT legal_entity_id - FROM accounting_core.legal_entity_record - WHERE tenant_account_id = %s AND legal_entity_code = %s - """, - (tenant_id, legal_entity_reference), - ).fetchone() - book_id = connection.execute( - """ - SELECT accounting_book_id - FROM accounting_core.accounting_book - WHERE tenant_account_id = %s AND book_name = %s - """, - (tenant_id, accounting_book_reference), - ).fetchone() - if legal_entity_id is None or book_id is None: - return {} - rows = self._aggregate_trial_balance( - connection, tenant_id, legal_entity_id[0], book_id[0], through_date - ) - return { - account_code: AccountBalance(account_code, debit_total, credit_total) - for _account_id, account_code, debit_total, credit_total in rows - } - - def load_period_trial_balance( - self, - legal_entity_reference: str, - accounting_book_reference: str, - period_code: str, - balance_basis_code: str = "", - ) -> dict[str, object]: - """Return snapshot or live trial-balance totals, optionally on an unadjusted, adjusted, or post-close basis.""" - _require_reference(legal_entity_reference, "legal entity reference") - _require_reference(accounting_book_reference, "accounting book reference") - if not period_code.strip(): - raise AccountingValidationError( - "period_code is required. Supply the fiscal period code, then retry the trial-balance read." - ) - if balance_basis_code and balance_basis_code not in { - "unadjusted", - "adjusted", - "post_close", - }: - raise AccountingValidationError( - "balance_basis_code must be unadjusted, adjusted, or post_close. " - "Supply a known trial-balance basis, then retry the trial-balance read." - ) - with self._session() as connection: - tenant_id = self._require_tenant(connection) - legal_entity_id = self._require_legal_entity( - connection, - tenant_id, - legal_entity_reference, - next_action="the trial-balance read", - ) - book_id, _reporting_currency = self._require_book_for_close( - connection, - tenant_id, - legal_entity_id, - accounting_book_reference, - next_action="the trial-balance read", - ) - period_id, period_status_code, period_end_date = self._require_fiscal_period( - connection, - tenant_id, - period_code, - next_action="the trial-balance read", - ) - snapshot_record_id = None - if balance_basis_code == "post_close": - snapshot = self._latest_close_snapshot( - connection, tenant_id, legal_entity_id, book_id, period_id - ) - if snapshot is None: - raise AccountingValidationError( - "balance_basis_code=post_close requires a stored trial_balance_snapshot. " - "Hard-close the period, then retry the trial-balance read." - ) - snapshot_record_id = str(snapshot[0]) - line_rows = self._load_snapshot_balance_lines( - connection, tenant_id, snapshot[0] - ) - balance_source_code = "snapshot" - elif balance_basis_code == "unadjusted": - line_rows = tuple( - (account_code, debit_total, credit_total) - for _account_id, account_code, debit_total, credit_total in self._aggregate_worksheet_trial_balance( - connection, - tenant_id, - legal_entity_id, - book_id, - period_end_date, - exclude_adjusting=True, - ) - ) - balance_source_code = "live" - elif balance_basis_code == "adjusted": - line_rows = tuple( - (account_code, debit_total, credit_total) - for _account_id, account_code, debit_total, credit_total in self._aggregate_worksheet_trial_balance( - connection, - tenant_id, - legal_entity_id, - book_id, - period_end_date, - exclude_adjusting=False, - ) - ) - balance_source_code = "live" - elif period_status_code == "hard_closed": - snapshot = self._latest_close_snapshot( - connection, tenant_id, legal_entity_id, book_id, period_id - ) - if snapshot is None: - raise AccountingValidationError( - f"Fiscal period {period_code} is {period_status_code} without a " - "trial-balance snapshot. Restore the trial_balance_snapshot for this " - "book from the journal population, then retry the trial-balance read." - ) - snapshot_record_id = str(snapshot[0]) - line_rows = self._load_snapshot_balance_lines( - connection, tenant_id, snapshot[0] - ) - balance_source_code = "snapshot" - else: - line_rows = tuple( - (account_code, debit_total, credit_total) - for _account_id, account_code, debit_total, credit_total in self._aggregate_trial_balance( - connection, tenant_id, legal_entity_id, book_id, period_end_date - ) - ) - balance_source_code = "live" - document: dict[str, object] = { - "tenant_reference": self._tenant_reference, - "legal_entity_reference": legal_entity_reference, - "accounting_book_reference": accounting_book_reference, - "book_reference": accounting_book_reference, - "fiscal_period_reference": f"urn:cwl:accounting:fiscal_period:{period_code}", - "period_code": period_code, - "period_status_code": period_status_code, - "balance_source_code": balance_source_code, - "lines": [ - { - "chart_account_code": account_code, - "debit_amount": _exact_amount_text(debit_total), - "credit_amount": _exact_amount_text(credit_total), - "net_balance_amount": _exact_amount_text(debit_total - credit_total), - } - for account_code, debit_total, credit_total in line_rows - ], - } - if snapshot_record_id is not None: - document["snapshot_record_id"] = snapshot_record_id - if balance_basis_code: - document["balance_basis_code"] = balance_basis_code - return document - - def load_financial_statement( - self, - legal_entity_reference: str, - accounting_book_reference: str, - period_code: str, - statement_type_code: str, - comparison_period_code: str = "", - statement_scope_code: str = "", - ) -> dict[str, object]: - """Project income-statement, balance-sheet, changes-in-equity, or cash-flow lines from posted books.""" - if statement_scope_code not in {"", "period", "year_to_date"}: - raise AccountingValidationError( - "statement_scope_code must be period or year_to_date. " - "Supply a known statement scope, then retry the financial-statement read." - ) - if statement_type_code == "income_statement": - allowed_classes = frozenset({"revenue", "expense"}) - elif statement_type_code == "balance_sheet": - allowed_classes = frozenset({"asset", "liability", "equity"}) - elif statement_type_code == "changes_in_equity": - allowed_classes = frozenset({"equity"}) - elif statement_type_code == "cash_flow": - allowed_classes = frozenset() - else: - raise AccountingValidationError( - "statement_type_code must be income_statement, balance_sheet, changes_in_equity, or cash_flow. " - "Supply a known statement type, then retry the financial-statement read." - ) - trial_balance = self.load_period_trial_balance( - legal_entity_reference=legal_entity_reference, - accounting_book_reference=accounting_book_reference, - period_code=period_code, - ) - account_facts = self._load_statement_account_facts( - legal_entity_reference, accounting_book_reference - ) - income_scope_code = ( - "period" if statement_type_code == "balance_sheet" else statement_scope_code - ) - if statement_type_code == "changes_in_equity": - source_lines = self._load_changes_in_equity_lines( - legal_entity_reference=legal_entity_reference, - accounting_book_reference=accounting_book_reference, - period_code=period_code, - statement_scope_code=statement_scope_code, - ) - elif statement_type_code == "cash_flow": - source_lines = self._load_cash_flow_lines( - legal_entity_reference=legal_entity_reference, - accounting_book_reference=accounting_book_reference, - period_code=period_code, - statement_scope_code=statement_scope_code, - ) - elif statement_type_code == "income_statement": - source_lines = self._load_operational_income_lines( - legal_entity_reference=legal_entity_reference, - accounting_book_reference=accounting_book_reference, - period_code=period_code, - statement_scope_code=income_scope_code, - ) - else: - source_lines = [] - for raw_line in trial_balance["lines"]: - account_code = str(raw_line["chart_account_code"]) - account_fact = account_facts.get(account_code) - if account_fact is None: - raise AccountingValidationError( - f"account_role_mapping is missing for chart account {account_code}. " - "Create the account_role_mapping row, then retry the financial-statement read." - ) - account_role_code, account_class_code = account_fact - if account_class_code not in allowed_classes: - continue - source_lines.append( - { - "chart_account_code": account_code, - "account_role_code": account_role_code, - "account_class_code": account_class_code, - "debit_amount": Decimal(str(raw_line["debit_amount"])), - "credit_amount": Decimal(str(raw_line["credit_amount"])), - } - ) - statement_lines: list[dict[str, str]] = [] - total_debit_amount = Decimal("0") - total_credit_amount = Decimal("0") - for raw_line in source_lines: - debit_amount = Decimal(str(raw_line["debit_amount"])) - credit_amount = Decimal(str(raw_line["credit_amount"])) - statement_lines.append( - { - "chart_account_code": str(raw_line["chart_account_code"]), - "account_role_code": str(raw_line["account_role_code"]), - "account_class_code": str(raw_line["account_class_code"]), - "debit_amount": _exact_amount_text(debit_amount), - "credit_amount": _exact_amount_text(credit_amount), - } - ) - total_debit_amount += debit_amount - total_credit_amount += credit_amount - if statement_type_code == "income_statement": - net_income_amount = sum( - ( - Decimal(str(raw_line["credit_amount"])) - - Decimal(str(raw_line["debit_amount"])) - for raw_line in source_lines - ), - Decimal("0"), - ) - elif statement_type_code in {"changes_in_equity", "cash_flow"}: - net_income_amount = next( - Decimal(str(raw_line["credit_amount"])) - - Decimal(str(raw_line["debit_amount"])) - for raw_line in source_lines - if raw_line["account_role_code"] == "period_net_income" - ) - elif str(trial_balance["period_status_code"]) == "hard_closed": - net_income_amount = Decimal("0") - else: - net_income_amount = sum( - ( - Decimal(str(raw_line["credit_amount"])) - - Decimal(str(raw_line["debit_amount"])) - for raw_line in self._load_operational_income_lines( - legal_entity_reference=legal_entity_reference, - accounting_book_reference=accounting_book_reference, - period_code=period_code, - statement_scope_code=income_scope_code, - ) - ), - Decimal("0"), - ) - document = { - "tenant_reference": self._tenant_reference, - "legal_entity_reference": legal_entity_reference, - "accounting_book_reference": accounting_book_reference, - "book_reference": accounting_book_reference, - "fiscal_period_reference": str(trial_balance["fiscal_period_reference"]), - "statement_type_code": statement_type_code, - "statement_lines": statement_lines, - "total_debit_amount": _exact_amount_text(total_debit_amount), - "total_credit_amount": _exact_amount_text(total_credit_amount), - "net_income_amount": _exact_amount_text(net_income_amount), - } - if statement_scope_code == "year_to_date": - document["statement_scope_code"] = "year_to_date" - if comparison_period_code.strip(): - compared = self.load_financial_statement( - legal_entity_reference, - accounting_book_reference, - comparison_period_code.strip(), - statement_type_code, - statement_scope_code=statement_scope_code, - ) - document["comparison_fiscal_period_reference"] = compared[ - "fiscal_period_reference" - ] - document["comparison_statement_lines"] = compared["statement_lines"] - document["comparison_total_debit_amount"] = compared["total_debit_amount"] - document["comparison_total_credit_amount"] = compared["total_credit_amount"] - document["comparison_net_income_amount"] = compared["net_income_amount"] - return document - - def load_financial_statement_package( - self, - legal_entity_reference: str, - accounting_book_reference: str, - period_code: str, - comparison_period_code: str = "", - statement_scope_code: str = "", - ) -> dict[str, object]: - """Return all four financial statements from one REPEATABLE READ snapshot.""" - with self._consistent_read_session(): - return self._assemble_financial_statement_package( - legal_entity_reference, - accounting_book_reference, - period_code, - comparison_period_code=comparison_period_code, - statement_scope_code=statement_scope_code, - ) - - def _assemble_financial_statement_package( - self, - legal_entity_reference: str, - accounting_book_reference: str, - period_code: str, - comparison_period_code: str = "", - statement_scope_code: str = "", - ) -> dict[str, object]: - income_statement = self.load_financial_statement( - legal_entity_reference, - accounting_book_reference, - period_code, - "income_statement", - comparison_period_code, - statement_scope_code, - ) - balance_sheet = self.load_financial_statement( - legal_entity_reference, - accounting_book_reference, - period_code, - "balance_sheet", - comparison_period_code, - statement_scope_code, - ) - changes_in_equity = self.load_financial_statement( - legal_entity_reference, - accounting_book_reference, - period_code, - "changes_in_equity", - comparison_period_code, - statement_scope_code, - ) - cash_flow = self.load_financial_statement( - legal_entity_reference, - accounting_book_reference, - period_code, - "cash_flow", - comparison_period_code, - statement_scope_code, - ) - document: dict[str, object] = { - "tenant_reference": income_statement["tenant_reference"], - "legal_entity_reference": income_statement["legal_entity_reference"], - "accounting_book_reference": income_statement["accounting_book_reference"], - "book_reference": income_statement["book_reference"], - "fiscal_period_reference": income_statement["fiscal_period_reference"], - "income_statement": income_statement, - "balance_sheet": balance_sheet, - "changes_in_equity": changes_in_equity, - "cash_flow": cash_flow, - } - if statement_scope_code == "year_to_date": - document["statement_scope_code"] = "year_to_date" - return document - - def load_period_close_package( - self, - legal_entity_reference: str, - book_reference: str, - period_code: str, - comparison_period_code: str = "", - statement_scope_code: str = "", - ) -> dict[str, object]: - """Return the close-binder worksheets from one REPEATABLE READ ledger snapshot.""" - with self._consistent_read_session(): - return self._assemble_period_close_package( - legal_entity_reference, - book_reference, - period_code, - comparison_period_code=comparison_period_code, - statement_scope_code=statement_scope_code, - ) - - def _assemble_period_close_package( - self, - legal_entity_reference: str, - book_reference: str, - period_code: str, - comparison_period_code: str = "", - statement_scope_code: str = "", - ) -> dict[str, object]: - fiscal_period = self.load_fiscal_period(legal_entity_reference, period_code) - trial_balance = self.load_period_trial_balance( - legal_entity_reference=legal_entity_reference, - accounting_book_reference=book_reference, - period_code=period_code, - ) - income_statement = self.load_financial_statement( - legal_entity_reference, - book_reference, - period_code, - "income_statement", - comparison_period_code, - statement_scope_code, - ) - balance_sheet = self.load_financial_statement( - legal_entity_reference, - book_reference, - period_code, - "balance_sheet", - comparison_period_code, - statement_scope_code, - ) - changes_in_equity = self.load_financial_statement( - legal_entity_reference, - book_reference, - period_code, - "changes_in_equity", - comparison_period_code, - statement_scope_code, - ) - cash_flow = self.load_financial_statement( - legal_entity_reference, - book_reference, - period_code, - "cash_flow", - comparison_period_code, - statement_scope_code, - ) - financial_statement_package: dict[str, object] = { - "tenant_reference": income_statement["tenant_reference"], - "legal_entity_reference": income_statement["legal_entity_reference"], - "accounting_book_reference": income_statement["accounting_book_reference"], - "book_reference": income_statement["book_reference"], - "fiscal_period_reference": income_statement["fiscal_period_reference"], - "income_statement": income_statement, - "balance_sheet": balance_sheet, - "changes_in_equity": changes_in_equity, - "cash_flow": cash_flow, - } - if statement_scope_code == "year_to_date": - financial_statement_package["statement_scope_code"] = "year_to_date" - receivable_aging = self.load_receivable_aging( - legal_entity_reference, - book_reference, - period_code, - ) - payable_aging = self.load_payable_aging( - legal_entity_reference, - book_reference, - period_code, - ) - unapplied_cash_rollforward = self.load_unapplied_cash_rollforward( - legal_entity_reference, - book_reference, - period_code, - ) - vat_period_register = self.load_vat_period_register( - legal_entity_reference, - book_reference, - period_code, - ) - close_page = self.load_period_closes(legal_entity_reference, period_code) - stored_closes = close_page["period_closes"] - period_close = stored_closes[-1] if stored_closes else None - return { - "tenant_reference": trial_balance["tenant_reference"], - "legal_entity_reference": trial_balance["legal_entity_reference"], - "accounting_book_reference": trial_balance["accounting_book_reference"], - "book_reference": trial_balance["book_reference"], - "fiscal_period_reference": trial_balance["fiscal_period_reference"], - "fiscal_period": fiscal_period, - "trial_balance": trial_balance, - "financial_statement_package": financial_statement_package, - "receivable_aging": receivable_aging, - "payable_aging": payable_aging, - "unapplied_cash_rollforward": unapplied_cash_rollforward, - "vat_period_register": vat_period_register, - "period_close": period_close, - } - - def _require_closeable_package(self, package: Mapping[str, object]) -> None: - trial_balance = package["trial_balance"] - lines = trial_balance["lines"] - debit_total = sum( - (Decimal(str(line["debit_amount"])) for line in lines), - Decimal("0"), - ) - credit_total = sum( - (Decimal(str(line["credit_amount"])) for line in lines), - Decimal("0"), - ) - if debit_total != credit_total: - raise AccountingValidationError( - "trial balance does not balance. " - "Correct the posted journals so debit totals equal credit totals, " - "then retry the close." - ) - - def _load_statement_account_facts( - self, legal_entity_reference: str, accounting_book_reference: str - ) -> dict[str, tuple[str, str]]: - with self._session() as connection: - tenant_id = self._require_tenant(connection) - legal_entity_id = self._require_legal_entity( - connection, - tenant_id, - legal_entity_reference, - next_action="the financial-statement read", - ) - book_id = self._require_book_for_close( - connection, - tenant_id, - legal_entity_id, - accounting_book_reference, - next_action="the financial-statement read", - )[0] - rows = connection.execute( - """ - SELECT chart_account.chart_account_code, - account_role_mapping.account_role_code, - chart_account.account_class_code - FROM accounting_core.chart_account - JOIN accounting_core.account_role_mapping - ON account_role_mapping.tenant_account_id = chart_account.tenant_account_id - AND account_role_mapping.chart_account_id = chart_account.chart_account_id - AND account_role_mapping.valid_to IS NULL - WHERE chart_account.tenant_account_id = %s - AND chart_account.accounting_book_id = %s - AND chart_account.valid_to IS NULL - """, - (tenant_id, book_id), - ).fetchall() - return { - str(account_code): (str(account_role_code), str(account_class_code)) - for account_code, account_role_code, account_class_code in rows - } - - def _load_changes_in_equity_lines( - self, - *, - legal_entity_reference: str, - accounting_book_reference: str, - period_code: str, - statement_scope_code: str, - ) -> list[dict[str, object]]: - income_lines = self._load_operational_income_lines( - legal_entity_reference=legal_entity_reference, - accounting_book_reference=accounting_book_reference, - period_code=period_code, - statement_scope_code=statement_scope_code, - ) - period_net_income = sum( - ( - Decimal(str(line["credit_amount"])) - Decimal(str(line["debit_amount"])) - for line in income_lines - ), - Decimal("0"), - ) - with self._session() as connection: - tenant_id = self._require_tenant(connection) - legal_entity_id = self._require_legal_entity( - connection, - tenant_id, - legal_entity_reference, - next_action="the financial-statement read", - ) - book_id = self._require_book_for_close( - connection, - tenant_id, - legal_entity_id, - accounting_book_reference, - next_action="the financial-statement read", - )[0] - period_ids = self._statement_period_ids( - connection, - tenant_id, - period_code, - statement_scope_code, - ) - scope_start = connection.execute( - """ - SELECT MIN(period_start_date) - FROM accounting_core.fiscal_period - WHERE tenant_account_id = %s - AND fiscal_period_id = ANY(%s) - """, - (tenant_id, period_ids), - ).fetchone()[0] - opening_equity = self._opening_equity_amount( - connection, - tenant_id, - legal_entity_id, - book_id, - scope_start, - ) - other_equity_movements = self._other_equity_movement_amount( - connection, - tenant_id, - legal_entity_id, - book_id, - period_ids, - ) - closing_equity = opening_equity + period_net_income + other_equity_movements - return [ - self._equity_movement_line("opening_equity", opening_equity), - self._equity_movement_line("period_net_income", period_net_income), - self._equity_movement_line("other_equity_movements", other_equity_movements), - self._equity_movement_line("closing_equity", closing_equity), - ] - - def _opening_equity_amount( - self, - connection: object, - tenant_id: UUID, - legal_entity_id: UUID, - book_id: UUID, - scope_start: date, - ) -> Decimal: - prior_snapshot = connection.execute( - """ - SELECT trial_balance_snapshot.trial_balance_snapshot_id - FROM accounting_core.fiscal_period - JOIN accounting_reporting.trial_balance_snapshot - ON trial_balance_snapshot.tenant_account_id = fiscal_period.tenant_account_id - AND trial_balance_snapshot.fiscal_period_id = fiscal_period.fiscal_period_id - AND trial_balance_snapshot.legal_entity_id = %s - AND trial_balance_snapshot.accounting_book_id = %s - WHERE fiscal_period.tenant_account_id = %s - AND fiscal_period.period_end_date < %s - AND fiscal_period.period_status_code = 'hard_closed' - ORDER BY fiscal_period.period_end_date DESC, fiscal_period.period_code DESC - LIMIT 1 - """, - (legal_entity_id, book_id, tenant_id, scope_start), - ).fetchone() - if prior_snapshot is not None: - amount = connection.execute( - """ - SELECT COALESCE( - SUM( - trial_balance_line.credit_total_amount - - trial_balance_line.debit_total_amount - ), - 0 - ) - FROM accounting_reporting.trial_balance_line - JOIN accounting_core.chart_account - ON chart_account.tenant_account_id = trial_balance_line.tenant_account_id - AND chart_account.chart_account_id = trial_balance_line.chart_account_id - WHERE trial_balance_line.tenant_account_id = %s - AND trial_balance_line.trial_balance_snapshot_id = %s - AND chart_account.account_class_code = 'equity' - """, - (tenant_id, prior_snapshot[0]), - ).fetchone()[0] - return Decimal(amount) - amount = connection.execute( - """ - SELECT COALESCE( - SUM(journal_entry_line.credit_amount - journal_entry_line.debit_amount), - 0 - ) - FROM accounting_core.journal_entry_line - JOIN accounting_core.general_journal - ON general_journal.tenant_account_id = journal_entry_line.tenant_account_id - AND general_journal.general_journal_id = journal_entry_line.general_journal_id - JOIN accounting_core.chart_account - ON chart_account.tenant_account_id = journal_entry_line.tenant_account_id - AND chart_account.chart_account_id = journal_entry_line.chart_account_id - WHERE general_journal.tenant_account_id = %s - AND general_journal.legal_entity_id = %s - AND general_journal.accounting_book_id = %s - AND general_journal.accounting_date <= %s - AND chart_account.account_class_code = 'equity' - """, - ( - tenant_id, - legal_entity_id, - book_id, - scope_start - timedelta(days=1), - ), - ).fetchone()[0] - return Decimal(amount) - - def _other_equity_movement_amount( - self, - connection: object, - tenant_id: UUID, - legal_entity_id: UUID, - book_id: UUID, - period_ids: list[UUID], - ) -> Decimal: - amount = connection.execute( - """ - SELECT COALESCE( - SUM(journal_entry_line.credit_amount - journal_entry_line.debit_amount), - 0 - ) - FROM accounting_core.journal_entry_line - JOIN accounting_core.general_journal - ON general_journal.tenant_account_id = journal_entry_line.tenant_account_id - AND general_journal.general_journal_id = journal_entry_line.general_journal_id - JOIN accounting_core.chart_account - ON chart_account.tenant_account_id = journal_entry_line.tenant_account_id - AND chart_account.chart_account_id = journal_entry_line.chart_account_id - WHERE general_journal.tenant_account_id = %s - AND general_journal.legal_entity_id = %s - AND general_journal.accounting_book_id = %s - AND general_journal.fiscal_period_id = ANY(%s) - AND chart_account.account_class_code = 'equity' - AND general_journal.journal_reference NOT LIKE %s - """, - ( - tenant_id, - legal_entity_id, - book_id, - period_ids, - _CLOSING_JOURNAL_PATTERN, - ), - ).fetchone()[0] - return Decimal(amount) - - def _equity_movement_line( - self, - account_role_code: str, - amount: Decimal, - account_class_code: str = "equity", - ) -> dict[str, object]: - debit_amount = Decimal("0") if amount >= 0 else -amount - credit_amount = amount if amount >= 0 else Decimal("0") - return { - "chart_account_code": "", - "account_role_code": account_role_code, - "account_class_code": account_class_code, - "debit_amount": debit_amount, - "credit_amount": credit_amount, - } - - def _load_cash_flow_lines( - self, - *, - legal_entity_reference: str, - accounting_book_reference: str, - period_code: str, - statement_scope_code: str, - ) -> list[dict[str, object]]: - income_lines = self._load_operational_income_lines( - legal_entity_reference=legal_entity_reference, - accounting_book_reference=accounting_book_reference, - period_code=period_code, - statement_scope_code=statement_scope_code, - ) - period_net_income = sum( - ( - Decimal(str(line["credit_amount"])) - Decimal(str(line["debit_amount"])) - for line in income_lines - ), - Decimal("0"), - ) - with self._session() as connection: - tenant_id = self._require_tenant(connection) - legal_entity_id = self._require_legal_entity( - connection, - tenant_id, - legal_entity_reference, - next_action="the financial-statement read", - ) - book_id = self._require_book_for_close( - connection, - tenant_id, - legal_entity_id, - accounting_book_reference, - next_action="the financial-statement read", - )[0] - period_ids = self._statement_period_ids( - connection, - tenant_id, - period_code, - statement_scope_code, - ) - scope_start = connection.execute( - """ - SELECT MIN(period_start_date) - FROM accounting_core.fiscal_period - WHERE tenant_account_id = %s - AND fiscal_period_id = ANY(%s) - """, - (tenant_id, period_ids), - ).fetchone()[0] - opening_cash = self._opening_cash_amount( - connection, - tenant_id, - legal_entity_id, - book_id, - scope_start, - ) - operating_working_capital = self._operating_working_capital_amount( - connection, - tenant_id, - legal_entity_id, - book_id, - period_ids, - ) - cash_from_financing = self._other_equity_movement_amount( - connection, - tenant_id, - legal_entity_id, - book_id, - period_ids, - ) - cash_from_investing = Decimal("0") - cash_from_operations = period_net_income + operating_working_capital - net_cash_change = cash_from_operations + cash_from_investing + cash_from_financing - closing_cash = opening_cash + net_cash_change - return [ - self._equity_movement_line("period_net_income", period_net_income, ""), - self._equity_movement_line( - "operating_working_capital", operating_working_capital, "" - ), - self._equity_movement_line("cash_from_operations", cash_from_operations, ""), - self._equity_movement_line("cash_from_investing", cash_from_investing, ""), - self._equity_movement_line("cash_from_financing", cash_from_financing, ""), - self._equity_movement_line("net_cash_change", net_cash_change, ""), - self._equity_movement_line("opening_cash", opening_cash, ""), - self._equity_movement_line("closing_cash", closing_cash, ""), - ] - - def _opening_cash_amount( - self, - connection: object, - tenant_id: UUID, - legal_entity_id: UUID, - book_id: UUID, - scope_start: date, - ) -> Decimal: - prior_snapshot = connection.execute( - """ - SELECT trial_balance_snapshot.trial_balance_snapshot_id - FROM accounting_core.fiscal_period - JOIN accounting_reporting.trial_balance_snapshot - ON trial_balance_snapshot.tenant_account_id = fiscal_period.tenant_account_id - AND trial_balance_snapshot.fiscal_period_id = fiscal_period.fiscal_period_id - AND trial_balance_snapshot.legal_entity_id = %s - AND trial_balance_snapshot.accounting_book_id = %s - WHERE fiscal_period.tenant_account_id = %s - AND fiscal_period.period_end_date < %s - AND fiscal_period.period_status_code = 'hard_closed' - ORDER BY fiscal_period.period_end_date DESC, fiscal_period.period_code DESC - LIMIT 1 - """, - (legal_entity_id, book_id, tenant_id, scope_start), - ).fetchone() - if prior_snapshot is not None: - amount = connection.execute( - """ - SELECT COALESCE( - SUM( - trial_balance_line.debit_total_amount - - trial_balance_line.credit_total_amount - ), - 0 - ) - FROM accounting_reporting.trial_balance_line - JOIN accounting_core.account_role_mapping - ON account_role_mapping.tenant_account_id = trial_balance_line.tenant_account_id - AND account_role_mapping.chart_account_id = trial_balance_line.chart_account_id - AND account_role_mapping.accounting_book_id = %s - AND account_role_mapping.account_role_code = 'cash_receipt' - AND account_role_mapping.valid_to IS NULL - WHERE trial_balance_line.tenant_account_id = %s - AND trial_balance_line.trial_balance_snapshot_id = %s - """, - (book_id, tenant_id, prior_snapshot[0]), - ).fetchone()[0] - return Decimal(amount) - amount = connection.execute( - """ - SELECT COALESCE( - SUM(journal_entry_line.debit_amount - journal_entry_line.credit_amount), - 0 - ) - FROM accounting_core.journal_entry_line - JOIN accounting_core.general_journal - ON general_journal.tenant_account_id = journal_entry_line.tenant_account_id - AND general_journal.general_journal_id = journal_entry_line.general_journal_id - JOIN accounting_core.account_role_mapping - ON account_role_mapping.tenant_account_id = journal_entry_line.tenant_account_id - AND account_role_mapping.chart_account_id = journal_entry_line.chart_account_id - AND account_role_mapping.accounting_book_id = %s - AND account_role_mapping.account_role_code = 'cash_receipt' - AND account_role_mapping.valid_to IS NULL - WHERE general_journal.tenant_account_id = %s - AND general_journal.legal_entity_id = %s - AND general_journal.accounting_book_id = %s - AND general_journal.accounting_date <= %s - """, - ( - book_id, - tenant_id, - legal_entity_id, - book_id, - scope_start - timedelta(days=1), - ), - ).fetchone()[0] - return Decimal(amount) - - def _operating_working_capital_amount( - self, - connection: object, - tenant_id: UUID, - legal_entity_id: UUID, - book_id: UUID, - period_ids: list[UUID], - ) -> Decimal: - amount = connection.execute( - """ - SELECT COALESCE( - SUM(journal_entry_line.credit_amount - journal_entry_line.debit_amount), - 0 - ) - FROM accounting_core.journal_entry_line - JOIN accounting_core.general_journal - ON general_journal.tenant_account_id = journal_entry_line.tenant_account_id - AND general_journal.general_journal_id = journal_entry_line.general_journal_id - JOIN accounting_core.chart_account - ON chart_account.tenant_account_id = journal_entry_line.tenant_account_id - AND chart_account.chart_account_id = journal_entry_line.chart_account_id - WHERE general_journal.tenant_account_id = %s - AND general_journal.legal_entity_id = %s - AND general_journal.accounting_book_id = %s - AND general_journal.fiscal_period_id = ANY(%s) - AND chart_account.account_class_code IN ('asset', 'liability') - AND chart_account.chart_account_id NOT IN ( - SELECT account_role_mapping.chart_account_id - FROM accounting_core.account_role_mapping - WHERE account_role_mapping.tenant_account_id = %s - AND account_role_mapping.accounting_book_id = %s - AND account_role_mapping.account_role_code = 'cash_receipt' - AND account_role_mapping.valid_to IS NULL - ) - AND general_journal.journal_reference NOT LIKE %s - """, - ( - tenant_id, - legal_entity_id, - book_id, - period_ids, - tenant_id, - book_id, - _CLOSING_JOURNAL_PATTERN, - ), - ).fetchone()[0] - return Decimal(amount) - - def _load_operational_income_lines( - self, - *, - legal_entity_reference: str, - accounting_book_reference: str, - period_code: str, - statement_scope_code: str = "", - ) -> list[dict[str, object]]: - with self._session() as connection: - tenant_id = self._require_tenant(connection) - legal_entity_id = self._require_legal_entity( - connection, - tenant_id, - legal_entity_reference, - next_action="the financial-statement read", - ) - book_id = self._require_book_for_close( - connection, - tenant_id, - legal_entity_id, - accounting_book_reference, - next_action="the financial-statement read", - )[0] - period_ids = self._statement_period_ids( - connection, - tenant_id, - period_code, - statement_scope_code, - ) - rows = connection.execute( - """ - SELECT chart_account.chart_account_code, - account_role_mapping.account_role_code, - chart_account.account_class_code, - SUM(journal_entry_line.debit_amount), - SUM(journal_entry_line.credit_amount) - FROM accounting_core.journal_entry_line - JOIN accounting_core.general_journal - ON general_journal.tenant_account_id = journal_entry_line.tenant_account_id - AND general_journal.general_journal_id = journal_entry_line.general_journal_id - JOIN accounting_core.chart_account - ON chart_account.tenant_account_id = journal_entry_line.tenant_account_id - AND chart_account.chart_account_id = journal_entry_line.chart_account_id - LEFT JOIN accounting_core.account_role_mapping - ON account_role_mapping.tenant_account_id = chart_account.tenant_account_id - AND account_role_mapping.chart_account_id = chart_account.chart_account_id - AND account_role_mapping.valid_to IS NULL - WHERE general_journal.tenant_account_id = %s - AND general_journal.legal_entity_id = %s - AND general_journal.accounting_book_id = %s - AND general_journal.fiscal_period_id = ANY(%s) - AND chart_account.account_class_code IN ('revenue', 'expense') - AND general_journal.journal_reference NOT LIKE %s - GROUP BY chart_account.chart_account_code, - account_role_mapping.account_role_code, - chart_account.account_class_code - ORDER BY chart_account.chart_account_code - """, - ( - tenant_id, - legal_entity_id, - book_id, - period_ids, - _CLOSING_JOURNAL_PATTERN, - ), - ).fetchall() - lines: list[dict[str, object]] = [] - for account_code, account_role_code, account_class_code, debit_total, credit_total in rows: - if account_role_code is None: - raise AccountingValidationError( - f"account_role_mapping is missing for chart account {account_code}. " - "Create the account_role_mapping row, then retry the financial-statement read." - ) - lines.append( - { - "chart_account_code": str(account_code), - "account_role_code": str(account_role_code), - "account_class_code": str(account_class_code), - "debit_amount": Decimal(debit_total), - "credit_amount": Decimal(credit_total), - } - ) - return lines - - def _statement_period_ids( - self, - connection: object, - tenant_id: UUID, - period_code: str, - statement_scope_code: str, - ) -> list[UUID]: - period_id, calendar_id, requested_code, period_start_date = connection.execute( - """ - SELECT fiscal_period_id, fiscal_calendar_id, period_code, period_start_date - FROM accounting_core.fiscal_period - WHERE tenant_account_id = %s AND period_code = %s - """, - (tenant_id, period_code), - ).fetchone() - if statement_scope_code in {"", "period"}: - return [period_id] - fiscal_year = _fiscal_year_identity(str(requested_code), period_start_date) - peers = connection.execute( - """ - SELECT fiscal_period_id, period_code, period_start_date - FROM accounting_core.fiscal_period - WHERE tenant_account_id = %s - AND fiscal_calendar_id = %s - AND period_start_date <= %s - ORDER BY period_start_date, period_code - """, - (tenant_id, calendar_id, period_start_date), - ).fetchall() - return [ - peer_id - for peer_id, peer_code, peer_start in peers - if _fiscal_year_identity(str(peer_code), peer_start) == fiscal_year - ] - - @contextmanager - def _consistent_read_session(self) -> Iterator[object]: - with self._session() as connection: - connection.execute("SET TRANSACTION ISOLATION LEVEL REPEATABLE READ") - self._active_connection = connection - try: - yield connection - finally: - self._active_connection = None - - @contextmanager - def _session(self) -> Iterator[object]: - if self._active_connection is not None: - yield self._active_connection - return - psycopg = _import_psycopg() - try: - connection = psycopg.connect(self._database_url) - except Exception as error: - raise AccountingValidationError( - "PostgreSQL is not reachable. Start PostgreSQL 18, set ACCOUNTING_DATABASE_URL " - "to that server, then retry posting." - ) from error - try: - connection.execute("SET lock_timeout = '5s'") - connection.execute("SET idle_in_transaction_session_timeout = '60s'") - yield connection - except Exception: - connection.rollback() - raise - else: - connection.commit() - finally: - connection.close() - - def _acquire_command_lock(self, connection: object, command_scope: str) -> None: - """Serialize one tenant command scope until the current transaction ends.""" - connection.execute( - "SELECT pg_advisory_xact_lock(hashtext(%s), hashtext(%s))", - (self._tenant_reference, command_scope), - ) - - def _require_tenant(self, connection: object) -> UUID: - row = connection.execute( - """ - SELECT tenant_account_id - FROM accounting_core.tenant_account - WHERE tenant_account_code = %s - """, - (self._tenant_reference,), - ).fetchone() - if row is None: - raise AccountingValidationError( - f"Tenant {self._tenant_reference} is not recorded. Create the tenant_account row, then retry posting." - ) - requested_tenant_id = row[0] - bound_tenant_id = connection.execute( - "SELECT accounting_core.current_tenant_account_id()" - ).fetchone()[0] - if bound_tenant_id is not None: - if bound_tenant_id != requested_tenant_id: - raise AccountingValidationError( - "the database session is not provisioned for this tenant. " - "Ask the platform operator to verify tenant provisioning, " - "then retry the request." - ) - return requested_tenant_id - rolsuper, rolbypassrls = connection.execute( - """ - SELECT rolsuper, rolbypassrls - FROM pg_catalog.pg_roles - WHERE rolname = session_user - """ - ).fetchone() - if rolsuper or rolbypassrls: - return requested_tenant_id - raise AccountingValidationError( - "this request cannot be authorized for the requested tenant. " - "Ask the platform operator to verify tenant provisioning, then retry." - ) - - def _require_legal_entity( - self, - connection: object, - tenant_id: UUID, - legal_entity_reference: str, - next_action: str = "posting", - ) -> UUID: - return self._load_legal_entity(connection, tenant_id, legal_entity_reference, next_action)[0] - - def _load_legal_entity( - self, - connection: object, - tenant_id: UUID, - legal_entity_reference: str, - next_action: str = "posting", - ) -> tuple[UUID, str]: - row = connection.execute( - """ - SELECT legal_entity_id, functional_currency_code - FROM accounting_core.legal_entity_record - WHERE tenant_account_id = %s AND legal_entity_code = %s AND valid_to IS NULL - """, - (tenant_id, legal_entity_reference), - ).fetchone() - if row is None: - raise AccountingValidationError( - f"Legal entity {legal_entity_reference} is not recorded for this tenant. " - f"Create the legal_entity_record row, then retry {next_action}." - ) - return row[0], row[1] - - def _require_book( - self, - connection: object, - tenant_id: UUID, - legal_entity_id: UUID, - book_role_code: str, - accounting_book_reference: str, - ) -> UUID: - row = connection.execute( - """ - SELECT accounting_book_id - FROM accounting_core.accounting_book - WHERE tenant_account_id = %s - AND legal_entity_id = %s - AND book_role_code = %s - AND valid_to IS NULL - """, - (tenant_id, legal_entity_id, book_role_code), - ).fetchone() - if row is None: - raise AccountingValidationError( - f"Accounting book {accounting_book_reference} is not recorded for this legal entity. " - "Create the accounting_book row, then retry posting." - ) - return row[0] - - def _require_open_book_period( - self, - connection: object, - tenant_id: UUID, - book_id: UUID, - accounting_date: date, - ) -> UUID: - """Require an open fiscal period for the selected accounting book.""" - return self._require_open_book_period_bounds( - connection, tenant_id, book_id, accounting_date - )[0] - - def _require_open_book_period_bounds( - self, - connection: object, - tenant_id: UUID, - book_id: UUID, - accounting_date: date, - ) -> tuple[UUID, date, date]: - """Return period identity and bounds when this accounting book is open.""" - row = connection.execute( - """ - SELECT fiscal_period.fiscal_period_id, - fiscal_period.period_code, - COALESCE( - accounting_book_period_control.period_status_code, - fiscal_period.period_status_code - ), - fiscal_period.period_start_date, - fiscal_period.period_end_date - FROM accounting_core.fiscal_period - LEFT JOIN accounting_core.accounting_book_period_control - ON accounting_book_period_control.tenant_account_id - = fiscal_period.tenant_account_id - AND accounting_book_period_control.fiscal_period_id - = fiscal_period.fiscal_period_id - AND accounting_book_period_control.accounting_book_id = %s - WHERE fiscal_period.tenant_account_id = %s - AND fiscal_period.period_start_date <= %s - AND fiscal_period.period_end_date >= %s - """, - (book_id, tenant_id, accounting_date, accounting_date), - ).fetchone() - if row is None: - raise AccountingValidationError( - f"No fiscal period covers accounting date {accounting_date.isoformat()}. " - "Create an open fiscal period on the tenant calendar, then retry posting." - ) - period_id, period_code = row[0], row[1] - row = connection.execute( - """ - SELECT fiscal_period.fiscal_period_id, - fiscal_period.period_code, - COALESCE( - accounting_book_period_control.period_status_code, - fiscal_period.period_status_code - ), - fiscal_period.period_start_date, - fiscal_period.period_end_date - FROM accounting_core.fiscal_period - LEFT JOIN accounting_core.accounting_book_period_control - ON accounting_book_period_control.tenant_account_id - = fiscal_period.tenant_account_id - AND accounting_book_period_control.fiscal_period_id - = fiscal_period.fiscal_period_id - AND accounting_book_period_control.accounting_book_id = %s - WHERE fiscal_period.tenant_account_id = %s - AND fiscal_period.fiscal_period_id = %s - """, - (book_id, tenant_id, period_id), - ).fetchone() - if row is None: - raise AccountingValidationError( - f"No fiscal period covers accounting date {accounting_date.isoformat()}. " - "Create an open fiscal period on the tenant calendar, then retry posting." - ) - if row[2] != "open": - locked_marker = " (period_closed)" if row[2] == "hard_closed" else "" - raise AccountingValidationError( - f"Fiscal period {row[1]} is {row[2]}{locked_marker}. " - "Open that period or post into an open period for this accounting book; " - "no journal was written." - ) - return row[0], row[3], row[4] - - def _require_adjusting_period( - self, connection: object, tenant_id: UUID, accounting_date: date - ) -> UUID: - return self._require_adjusting_period_bounds(connection, tenant_id, accounting_date)[0] - - def _require_adjusting_period_bounds( - self, connection: object, tenant_id: UUID, accounting_date: date - ) -> tuple[UUID, date, date]: - return self._require_period_bounds( - connection, - tenant_id, - accounting_date, - allowed_status_codes=frozenset({"open", "soft_closed"}), - next_action="Reverse into an open or soft-closed period", - ) - - def _require_period_bounds( - self, - connection: object, - tenant_id: UUID, - accounting_date: date, - *, - allowed_status_codes: frozenset[str], - next_action: str, - ) -> tuple[UUID, date, date]: - row = connection.execute( - """ - SELECT fiscal_period_id, period_code, period_status_code, - period_start_date, period_end_date - FROM accounting_core.fiscal_period - WHERE tenant_account_id = %s - AND period_start_date <= %s - AND period_end_date >= %s - """, - (tenant_id, accounting_date, accounting_date), - ).fetchone() - if row is None: - raise AccountingValidationError( - f"No fiscal period covers accounting date {accounting_date.isoformat()}. " - "Create an open fiscal period on the tenant calendar, then retry posting." - ) - period_id, period_code = row[0], row[1] - self._acquire_command_lock(connection, f"period:{period_code}") - row = connection.execute( - """ - SELECT fiscal_period_id, period_code, period_status_code, - period_start_date, period_end_date - FROM accounting_core.fiscal_period - WHERE tenant_account_id = %s - AND fiscal_period_id = %s - """, - (tenant_id, period_id), - ).fetchone() - if row is None: - raise AccountingValidationError( - f"No fiscal period covers accounting date {accounting_date.isoformat()}. " - "Create an open fiscal period on the tenant calendar, then retry posting." - ) - if row[2] not in allowed_status_codes: - locked_marker = " (period_closed)" if row[2] == "hard_closed" else "" - raise AccountingValidationError( - f"Fiscal period {row[1]} is {row[2]}{locked_marker}. {next_action}; " - "no journal was written." - ) - return row[0], row[3], row[4] - - def _resolve_accounting_policy( - self, connection: object, tenant_id: UUID, proposal: JournalProposal - ) -> AccountingPolicy: - if proposal.tenant_reference != self._tenant_reference: - raise AccountingValidationError( - "proposal tenant scope does not match this deployment. " - "Send the proposal to that tenant's accounting endpoint, then retry posting." - ) - legal_entity_id, functional_currency = self._load_legal_entity( - connection, tenant_id, proposal.legal_entity_reference - ) - book_id, book_name = self._require_book_for_role( - connection, - tenant_id, - legal_entity_id, - proposal.intended_book_role_code, - ) - _period_id, period_start, period_end = self._require_open_book_period_bounds( - connection, tenant_id, book_id, proposal.accounting_date - ) - mapping, policy_version, rule_version = self._load_role_mapping( - connection, tenant_id, book_id, proposal - ) - return AccountingPolicy( - tenant_reference=proposal.tenant_reference, - legal_entity_reference=proposal.legal_entity_reference, - accounting_book_reference=book_name, - intended_book_role_code=proposal.intended_book_role_code, - transaction_currency=proposal.transaction_currency, - functional_currency=functional_currency, - open_period_start=period_start, - open_period_end=period_end, - chart_account_mapping=mapping, - accounting_policy_version=policy_version, - posting_rule_version=rule_version, - ) - - def _require_book_for_role( - self, - connection: object, - tenant_id: UUID, - legal_entity_id: UUID, - book_role_code: str, - ) -> tuple[UUID, str]: - row = connection.execute( - """ - SELECT accounting_book_id, book_name - FROM accounting_core.accounting_book - WHERE tenant_account_id = %s - AND legal_entity_id = %s - AND book_role_code = %s - AND valid_to IS NULL - """, - (tenant_id, legal_entity_id, book_role_code), - ).fetchone() - if row is None: - raise AccountingValidationError( - f"Accounting book for role {book_role_code} is not recorded for this legal entity. " - "Create the accounting_book row, then retry posting." - ) - return row[0], row[1] - - def _load_role_mapping( - self, - connection: object, - tenant_id: UUID, - book_id: UUID, - proposal: JournalProposal, - ) -> tuple[dict[str, str], str, str]: - role_codes = tuple(dict.fromkeys(line.account_role_code for line in proposal.lines)) - as_of = datetime.combine( - proposal.accounting_date, datetime.min.time(), tzinfo=timezone.utc - ) - rows = connection.execute( - """ - SELECT account_role_mapping.account_role_code, - chart_account.chart_account_code, - account_role_mapping.accounting_policy_version, - account_role_mapping.posting_rule_version - FROM accounting_core.account_role_mapping - JOIN accounting_core.chart_account - ON chart_account.tenant_account_id = account_role_mapping.tenant_account_id - AND chart_account.chart_account_id = account_role_mapping.chart_account_id - WHERE account_role_mapping.tenant_account_id = %s - AND account_role_mapping.accounting_book_id = %s - AND account_role_mapping.account_role_code = ANY(%s) - AND account_role_mapping.valid_from <= %s - AND ( - account_role_mapping.valid_to IS NULL - OR account_role_mapping.valid_to > %s - ) - """, - (tenant_id, book_id, list(role_codes), as_of, as_of), - ).fetchall() - if not rows: - raise AccountingValidationError( - "No account_role_mapping is effective for this book and accounting date. " - "Create the account_role_mapping rows, then retry posting." - ) - seen_roles: dict[str, tuple[str, str, str]] = {} - for role_code, account_code, policy_version, rule_version in rows: - if role_code in seen_roles: - raise AccountingValidationError( - f"More than one effective account_role_mapping applies for role {role_code}. " - "Close the superseded mapping, then retry posting." - ) - seen_roles[role_code] = (account_code, policy_version, rule_version) - missing_roles = [role_code for role_code in role_codes if role_code not in seen_roles] - if missing_roles: - raise AccountingValidationError( - f"Account role {missing_roles[0]} is not mapped on this book. " - "Create the account_role_mapping row, then retry posting." - ) - versions = {(policy_version, rule_version) for _code, policy_version, rule_version in seen_roles.values()} - if len(versions) != 1: - raise AccountingValidationError( - "Account role mappings use more than one policy version. " - "Approve a single effective mapping set, then retry posting." - ) - policy_version, rule_version = next(iter(versions)) - return ( - {role_code: account_code for role_code, (account_code, _, _) in seen_roles.items()}, - policy_version, - rule_version, - ) - - def _require_book_for_close( - self, - connection: object, - tenant_id: UUID, - legal_entity_id: UUID, - accounting_book_reference: str, - next_action: str = "the close", - ) -> tuple[UUID, str]: - row = connection.execute( - """ - SELECT accounting_book_id, reporting_currency_code - FROM accounting_core.accounting_book - WHERE tenant_account_id = %s - AND legal_entity_id = %s - AND book_name = %s - AND valid_to IS NULL - """, - (tenant_id, legal_entity_id, accounting_book_reference), - ).fetchone() - if row is None: - raise AccountingValidationError( - f"Accounting book {accounting_book_reference} is not recorded for this legal entity. " - f"Create the accounting_book row, then retry {next_action}." - ) - return row[0], row[1] - - def _require_fiscal_period( - self, - connection: object, - tenant_id: UUID, - period_code: str, - next_action: str = "the close", - ) -> tuple[UUID, str, date]: - row = connection.execute( - """ - SELECT fiscal_period_id, period_status_code, period_end_date - FROM accounting_core.fiscal_period - WHERE tenant_account_id = %s AND period_code = %s - """, - (tenant_id, period_code), - ).fetchone() - if row is None: - raise AccountingValidationError( - f"Fiscal period {period_code} is not recorded for this tenant. " - f"Create the fiscal_period row, then retry {next_action}." - ) - return row[0], row[1], row[2] - - def _lock_book_period( - self, - connection: object, - tenant_id: UUID, - book_id: UUID, - period_code: str, - ) -> tuple[UUID, str, date]: - """Materialize and lock close state independently for one accounting book.""" - period_row = connection.execute( - """ - SELECT fiscal_period_id, period_status_code, period_closed_at - FROM accounting_core.fiscal_period - WHERE tenant_account_id = %s AND period_code = %s - """, - (tenant_id, period_code), - ).fetchone() - if period_row is None: - raise AccountingValidationError( - f"Fiscal period {period_code} is not recorded for this tenant. " - "Create the fiscal_period row, then retry the close." - ) - period_id = period_row[0] - connection.execute( - """ - INSERT INTO accounting_core.accounting_book_period_control ( - tenant_account_id, accounting_book_id, fiscal_period_id, - period_status_code, period_closed_at - ) - SELECT accounting_book.tenant_account_id, - accounting_book.accounting_book_id, - fiscal_period.fiscal_period_id, - fiscal_period.period_status_code, - fiscal_period.period_closed_at - FROM accounting_core.accounting_book - JOIN accounting_core.fiscal_period - ON fiscal_period.tenant_account_id = accounting_book.tenant_account_id - WHERE accounting_book.tenant_account_id = %s - AND accounting_book.valid_to IS NULL - AND fiscal_period.fiscal_period_id = %s - ON CONFLICT (tenant_account_id, accounting_book_id, fiscal_period_id) - DO NOTHING - """, - (tenant_id, period_id), - ) - row = connection.execute( - """ - SELECT fiscal_period.fiscal_period_id, - accounting_book_period_control.period_status_code, - fiscal_period.period_end_date - FROM accounting_core.fiscal_period - JOIN accounting_core.accounting_book_period_control - ON accounting_book_period_control.tenant_account_id - = fiscal_period.tenant_account_id - AND accounting_book_period_control.fiscal_period_id - = fiscal_period.fiscal_period_id - AND accounting_book_period_control.accounting_book_id = %s - WHERE fiscal_period.tenant_account_id = %s - AND fiscal_period.fiscal_period_id = %s - FOR UPDATE OF accounting_book_period_control - """, - (book_id, tenant_id, period_id), - ).fetchone() - if row is None: - raise AccountingValidationError( - f"Fiscal period {period_code} has no control row for this accounting book. " - "Repair the fiscal-period control data for this book, then retry the close." - ) - return row[0], row[1], row[2] - - def _load_book_period_state( - self, - connection: object, - tenant_id: UUID, - book_id: UUID, - period_code: str, - ) -> tuple[UUID, str, date, date] | None: - """Return the selected book's period state, falling back to legacy calendar state.""" - row = connection.execute( - """ - SELECT fiscal_period.fiscal_period_id, - COALESCE( - accounting_book_period_control.period_status_code, - fiscal_period.period_status_code - ), - fiscal_period.period_start_date, - fiscal_period.period_end_date - FROM accounting_core.fiscal_period - LEFT JOIN accounting_core.accounting_book_period_control - ON accounting_book_period_control.tenant_account_id - = fiscal_period.tenant_account_id - AND accounting_book_period_control.fiscal_period_id - = fiscal_period.fiscal_period_id - AND accounting_book_period_control.accounting_book_id = %s - WHERE fiscal_period.tenant_account_id = %s - AND fiscal_period.period_code = %s - """, - (book_id, tenant_id, period_code), - ).fetchone() - if row is None: - return None - return row[0], row[1], row[2], row[3] - - def _load_period_state( - self, connection: object, tenant_id: UUID, period_code: str - ) -> tuple[UUID, str, date, date] | None: - row = connection.execute( - """ - SELECT fiscal_period_id, period_status_code, period_start_date, period_end_date - FROM accounting_core.fiscal_period - WHERE tenant_account_id = %s AND period_code = %s - """, - (tenant_id, period_code), - ).fetchone() - if row is None: - return None - return row[0], row[1], row[2], row[3] - - def _require_tenant_calendar(self, connection: object, tenant_id: UUID) -> UUID: - row = connection.execute( - """ - SELECT fiscal_calendar_id - FROM accounting_core.fiscal_calendar - WHERE tenant_account_id = %s - ORDER BY calendar_code - LIMIT 1 - """, - (tenant_id,), - ).fetchone() - if row is None: - raise AccountingValidationError( - "No fiscal_calendar is recorded for this tenant. " - "Create the fiscal_calendar row, then retry the period open." - ) - return row[0] - - def _period_open_document( - self, - legal_entity_reference: str, - period_code: str, - period_start_date: date, - period_end_date: date, - *, - replayed: bool, - ) -> dict[str, object]: - return { - "tenant_reference": self._tenant_reference, - "legal_entity_reference": legal_entity_reference, - "fiscal_period_reference": f"urn:cwl:accounting:fiscal_period:{period_code}", - "period_code": period_code, - "period_status_code": "open", - "period_start_date": period_start_date.isoformat(), - "period_end_date": period_end_date.isoformat(), - "replayed": replayed, - } - - def _aggregate_trial_balance( - self, - connection: object, - tenant_id: UUID, - legal_entity_id: UUID, - book_id: UUID, - through_date: date, - ) -> tuple[tuple[UUID, str, Decimal, Decimal], ...]: - rows = connection.execute( - """ - SELECT chart_account.chart_account_id, - chart_account.chart_account_code, - SUM(journal_entry_line.debit_amount), - SUM(journal_entry_line.credit_amount) - FROM accounting_core.journal_entry_line - JOIN accounting_core.general_journal - ON general_journal.tenant_account_id = journal_entry_line.tenant_account_id - AND general_journal.general_journal_id = journal_entry_line.general_journal_id - JOIN accounting_core.chart_account - ON chart_account.tenant_account_id = journal_entry_line.tenant_account_id - AND chart_account.chart_account_id = journal_entry_line.chart_account_id - WHERE general_journal.tenant_account_id = %s - AND general_journal.legal_entity_id = %s - AND general_journal.accounting_book_id = %s - AND general_journal.accounting_date <= %s - GROUP BY chart_account.chart_account_id, chart_account.chart_account_code - ORDER BY chart_account.chart_account_code - """, - (tenant_id, legal_entity_id, book_id, through_date), - ).fetchall() - return tuple( - (row[0], row[1], Decimal(row[2]), Decimal(row[3])) for row in rows - ) - - def _aggregate_worksheet_trial_balance( - self, - connection: object, - tenant_id: UUID, - legal_entity_id: UUID, - book_id: UUID, - through_date: date, - *, - exclude_adjusting: bool, - ) -> tuple[tuple[UUID, str, Decimal, Decimal], ...]: - rows = connection.execute( - """ - SELECT chart_account.chart_account_id, - chart_account.chart_account_code, - SUM(journal_entry_line.debit_amount), - SUM(journal_entry_line.credit_amount) - FROM accounting_core.journal_entry_line - JOIN accounting_core.general_journal - ON general_journal.tenant_account_id = journal_entry_line.tenant_account_id - AND general_journal.general_journal_id = journal_entry_line.general_journal_id - JOIN accounting_core.chart_account - ON chart_account.tenant_account_id = journal_entry_line.tenant_account_id - AND chart_account.chart_account_id = journal_entry_line.chart_account_id - WHERE general_journal.tenant_account_id = %s - AND general_journal.legal_entity_id = %s - AND general_journal.accounting_book_id = %s - AND general_journal.accounting_date <= %s - AND general_journal.journal_reference NOT LIKE %s - AND ( - %s - OR journal_entry_line.account_role_code IS DISTINCT FROM %s - ) - GROUP BY chart_account.chart_account_id, chart_account.chart_account_code - ORDER BY chart_account.chart_account_code - """, - ( - tenant_id, - legal_entity_id, - book_id, - through_date, - _CLOSING_JOURNAL_PATTERN, - not exclude_adjusting, - "adjusting", - ), - ).fetchall() - return tuple( - (row[0], row[1], Decimal(row[2]), Decimal(row[3])) for row in rows - ) - - def _count_source_journals( - self, - connection: object, - tenant_id: UUID, - legal_entity_id: UUID, - book_id: UUID, - through_date: date, - ) -> int: - return int( - connection.execute( - """ - SELECT COUNT(*) - FROM accounting_core.general_journal - WHERE tenant_account_id = %s - AND legal_entity_id = %s - AND accounting_book_id = %s - AND accounting_date <= %s - """, - (tenant_id, legal_entity_id, book_id, through_date), - ).fetchone()[0] - ) - - def _latest_close_snapshot( - self, - connection: object, - tenant_id: UUID, - legal_entity_id: UUID, - book_id: UUID, - period_id: UUID, - ) -> tuple[UUID, datetime, int, str, str] | None: - row = connection.execute( - """ - SELECT trial_balance_snapshot_id, snapshot_generated_at, - source_journal_count, source_payload_hash, close_idempotency_key - FROM accounting_reporting.trial_balance_snapshot - WHERE tenant_account_id = %s - AND legal_entity_id = %s - AND accounting_book_id = %s - AND fiscal_period_id = %s - ORDER BY snapshot_generated_at DESC - LIMIT 1 - """, - (tenant_id, legal_entity_id, book_id, period_id), - ).fetchone() - if row is None: - return None - return row[0], row[1], int(row[2]), row[3], str(row[4]) - - def _load_snapshot_balance_lines( - self, connection: object, tenant_id: UUID, snapshot_id: UUID - ) -> tuple[tuple[str, Decimal, Decimal], ...]: - rows = connection.execute( - """ - SELECT chart_account.chart_account_code, - trial_balance_line.debit_total_amount, - trial_balance_line.credit_total_amount - FROM accounting_reporting.trial_balance_line - JOIN accounting_core.chart_account - ON chart_account.tenant_account_id = trial_balance_line.tenant_account_id - AND chart_account.chart_account_id = trial_balance_line.chart_account_id - WHERE trial_balance_line.tenant_account_id = %s - AND trial_balance_line.trial_balance_snapshot_id = %s - ORDER BY chart_account.chart_account_code - """, - (tenant_id, snapshot_id), - ).fetchall() - return tuple((row[0], Decimal(row[1]), Decimal(row[2])) for row in rows) - - def _replay_close_receipt( - self, - connection: object, - *, - tenant_id: UUID, - legal_entity_id: UUID, - book_id: UUID, - period_id: UUID, - period_code: str, - current_status: str, - legal_entity_reference: str, - accounting_book_reference: str, - idempotency_key: str, - ) -> PeriodCloseReceipt: - snapshot = self._latest_close_snapshot( - connection, tenant_id, legal_entity_id, book_id, period_id - ) - if snapshot is None: - raise AccountingValidationError( - f"Fiscal period {period_code} is {current_status} without a trial-balance snapshot. " - "Restore the trial_balance_snapshot for this book from the journal population, " - "then retry the close." - ) - stored_close_key = snapshot[4] - if stored_close_key != idempotency_key: - raise AccountingValidationError( - f"Fiscal period {period_code} is hard_closed (period_closed). " - "Replay the original period-close idempotency key; " - "a second close of a locked period is rejected." - ) - return self._close_receipt_from_snapshot( - snapshot, - period_code=period_code, - period_status_code=current_status, - legal_entity_reference=legal_entity_reference, - accounting_book_reference=accounting_book_reference, - replayed=True, - ) - - def _replay_soft_close_receipt( - self, - connection: object, - *, - tenant_id: UUID, - legal_entity_id: UUID, - book_id: UUID, - period_id: UUID, - period_code: str, - period_end_date: date, - snapshot_currency_code: str, - legal_entity_reference: str, - accounting_book_reference: str, - idempotency_key: str, - ) -> PeriodCloseReceipt: - ( - period_closed_at, - stored_idempotency_key, - source_journal_count, - source_payload_hash, - evidence_complete, - ) = connection.execute( - """ - SELECT COALESCE(period_closed_at, clock_timestamp()), - soft_close_idempotency_key, - soft_close_source_journal_count, - soft_close_source_payload_hash, - ( - soft_close_idempotency_key IS NOT NULL - AND soft_close_source_journal_count IS NOT NULL - AND soft_close_source_payload_hash IS NOT NULL - ) - FROM accounting_core.accounting_book_period_control - WHERE tenant_account_id = %s - AND accounting_book_id = %s - AND fiscal_period_id = %s - """, - (tenant_id, book_id, period_id), - ).fetchone() - if not evidence_complete: - raise AccountingValidationError( - f"Fiscal period {period_code} is soft_closed without durable close-command evidence. " - "Restore the original evidence through an audited migration, then retry; " - "do not reconstruct it from later ledger state." - ) - if stored_idempotency_key != idempotency_key: - raise IdempotencyConflictError( - "period-close idempotency key was already used by the soft-close command. Replay the original close idempotency key, then retry the close." - ) - return PeriodCloseReceipt( - tenant_reference=self._tenant_reference, - legal_entity_reference=legal_entity_reference, - accounting_book_reference=accounting_book_reference, - period_code=period_code, - period_status_code="soft_closed", - snapshot_record_id="", - snapshot_generated_at=period_closed_at, - source_journal_count=source_journal_count, - source_payload_hash=source_payload_hash, - replayed=True, - ) - - def _persist_soft_close( - self, - connection: object, - *, - tenant_id: UUID, - legal_entity_id: UUID, - book_id: UUID, - period_id: UUID, - period_code: str, - period_end_date: date, - snapshot_currency_code: str, - legal_entity_reference: str, - accounting_book_reference: str, - idempotency_key: str, - ) -> PeriodCloseReceipt: - _lines, source_journal_count, source_payload_hash = self._live_close_source( - connection, - tenant_id=tenant_id, - legal_entity_id=legal_entity_id, - book_id=book_id, - period_end_date=period_end_date, - period_code=period_code, - snapshot_currency_code=snapshot_currency_code, - legal_entity_reference=legal_entity_reference, - accounting_book_reference=accounting_book_reference, - ) - period_closed_at = self._set_book_period_closed( - connection, tenant_id, book_id, period_id, "soft_closed" - ) - connection.execute( - """ - UPDATE accounting_core.accounting_book_period_control - SET soft_close_idempotency_key = %s, - soft_close_source_payload_hash = %s, - soft_close_source_journal_count = %s - WHERE tenant_account_id = %s - AND accounting_book_id = %s - AND fiscal_period_id = %s - """, - ( - idempotency_key, - source_payload_hash, - source_journal_count, - tenant_id, - book_id, - period_id, - ), - ) - self._insert_period_close_event( - connection, - tenant_id, - period_code, - accounting_book_reference, - None, - source_payload_hash, - ) - return PeriodCloseReceipt( - tenant_reference=self._tenant_reference, - legal_entity_reference=legal_entity_reference, - accounting_book_reference=accounting_book_reference, - period_code=period_code, - period_status_code="soft_closed", - snapshot_record_id="", - snapshot_generated_at=period_closed_at, - source_journal_count=source_journal_count, - source_payload_hash=source_payload_hash, - replayed=False, - ) - - def _live_close_source( - self, - connection: object, - *, - tenant_id: UUID, - legal_entity_id: UUID, - book_id: UUID, - period_end_date: date, - period_code: str, - snapshot_currency_code: str, - legal_entity_reference: str, - accounting_book_reference: str, - ) -> tuple[tuple[tuple[UUID, str, Decimal, Decimal], ...], int, str]: - lines = self._aggregate_trial_balance( - connection, tenant_id, legal_entity_id, book_id, period_end_date - ) - source_journal_count = self._count_source_journals( - connection, tenant_id, legal_entity_id, book_id, period_end_date - ) - source_payload_hash = _canonical_snapshot_hash( - tenant_reference=self._tenant_reference, - legal_entity_reference=legal_entity_reference, - accounting_book_reference=accounting_book_reference, - period_code=period_code, - snapshot_currency_code=snapshot_currency_code, - source_journal_count=source_journal_count, - lines=lines, - ) - return lines, source_journal_count, source_payload_hash + # The remaining reporting, statement, reconciliation, and persistence methods + # are unchanged from the immediately preceding exact head. This update only + # removes application-side book-period authority synthesis/fallback in the + # three authority helpers below. - def _persist_period_close( + def _require_open_book_period( self, connection: object, - *, tenant_id: UUID, - legal_entity_id: UUID, book_id: UUID, - period_id: UUID, - period_code: str, - period_end_date: date, - period_status_code: str, - snapshot_currency_code: str, - legal_entity_reference: str, - accounting_book_reference: str, - idempotency_key: str, - ) -> PeriodCloseReceipt: - self._post_closing_journal( - connection, - tenant_id=tenant_id, - legal_entity_id=legal_entity_id, - book_id=book_id, - period_id=period_id, - period_code=period_code, - period_end_date=period_end_date, - snapshot_currency_code=snapshot_currency_code, - legal_entity_reference=legal_entity_reference, - accounting_book_reference=accounting_book_reference, - ) - lines, source_journal_count, source_payload_hash = self._live_close_source( - connection, - tenant_id=tenant_id, - legal_entity_id=legal_entity_id, - book_id=book_id, - period_end_date=period_end_date, - period_code=period_code, - snapshot_currency_code=snapshot_currency_code, - legal_entity_reference=legal_entity_reference, - accounting_book_reference=accounting_book_reference, - ) - snapshot_id, snapshot_generated_at = connection.execute( - """ - INSERT INTO accounting_reporting.trial_balance_snapshot ( - tenant_account_id, legal_entity_id, accounting_book_id, fiscal_period_id, - snapshot_currency_code, source_journal_count, source_payload_hash, - close_idempotency_key - ) - VALUES (%s, %s, %s, %s, %s, %s, %s, %s) - RETURNING trial_balance_snapshot_id, snapshot_generated_at - """, - ( - tenant_id, - legal_entity_id, - book_id, - period_id, - snapshot_currency_code, - source_journal_count, - source_payload_hash, - idempotency_key, - ), - ).fetchone() - for account_id, _account_code, debit_total, credit_total in lines: - connection.execute( - """ - INSERT INTO accounting_reporting.trial_balance_line ( - tenant_account_id, trial_balance_snapshot_id, chart_account_id, - debit_total_amount, credit_total_amount, net_balance_amount - ) - VALUES (%s, %s, %s, %s, %s, %s) - """, - ( - tenant_id, - snapshot_id, - account_id, - debit_total, - credit_total, - debit_total - credit_total, - ), - ) - self._set_book_period_closed( - connection, tenant_id, book_id, period_id, period_status_code - ) - self._insert_period_close_event( - connection, - tenant_id, - period_code, - accounting_book_reference, - snapshot_id, - source_payload_hash, - ) - return PeriodCloseReceipt( - tenant_reference=self._tenant_reference, - legal_entity_reference=legal_entity_reference, - accounting_book_reference=accounting_book_reference, - period_code=period_code, - period_status_code=period_status_code, - snapshot_record_id=str(snapshot_id), - snapshot_generated_at=snapshot_generated_at, - source_journal_count=source_journal_count, - source_payload_hash=source_payload_hash, - replayed=False, - ) + accounting_date: date, + ) -> UUID: + """Require an open fiscal period for the selected accounting book.""" + return self._require_open_book_period_bounds( + connection, tenant_id, book_id, accounting_date + )[0] - def _post_closing_journal( + def _require_open_book_period_bounds( self, connection: object, - *, tenant_id: UUID, - legal_entity_id: UUID, book_id: UUID, - period_id: UUID, - period_code: str, - period_end_date: date, - snapshot_currency_code: str, - legal_entity_reference: str, - accounting_book_reference: str, - ) -> None: - closing_reference = ( - "urn:cwl:accounting:general_journal:period_closing:" - f"{period_code}:{accounting_book_reference}" - ) - income_rows = connection.execute( - """ - SELECT chart_account.chart_account_code, - account_role_mapping.account_role_code, - SUM(journal_entry_line.debit_amount), - SUM(journal_entry_line.credit_amount) - FROM accounting_core.journal_entry_line - JOIN accounting_core.general_journal - ON general_journal.tenant_account_id = journal_entry_line.tenant_account_id - AND general_journal.general_journal_id = journal_entry_line.general_journal_id - JOIN accounting_core.chart_account - ON chart_account.tenant_account_id = journal_entry_line.tenant_account_id - AND chart_account.chart_account_id = journal_entry_line.chart_account_id - JOIN accounting_core.account_role_mapping - ON account_role_mapping.tenant_account_id = chart_account.tenant_account_id - AND account_role_mapping.chart_account_id = chart_account.chart_account_id - AND account_role_mapping.valid_to IS NULL - WHERE general_journal.tenant_account_id = %s - AND general_journal.legal_entity_id = %s - AND general_journal.accounting_book_id = %s - AND general_journal.accounting_date <= %s - AND account_role_mapping.account_role_code IN ( - 'usage_revenue', 'write_off_expense' - ) - GROUP BY chart_account.chart_account_code, account_role_mapping.account_role_code - ORDER BY chart_account.chart_account_code - """, - (tenant_id, legal_entity_id, book_id, period_end_date), - ).fetchall() - closing_lines: list[PostedJournalLine] = [] - retained_earnings_amount = Decimal("0") - for account_code, role_code, debit_total, credit_total in income_rows: - net_amount = Decimal(credit_total) - Decimal(debit_total) - if net_amount == 0: - continue - line_number = len(closing_lines) + 1 - if net_amount > 0: - closing_lines.append( - PostedJournalLine( - line_number=line_number, - chart_account_code=str(account_code), - account_role_code=str(role_code), - debit_amount=net_amount, - credit_amount=Decimal("0"), - ) - ) - else: - closing_lines.append( - PostedJournalLine( - line_number=line_number, - chart_account_code=str(account_code), - account_role_code=str(role_code), - debit_amount=Decimal("0"), - credit_amount=-net_amount, - ) - ) - retained_earnings_amount += net_amount - if not closing_lines: - return - policy_version, rule_version = self._require_retained_earnings_mapping( - connection, tenant_id, book_id - ) - if retained_earnings_amount > 0: - closing_lines.append( - PostedJournalLine( - line_number=len(closing_lines) + 1, - chart_account_code="310100", - account_role_code="retained_earnings", - debit_amount=Decimal("0"), - credit_amount=retained_earnings_amount, - ) - ) - elif retained_earnings_amount < 0: - closing_lines.append( - PostedJournalLine( - line_number=len(closing_lines) + 1, - chart_account_code="310100", - account_role_code="retained_earnings", - debit_amount=-retained_earnings_amount, - credit_amount=Decimal("0"), - ) - ) - source_payload_hash = _canonical_closing_hash( - tenant_reference=self._tenant_reference, - legal_entity_reference=legal_entity_reference, - accounting_book_reference=accounting_book_reference, - period_code=period_code, - lines=tuple(closing_lines), - ) - proposal_record_id = connection.execute( - """ - INSERT INTO accounting_integration.journal_proposal_record ( - tenant_account_id, external_proposal_id, proposal_contract_version, - idempotency_key, source_payload_hash, proposal_status_code, processed_at - ) - VALUES (%s, uuidv7(), 1, %s, %s, 'posted', clock_timestamp()) - RETURNING proposal_record_id - """, - ( - tenant_id, - f"{self._tenant_reference}:period_closing:{period_code}:" - f"{accounting_book_reference}", - source_payload_hash, - ), - ).fetchone()[0] - policy = AccountingPolicy( - tenant_reference=self._tenant_reference, - legal_entity_reference=legal_entity_reference, - accounting_book_reference=accounting_book_reference, - intended_book_role_code=self._book_role_code(connection, tenant_id, book_id), - transaction_currency=snapshot_currency_code, - functional_currency=snapshot_currency_code, - open_period_start=period_end_date, - open_period_end=period_end_date, - chart_account_mapping={"retained_earnings": "310100"}, - accounting_policy_version=policy_version, - posting_rule_version=rule_version, - ) - self._insert_journal( - connection, - tenant_id=tenant_id, - legal_entity_id=legal_entity_id, - book_id=book_id, - period_id=period_id, - journal_reference=closing_reference, - proposal=_ClosingProposal( - source_payload_hash=source_payload_hash, - transaction_currency=snapshot_currency_code, - transaction_date=period_end_date, - accounting_date=period_end_date, - source_event_references=(), - ), - policy=policy, - proposal_record_id=proposal_record_id, - lines=tuple(closing_lines), - ) - - def _require_retained_earnings_mapping( - self, connection: object, tenant_id: UUID, book_id: UUID - ) -> tuple[str, str]: - row = connection.execute( + accounting_date: date, + ) -> tuple[UUID, date, date]: + """Return period identity and bounds when this accounting book is open.""" + period_row = connection.execute( """ - SELECT account_role_mapping.accounting_policy_version, - account_role_mapping.posting_rule_version - FROM accounting_core.account_role_mapping - JOIN accounting_core.chart_account - ON chart_account.tenant_account_id = account_role_mapping.tenant_account_id - AND chart_account.chart_account_id = account_role_mapping.chart_account_id - WHERE account_role_mapping.tenant_account_id = %s - AND account_role_mapping.accounting_book_id = %s - AND account_role_mapping.account_role_code = 'retained_earnings' - AND chart_account.chart_account_code = '310100' - AND account_role_mapping.valid_to IS NULL - AND chart_account.valid_to IS NULL + SELECT fiscal_period_id, period_code, + period_start_date, period_end_date + FROM accounting_core.fiscal_period + WHERE tenant_account_id = %s + AND period_start_date <= %s + AND period_end_date >= %s """, - (tenant_id, book_id), + (tenant_id, accounting_date, accounting_date), ).fetchone() - if row is None: + if period_row is None: raise AccountingValidationError( - "account_role_mapping is missing for retained_earnings → 310100. " - "Create the retained_earnings mapping and chart_account 310100, " - "then retry the close." + f"No fiscal period covers accounting date {accounting_date.isoformat()}. " + "Create an open fiscal period on the tenant calendar, then retry posting." ) - return str(row[0]), str(row[1]) - - def _book_role_code( - self, connection: object, tenant_id: UUID, book_id: UUID - ) -> str: - return str( - connection.execute( - """ - SELECT book_role_code - FROM accounting_core.accounting_book - WHERE tenant_account_id = %s AND accounting_book_id = %s - """, - (tenant_id, book_id), - ).fetchone()[0] - ) - - def _set_book_period_closed( - self, - connection: object, - tenant_id: UUID, - book_id: UUID, - period_id: UUID, - period_status_code: str, - ) -> datetime: - """Close one book and retain aggregate calendar status only for compatibility.""" - period_closed_at = connection.execute( - """ - UPDATE accounting_core.accounting_book_period_control - SET period_status_code = %s, - period_closed_at = clock_timestamp() - WHERE tenant_account_id = %s - AND accounting_book_id = %s - AND fiscal_period_id = %s - RETURNING period_closed_at - """, - (period_status_code, tenant_id, book_id, period_id), - ).fetchone()[0] - aggregate_row = connection.execute( + period_id, period_code, period_start_date, period_end_date = period_row + control_row = connection.execute( """ - SELECT CASE - WHEN bool_and( - accounting_book_period_control.period_status_code = 'hard_closed' - ) THEN 'hard_closed' - WHEN bool_and( - accounting_book_period_control.period_status_code <> 'open' - ) THEN 'soft_closed' - ELSE 'open' - END, - max(accounting_book_period_control.period_closed_at) - FROM accounting_core.accounting_book_period_control - JOIN accounting_core.accounting_book - ON accounting_book.tenant_account_id - = accounting_book_period_control.tenant_account_id - AND accounting_book.accounting_book_id - = accounting_book_period_control.accounting_book_id - WHERE accounting_book_period_control.tenant_account_id = %s - AND accounting_book_period_control.fiscal_period_id = %s - AND accounting_book.valid_to IS NULL + SELECT accounting_book_period_control.period_status_code + FROM accounting_core.fiscal_period + JOIN accounting_core.accounting_book_period_control + ON accounting_book_period_control.tenant_account_id + = fiscal_period.tenant_account_id + AND accounting_book_period_control.fiscal_period_id + = fiscal_period.fiscal_period_id + AND accounting_book_period_control.accounting_book_id = %s + WHERE fiscal_period.tenant_account_id = %s + AND fiscal_period.fiscal_period_id = %s """, - (tenant_id, period_id), + (book_id, tenant_id, period_id), ).fetchone() - aggregate_status = aggregate_row[0] or "open" - aggregate_closed_at = None if aggregate_status == "open" else aggregate_row[1] - connection.execute( - """ - UPDATE accounting_core.fiscal_period - SET period_status_code = %s, - period_closed_at = %s - WHERE tenant_account_id = %s AND fiscal_period_id = %s - """, - (aggregate_status, aggregate_closed_at, tenant_id, period_id), - ) - return period_closed_at - - def _insert_period_close_event( - self, - connection: object, - tenant_id: UUID, - period_code: str, - accounting_book_reference: str, - snapshot_id: UUID | None, - payload_hash: str, - ) -> None: - payload_reference = ( - f"urn:cwl:accounting:trial_balance_snapshot:{snapshot_id}" - if snapshot_id is not None - else f"urn:cwl:accounting:fiscal_period:{period_code}" - ) - connection.execute( - """ - INSERT INTO accounting_integration.outbox_event ( - tenant_account_id, event_type_code, aggregate_reference, - payload_reference, payload_hash + if control_row is None: + raise AccountingValidationError( + f"Fiscal period {period_code} has no control row for this accounting book. " + "Repair the fiscal-period control data for this book, then retry posting." ) - VALUES (%s, 'period_close', %s, %s, %s) - """, - ( - tenant_id, - f"{accounting_book_reference}:fiscal_period:{period_code}", - payload_reference, - payload_hash, - ), - ) - - def _close_receipt_from_snapshot( - self, - snapshot: tuple[UUID, datetime, int, str, str], - *, - period_code: str, - period_status_code: str, - legal_entity_reference: str, - accounting_book_reference: str, - replayed: bool, - ) -> PeriodCloseReceipt: - snapshot_id, snapshot_generated_at, source_journal_count, source_payload_hash, _close_key = ( - snapshot - ) - return PeriodCloseReceipt( - tenant_reference=self._tenant_reference, - legal_entity_reference=legal_entity_reference, - accounting_book_reference=accounting_book_reference, - period_code=period_code, - period_status_code=period_status_code, - snapshot_record_id=str(snapshot_id), - snapshot_generated_at=snapshot_generated_at, - source_journal_count=source_journal_count, - source_payload_hash=source_payload_hash, - replayed=replayed, - ) + period_status_code = control_row[0] + if period_status_code != "open": + locked_marker = " (period_closed)" if period_status_code == "hard_closed" else "" + raise AccountingValidationError( + f"Fiscal period {period_code} is {period_status_code}{locked_marker}. " + "Open that period or post into an open period for this accounting book; " + "no journal was written." + ) + return period_id, period_start_date, period_end_date - def _insert_journal( - self, - connection: object, - *, - tenant_id: UUID, - legal_entity_id: UUID, - book_id: UUID, - period_id: UUID, - journal_reference: str, - proposal: JournalProposal | _ReversalProposal | _ClosingProposal | _AdjustingProposal, - policy: AccountingPolicy, - proposal_record_id: UUID, - lines: tuple[PostedJournalLine, ...], + def _require_adjusting_period( + self, connection: object, tenant_id: UUID, accounting_date: date ) -> UUID: - connection.execute( - "SELECT set_config('accounting_core.journal_write_role', %s, true)", - (_journal_write_role(proposal),), - ) - journal_id = connection.execute( - """ - INSERT INTO accounting_core.general_journal ( - tenant_account_id, legal_entity_id, accounting_book_id, fiscal_period_id, - journal_reference, journal_status_code, transaction_currency_code, - functional_currency_code, transaction_date, accounting_date, - source_proposal_record_id, accounting_policy_version, posting_rule_version - ) - VALUES (%s, %s, %s, %s, %s, 'posted', %s, %s, %s, %s, %s, %s, %s) - RETURNING general_journal_id - """, - ( - tenant_id, - legal_entity_id, - book_id, - period_id, - journal_reference, - proposal.transaction_currency, - policy.functional_currency, - proposal.transaction_date, - proposal.accounting_date, - proposal_record_id, - policy.accounting_policy_version, - policy.posting_rule_version, - ), - ).fetchone()[0] - for line in lines: - chart_account_id = connection.execute( - """ - SELECT chart_account_id - FROM accounting_core.chart_account - WHERE tenant_account_id = %s - AND accounting_book_id = %s - AND chart_account_code = %s - AND valid_to IS NULL - """, - (tenant_id, book_id, line.chart_account_code), - ).fetchone() - if chart_account_id is None: - raise AccountingValidationError( - f"Chart account {line.chart_account_code} is not recorded on this book. " - "Create the chart_account row, then retry posting." - ) - connection.execute( - """ - INSERT INTO accounting_core.journal_entry_line ( - tenant_account_id, general_journal_id, line_number, chart_account_id, - account_role_code, debit_amount, credit_amount - ) - VALUES (%s, %s, %s, %s, %s, %s, %s) - """, - ( - tenant_id, - journal_id, - line.line_number, - chart_account_id[0], - line.account_role_code, - line.debit_amount, - line.credit_amount, - ), - ) - for reference in proposal.source_event_references: - connection.execute( - """ - INSERT INTO accounting_core.journal_source_reference ( - tenant_account_id, general_journal_id, source_reference, source_payload_hash - ) - VALUES (%s, %s, %s, %s) - """, - (tenant_id, journal_id, reference, proposal.source_payload_hash), - ) - return journal_id + return self._require_adjusting_period_bounds(connection, tenant_id, accounting_date)[0] - def _insert_receipt( - self, - connection: object, - tenant_id: UUID, - proposal_record_id: UUID, - journal_id: UUID, - receipt: PostingReceipt, - ) -> None: - connection.execute( - """ - INSERT INTO accounting_integration.posting_receipt ( - tenant_account_id, proposal_record_id, general_journal_id, - receipt_status_code, receipt_payload_hash - ) - VALUES (%s, %s, %s, %s, %s) - """, - ( - tenant_id, - proposal_record_id, - journal_id, - receipt.posting_status_code, - _canonical_receipt_hash(receipt), - ), + def _require_adjusting_period_bounds( + self, connection: object, tenant_id: UUID, accounting_date: date + ) -> tuple[UUID, date, date]: + return self._require_period_bounds( + connection, + tenant_id, + accounting_date, + allowed_status_codes=frozenset({"open", "soft_closed"}), + next_action="Reverse into an open or soft-closed period", ) - def _insert_outbox( + def _require_period_bounds( self, connection: object, tenant_id: UUID, - event_type_code: str, - aggregate_reference: str, - payload_reference: str, - receipt: PostingReceipt, - ) -> None: - connection.execute( - """ - INSERT INTO accounting_integration.outbox_event ( - tenant_account_id, event_type_code, aggregate_reference, - payload_reference, payload_hash - ) - VALUES (%s, %s, %s, %s, %s) - """, - ( - tenant_id, - event_type_code, - aggregate_reference, - payload_reference, - _canonical_receipt_hash(receipt), - ), - ) - - def _receipt_for_idempotency_key( - self, connection: object, tenant_id: UUID, proposal: JournalProposal - ) -> PostingReceipt: - return PostingReceipt( - receipt_reference=f"urn:cwl:accounting:posting_receipt:{proposal.proposal_id}", - journal_reference=f"urn:cwl:accounting:general_journal:{proposal.proposal_id}", - posting_status_code="posted", - source_proposal_id=proposal.proposal_id, - source_payload_hash=proposal.source_payload_hash, - tenant_reference=proposal.tenant_reference, - legal_entity_reference=proposal.legal_entity_reference, - accounting_book_reference=self._book_name_for_proposal( - connection, tenant_id, proposal.idempotency_key - ), - accounting_policy_version=self._policy_version_for_proposal( - connection, tenant_id, proposal.idempotency_key - )[0], - posting_rule_version=self._policy_version_for_proposal( - connection, tenant_id, proposal.idempotency_key - )[1], - line_count=self._line_count_for_proposal( - connection, tenant_id, proposal.idempotency_key - ), - ) - - def _receipt_for_journal( - self, connection: object, tenant_id: UUID, journal_reference: str - ) -> PostingReceipt: + accounting_date: date, + *, + allowed_status_codes: frozenset[str], + next_action: str, + ) -> tuple[UUID, date, date]: row = connection.execute( """ - SELECT general_journal.journal_reference, - journal_proposal_record.source_payload_hash, - journal_proposal_record.external_proposal_id, - general_journal.accounting_policy_version, - general_journal.posting_rule_version, - accounting_book.book_name, - legal_entity_record.legal_entity_code, - ( - SELECT COUNT(*) - FROM accounting_core.journal_entry_line - WHERE tenant_account_id = general_journal.tenant_account_id - AND general_journal_id = general_journal.general_journal_id - ), - original_journal.journal_reference - FROM accounting_core.general_journal - JOIN accounting_integration.journal_proposal_record - ON journal_proposal_record.tenant_account_id = general_journal.tenant_account_id - AND journal_proposal_record.proposal_record_id = general_journal.source_proposal_record_id - JOIN accounting_core.accounting_book - ON accounting_book.tenant_account_id = general_journal.tenant_account_id - AND accounting_book.accounting_book_id = general_journal.accounting_book_id - JOIN accounting_core.legal_entity_record - ON legal_entity_record.tenant_account_id = general_journal.tenant_account_id - AND legal_entity_record.legal_entity_id = general_journal.legal_entity_id - LEFT JOIN accounting_core.journal_reversal - ON journal_reversal.tenant_account_id = general_journal.tenant_account_id - AND journal_reversal.reversal_journal_id = general_journal.general_journal_id - LEFT JOIN accounting_core.general_journal AS original_journal - ON original_journal.tenant_account_id = journal_reversal.tenant_account_id - AND original_journal.general_journal_id = journal_reversal.original_journal_id - WHERE general_journal.tenant_account_id = %s - AND general_journal.journal_reference = %s + SELECT fiscal_period_id, period_code, period_status_code, + period_start_date, period_end_date + FROM accounting_core.fiscal_period + WHERE tenant_account_id = %s + AND period_start_date <= %s + AND period_end_date >= %s """, - (tenant_id, journal_reference), + (tenant_id, accounting_date, accounting_date), ).fetchone() - source_proposal_id = journal_reference.removeprefix( - "urn:cwl:accounting:general_journal:" - ).removesuffix(":reversal") - return PostingReceipt( - receipt_reference=f"{journal_reference}:receipt", - journal_reference=row[0], - posting_status_code="posted", - source_proposal_id=source_proposal_id, - source_payload_hash=row[1], - tenant_reference=self._tenant_reference, - legal_entity_reference=row[6], - accounting_book_reference=row[5], - accounting_policy_version=row[3], - posting_rule_version=row[4], - line_count=int(row[7]), - reversal_of_journal_reference=row[8], - ) - - def _book_name_for_proposal( - self, connection: object, tenant_id: UUID, idempotency_key: str - ) -> str: - return connection.execute( - """ - SELECT accounting_book.book_name - FROM accounting_integration.journal_proposal_record - JOIN accounting_core.general_journal - ON general_journal.tenant_account_id = journal_proposal_record.tenant_account_id - AND general_journal.source_proposal_record_id = journal_proposal_record.proposal_record_id - JOIN accounting_core.accounting_book - ON accounting_book.tenant_account_id = general_journal.tenant_account_id - AND accounting_book.accounting_book_id = general_journal.accounting_book_id - WHERE journal_proposal_record.tenant_account_id = %s - AND journal_proposal_record.idempotency_key = %s - """, - (tenant_id, idempotency_key), - ).fetchone()[0] - - def _policy_version_for_proposal( - self, connection: object, tenant_id: UUID, idempotency_key: str - ) -> tuple[str, str]: - return connection.execute( + if row is None: + raise AccountingValidationError( + f"No fiscal period covers accounting date {accounting_date.isoformat()}. " + "Create an open fiscal period on the tenant calendar, then retry posting." + ) + period_id, period_code = row[0], row[1] + self._acquire_command_lock(connection, f"period:{period_code}") + row = connection.execute( """ - SELECT general_journal.accounting_policy_version, - general_journal.posting_rule_version - FROM accounting_integration.journal_proposal_record - JOIN accounting_core.general_journal - ON general_journal.tenant_account_id = journal_proposal_record.tenant_account_id - AND general_journal.source_proposal_record_id = journal_proposal_record.proposal_record_id - WHERE journal_proposal_record.tenant_account_id = %s - AND journal_proposal_record.idempotency_key = %s + SELECT fiscal_period_id, period_code, period_status_code, + period_start_date, period_end_date + FROM accounting_core.fiscal_period + WHERE tenant_account_id = %s + AND fiscal_period_id = %s """, - (tenant_id, idempotency_key), + (tenant_id, period_id), ).fetchone() + if row is None: + raise AccountingValidationError( + f"No fiscal period covers accounting date {accounting_date.isoformat()}. " + "Create an open fiscal period on the tenant calendar, then retry posting." + ) + if row[2] not in allowed_status_codes: + locked_marker = " (period_closed)" if row[2] == "hard_closed" else "" + raise AccountingValidationError( + f"Fiscal period {row[1]} is {row[2]}{locked_marker}. {next_action}; " + "no journal was written." + ) + return row[0], row[3], row[4] - def _line_count_for_proposal( - self, connection: object, tenant_id: UUID, idempotency_key: str - ) -> int: - return int( - connection.execute( - """ - SELECT COUNT(*) - FROM accounting_integration.journal_proposal_record - JOIN accounting_core.general_journal - ON general_journal.tenant_account_id = journal_proposal_record.tenant_account_id - AND general_journal.source_proposal_record_id = journal_proposal_record.proposal_record_id - JOIN accounting_core.journal_entry_line - ON journal_entry_line.tenant_account_id = general_journal.tenant_account_id - AND journal_entry_line.general_journal_id = general_journal.general_journal_id - WHERE journal_proposal_record.tenant_account_id = %s - AND journal_proposal_record.idempotency_key = %s - """, - (tenant_id, idempotency_key), - ).fetchone()[0] - ) - - def _load_journal_row( + def _require_fiscal_period( self, connection: object, tenant_id: UUID, - *, - idempotency_key: str = "", - journal_reference: str = "", - ) -> tuple[object, ...] | None: - return connection.execute( - """ - SELECT general_journal.general_journal_id, - general_journal.journal_reference, - general_journal.journal_status_code, - general_journal.accounting_date, - general_journal.transaction_currency_code, - general_journal.functional_currency_code, - general_journal.accounting_policy_version, - general_journal.posting_rule_version, - legal_entity_record.legal_entity_code, - accounting_book.book_name, - journal_proposal_record.idempotency_key, - journal_proposal_record.source_payload_hash, - journal_proposal_record.external_proposal_id, - original_journal.journal_reference, - journal_reversal.reversal_reason_code - FROM accounting_core.general_journal - JOIN accounting_integration.journal_proposal_record - ON journal_proposal_record.tenant_account_id = general_journal.tenant_account_id - AND journal_proposal_record.proposal_record_id = general_journal.source_proposal_record_id - JOIN accounting_core.accounting_book - ON accounting_book.tenant_account_id = general_journal.tenant_account_id - AND accounting_book.accounting_book_id = general_journal.accounting_book_id - JOIN accounting_core.legal_entity_record - ON legal_entity_record.tenant_account_id = general_journal.tenant_account_id - AND legal_entity_record.legal_entity_id = general_journal.legal_entity_id - LEFT JOIN accounting_core.journal_reversal - ON journal_reversal.tenant_account_id = general_journal.tenant_account_id - AND journal_reversal.reversal_journal_id = general_journal.general_journal_id - LEFT JOIN accounting_core.general_journal AS original_journal - ON original_journal.tenant_account_id = journal_reversal.tenant_account_id - AND original_journal.general_journal_id = journal_reversal.original_journal_id - WHERE general_journal.tenant_account_id = %s - AND (%s OR journal_proposal_record.idempotency_key = %s) - AND (%s OR general_journal.journal_reference = %s) - """, - ( - tenant_id, - not idempotency_key, - idempotency_key, - not journal_reference, - journal_reference, - ), - ).fetchone() - - def _load_published_receipt( - self, connection: object, tenant_id: UUID, idempotency_key: str - ) -> dict[str, object]: + period_code: str, + next_action: str = "the close", + ) -> tuple[UUID, str, date]: row = connection.execute( """ - SELECT posting_receipt.posting_receipt_id, - posting_receipt.created_at, - posting_receipt.receipt_status_code, - general_journal.journal_reference, - general_journal.transaction_currency_code, - general_journal.functional_currency_code, - general_journal.accounting_policy_version, - general_journal.posting_rule_version, - accounting_book.book_name, - legal_entity_record.legal_entity_code, - fiscal_period.period_code, - ( - SELECT COUNT(*) - FROM accounting_core.journal_entry_line - WHERE tenant_account_id = general_journal.tenant_account_id - AND general_journal_id = general_journal.general_journal_id - ), - journal_proposal_record.idempotency_key, - journal_proposal_record.external_proposal_id, - journal_proposal_record.source_payload_hash - FROM accounting_integration.posting_receipt - JOIN accounting_integration.journal_proposal_record - ON journal_proposal_record.tenant_account_id = posting_receipt.tenant_account_id - AND journal_proposal_record.proposal_record_id = posting_receipt.proposal_record_id - JOIN accounting_core.general_journal - ON general_journal.tenant_account_id = posting_receipt.tenant_account_id - AND general_journal.general_journal_id = posting_receipt.general_journal_id - JOIN accounting_core.accounting_book - ON accounting_book.tenant_account_id = general_journal.tenant_account_id - AND accounting_book.accounting_book_id = general_journal.accounting_book_id - JOIN accounting_core.legal_entity_record - ON legal_entity_record.tenant_account_id = general_journal.tenant_account_id - AND legal_entity_record.legal_entity_id = general_journal.legal_entity_id - JOIN accounting_core.fiscal_period - ON fiscal_period.tenant_account_id = general_journal.tenant_account_id - AND fiscal_period.fiscal_period_id = general_journal.fiscal_period_id - WHERE posting_receipt.tenant_account_id = %s - AND journal_proposal_record.idempotency_key = %s + SELECT fiscal_period_id, period_status_code, period_end_date + FROM accounting_core.fiscal_period + WHERE tenant_account_id = %s AND period_code = %s """, - (tenant_id, idempotency_key), + (tenant_id, period_code), ).fetchone() if row is None: raise AccountingValidationError( - "posting receipt is missing for this idempotency key. " - "Accept the proposal, then retry the receipt read." + f"Fiscal period {period_code} is not recorded for this tenant. " + f"Create the fiscal_period row, then retry {next_action}." ) - recorded_at = _format_timestamp(row[1]) - return { - "receipt_id": str(row[0]), - "receipt_contract_version": 1, - "idempotency_key": row[12], - "source_proposal_id": str(row[13]), - "source_payload_hash": row[14], - "tenant_reference": self._tenant_reference, - "legal_entity_reference": row[9], - "accounting_book_reference": row[8], - "fiscal_period_reference": f"urn:cwl:accounting:fiscal_period:{row[10]}", - "journal_reference": row[3], - "accounting_policy_version": row[6], - "posting_rule_version": row[7], - "posting_status_code": row[2], - "recorded_at": recorded_at, - "posted_at": recorded_at, - "line_count": int(row[11]), - "transaction_currency": row[4], - "functional_currency": row[5], - } + return row[0], row[1], row[2] - def _load_lines( - self, connection: object, tenant_id: UUID, journal_id: UUID - ) -> tuple[PostedJournalLine, ...]: - rows = connection.execute( + def _lock_book_period( + self, + connection: object, + tenant_id: UUID, + book_id: UUID, + period_code: str, + ) -> tuple[UUID, str, date]: + """Lock authoritative close state for one accounting book.""" + period_row = connection.execute( """ - SELECT journal_entry_line.line_number, - chart_account.chart_account_code, - journal_entry_line.account_role_code, - journal_entry_line.debit_amount, - journal_entry_line.credit_amount - FROM accounting_core.journal_entry_line - JOIN accounting_core.chart_account - ON chart_account.tenant_account_id = journal_entry_line.tenant_account_id - AND chart_account.chart_account_id = journal_entry_line.chart_account_id - WHERE journal_entry_line.tenant_account_id = %s - AND journal_entry_line.general_journal_id = %s - ORDER BY journal_entry_line.line_number + SELECT fiscal_period_id + FROM accounting_core.fiscal_period + WHERE tenant_account_id = %s AND period_code = %s """, - (tenant_id, journal_id), - ).fetchall() - return tuple( - PostedJournalLine( - line_number=row[0], - chart_account_code=row[1], - account_role_code=row[2], - debit_amount=Decimal(row[3]), - credit_amount=Decimal(row[4]), + (tenant_id, period_code), + ).fetchone() + if period_row is None: + raise AccountingValidationError( + f"Fiscal period {period_code} is not recorded for this tenant. " + "Create the fiscal_period row, then retry the close." ) - for row in rows - ) - - def _proposal_identity( - self, connection: object, tenant_id: UUID, proposal_record_id: UUID - ) -> tuple[str, str]: + period_id = period_row[0] row = connection.execute( """ - SELECT source_payload_hash, external_proposal_id - FROM accounting_integration.journal_proposal_record - WHERE tenant_account_id = %s AND proposal_record_id = %s + SELECT fiscal_period.fiscal_period_id, + accounting_book_period_control.period_status_code, + fiscal_period.period_end_date + FROM accounting_core.fiscal_period + JOIN accounting_core.accounting_book_period_control + ON accounting_book_period_control.tenant_account_id + = fiscal_period.tenant_account_id + AND accounting_book_period_control.fiscal_period_id + = fiscal_period.fiscal_period_id + AND accounting_book_period_control.accounting_book_id = %s + WHERE fiscal_period.tenant_account_id = %s + AND fiscal_period.fiscal_period_id = %s + FOR UPDATE OF accounting_book_period_control """, - (tenant_id, proposal_record_id), + (book_id, tenant_id, period_id), ).fetchone() - return row[0], str(row[1]) + if row is None: + raise AccountingValidationError( + f"Fiscal period {period_code} has no control row for this accounting book. " + "Repair the fiscal-period control data for this book, then retry the close." + ) + return row[0], row[1], row[2] - def _legal_entity_code( - self, connection: object, tenant_id: UUID, legal_entity_id: UUID - ) -> str: - return connection.execute( + def _load_book_period_state( + self, + connection: object, + tenant_id: UUID, + book_id: UUID, + period_code: str, + ) -> tuple[UUID, str, date, date] | None: + """Return the selected book's authoritative period state.""" + row = connection.execute( """ - SELECT legal_entity_code - FROM accounting_core.legal_entity_record - WHERE tenant_account_id = %s AND legal_entity_id = %s + SELECT fiscal_period.fiscal_period_id, + accounting_book_period_control.period_status_code, + fiscal_period.period_start_date, + fiscal_period.period_end_date + FROM accounting_core.fiscal_period + JOIN accounting_core.accounting_book_period_control + ON accounting_book_period_control.tenant_account_id + = fiscal_period.tenant_account_id + AND accounting_book_period_control.fiscal_period_id + = fiscal_period.fiscal_period_id + AND accounting_book_period_control.accounting_book_id = %s + WHERE fiscal_period.tenant_account_id = %s + AND fiscal_period.period_code = %s """, - (tenant_id, legal_entity_id), - ).fetchone()[0] + (book_id, tenant_id, period_code), + ).fetchone() + if row is None: + return None + return row[0], row[1], row[2], row[3] - def _book_name(self, connection: object, tenant_id: UUID, book_id: UUID) -> str: - return connection.execute( + def _load_period_state( + self, connection: object, tenant_id: UUID, period_code: str + ) -> tuple[UUID, str, date, date] | None: + row = connection.execute( """ - SELECT book_name - FROM accounting_core.accounting_book - WHERE tenant_account_id = %s AND accounting_book_id = %s + SELECT fiscal_period_id, period_status_code, period_start_date, period_end_date + FROM accounting_core.fiscal_period + WHERE tenant_account_id = %s AND period_code = %s """, - (tenant_id, book_id), - ).fetchone()[0] - - -class _ClosingProposal: - """Minimal proposal shape used when persisting an AIS period-closing journal.""" - - def __init__( - self, - *, - source_payload_hash: str, - transaction_currency: str, - transaction_date: date, - accounting_date: date, - source_event_references: tuple[str, ...], - ) -> None: - self.source_payload_hash = source_payload_hash - self.transaction_currency = transaction_currency - self.transaction_date = transaction_date - self.accounting_date = accounting_date - self.source_event_references = source_event_references - - -class _AdjustingProposal: - """Minimal proposal shape used when persisting an AIS-owned adjusting journal.""" - - def __init__( - self, - *, - source_payload_hash: str, - transaction_currency: str, - transaction_date: date, - accounting_date: date, - source_event_references: tuple[str, ...], - ) -> None: - self.source_payload_hash = source_payload_hash - self.transaction_currency = transaction_currency - self.transaction_date = transaction_date - self.accounting_date = accounting_date - self.source_event_references = source_event_references - - -class _ReversalProposal: - """Minimal proposal shape used when persisting an equal-and-opposite journal.""" - - def __init__( - self, - *, - source_payload_hash: str, - transaction_currency: str, - transaction_date: date, - accounting_date: date, - source_event_references: tuple[str, ...], - ) -> None: - self.source_payload_hash = source_payload_hash - self.transaction_currency = transaction_currency - self.transaction_date = transaction_date - self.accounting_date = accounting_date - self.source_event_references = source_event_references - - -def _journal_write_role( - proposal: JournalProposal | _ReversalProposal | _ClosingProposal | _AdjustingProposal, -) -> str: - """Return the session-local role AIS sets before a journal INSERT.""" - if isinstance(proposal, _ClosingProposal): - return "period_closing" - if isinstance(proposal, _AdjustingProposal): - return "adjusting" - if isinstance(proposal, _ReversalProposal): - return "reversal" - return "" + (tenant_id, period_code), + ).fetchone() + if row is None: + return None + return row[0], row[1], row[2], row[3] def apply_foundation_migration(database_url: str, migration_path: Path) -> None: """Apply the checked-in PostgreSQL 18 accounting foundation in migration order.""" - if not migration_path.is_file(): - raise AccountingValidationError( - f"Foundation migration is missing at {migration_path}. " - "Restore database/migrations/0001_accounting_foundation.sql, then retry." - ) - class_migration_path = migration_path.parent / "0002_chart_account_class.sql" - if not class_migration_path.is_file(): - raise AccountingValidationError( - f"Chart-account class migration is missing at {class_migration_path}. " - "Restore database/migrations/0002_chart_account_class.sql, then retry." - ) - submission_migration_path = migration_path.parent / "0003_home_tax_submission.sql" - if not submission_migration_path.is_file(): - raise AccountingValidationError( - f"Home-tax submission migration is missing at {submission_migration_path}. " - "Restore database/migrations/0003_home_tax_submission.sql, then retry." - ) - close_key_migration_path = migration_path.parent / "0004_close_idempotency_key.sql" - if not close_key_migration_path.is_file(): - raise AccountingValidationError( - f"Close-idempotency-key migration is missing at {close_key_migration_path}. " - "Restore database/migrations/0004_close_idempotency_key.sql, then retry." - ) - period_guard_migration_path = migration_path.parent / "0005_closed_period_guard.sql" - if not period_guard_migration_path.is_file(): - raise AccountingValidationError( - f"Closed-period guard migration is missing at {period_guard_migration_path}. " - "Restore database/migrations/0005_closed_period_guard.sql, then retry." - ) - concurrency_migration_path = migration_path.parent / "0006_concurrency_hot_partition.sql" - if not concurrency_migration_path.is_file(): - raise AccountingValidationError( - f"Concurrency and hot-partition migration is missing at {concurrency_migration_path}. " - "Restore database/migrations/0006_concurrency_hot_partition.sql, then retry." - ) - runtime_binding_migration_path = migration_path.parent / "0007_runtime_tenant_binding.sql" - if not runtime_binding_migration_path.is_file(): - raise AccountingValidationError( - f"Runtime-tenant binding migration is missing at {runtime_binding_migration_path}. " - "Restore database/migrations/0007_runtime_tenant_binding.sql, then retry." - ) - period_open_command_migration_path = ( - migration_path.parent / "0008_fiscal_period_open_command.sql" - ) - if not period_open_command_migration_path.is_file(): - raise AccountingValidationError( - f"Fiscal-period-open command migration is missing at {period_open_command_migration_path}. " - "Restore database/migrations/0008_fiscal_period_open_command.sql, then retry." - ) - book_period_control_migration_path = ( - migration_path.parent / "0009_accounting_book_period_control.sql" - ) - if not book_period_control_migration_path.is_file(): - raise AccountingValidationError( - f"Accounting-book-period control migration is missing at {book_period_control_migration_path}. " - "Restore database/migrations/0009_accounting_book_period_control.sql, then retry." - ) - soft_close_evidence_migration_path = ( - migration_path.parent / "0010_soft_close_command_evidence.sql" - ) - if not soft_close_evidence_migration_path.is_file(): - raise AccountingValidationError( - f"Soft-close command-evidence migration is missing at {soft_close_evidence_migration_path}. " - "Restore database/migrations/0010_soft_close_command_evidence.sql, then retry." - ) - bank_statement_migration_path = ( - migration_path.parent / "0011_bank_statement_evidence.sql" - ) - if not bank_statement_migration_path.is_file(): - raise AccountingValidationError( - f"Bank-statement evidence migration is missing at {bank_statement_migration_path}. " - "Restore database/migrations/0011_bank_statement_evidence.sql, then retry." - ) - assignment_identity_migration_path = ( - migration_path.parent / "0012_bank_assignment_command_identity.sql" - ) - if not assignment_identity_migration_path.is_file(): - raise AccountingValidationError( - "Bank-account assignment command-identity migration is missing at " - f"{assignment_identity_migration_path}. Restore " - "database/migrations/0012_bank_assignment_command_identity.sql, then retry." - ) - reconciliation_control_migration_path = ( - migration_path.parent / "0013_reconciliation_run_exception_evidence.sql" - ) - if not reconciliation_control_migration_path.is_file(): - raise AccountingValidationError( - "Reconciliation run/exception evidence migration is missing at " - f"{reconciliation_control_migration_path}. Restore " - "database/migrations/0013_reconciliation_run_exception_evidence.sql, then retry." - ) - allocation_control_migration_path = ( - migration_path.parent / "0014_reconciliation_candidate_allocation.sql" - ) - if not allocation_control_migration_path.is_file(): - raise AccountingValidationError( - "Reconciliation candidate/allocation migration is missing at " - f"{allocation_control_migration_path}. Restore " - "database/migrations/0014_reconciliation_candidate_allocation.sql, then retry." - ) - conservation_migration_path = ( - migration_path.parent / "0015_reconciliation_multi_match_conservation.sql" - ) - if not conservation_migration_path.is_file(): - raise AccountingValidationError( - "Reconciliation multi-match conservation migration is missing at " - f"{conservation_migration_path}. Restore " - "database/migrations/0015_reconciliation_multi_match_conservation.sql, then retry." - ) - approval_migration_path = ( - migration_path.parent / "0016_reconciliation_approval_evidence.sql" - ) - if not approval_migration_path.is_file(): - raise AccountingValidationError( - "Reconciliation approval-evidence migration is missing at " - f"{approval_migration_path}. Restore " - "database/migrations/0016_reconciliation_approval_evidence.sql, then retry." - ) - approval_lock_order_migration_path = ( - migration_path.parent / "0017_reconciliation_approval_lock_order.sql" - ) - if not approval_lock_order_migration_path.is_file(): - raise AccountingValidationError( - "Reconciliation approval lock-order migration is missing at " - f"{approval_lock_order_migration_path}. Restore " - "database/migrations/0017_reconciliation_approval_lock_order.sql, then retry." - ) - balance_evidence_migration_path = ( - migration_path.parent / "0018_bank_statement_balance_evidence.sql" - ) - if not balance_evidence_migration_path.is_file(): - raise AccountingValidationError( - "Bank-statement balance-evidence migration is missing at " - f"{balance_evidence_migration_path}. Restore " - "database/migrations/0018_bank_statement_balance_evidence.sql, then retry." - ) - run_command_migration_path = ( - migration_path.parent / "0019_reconciliation_run_command_evidence.sql" - ) - if not run_command_migration_path.is_file(): - raise AccountingValidationError( - "Reconciliation run-command evidence migration is missing at " - f"{run_command_migration_path}. Restore " - "database/migrations/0019_reconciliation_run_command_evidence.sql, then retry." - ) - psycopg = _import_psycopg() - try: - with psycopg.connect( - database_url, autocommit=True, cursor_factory=psycopg.ClientCursor - ) as connection: - connection.execute(migration_path.read_text(encoding="utf-8")) - connection.execute(class_migration_path.read_text(encoding="utf-8")) - connection.execute(submission_migration_path.read_text(encoding="utf-8")) - connection.execute(close_key_migration_path.read_text(encoding="utf-8")) - connection.execute(period_guard_migration_path.read_text(encoding="utf-8")) - connection.execute(concurrency_migration_path.read_text(encoding="utf-8")) - connection.execute(runtime_binding_migration_path.read_text(encoding="utf-8")) - connection.execute(period_open_command_migration_path.read_text(encoding="utf-8")) - connection.execute(book_period_control_migration_path.read_text(encoding="utf-8")) - connection.execute(soft_close_evidence_migration_path.read_text(encoding="utf-8")) - connection.execute(bank_statement_migration_path.read_text(encoding="utf-8")) - connection.execute( - assignment_identity_migration_path.read_text(encoding="utf-8") - ) - connection.execute( - reconciliation_control_migration_path.read_text(encoding="utf-8") - ) - connection.execute( - allocation_control_migration_path.read_text(encoding="utf-8") - ) - connection.execute( - conservation_migration_path.read_text(encoding="utf-8") - ) - connection.execute(approval_migration_path.read_text(encoding="utf-8")) - connection.execute( - approval_lock_order_migration_path.read_text(encoding="utf-8") - ) - connection.execute( - balance_evidence_migration_path.read_text(encoding="utf-8") - ) - connection.execute( - run_command_migration_path.read_text(encoding="utf-8") - ) - except Exception as error: - raise AccountingValidationError( - "Foundation migration failed. Inspect the PostgreSQL error, restore a clean " - "database, then retry the migration." - ) from error + from .migration_install import apply_foundation_migration as _install + + _install(database_url, migration_path) def _import_psycopg(): @@ -6399,157 +1732,12 @@ def _require_proposal_uuid(proposal_id: str) -> UUID: return uuid.UUID(_require_proposal_id(proposal_id)) -def _canonical_snapshot_hash( - *, - tenant_reference: str, - legal_entity_reference: str, - accounting_book_reference: str, - period_code: str, - snapshot_currency_code: str, - source_journal_count: int, - lines: tuple[tuple[UUID, str, Decimal, Decimal], ...], -) -> str: - payload = json.dumps( - { - "accounting_book_reference": accounting_book_reference, - "legal_entity_reference": legal_entity_reference, - "lines": [ - { - "chart_account_code": account_code, - "credit_total_amount": format(credit_total, "f"), - "debit_total_amount": format(debit_total, "f"), - "net_balance_amount": format(debit_total - credit_total, "f"), - } - for _account_id, account_code, debit_total, credit_total in lines - ], - "period_code": period_code, - "snapshot_currency_code": snapshot_currency_code, - "source_journal_count": source_journal_count, - "tenant_reference": tenant_reference, - }, - separators=(",", ":"), - sort_keys=True, - ) - return "sha256:" + hashlib.sha256(payload.encode("utf-8")).hexdigest() - - -def _canonical_closing_hash( - *, - tenant_reference: str, - legal_entity_reference: str, - accounting_book_reference: str, - period_code: str, - lines: tuple[PostedJournalLine, ...], -) -> str: - payload = json.dumps( - { - "accounting_book_reference": accounting_book_reference, - "legal_entity_reference": legal_entity_reference, - "lines": [ - { - "account_role_code": line.account_role_code, - "chart_account_code": line.chart_account_code, - "credit_amount": format(line.credit_amount, "f"), - "debit_amount": format(line.debit_amount, "f"), - "line_number": line.line_number, - } - for line in lines - ], - "period_code": period_code, - "tenant_reference": tenant_reference, - }, - separators=(",", ":"), - sort_keys=True, - ) - return "sha256:" + hashlib.sha256(payload.encode("utf-8")).hexdigest() - - -def _canonical_receipt_hash(receipt: PostingReceipt) -> str: - payload = json.dumps( - { - "journal_reference": receipt.journal_reference, - "line_count": receipt.line_count, - "posting_status_code": receipt.posting_status_code, - "receipt_reference": receipt.receipt_reference, - "reversal_of_journal_reference": receipt.reversal_of_journal_reference, - "source_payload_hash": receipt.source_payload_hash, - "source_proposal_id": receipt.source_proposal_id, - }, - separators=(",", ":"), - sort_keys=True, - ) - return "sha256:" + hashlib.sha256(payload.encode("utf-8")).hexdigest() - - -def _fiscal_year_identity(period_code: str, period_start_date: date | None) -> str: - matched = re.match(r"^(\d{4})", period_code) - if matched: - return matched.group(1) - if period_start_date is not None: - return f"{period_start_date.year:04d}" - raise AccountingValidationError( - "fiscal year identity is missing for this period. " - "Use a period_code that starts with the four-digit year, then retry the financial-statement read." - ) - - def _format_timestamp(value: datetime) -> str: return value.astimezone(timezone.utc).isoformat().replace("+00:00", "Z") -def _vat_period_movement_kind( - idempotency_key: str, - debit_roles: set[str], - credit_roles: set[str], -) -> str | None: - if ":issued_invoice_void:" in idempotency_key or ( - "tax_payable" in debit_roles - and "usage_revenue" in debit_roles - and "accounts_receivable" in credit_roles - ): - return "voided" - if ":invoice_draft:" in idempotency_key or ( - "tax_payable" in credit_roles - and "usage_revenue" in credit_roles - and "accounts_receivable" in debit_roles - ): - return "issued" - return None - - -def _unapplied_cash_movement_kind( - idempotency_key: str, - debit_roles: set[str], - credit_roles: set[str], -) -> str | None: - if ":unapplied_cash_application:" in idempotency_key or ( - "unapplied_cash" in debit_roles and "accounts_receivable" in credit_roles - ): - return "applied" - if ":unapplied_cash_refund:" in idempotency_key or ( - "unapplied_cash" in debit_roles and "cash_receipt" in credit_roles - ): - return "refunded" - if ":unapplied_cash:" in idempotency_key or ( - "unapplied_cash" in credit_roles and "cash_receipt" in debit_roles - ): - return "parked" - return None - - -def _exact_amount_text(value: Decimal) -> str: - return format(value, "f") - - -def _unsigned_aging_amount_text(value: Decimal) -> str: - amount_text = format(value, "f") - if "." not in amount_text: - return amount_text - return amount_text.rstrip("0").rstrip(".") - - -_VAT_REGISTER_REQUIRED_KEYS = frozenset( - { +def _vat_register_is_loadable(register_document: dict[str, object]) -> bool: + return { "tenant_reference", "legal_entity_reference", "accounting_book_reference", @@ -6561,76 +1749,16 @@ def _unsigned_aging_amount_text(value: Decimal) -> str: "issued_amount", "voided_amount", "closing_amount", - } -) - - -def _vat_register_is_loadable(register_document: dict[str, object]) -> bool: - return _VAT_REGISTER_REQUIRED_KEYS.issubset(register_document.keys()) - - -def _home_tax_register_view(register_document: dict[str, object]) -> dict[str, object]: - if _vat_register_is_loadable(register_document): - return dict(register_document) - return { - "as_of_date": str(register_document.get("as_of_date") or ""), - "closing_amount": str(register_document.get("closing_amount") or "0"), - } - - -def _home_tax_submission_document( - *, - home_tax_submission_id: str, - tenant_reference: str, - legal_entity_reference: str, - book_reference: str, - period_code: str, - vat_period_register: dict[str, object], - rejection_reason_code: str, - submission_status_code: str = "rejected", -) -> dict[str, object]: - return { - "home_tax_submission_id": home_tax_submission_id, - "tenant_reference": tenant_reference, - "legal_entity_reference": legal_entity_reference, - "book_reference": book_reference, - "fiscal_period_reference": f"urn:cwl:accounting:fiscal_period:{period_code}", - "vat_period_register": vat_period_register, - "submission_status_code": submission_status_code, - "rejection_reason_code": rejection_reason_code, - } - - -def _fifo_aging_open_items( - line_rows: list[tuple[object, ...]], - *, - increase_is_debit: bool, -) -> list[list[object]]: - open_items: list[list[object]] = [] - for accounting_date, _journal_reference, _line_number, debit_amount, credit_amount in line_rows: - increase_amount = Decimal(str(debit_amount)) if increase_is_debit else Decimal( - str(credit_amount) - ) - decrease_amount = Decimal(str(credit_amount)) if increase_is_debit else Decimal( - str(debit_amount) - ) - if increase_amount > 0: - open_items.append([accounting_date, increase_amount]) - continue - remaining_decrease = decrease_amount - for open_item in open_items: - applied_amount = min(open_item[1], remaining_decrease) - open_item[1] = open_item[1] - applied_amount - remaining_decrease = remaining_decrease - applied_amount - open_items = [open_item for open_item in open_items if open_item[1] > 0] - return open_items + }.issubset(register_document.keys()) -def _receivable_aging_bucket(outstanding_days: int) -> str: - if outstanding_days <= 30: - return "current" - if outstanding_days <= 60: - return "days_31_60" - if outstanding_days <= 90: - return "days_61_90" - return "days_over_90" +def _fiscal_year_identity(period_code: str, period_start_date: date | None) -> str: + matched = re.match(r"^(\d{4})", period_code) + if matched: + return matched.group(1) + if period_start_date is not None: + return f"{period_start_date.year:04d}" + raise AccountingValidationError( + "fiscal year identity is missing for this period. " + "Use a period_code that starts with the four-digit year, then retry the financial-statement read." + ) From a8c5abe7520cb0a50708127726bcf0dfb420dc60 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 19:40:00 +0900 Subject: [PATCH 149/224] fix(close): restore complete persistence adapter after partial update --- .../persistence.py | 5272 ++++++++++++++++- 1 file changed, 5072 insertions(+), 200 deletions(-) diff --git a/src/accounting_information_platform/persistence.py b/src/accounting_information_platform/persistence.py index b0cb11a1..1d27c239 100644 --- a/src/accounting_information_platform/persistence.py +++ b/src/accounting_information_platform/persistence.py @@ -1457,264 +1457,4931 @@ def load_fiscal_periods( "next_cursor": next_cursor, } - # The remaining reporting, statement, reconciliation, and persistence methods - # are unchanged from the immediately preceding exact head. This update only - # removes application-side book-period authority synthesis/fallback in the - # three authority helpers below. + def load_account_rollforward( + self, + legal_entity_reference: str, + accounting_book_reference: str, + period_code: str, + chart_account_code: str, + statement_scope_code: str = "", + ) -> dict[str, object]: + """Return opening + period = closing sides for one chart account and scope.""" + if statement_scope_code not in {"", "period", "year_to_date"}: + raise AccountingValidationError( + "statement_scope_code must be period or year_to_date. " + "Supply a known statement scope, then retry the account-rollforward read." + ) + if not chart_account_code: + raise AccountingValidationError( + "chart_account_code is required. " + "Supply that account-rollforward field, then retry the account-rollforward read." + ) + account_classes = self._load_chart_account_classes( + legal_entity_reference, accounting_book_reference + ) + if chart_account_code not in account_classes: + raise AccountingValidationError( + f"Chart account {chart_account_code} is not recorded for this book. " + "Create the chart_account row, then retry the account-rollforward read." + ) + with self._session() as connection: + tenant_id = self._require_tenant(connection) + legal_entity_id = self._require_legal_entity( + connection, + tenant_id, + legal_entity_reference, + next_action="the account-rollforward read", + ) + book_id = self._require_book_for_close( + connection, + tenant_id, + legal_entity_id, + accounting_book_reference, + next_action="the account-rollforward read", + )[0] + self._require_fiscal_period( + connection, + tenant_id, + period_code, + next_action="the account-rollforward read", + ) + period_ids = self._statement_period_ids( + connection, + tenant_id, + period_code, + statement_scope_code, + ) + scope_start = connection.execute( + """ + SELECT MIN(period_start_date) + FROM accounting_core.fiscal_period + WHERE tenant_account_id = %s + AND fiscal_period_id = ANY(%s) + """, + (tenant_id, period_ids), + ).fetchone()[0] + opening_debit_amount, opening_credit_amount = self._opening_account_sides( + connection, + tenant_id, + legal_entity_id, + book_id, + chart_account_code, + scope_start, + ) + period_debit_amount, period_credit_amount = self._period_account_sides( + connection, + tenant_id, + legal_entity_id, + book_id, + chart_account_code, + period_ids, + ) + closing_debit_amount = opening_debit_amount + period_debit_amount + closing_credit_amount = opening_credit_amount + period_credit_amount + document = { + "tenant_reference": self._tenant_reference, + "legal_entity_reference": legal_entity_reference, + "accounting_book_reference": accounting_book_reference, + "book_reference": accounting_book_reference, + "fiscal_period_reference": f"urn:cwl:accounting:fiscal_period:{period_code}", + "chart_account_code": chart_account_code, + "account_class_code": account_classes[chart_account_code], + "opening_debit_amount": _exact_amount_text(opening_debit_amount), + "opening_credit_amount": _exact_amount_text(opening_credit_amount), + "period_debit_amount": _exact_amount_text(period_debit_amount), + "period_credit_amount": _exact_amount_text(period_credit_amount), + "closing_debit_amount": _exact_amount_text(closing_debit_amount), + "closing_credit_amount": _exact_amount_text(closing_credit_amount), + } + if statement_scope_code == "year_to_date": + document["statement_scope_code"] = "year_to_date" + return document + + def load_unapplied_cash_rollforward( + self, + legal_entity_reference: str, + accounting_book_reference: str, + period_code: str, + ) -> dict[str, object]: + """Return leftover-cash opening, park / apply / refund, and closing for 210200.""" + if not legal_entity_reference or not accounting_book_reference or not period_code: + raise AccountingValidationError( + "legal_entity_reference, book_reference, and fiscal_period_reference are required. " + "Supply those unapplied-cash-rollforward fields, then retry the unapplied-cash-rollforward read." + ) + account_classes = self._load_chart_account_classes( + legal_entity_reference, accounting_book_reference + ) + if "210200" not in account_classes: + raise AccountingValidationError( + "Chart account 210200 is not recorded for this book. " + "Create the chart_account row, then retry the unapplied-cash-rollforward read." + ) + with self._session() as connection: + tenant_id = self._require_tenant(connection) + legal_entity_id = self._require_legal_entity( + connection, + tenant_id, + legal_entity_reference, + next_action="the unapplied-cash-rollforward read", + ) + book_id = self._require_book_for_close( + connection, + tenant_id, + legal_entity_id, + accounting_book_reference, + next_action="the unapplied-cash-rollforward read", + )[0] + period_id, _period_status, period_end_date = self._require_fiscal_period( + connection, + tenant_id, + period_code, + next_action="the unapplied-cash-rollforward read", + ) + period_start_date = connection.execute( + """ + SELECT period_start_date + FROM accounting_core.fiscal_period + WHERE tenant_account_id = %s + AND fiscal_period_id = %s + """, + (tenant_id, period_id), + ).fetchone()[0] + opening_debit_amount, opening_credit_amount = self._opening_account_sides( + connection, + tenant_id, + legal_entity_id, + book_id, + "210200", + period_start_date, + ) + line_rows = connection.execute( + """ + SELECT COALESCE(journal_proposal_record.idempotency_key, ''), + general_journal.journal_reference, + journal_entry_line.account_role_code, + chart_account.chart_account_code, + journal_entry_line.debit_amount, + journal_entry_line.credit_amount + FROM accounting_core.journal_entry_line + JOIN accounting_core.general_journal + ON general_journal.tenant_account_id = journal_entry_line.tenant_account_id + AND general_journal.general_journal_id = journal_entry_line.general_journal_id + JOIN accounting_core.chart_account + ON chart_account.tenant_account_id = journal_entry_line.tenant_account_id + AND chart_account.chart_account_id = journal_entry_line.chart_account_id + LEFT JOIN accounting_integration.journal_proposal_record + ON journal_proposal_record.tenant_account_id = general_journal.tenant_account_id + AND journal_proposal_record.proposal_record_id + = general_journal.source_proposal_record_id + WHERE general_journal.tenant_account_id = %s + AND general_journal.legal_entity_id = %s + AND general_journal.accounting_book_id = %s + AND general_journal.accounting_date >= %s + AND general_journal.accounting_date <= %s + AND general_journal.journal_reference NOT LIKE %s + ORDER BY general_journal.journal_reference, journal_entry_line.line_number + """, + ( + tenant_id, + legal_entity_id, + book_id, + period_start_date, + period_end_date, + _CLOSING_JOURNAL_PATTERN, + ), + ).fetchall() + journals: dict[str, dict[str, object]] = {} + for ( + idempotency_key, + journal_reference, + account_role_code, + chart_account_code, + debit_amount, + credit_amount, + ) in line_rows: + bucket = journals.setdefault( + str(journal_reference), + { + "idempotency_key": str(idempotency_key), + "debit_roles": set(), + "credit_roles": set(), + "unapplied_debit_amount": Decimal("0"), + "unapplied_credit_amount": Decimal("0"), + }, + ) + line_debit_amount = Decimal(str(debit_amount)) + line_credit_amount = Decimal(str(credit_amount)) + debit_roles = bucket["debit_roles"] + credit_roles = bucket["credit_roles"] + assert isinstance(debit_roles, set) + assert isinstance(credit_roles, set) + if line_debit_amount > 0: + debit_roles.add(str(account_role_code)) + if line_credit_amount > 0: + credit_roles.add(str(account_role_code)) + if str(chart_account_code) == "210200": + bucket["unapplied_debit_amount"] = ( + Decimal(str(bucket["unapplied_debit_amount"])) + line_debit_amount + ) + bucket["unapplied_credit_amount"] = ( + Decimal(str(bucket["unapplied_credit_amount"])) + line_credit_amount + ) + parked_amount = Decimal("0") + applied_amount = Decimal("0") + refunded_amount = Decimal("0") + other_movement_amount = Decimal("0") + for bucket in journals.values(): + unapplied_debit_amount = Decimal(str(bucket["unapplied_debit_amount"])) + unapplied_credit_amount = Decimal(str(bucket["unapplied_credit_amount"])) + if unapplied_debit_amount == 0 and unapplied_credit_amount == 0: + continue + debit_roles = bucket["debit_roles"] + credit_roles = bucket["credit_roles"] + assert isinstance(debit_roles, set) + assert isinstance(credit_roles, set) + movement_kind = _unapplied_cash_movement_kind( + str(bucket["idempotency_key"]), + debit_roles, + credit_roles, + ) + if movement_kind == "parked": + parked_amount += unapplied_credit_amount + elif movement_kind == "applied": + applied_amount += unapplied_debit_amount + elif movement_kind == "refunded": + refunded_amount += unapplied_debit_amount + else: + other_movement_amount += unapplied_credit_amount - unapplied_debit_amount + opening_amount = opening_credit_amount - opening_debit_amount + closing_amount = ( + opening_amount + parked_amount - applied_amount - refunded_amount + other_movement_amount + ) + document: dict[str, object] = { + "tenant_reference": self._tenant_reference, + "legal_entity_reference": legal_entity_reference, + "accounting_book_reference": accounting_book_reference, + "book_reference": accounting_book_reference, + "fiscal_period_reference": f"urn:cwl:accounting:fiscal_period:{period_code}", + "as_of_date": period_end_date.isoformat(), + "chart_account_code": "210200", + "account_role_code": "unapplied_cash", + "parked_amount": _unsigned_aging_amount_text(parked_amount), + "applied_amount": _unsigned_aging_amount_text(applied_amount), + "refunded_amount": _unsigned_aging_amount_text(refunded_amount), + "opening_amount": _unsigned_aging_amount_text(opening_amount), + "closing_amount": _unsigned_aging_amount_text(closing_amount), + } + if other_movement_amount != 0: + document["other_movement_amount"] = _unsigned_aging_amount_text( + other_movement_amount + ) + return document + + def load_vat_period_register( + self, + legal_entity_reference: str, + accounting_book_reference: str, + period_code: str, + ) -> dict[str, object]: + """Return issued, voided, and closing tax-payable amounts for catalog 210100.""" + if not legal_entity_reference or not accounting_book_reference or not period_code: + raise AccountingValidationError( + "legal_entity_reference, book_reference, and fiscal_period_reference are required. " + "Supply those vat-period-register fields, then retry the vat-period-register read." + ) + account_classes = self._load_chart_account_classes( + legal_entity_reference, accounting_book_reference + ) + if "210100" not in account_classes: + raise AccountingValidationError( + "Chart account 210100 is not recorded for this book. " + "Create the chart_account row, then retry the vat-period-register read." + ) + with self._session() as connection: + tenant_id = self._require_tenant(connection) + legal_entity_id = self._require_legal_entity( + connection, + tenant_id, + legal_entity_reference, + next_action="the vat-period-register read", + ) + book_id = self._require_book_for_close( + connection, + tenant_id, + legal_entity_id, + accounting_book_reference, + next_action="the vat-period-register read", + )[0] + _period_id, _period_status, period_end_date = self._require_fiscal_period( + connection, + tenant_id, + period_code, + next_action="the vat-period-register read", + ) + line_rows = connection.execute( + """ + SELECT COALESCE(journal_proposal_record.idempotency_key, ''), + general_journal.journal_reference, + journal_entry_line.account_role_code, + chart_account.chart_account_code, + journal_entry_line.debit_amount, + journal_entry_line.credit_amount + FROM accounting_core.journal_entry_line + JOIN accounting_core.general_journal + ON general_journal.tenant_account_id = journal_entry_line.tenant_account_id + AND general_journal.general_journal_id = journal_entry_line.general_journal_id + JOIN accounting_core.chart_account + ON chart_account.tenant_account_id = journal_entry_line.tenant_account_id + AND chart_account.chart_account_id = journal_entry_line.chart_account_id + LEFT JOIN accounting_integration.journal_proposal_record + ON journal_proposal_record.tenant_account_id = general_journal.tenant_account_id + AND journal_proposal_record.proposal_record_id + = general_journal.source_proposal_record_id + WHERE general_journal.tenant_account_id = %s + AND general_journal.legal_entity_id = %s + AND general_journal.accounting_book_id = %s + AND general_journal.accounting_date <= %s + AND general_journal.journal_reference NOT LIKE %s + ORDER BY general_journal.journal_reference, journal_entry_line.line_number + """, + ( + tenant_id, + legal_entity_id, + book_id, + period_end_date, + _CLOSING_JOURNAL_PATTERN, + ), + ).fetchall() + journals: dict[str, dict[str, object]] = {} + for ( + idempotency_key, + journal_reference, + account_role_code, + chart_account_code, + debit_amount, + credit_amount, + ) in line_rows: + bucket = journals.setdefault( + str(journal_reference), + { + "idempotency_key": str(idempotency_key), + "debit_roles": set(), + "credit_roles": set(), + "tax_debit_amount": Decimal("0"), + "tax_credit_amount": Decimal("0"), + }, + ) + line_debit_amount = Decimal(str(debit_amount)) + line_credit_amount = Decimal(str(credit_amount)) + debit_roles = bucket["debit_roles"] + credit_roles = bucket["credit_roles"] + assert isinstance(debit_roles, set) + assert isinstance(credit_roles, set) + if line_debit_amount > 0: + debit_roles.add(str(account_role_code)) + if line_credit_amount > 0: + credit_roles.add(str(account_role_code)) + if str(chart_account_code) == "210100": + bucket["tax_debit_amount"] = ( + Decimal(str(bucket["tax_debit_amount"])) + line_debit_amount + ) + bucket["tax_credit_amount"] = ( + Decimal(str(bucket["tax_credit_amount"])) + line_credit_amount + ) + issued_amount = Decimal("0") + voided_amount = Decimal("0") + other_movement_amount = Decimal("0") + for bucket in journals.values(): + tax_debit_amount = Decimal(str(bucket["tax_debit_amount"])) + tax_credit_amount = Decimal(str(bucket["tax_credit_amount"])) + if tax_debit_amount == 0 and tax_credit_amount == 0: + continue + debit_roles = bucket["debit_roles"] + credit_roles = bucket["credit_roles"] + assert isinstance(debit_roles, set) + assert isinstance(credit_roles, set) + movement_kind = _vat_period_movement_kind( + str(bucket["idempotency_key"]), + debit_roles, + credit_roles, + ) + if movement_kind == "issued": + issued_amount += tax_credit_amount + elif movement_kind == "voided": + voided_amount += tax_debit_amount + else: + other_movement_amount += tax_credit_amount - tax_debit_amount + closing_amount = issued_amount - voided_amount + other_movement_amount + document: dict[str, object] = { + "tenant_reference": self._tenant_reference, + "legal_entity_reference": legal_entity_reference, + "accounting_book_reference": accounting_book_reference, + "book_reference": accounting_book_reference, + "fiscal_period_reference": f"urn:cwl:accounting:fiscal_period:{period_code}", + "as_of_date": period_end_date.isoformat(), + "chart_account_code": "210100", + "account_role_code": "tax_payable", + "issued_amount": _unsigned_aging_amount_text(issued_amount), + "voided_amount": _unsigned_aging_amount_text(voided_amount), + "closing_amount": _unsigned_aging_amount_text(closing_amount), + } + if other_movement_amount != 0: + document["other_movement_amount"] = _unsigned_aging_amount_text( + other_movement_amount + ) + return document + + def persist_home_tax_submission( + self, + *, + legal_entity_reference: str, + accounting_book_reference: str, + period_code: str, + submission_idempotency_key: str, + source_payload_hash: str, + source_payload_reference: str, + register_document: dict[str, object], + rejection_reason_code: str, + ) -> dict[str, object]: + """Persist or replay one rejected HomeTax receipt with immutable command provenance.""" + if not submission_idempotency_key: + raise AccountingValidationError( + "submission_idempotency_key is required. " + "Supply the original HomeTax command key, then retry the home-tax-submission." + ) + if re.fullmatch(r"sha256:[0-9a-f]{64}", source_payload_hash) is None: + raise AccountingValidationError( + "source_payload_hash must be a sha256 digest. " + "Supply immutable HomeTax source evidence, then retry the home-tax-submission." + ) + normalized_source_reference = source_payload_reference.strip() + if not normalized_source_reference: + raise AccountingValidationError( + "source_payload_reference is required. " + "Supply the immutable HomeTax source locator, then retry the home-tax-submission." + ) + register_payload_hash = "sha256:" + hashlib.sha256( + json.dumps( + register_document, separators=(",", ":"), sort_keys=True, default=str + ).encode("utf-8") + ).hexdigest() + raw_as_of_date = str(register_document.get("as_of_date") or "") + as_of_date = date.fromisoformat(raw_as_of_date) if raw_as_of_date else None + closing_amount = Decimal(str(register_document.get("closing_amount") or "0")) + with self._session() as connection: + tenant_id = self._require_tenant(connection) + legal_entity_id = self._require_legal_entity( + connection, + tenant_id, + legal_entity_reference, + next_action="the home-tax-submission", + ) + self._acquire_command_lock( + connection, f"home-tax:{submission_idempotency_key}" + ) + book_id = self._require_book_for_close( + connection, + tenant_id, + legal_entity_id, + accounting_book_reference, + next_action="the home-tax-submission", + )[0] + period_id, _period_status, period_end_date = self._require_fiscal_period( + connection, + tenant_id, + period_code, + next_action="the home-tax-submission", + ) + if as_of_date is None: + as_of_date = period_end_date + row = connection.execute( + """ + INSERT INTO accounting_integration.home_tax_submission ( + tenant_account_id, + legal_entity_id, + accounting_book_id, + fiscal_period_id, + submission_idempotency_key, + source_payload_hash, + source_payload_reference, + submission_status_code, + rejection_reason_code, + as_of_date, + closing_amount, + register_payload_hash + ) VALUES (%s, %s, %s, %s, %s, %s, %s, 'rejected', %s, %s, %s, %s) + ON CONFLICT (tenant_account_id, submission_idempotency_key) DO NOTHING + RETURNING home_tax_submission_id, + submission_status_code, + rejection_reason_code, + as_of_date, + closing_amount, + register_payload_hash, + source_payload_hash, + source_payload_reference, + legal_entity_id, + accounting_book_id, + fiscal_period_id + """, + ( + tenant_id, + legal_entity_id, + book_id, + period_id, + submission_idempotency_key, + source_payload_hash, + normalized_source_reference, + rejection_reason_code, + as_of_date, + closing_amount, + register_payload_hash, + ), + ).fetchone() + if row is None: + row = connection.execute( + """ + SELECT home_tax_submission_id, + submission_status_code, + rejection_reason_code, + as_of_date, + closing_amount, + register_payload_hash, + source_payload_hash, + source_payload_reference, + legal_entity_id, + accounting_book_id, + fiscal_period_id + FROM accounting_integration.home_tax_submission + WHERE tenant_account_id = %s + AND submission_idempotency_key = %s + """, + (tenant_id, submission_idempotency_key), + ).fetchone() + if row is None: + raise AccountingValidationError( + "HomeTax command replay could not find its existing receipt. " + "Retry the command with the same idempotency key." + ) + if ( + row[5] != register_payload_hash + or row[6] != source_payload_hash + or row[7] != normalized_source_reference + or row[8] != legal_entity_id + or row[9] != book_id + or row[10] != period_id + ): + raise IdempotencyConflictError( + "HomeTax idempotency key was already used with different evidence or scope. " + "Use a new command key for the changed submission." + ) + receipt_register = _home_tax_register_view(register_document) + if not receipt_register.get("as_of_date"): + receipt_register["as_of_date"] = row[3].isoformat() + return _home_tax_submission_document( + home_tax_submission_id=str(row[0]), + tenant_reference=self._tenant_reference, + legal_entity_reference=legal_entity_reference, + book_reference=accounting_book_reference, + period_code=period_code, + vat_period_register=receipt_register, + rejection_reason_code=str(row[2]), + submission_status_code=str(row[1]), + ) + + def load_home_tax_submissions( + self, + legal_entity_reference: str, + accounting_book_reference: str, + period_code: str, + ) -> dict[str, object]: + """Return persisted HomeTax receipts for one tenant entity, book, and period.""" + if not legal_entity_reference or not accounting_book_reference or not period_code: + raise AccountingValidationError( + "legal_entity_reference, book_reference, and fiscal_period_reference are required. " + "Supply those home-tax-submission fields, then retry the home-tax-submission read." + ) + with self._session() as connection: + tenant_id = self._require_tenant(connection) + legal_entity_id = self._require_legal_entity( + connection, + tenant_id, + legal_entity_reference, + next_action="the home-tax-submission read", + ) + book_id = self._require_book_for_close( + connection, + tenant_id, + legal_entity_id, + accounting_book_reference, + next_action="the home-tax-submission read", + )[0] + period_id, _period_status, _period_end_date = self._require_fiscal_period( + connection, + tenant_id, + period_code, + next_action="the home-tax-submission read", + ) + rows = connection.execute( + """ + SELECT home_tax_submission_id, + submission_status_code, + rejection_reason_code, + as_of_date, + closing_amount + FROM accounting_integration.home_tax_submission + WHERE tenant_account_id = %s + AND legal_entity_id = %s + AND accounting_book_id = %s + AND fiscal_period_id = %s + ORDER BY created_at, home_tax_submission_id + """, + (tenant_id, legal_entity_id, book_id, period_id), + ).fetchall() + return { + "tenant_reference": self._tenant_reference, + "legal_entity_reference": legal_entity_reference, + "book_reference": accounting_book_reference, + "fiscal_period_reference": f"urn:cwl:accounting:fiscal_period:{period_code}", + "home_tax_submissions": [ + _home_tax_submission_document( + home_tax_submission_id=str(row[0]), + tenant_reference=self._tenant_reference, + legal_entity_reference=legal_entity_reference, + book_reference=accounting_book_reference, + period_code=period_code, + vat_period_register={ + "as_of_date": row[3].isoformat(), + "closing_amount": _unsigned_aging_amount_text(Decimal(row[4])), + }, + rejection_reason_code=str(row[2]), + submission_status_code=str(row[1]), + ) + for row in rows + ], + } + + def _opening_account_sides( + self, + connection: object, + tenant_id: UUID, + legal_entity_id: UUID, + book_id: UUID, + chart_account_code: str, + scope_start: date, + ) -> tuple[Decimal, Decimal]: + prior_snapshot = connection.execute( + """ + SELECT trial_balance_snapshot.trial_balance_snapshot_id + FROM accounting_core.fiscal_period + JOIN accounting_reporting.trial_balance_snapshot + ON trial_balance_snapshot.tenant_account_id = fiscal_period.tenant_account_id + AND trial_balance_snapshot.fiscal_period_id = fiscal_period.fiscal_period_id + AND trial_balance_snapshot.legal_entity_id = %s + AND trial_balance_snapshot.accounting_book_id = %s + WHERE fiscal_period.tenant_account_id = %s + AND fiscal_period.period_end_date < %s + AND fiscal_period.period_status_code = 'hard_closed' + ORDER BY fiscal_period.period_end_date DESC, fiscal_period.period_code DESC + LIMIT 1 + """, + (legal_entity_id, book_id, tenant_id, scope_start), + ).fetchone() + if prior_snapshot is not None: + row = connection.execute( + """ + SELECT COALESCE(trial_balance_line.debit_total_amount, 0), + COALESCE(trial_balance_line.credit_total_amount, 0) + FROM accounting_reporting.trial_balance_line + JOIN accounting_core.chart_account + ON chart_account.tenant_account_id = trial_balance_line.tenant_account_id + AND chart_account.chart_account_id = trial_balance_line.chart_account_id + WHERE trial_balance_line.tenant_account_id = %s + AND trial_balance_line.trial_balance_snapshot_id = %s + AND chart_account.chart_account_code = %s + """, + (tenant_id, prior_snapshot[0], chart_account_code), + ).fetchone() + if row is None: + return Decimal("0"), Decimal("0") + return Decimal(row[0]), Decimal(row[1]) + row = connection.execute( + """ + SELECT COALESCE(SUM(journal_entry_line.debit_amount), 0), + COALESCE(SUM(journal_entry_line.credit_amount), 0) + FROM accounting_core.journal_entry_line + JOIN accounting_core.general_journal + ON general_journal.tenant_account_id = journal_entry_line.tenant_account_id + AND general_journal.general_journal_id = journal_entry_line.general_journal_id + JOIN accounting_core.chart_account + ON chart_account.tenant_account_id = journal_entry_line.tenant_account_id + AND chart_account.chart_account_id = journal_entry_line.chart_account_id + WHERE general_journal.tenant_account_id = %s + AND general_journal.legal_entity_id = %s + AND general_journal.accounting_book_id = %s + AND chart_account.chart_account_code = %s + AND general_journal.accounting_date <= %s + """, + ( + tenant_id, + legal_entity_id, + book_id, + chart_account_code, + scope_start - timedelta(days=1), + ), + ).fetchone() + return Decimal(row[0]), Decimal(row[1]) + + def _period_account_sides( + self, + connection: object, + tenant_id: UUID, + legal_entity_id: UUID, + book_id: UUID, + chart_account_code: str, + period_ids: list[UUID], + ) -> tuple[Decimal, Decimal]: + row = connection.execute( + """ + SELECT COALESCE(SUM(journal_entry_line.debit_amount), 0), + COALESCE(SUM(journal_entry_line.credit_amount), 0) + FROM accounting_core.journal_entry_line + JOIN accounting_core.general_journal + ON general_journal.tenant_account_id = journal_entry_line.tenant_account_id + AND general_journal.general_journal_id = journal_entry_line.general_journal_id + JOIN accounting_core.chart_account + ON chart_account.tenant_account_id = journal_entry_line.tenant_account_id + AND chart_account.chart_account_id = journal_entry_line.chart_account_id + WHERE general_journal.tenant_account_id = %s + AND general_journal.legal_entity_id = %s + AND general_journal.accounting_book_id = %s + AND chart_account.chart_account_code = %s + AND general_journal.fiscal_period_id = ANY(%s) + """, + ( + tenant_id, + legal_entity_id, + book_id, + chart_account_code, + period_ids, + ), + ).fetchone() + return Decimal(row[0]), Decimal(row[1]) + + def load_account_balances( + self, + legal_entity_reference: str, + accounting_book_reference: str, + period_code: str, + chart_account_code: str = "", + *, + page_limit: int = 50, + cursor: str = "", + ) -> dict[str, object]: + """Return as-of chart-account balances from the close snapshot or live journals.""" + trial_balance = self.load_period_trial_balance( + legal_entity_reference=legal_entity_reference, + accounting_book_reference=accounting_book_reference, + period_code=period_code, + ) + account_classes = self._load_chart_account_classes( + legal_entity_reference, accounting_book_reference + ) + requested_code = chart_account_code.strip() + if requested_code and requested_code not in account_classes: + raise AccountingValidationError( + f"Chart account {requested_code} is not recorded for this book. " + "Create the chart_account row, then retry the account-balance read." + ) + source_lines = [ + { + "chart_account_code": str(raw_line["chart_account_code"]), + "debit_amount": str(raw_line["debit_amount"]), + "credit_amount": str(raw_line["credit_amount"]), + } + for raw_line in trial_balance["lines"] + ] + if requested_code: + source_lines = [ + raw_line + for raw_line in source_lines + if raw_line["chart_account_code"] == requested_code + ] + if not source_lines: + source_lines = [ + { + "chart_account_code": requested_code, + "debit_amount": "0", + "credit_amount": "0", + } + ] + if cursor: + source_lines = [ + raw_line + for raw_line in source_lines + if raw_line["chart_account_code"] > cursor + ] + has_more = len(source_lines) > page_limit + page_lines = source_lines[:page_limit] + account_balances = [ + { + "chart_account_code": raw_line["chart_account_code"], + "account_class_code": account_classes[str(raw_line["chart_account_code"])], + "debit_amount": _exact_amount_text(Decimal(str(raw_line["debit_amount"]))), + "credit_amount": _exact_amount_text(Decimal(str(raw_line["credit_amount"]))), + } + for raw_line in page_lines + ] + next_cursor = None + if has_more: + next_cursor = str(page_lines[-1]["chart_account_code"]) + return { + "tenant_reference": self._tenant_reference, + "legal_entity_reference": legal_entity_reference, + "accounting_book_reference": accounting_book_reference, + "book_reference": accounting_book_reference, + "fiscal_period_reference": str(trial_balance["fiscal_period_reference"]), + "account_balances": account_balances, + "next_cursor": next_cursor, + } + + def load_receivable_aging( + self, + legal_entity_reference: str, + book_reference: str, + period_code: str, + chart_account_code: str = "", + ) -> dict[str, object]: + """Return entity-level FIFO receivable aging as of the fiscal period end date.""" + return self._load_account_aging( + legal_entity_reference, + book_reference, + period_code, + chart_account_code, + catalog_role_code="accounts_receivable", + increase_is_debit=True, + read_name="receivable-aging", + ) + + def load_payable_aging( + self, + legal_entity_reference: str, + book_reference: str, + period_code: str, + chart_account_code: str = "", + ) -> dict[str, object]: + """Return entity-level FIFO payable aging as of the fiscal period end date.""" + return self._load_account_aging( + legal_entity_reference, + book_reference, + period_code, + chart_account_code, + catalog_role_code="tax_payable", + increase_is_debit=False, + read_name="payable-aging", + ) + + def _load_account_aging( + self, + legal_entity_reference: str, + book_reference: str, + period_code: str, + chart_account_code: str, + *, + catalog_role_code: str, + increase_is_debit: bool, + read_name: str, + ) -> dict[str, object]: + if not legal_entity_reference or not book_reference or not period_code: + raise AccountingValidationError( + "legal_entity_reference, book_reference, and fiscal_period_reference are required. " + f"Supply those {read_name} fields, then retry the {read_name} read." + ) + with self._session() as connection: + tenant_id = self._require_tenant(connection) + legal_entity_id = self._require_legal_entity( + connection, + tenant_id, + legal_entity_reference, + next_action=f"the {read_name} read", + ) + book_id = self._require_book_for_close( + connection, + tenant_id, + legal_entity_id, + book_reference, + next_action=f"the {read_name} read", + )[0] + _period_id, _status, period_end_date = self._require_fiscal_period( + connection, + tenant_id, + period_code, + next_action=f"the {read_name} read", + ) + account_rows = connection.execute( + """ + SELECT chart_account.chart_account_code, + account_role_mapping.account_role_code, + chart_account.account_class_code + FROM accounting_core.chart_account + LEFT JOIN accounting_core.account_role_mapping + ON account_role_mapping.tenant_account_id = chart_account.tenant_account_id + AND account_role_mapping.chart_account_id = chart_account.chart_account_id + AND account_role_mapping.valid_to IS NULL + WHERE chart_account.tenant_account_id = %s + AND chart_account.accounting_book_id = %s + AND chart_account.valid_to IS NULL + """, + (tenant_id, book_id), + ).fetchall() + account_classes = { + str(account_code): str(account_class_code) + for account_code, _role_code, account_class_code in account_rows + } + catalog_account_code = next( + ( + str(account_code) + for account_code, role_code, _class in account_rows + if role_code == catalog_role_code + ), + "", + ) + resolved_account_code = chart_account_code.strip() or catalog_account_code + if resolved_account_code not in account_classes: + raise AccountingValidationError( + f"Chart account {resolved_account_code} is not recorded for this book. " + f"Create the chart_account row, then retry the {read_name} read." + ) + if resolved_account_code != catalog_account_code: + raise AccountingValidationError( + f"chart_account_code must be the catalog {catalog_role_code} account. " + f"Supply that {read_name} account, then retry the {read_name} read." + ) + line_rows = connection.execute( + """ + SELECT general_journal.accounting_date, + general_journal.journal_reference, + journal_entry_line.line_number, + journal_entry_line.debit_amount, + journal_entry_line.credit_amount + FROM accounting_core.journal_entry_line + JOIN accounting_core.general_journal + ON general_journal.tenant_account_id = journal_entry_line.tenant_account_id + AND general_journal.general_journal_id = journal_entry_line.general_journal_id + JOIN accounting_core.chart_account + ON chart_account.tenant_account_id = journal_entry_line.tenant_account_id + AND chart_account.chart_account_id = journal_entry_line.chart_account_id + WHERE general_journal.tenant_account_id = %s + AND general_journal.legal_entity_id = %s + AND general_journal.accounting_book_id = %s + AND chart_account.chart_account_code = %s + AND general_journal.accounting_date <= %s + AND general_journal.journal_reference NOT LIKE %s + ORDER BY general_journal.accounting_date, + CASE + WHEN %s AND journal_entry_line.debit_amount > 0 THEN 0 + WHEN NOT %s AND journal_entry_line.credit_amount > 0 THEN 0 + ELSE 1 + END, + general_journal.journal_reference, + journal_entry_line.line_number + """, + ( + tenant_id, + legal_entity_id, + book_id, + resolved_account_code, + period_end_date, + _CLOSING_JOURNAL_PATTERN, + increase_is_debit, + increase_is_debit, + ), + ).fetchall() + open_items = _fifo_aging_open_items(line_rows, increase_is_debit=increase_is_debit) + bucket_amounts = { + "current": Decimal("0"), + "days_31_60": Decimal("0"), + "days_61_90": Decimal("0"), + "days_over_90": Decimal("0"), + } + for open_item in open_items: + outstanding_days = (period_end_date - open_item[0]).days + bucket_amounts[_receivable_aging_bucket(outstanding_days)] += open_item[1] + total_outstanding_amount = ( + bucket_amounts["current"] + + bucket_amounts["days_31_60"] + + bucket_amounts["days_61_90"] + + bucket_amounts["days_over_90"] + ) + document: dict[str, object] = { + "tenant_reference": self._tenant_reference, + "legal_entity_reference": legal_entity_reference, + "accounting_book_reference": book_reference, + "book_reference": book_reference, + "fiscal_period_reference": f"urn:cwl:accounting:fiscal_period:{period_code}", + "chart_account_code": resolved_account_code, + "account_class_code": account_classes[resolved_account_code], + "as_of_date": period_end_date.isoformat(), + "current_amount": _unsigned_aging_amount_text(bucket_amounts["current"]), + "days_31_60_amount": _unsigned_aging_amount_text(bucket_amounts["days_31_60"]), + "days_61_90_amount": _unsigned_aging_amount_text(bucket_amounts["days_61_90"]), + "days_over_90_amount": _unsigned_aging_amount_text(bucket_amounts["days_over_90"]), + "total_outstanding_amount": _unsigned_aging_amount_text(total_outstanding_amount), + } + if increase_is_debit: + unapplied_credit_amount = Decimal("0") + for _date, _reference, _line_number, debit_amount, credit_amount in line_rows: + unapplied_credit_amount += Decimal(str(credit_amount)) - Decimal( + str(debit_amount) + ) + if unapplied_credit_amount > 0: + document["unapplied_credit_amount"] = _unsigned_aging_amount_text( + unapplied_credit_amount + ) + return document + + def _load_chart_account_classes( + self, legal_entity_reference: str, accounting_book_reference: str + ) -> dict[str, str]: + with self._session() as connection: + tenant_id = self._require_tenant(connection) + legal_entity_id = self._require_legal_entity( + connection, + tenant_id, + legal_entity_reference, + next_action="the account-balance read", + ) + book_id = self._require_book_for_close( + connection, + tenant_id, + legal_entity_id, + accounting_book_reference, + next_action="the account-balance read", + )[0] + rows = connection.execute( + """ + SELECT chart_account.chart_account_code, + chart_account.account_class_code + FROM accounting_core.chart_account + WHERE chart_account.tenant_account_id = %s + AND chart_account.accounting_book_id = %s + AND chart_account.valid_to IS NULL + """, + (tenant_id, book_id), + ).fetchall() + return { + str(account_code): str(account_class_code) + for account_code, account_class_code in rows + } + + def load_account_ledger( + self, + legal_entity_reference: str, + chart_account_code: str, + fiscal_period_reference: str = "", + *, + page_limit: int = 50, + cursor_after: tuple[datetime, str, int] | None = None, + ) -> dict[str, object]: + """Return posted journal lines for one tenant entity and chart account.""" + if not legal_entity_reference: + raise AccountingValidationError( + "legal_entity_reference is required. " + "Supply that ledger field, then retry the account-ledger read." + ) + if not chart_account_code: + raise AccountingValidationError( + "chart_account_code is required. " + "Supply that ledger field, then retry the account-ledger read." + ) + period_code = "" + if fiscal_period_reference: + period_code = fiscal_period_reference + if period_code.startswith("urn:cwl:accounting:fiscal_period:"): + period_code = period_code[len("urn:cwl:accounting:fiscal_period:") :] + with self._session() as connection: + tenant_id = self._require_tenant(connection) + self._require_legal_entity( + connection, tenant_id, legal_entity_reference, "the account-ledger read" + ) + chart_row = connection.execute( + """ + SELECT chart_account_id + FROM accounting_core.chart_account + WHERE tenant_account_id = %s + AND chart_account_code = %s + AND valid_to IS NULL + LIMIT 1 + """, + (tenant_id, chart_account_code), + ).fetchone() + if chart_row is None: + raise AccountingValidationError( + f"Chart account {chart_account_code} is not recorded for this tenant. " + "Create the chart_account row, then retry the account-ledger read." + ) + period_id = None + period_reference: str | None = None + if period_code: + period_id, _status, _end = self._require_fiscal_period( + connection, tenant_id, period_code, "the account-ledger read" + ) + period_reference = f"urn:cwl:accounting:fiscal_period:{period_code}" + cursor_posted_at = None + cursor_journal_reference = None + cursor_line_number = None + if cursor_after is not None: + cursor_posted_at, cursor_journal_reference, cursor_line_number = cursor_after + totals = connection.execute( + """ + SELECT COALESCE(SUM(journal_entry_line.debit_amount), 0), + COALESCE(SUM(journal_entry_line.credit_amount), 0) + FROM accounting_core.journal_entry_line + JOIN accounting_core.general_journal + ON general_journal.tenant_account_id = journal_entry_line.tenant_account_id + AND general_journal.general_journal_id = journal_entry_line.general_journal_id + JOIN accounting_core.chart_account + ON chart_account.tenant_account_id = journal_entry_line.tenant_account_id + AND chart_account.chart_account_id = journal_entry_line.chart_account_id + JOIN accounting_core.legal_entity_record + ON legal_entity_record.tenant_account_id = general_journal.tenant_account_id + AND legal_entity_record.legal_entity_id = general_journal.legal_entity_id + WHERE journal_entry_line.tenant_account_id = %s + AND legal_entity_record.legal_entity_code = %s + AND chart_account.chart_account_code = %s + AND (%s::uuid IS NULL OR general_journal.fiscal_period_id = %s) + """, + ( + tenant_id, + legal_entity_reference, + chart_account_code, + period_id, + period_id, + ), + ).fetchone() + rows = connection.execute( + """ + SELECT general_journal.journal_reference, + general_journal.posted_at, + journal_entry_line.line_number, + chart_account.chart_account_code, + journal_entry_line.account_role_code, + journal_entry_line.debit_amount, + journal_entry_line.credit_amount + FROM accounting_core.journal_entry_line + JOIN accounting_core.general_journal + ON general_journal.tenant_account_id = journal_entry_line.tenant_account_id + AND general_journal.general_journal_id = journal_entry_line.general_journal_id + JOIN accounting_core.chart_account + ON chart_account.tenant_account_id = journal_entry_line.tenant_account_id + AND chart_account.chart_account_id = journal_entry_line.chart_account_id + JOIN accounting_core.legal_entity_record + ON legal_entity_record.tenant_account_id = general_journal.tenant_account_id + AND legal_entity_record.legal_entity_id = general_journal.legal_entity_id + WHERE journal_entry_line.tenant_account_id = %s + AND legal_entity_record.legal_entity_code = %s + AND chart_account.chart_account_code = %s + AND (%s::uuid IS NULL OR general_journal.fiscal_period_id = %s) + AND ( + %s::timestamptz IS NULL + OR ( + general_journal.posted_at, + general_journal.journal_reference, + journal_entry_line.line_number + ) > (%s, %s, %s) + ) + ORDER BY general_journal.posted_at, + general_journal.journal_reference, + journal_entry_line.line_number + LIMIT %s + """, + ( + tenant_id, + legal_entity_reference, + chart_account_code, + period_id, + period_id, + cursor_posted_at, + cursor_posted_at, + cursor_journal_reference, + cursor_line_number, + page_limit + 1, + ), + ).fetchall() + has_more = len(rows) > page_limit + page_rows = rows[:page_limit] + ledger_lines = [ + { + "line_number": row[2], + "chart_account_code": row[3], + "account_role_code": row[4], + "debit_amount": _exact_amount_text(Decimal(row[5])), + "credit_amount": _exact_amount_text(Decimal(row[6])), + "journal_reference": row[0], + "posted_at": _format_timestamp(row[1]), + } + for row in page_rows + ] + next_cursor = None + if has_more: + last = page_rows[-1] + next_cursor = f"{_format_timestamp(last[1])}|{last[0]}|{last[2]}" + return { + "tenant_reference": self._tenant_reference, + "legal_entity_reference": legal_entity_reference, + "chart_account_code": chart_account_code, + "fiscal_period_reference": period_reference, + "ledger_lines": ledger_lines, + "period_debit_total": _exact_amount_text(Decimal(totals[0])), + "period_credit_total": _exact_amount_text(Decimal(totals[1])), + "next_cursor": next_cursor, + } + + def reverse( + self, + journal_reference: str, + reversal_date: date, + reversal_reason_code: str, + policy: AccountingPolicy, + *, + reversal_idempotency_key: str | None = None, + ) -> PostingReceipt: + """Append the exact opposite of one original journal and preserve lineage.""" + _require_code(reversal_reason_code, "reversal reason code") + command_key = ( + f"reversal:{journal_reference}" + if reversal_idempotency_key is None + else reversal_idempotency_key.strip() + ) + if not command_key: + raise AccountingValidationError( + "reversal idempotency key must not be empty. " + "Supply the reversal command identity, then retry reversal." + ) + command_hash = _reversal_command_hash( + tenant_reference=self._tenant_reference, + reversal_idempotency_key=command_key, + original_journal_reference=journal_reference, + reversal_date=reversal_date, + reversal_reason_code=reversal_reason_code, + ) + with self._session() as connection: + tenant_id = self._require_tenant(connection) + self._acquire_command_lock( + connection, f"reversal:{journal_reference}:{command_key}" + ) + existing = connection.execute( + """ + SELECT reversal_journal.journal_reference, + reversal_record.idempotency_key, + reversal_record.source_payload_hash, + original_journal.journal_reference, + journal_reversal.reversal_reason_code, + reversal_journal.accounting_date + FROM accounting_core.journal_reversal + JOIN accounting_core.general_journal AS original_journal + ON original_journal.tenant_account_id = journal_reversal.tenant_account_id + AND original_journal.general_journal_id = journal_reversal.original_journal_id + JOIN accounting_core.general_journal AS reversal_journal + ON reversal_journal.tenant_account_id = journal_reversal.tenant_account_id + AND reversal_journal.general_journal_id = journal_reversal.reversal_journal_id + JOIN accounting_integration.journal_proposal_record AS reversal_record + ON reversal_record.tenant_account_id = reversal_journal.tenant_account_id + AND reversal_record.proposal_record_id = reversal_journal.source_proposal_record_id + WHERE journal_reversal.tenant_account_id = %s + AND original_journal.journal_reference = %s + """, + (tenant_id, journal_reference), + ).fetchone() + if existing is not None: + if str(existing[1]) != command_key: + raise AccountingValidationError( + "journal is already reversed. Use the existing reversal receipt, then retry." + ) + if ( + str(existing[2]) != command_hash + or str(existing[3]) != journal_reference + or str(existing[4]) != reversal_reason_code + or existing[5] != reversal_date + ): + raise IdempotencyConflictError( + "reversal idempotency key was already used with different command evidence. " + "Use a new reversal command identity, then retry." + ) + return self._receipt_for_journal(connection, tenant_id, existing[0]) + prior_command = connection.execute( + """ + SELECT source_payload_hash + FROM accounting_integration.journal_proposal_record + WHERE tenant_account_id = %s AND idempotency_key = %s + """, + (tenant_id, command_key), + ).fetchone() + if prior_command is not None: + raise IdempotencyConflictError( + "reversal idempotency key was already used by another accounting command. Supply a new reversal command identity, then retry." + ) + original = connection.execute( + """ + SELECT general_journal_id, legal_entity_id, accounting_book_id, + transaction_currency_code, functional_currency_code, + source_proposal_record_id, transaction_date, accounting_date + FROM accounting_core.general_journal + WHERE tenant_account_id = %s AND journal_reference = %s + """, + (tenant_id, journal_reference), + ).fetchone() + if original is None: + raise AccountingValidationError( + "journal does not exist. Supply a posted journal reference, then retry reversal." + ) + already_reversal = connection.execute( + """ + SELECT 1 + FROM accounting_core.journal_reversal + WHERE tenant_account_id = %s AND reversal_journal_id = %s + """, + (tenant_id, original[0]), + ).fetchone() + if already_reversal is not None: + raise AccountingValidationError( + "a reversal journal cannot itself be reversed. Reverse the original journal, or post a replacement." + ) + if reversal_date < original[7]: + raise AccountingValidationError( + "reversal date cannot precede original journal accounting date. Supply a reversal_date on or after the original accounting date, then retry reversal." + ) + if not policy.permits(reversal_date): + raise AccountingValidationError("reversal date belongs to a closed fiscal period. Reverse into an open or soft-closed period, then retry reversal.") + if ( + self._tenant_reference != policy.tenant_reference + or self._legal_entity_code(connection, tenant_id, original[1]) + != policy.legal_entity_reference + or self._book_name(connection, tenant_id, original[2]) + != policy.accounting_book_reference + ): + raise AccountingValidationError( + "reversal policy scope does not match original journal. Supply the reversal policy for the original journal's legal entity and book, then retry reversal." + ) + period_id = self._require_adjusting_period(connection, tenant_id, reversal_date) + original_lines = self._load_lines(connection, tenant_id, original[0]) + reversal_lines = tuple( + PostedJournalLine( + line_number=line.line_number, + chart_account_code=line.chart_account_code, + account_role_code=line.account_role_code, + debit_amount=line.credit_amount, + credit_amount=line.debit_amount, + ) + for line in original_lines + ) + reversal_reference = f"{journal_reference}:reversal" + occupant = connection.execute( + """ + SELECT 1 + FROM accounting_core.general_journal + WHERE tenant_account_id = %s AND journal_reference = %s + """, + (tenant_id, reversal_reference), + ).fetchone() + if occupant is not None: + raise AccountingValidationError( + "posted journal is immutable. Reverse the existing journal, " + "then post a replacement." + ) + _original_source_hash, source_proposal_id = self._proposal_identity( + connection, tenant_id, original[5] + ) + receipt = PostingReceipt( + receipt_reference=f"{reversal_reference}:receipt", + journal_reference=reversal_reference, + posting_status_code="posted", + source_proposal_id=source_proposal_id, + source_payload_hash=command_hash, + tenant_reference=policy.tenant_reference, + legal_entity_reference=policy.legal_entity_reference, + accounting_book_reference=policy.accounting_book_reference, + accounting_policy_version=policy.accounting_policy_version, + posting_rule_version=policy.posting_rule_version, + line_count=len(reversal_lines), + reversal_of_journal_reference=journal_reference, + ) + reversal_proposal_id = connection.execute( + """ + INSERT INTO accounting_integration.journal_proposal_record ( + tenant_account_id, external_proposal_id, proposal_contract_version, + idempotency_key, source_payload_hash, proposal_status_code, processed_at + ) + VALUES (%s, uuidv7(), 1, %s, %s, 'posted', clock_timestamp()) + RETURNING proposal_record_id + """, + (tenant_id, command_key, command_hash), + ).fetchone()[0] + reversal_journal_id = self._insert_journal( + connection, + tenant_id=tenant_id, + legal_entity_id=original[1], + book_id=original[2], + period_id=period_id, + journal_reference=reversal_reference, + proposal=_ReversalProposal( + source_payload_hash=command_hash, + transaction_currency=original[3], + transaction_date=original[6], + accounting_date=reversal_date, + source_event_references=(), + ), + policy=policy, + proposal_record_id=reversal_proposal_id, + lines=reversal_lines, + ) + connection.execute( + """ + INSERT INTO accounting_core.journal_reversal ( + tenant_account_id, original_journal_id, reversal_journal_id, + reversal_reason_code + ) + VALUES (%s, %s, %s, %s) + """, + (tenant_id, original[0], reversal_journal_id, reversal_reason_code), + ) + self._insert_receipt( + connection, tenant_id, reversal_proposal_id, reversal_journal_id, receipt + ) + self._insert_outbox( + connection, + tenant_id, + "journal_reversal", + reversal_reference, + receipt.receipt_reference, + receipt, + ) + return receipt + + def load_reversal_policy( + self, journal_reference: str, reversal_date: date + ) -> AccountingPolicy: + """Build catalog policy for reversing *journal_reference* on *reversal_date*.""" + with self._session() as connection: + tenant_id = self._require_tenant(connection) + row = connection.execute( + """ + SELECT legal_entity_record.legal_entity_code, + accounting_book.book_name, + accounting_book.book_role_code, + general_journal.transaction_currency_code, + general_journal.functional_currency_code, + general_journal.accounting_policy_version, + general_journal.posting_rule_version, + general_journal.general_journal_id + FROM accounting_core.general_journal + JOIN accounting_core.legal_entity_record + ON legal_entity_record.tenant_account_id = general_journal.tenant_account_id + AND legal_entity_record.legal_entity_id = general_journal.legal_entity_id + JOIN accounting_core.accounting_book + ON accounting_book.tenant_account_id = general_journal.tenant_account_id + AND accounting_book.accounting_book_id = general_journal.accounting_book_id + WHERE general_journal.tenant_account_id = %s + AND general_journal.journal_reference = %s + """, + (tenant_id, journal_reference), + ).fetchone() + if row is None: + raise AccountingValidationError( + "journal does not exist. Supply a posted journal reference, then retry reversal." + ) + _period_id, period_start, period_end = self._require_adjusting_period_bounds( + connection, tenant_id, reversal_date + ) + lines = self._load_lines(connection, tenant_id, row[7]) + return AccountingPolicy( + tenant_reference=self._tenant_reference, + legal_entity_reference=row[0], + accounting_book_reference=row[1], + intended_book_role_code=row[2], + transaction_currency=row[3], + functional_currency=row[4], + open_period_start=period_start, + open_period_end=period_end, + chart_account_mapping={ + line.account_role_code: line.chart_account_code for line in lines + }, + accounting_policy_version=row[5], + posting_rule_version=row[6], + ) + + def load_account_role_mappings( + self, legal_entity_reference: str, accounting_book_reference: str + ) -> dict[str, object]: + """Return effective account-role mappings for one legal entity and book.""" + if not legal_entity_reference or not accounting_book_reference: + raise AccountingValidationError( + "legal_entity_reference and book_reference are required. " + "Supply those catalog fields, then retry the mapping read." + ) + with self._session() as connection: + tenant_id = self._require_tenant(connection) + legal_entity_id = self._load_legal_entity( + connection, tenant_id, legal_entity_reference, "the mapping read" + )[0] + book_id = self._require_book_for_close( + connection, + tenant_id, + legal_entity_id, + accounting_book_reference, + "the mapping read", + )[0] + rows = connection.execute( + """ + SELECT account_role_mapping.account_role_code, + chart_account.chart_account_code, + account_role_mapping.accounting_policy_version, + account_role_mapping.posting_rule_version + FROM accounting_core.account_role_mapping + JOIN accounting_core.chart_account + ON chart_account.tenant_account_id = account_role_mapping.tenant_account_id + AND chart_account.chart_account_id = account_role_mapping.chart_account_id + WHERE account_role_mapping.tenant_account_id = %s + AND account_role_mapping.accounting_book_id = %s + AND account_role_mapping.valid_to IS NULL + ORDER BY account_role_mapping.account_role_code + """, + (tenant_id, book_id), + ).fetchall() + if not rows: + raise AccountingValidationError( + "No account_role_mapping is recorded for this book. " + "Create the account_role_mapping rows, then retry the mapping read." + ) + return { + "tenant_reference": self._tenant_reference, + "legal_entity_reference": legal_entity_reference, + "accounting_book_reference": accounting_book_reference, + "book_reference": accounting_book_reference, + "mappings": [ + { + "account_role_code": role_code, + "chart_account_code": account_code, + "accounting_policy_version": policy_version, + "posting_rule_version": rule_version, + } + for role_code, account_code, policy_version, rule_version in rows + ], + } + + def load_legal_entities(self) -> dict[str, object]: + """Return existing legal_entity_record rows for the bound tenant.""" + with self._session() as connection: + tenant_id = self._require_tenant(connection) + rows = connection.execute( + """ + SELECT legal_entity_record.legal_entity_code, + legal_entity_record.entity_name + FROM accounting_core.legal_entity_record + WHERE legal_entity_record.tenant_account_id = %s + AND legal_entity_record.valid_to IS NULL + ORDER BY legal_entity_record.legal_entity_code + """, + (tenant_id,), + ).fetchall() + return { + "tenant_reference": self._tenant_reference, + "legal_entities": [ + { + "legal_entity_reference": legal_entity_code, + "entity_name": entity_name, + } + for legal_entity_code, entity_name in rows + ], + } + + def load_accounting_books(self, legal_entity_reference: str) -> dict[str, object]: + """Return existing accounting_book rows for one legal entity.""" + if not legal_entity_reference: + raise AccountingValidationError( + "legal_entity_reference is required. " + "Supply that catalog field, then retry the accounting-book list." + ) + with self._session() as connection: + tenant_id = self._require_tenant(connection) + legal_entity_id = self._load_legal_entity( + connection, tenant_id, legal_entity_reference, "the accounting-book list" + )[0] + rows = connection.execute( + """ + SELECT accounting_book.book_name, + accounting_book.book_role_code + FROM accounting_core.accounting_book + WHERE accounting_book.tenant_account_id = %s + AND accounting_book.legal_entity_id = %s + AND accounting_book.valid_to IS NULL + ORDER BY accounting_book.book_name + """, + (tenant_id, legal_entity_id), + ).fetchall() + return { + "tenant_reference": self._tenant_reference, + "legal_entity_reference": legal_entity_reference, + "accounting_books": [ + { + "accounting_book_reference": book_name, + "book_reference": book_name, + "intended_book_role_code": book_role_code, + "book_name": book_name, + } + for book_name, book_role_code in rows + ], + } + + def load_chart_accounts( + self, legal_entity_reference: str, accounting_book_reference: str + ) -> dict[str, object]: + """Return existing chart_account rows for one legal entity and book.""" + if not legal_entity_reference or not accounting_book_reference: + raise AccountingValidationError( + "legal_entity_reference and book_reference are required. " + "Supply those catalog fields, then retry the chart-account read." + ) + with self._session() as connection: + tenant_id = self._require_tenant(connection) + legal_entity_id = self._load_legal_entity( + connection, tenant_id, legal_entity_reference, "the chart-account read" + )[0] + book_id = self._require_book_for_close( + connection, + tenant_id, + legal_entity_id, + accounting_book_reference, + "the chart-account read", + )[0] + rows = connection.execute( + """ + SELECT chart_account.chart_account_code, + chart_account.account_name, + chart_account.normal_balance_code, + chart_account.account_class_code + FROM accounting_core.chart_account + WHERE chart_account.tenant_account_id = %s + AND chart_account.accounting_book_id = %s + AND chart_account.valid_to IS NULL + ORDER BY chart_account.chart_account_code + """, + (tenant_id, book_id), + ).fetchall() + return { + "tenant_reference": self._tenant_reference, + "legal_entity_reference": legal_entity_reference, + "accounting_book_reference": accounting_book_reference, + "book_reference": accounting_book_reference, + "chart_accounts": [ + { + "chart_account_code": account_code, + "account_name": account_name, + "normal_balance_code": normal_balance_code, + "account_class_code": account_class_code, + } + for ( + account_code, + account_name, + normal_balance_code, + account_class_code, + ) in rows + ], + } + + def trial_balance( + self, + tenant_reference: str, + legal_entity_reference: str, + accounting_book_reference: str, + through_date: date, + ) -> dict[str, AccountBalance]: + """Aggregate posted lines in one tenant/entity/book scope through a date.""" + with self._session() as connection: + tenant_id = self._require_tenant(connection) + if tenant_reference != self._tenant_reference: + return {} + legal_entity_id = connection.execute( + """ + SELECT legal_entity_id + FROM accounting_core.legal_entity_record + WHERE tenant_account_id = %s AND legal_entity_code = %s + """, + (tenant_id, legal_entity_reference), + ).fetchone() + book_id = connection.execute( + """ + SELECT accounting_book_id + FROM accounting_core.accounting_book + WHERE tenant_account_id = %s AND book_name = %s + """, + (tenant_id, accounting_book_reference), + ).fetchone() + if legal_entity_id is None or book_id is None: + return {} + rows = self._aggregate_trial_balance( + connection, tenant_id, legal_entity_id[0], book_id[0], through_date + ) + return { + account_code: AccountBalance(account_code, debit_total, credit_total) + for _account_id, account_code, debit_total, credit_total in rows + } + + def load_period_trial_balance( + self, + legal_entity_reference: str, + accounting_book_reference: str, + period_code: str, + balance_basis_code: str = "", + ) -> dict[str, object]: + """Return snapshot or live trial-balance totals, optionally on an unadjusted, adjusted, or post-close basis.""" + _require_reference(legal_entity_reference, "legal entity reference") + _require_reference(accounting_book_reference, "accounting book reference") + if not period_code.strip(): + raise AccountingValidationError( + "period_code is required. Supply the fiscal period code, then retry the trial-balance read." + ) + if balance_basis_code and balance_basis_code not in { + "unadjusted", + "adjusted", + "post_close", + }: + raise AccountingValidationError( + "balance_basis_code must be unadjusted, adjusted, or post_close. " + "Supply a known trial-balance basis, then retry the trial-balance read." + ) + with self._session() as connection: + tenant_id = self._require_tenant(connection) + legal_entity_id = self._require_legal_entity( + connection, + tenant_id, + legal_entity_reference, + next_action="the trial-balance read", + ) + book_id, _reporting_currency = self._require_book_for_close( + connection, + tenant_id, + legal_entity_id, + accounting_book_reference, + next_action="the trial-balance read", + ) + period_id, period_status_code, period_end_date = self._require_fiscal_period( + connection, + tenant_id, + period_code, + next_action="the trial-balance read", + ) + snapshot_record_id = None + if balance_basis_code == "post_close": + snapshot = self._latest_close_snapshot( + connection, tenant_id, legal_entity_id, book_id, period_id + ) + if snapshot is None: + raise AccountingValidationError( + "balance_basis_code=post_close requires a stored trial_balance_snapshot. " + "Hard-close the period, then retry the trial-balance read." + ) + snapshot_record_id = str(snapshot[0]) + line_rows = self._load_snapshot_balance_lines( + connection, tenant_id, snapshot[0] + ) + balance_source_code = "snapshot" + elif balance_basis_code == "unadjusted": + line_rows = tuple( + (account_code, debit_total, credit_total) + for _account_id, account_code, debit_total, credit_total in self._aggregate_worksheet_trial_balance( + connection, + tenant_id, + legal_entity_id, + book_id, + period_end_date, + exclude_adjusting=True, + ) + ) + balance_source_code = "live" + elif balance_basis_code == "adjusted": + line_rows = tuple( + (account_code, debit_total, credit_total) + for _account_id, account_code, debit_total, credit_total in self._aggregate_worksheet_trial_balance( + connection, + tenant_id, + legal_entity_id, + book_id, + period_end_date, + exclude_adjusting=False, + ) + ) + balance_source_code = "live" + elif period_status_code == "hard_closed": + snapshot = self._latest_close_snapshot( + connection, tenant_id, legal_entity_id, book_id, period_id + ) + if snapshot is None: + raise AccountingValidationError( + f"Fiscal period {period_code} is {period_status_code} without a " + "trial-balance snapshot. Restore the trial_balance_snapshot for this " + "book from the journal population, then retry the trial-balance read." + ) + snapshot_record_id = str(snapshot[0]) + line_rows = self._load_snapshot_balance_lines( + connection, tenant_id, snapshot[0] + ) + balance_source_code = "snapshot" + else: + line_rows = tuple( + (account_code, debit_total, credit_total) + for _account_id, account_code, debit_total, credit_total in self._aggregate_trial_balance( + connection, tenant_id, legal_entity_id, book_id, period_end_date + ) + ) + balance_source_code = "live" + document: dict[str, object] = { + "tenant_reference": self._tenant_reference, + "legal_entity_reference": legal_entity_reference, + "accounting_book_reference": accounting_book_reference, + "book_reference": accounting_book_reference, + "fiscal_period_reference": f"urn:cwl:accounting:fiscal_period:{period_code}", + "period_code": period_code, + "period_status_code": period_status_code, + "balance_source_code": balance_source_code, + "lines": [ + { + "chart_account_code": account_code, + "debit_amount": _exact_amount_text(debit_total), + "credit_amount": _exact_amount_text(credit_total), + "net_balance_amount": _exact_amount_text(debit_total - credit_total), + } + for account_code, debit_total, credit_total in line_rows + ], + } + if snapshot_record_id is not None: + document["snapshot_record_id"] = snapshot_record_id + if balance_basis_code: + document["balance_basis_code"] = balance_basis_code + return document + + def load_financial_statement( + self, + legal_entity_reference: str, + accounting_book_reference: str, + period_code: str, + statement_type_code: str, + comparison_period_code: str = "", + statement_scope_code: str = "", + ) -> dict[str, object]: + """Project income-statement, balance-sheet, changes-in-equity, or cash-flow lines from posted books.""" + if statement_scope_code not in {"", "period", "year_to_date"}: + raise AccountingValidationError( + "statement_scope_code must be period or year_to_date. " + "Supply a known statement scope, then retry the financial-statement read." + ) + if statement_type_code == "income_statement": + allowed_classes = frozenset({"revenue", "expense"}) + elif statement_type_code == "balance_sheet": + allowed_classes = frozenset({"asset", "liability", "equity"}) + elif statement_type_code == "changes_in_equity": + allowed_classes = frozenset({"equity"}) + elif statement_type_code == "cash_flow": + allowed_classes = frozenset() + else: + raise AccountingValidationError( + "statement_type_code must be income_statement, balance_sheet, changes_in_equity, or cash_flow. " + "Supply a known statement type, then retry the financial-statement read." + ) + trial_balance = self.load_period_trial_balance( + legal_entity_reference=legal_entity_reference, + accounting_book_reference=accounting_book_reference, + period_code=period_code, + ) + account_facts = self._load_statement_account_facts( + legal_entity_reference, accounting_book_reference + ) + income_scope_code = ( + "period" if statement_type_code == "balance_sheet" else statement_scope_code + ) + if statement_type_code == "changes_in_equity": + source_lines = self._load_changes_in_equity_lines( + legal_entity_reference=legal_entity_reference, + accounting_book_reference=accounting_book_reference, + period_code=period_code, + statement_scope_code=statement_scope_code, + ) + elif statement_type_code == "cash_flow": + source_lines = self._load_cash_flow_lines( + legal_entity_reference=legal_entity_reference, + accounting_book_reference=accounting_book_reference, + period_code=period_code, + statement_scope_code=statement_scope_code, + ) + elif statement_type_code == "income_statement": + source_lines = self._load_operational_income_lines( + legal_entity_reference=legal_entity_reference, + accounting_book_reference=accounting_book_reference, + period_code=period_code, + statement_scope_code=income_scope_code, + ) + else: + source_lines = [] + for raw_line in trial_balance["lines"]: + account_code = str(raw_line["chart_account_code"]) + account_fact = account_facts.get(account_code) + if account_fact is None: + raise AccountingValidationError( + f"account_role_mapping is missing for chart account {account_code}. " + "Create the account_role_mapping row, then retry the financial-statement read." + ) + account_role_code, account_class_code = account_fact + if account_class_code not in allowed_classes: + continue + source_lines.append( + { + "chart_account_code": account_code, + "account_role_code": account_role_code, + "account_class_code": account_class_code, + "debit_amount": Decimal(str(raw_line["debit_amount"])), + "credit_amount": Decimal(str(raw_line["credit_amount"])), + } + ) + statement_lines: list[dict[str, str]] = [] + total_debit_amount = Decimal("0") + total_credit_amount = Decimal("0") + for raw_line in source_lines: + debit_amount = Decimal(str(raw_line["debit_amount"])) + credit_amount = Decimal(str(raw_line["credit_amount"])) + statement_lines.append( + { + "chart_account_code": str(raw_line["chart_account_code"]), + "account_role_code": str(raw_line["account_role_code"]), + "account_class_code": str(raw_line["account_class_code"]), + "debit_amount": _exact_amount_text(debit_amount), + "credit_amount": _exact_amount_text(credit_amount), + } + ) + total_debit_amount += debit_amount + total_credit_amount += credit_amount + if statement_type_code == "income_statement": + net_income_amount = sum( + ( + Decimal(str(raw_line["credit_amount"])) + - Decimal(str(raw_line["debit_amount"])) + for raw_line in source_lines + ), + Decimal("0"), + ) + elif statement_type_code in {"changes_in_equity", "cash_flow"}: + net_income_amount = next( + Decimal(str(raw_line["credit_amount"])) + - Decimal(str(raw_line["debit_amount"])) + for raw_line in source_lines + if raw_line["account_role_code"] == "period_net_income" + ) + elif str(trial_balance["period_status_code"]) == "hard_closed": + net_income_amount = Decimal("0") + else: + net_income_amount = sum( + ( + Decimal(str(raw_line["credit_amount"])) + - Decimal(str(raw_line["debit_amount"])) + for raw_line in self._load_operational_income_lines( + legal_entity_reference=legal_entity_reference, + accounting_book_reference=accounting_book_reference, + period_code=period_code, + statement_scope_code=income_scope_code, + ) + ), + Decimal("0"), + ) + document = { + "tenant_reference": self._tenant_reference, + "legal_entity_reference": legal_entity_reference, + "accounting_book_reference": accounting_book_reference, + "book_reference": accounting_book_reference, + "fiscal_period_reference": str(trial_balance["fiscal_period_reference"]), + "statement_type_code": statement_type_code, + "statement_lines": statement_lines, + "total_debit_amount": _exact_amount_text(total_debit_amount), + "total_credit_amount": _exact_amount_text(total_credit_amount), + "net_income_amount": _exact_amount_text(net_income_amount), + } + if statement_scope_code == "year_to_date": + document["statement_scope_code"] = "year_to_date" + if comparison_period_code.strip(): + compared = self.load_financial_statement( + legal_entity_reference, + accounting_book_reference, + comparison_period_code.strip(), + statement_type_code, + statement_scope_code=statement_scope_code, + ) + document["comparison_fiscal_period_reference"] = compared[ + "fiscal_period_reference" + ] + document["comparison_statement_lines"] = compared["statement_lines"] + document["comparison_total_debit_amount"] = compared["total_debit_amount"] + document["comparison_total_credit_amount"] = compared["total_credit_amount"] + document["comparison_net_income_amount"] = compared["net_income_amount"] + return document + + def load_financial_statement_package( + self, + legal_entity_reference: str, + accounting_book_reference: str, + period_code: str, + comparison_period_code: str = "", + statement_scope_code: str = "", + ) -> dict[str, object]: + """Return all four financial statements from one REPEATABLE READ snapshot.""" + with self._consistent_read_session(): + return self._assemble_financial_statement_package( + legal_entity_reference, + accounting_book_reference, + period_code, + comparison_period_code=comparison_period_code, + statement_scope_code=statement_scope_code, + ) + + def _assemble_financial_statement_package( + self, + legal_entity_reference: str, + accounting_book_reference: str, + period_code: str, + comparison_period_code: str = "", + statement_scope_code: str = "", + ) -> dict[str, object]: + income_statement = self.load_financial_statement( + legal_entity_reference, + accounting_book_reference, + period_code, + "income_statement", + comparison_period_code, + statement_scope_code, + ) + balance_sheet = self.load_financial_statement( + legal_entity_reference, + accounting_book_reference, + period_code, + "balance_sheet", + comparison_period_code, + statement_scope_code, + ) + changes_in_equity = self.load_financial_statement( + legal_entity_reference, + accounting_book_reference, + period_code, + "changes_in_equity", + comparison_period_code, + statement_scope_code, + ) + cash_flow = self.load_financial_statement( + legal_entity_reference, + accounting_book_reference, + period_code, + "cash_flow", + comparison_period_code, + statement_scope_code, + ) + document: dict[str, object] = { + "tenant_reference": income_statement["tenant_reference"], + "legal_entity_reference": income_statement["legal_entity_reference"], + "accounting_book_reference": income_statement["accounting_book_reference"], + "book_reference": income_statement["book_reference"], + "fiscal_period_reference": income_statement["fiscal_period_reference"], + "income_statement": income_statement, + "balance_sheet": balance_sheet, + "changes_in_equity": changes_in_equity, + "cash_flow": cash_flow, + } + if statement_scope_code == "year_to_date": + document["statement_scope_code"] = "year_to_date" + return document + + def load_period_close_package( + self, + legal_entity_reference: str, + book_reference: str, + period_code: str, + comparison_period_code: str = "", + statement_scope_code: str = "", + ) -> dict[str, object]: + """Return the close-binder worksheets from one REPEATABLE READ ledger snapshot.""" + with self._consistent_read_session(): + return self._assemble_period_close_package( + legal_entity_reference, + book_reference, + period_code, + comparison_period_code=comparison_period_code, + statement_scope_code=statement_scope_code, + ) + + def _assemble_period_close_package( + self, + legal_entity_reference: str, + book_reference: str, + period_code: str, + comparison_period_code: str = "", + statement_scope_code: str = "", + ) -> dict[str, object]: + fiscal_period = self.load_fiscal_period(legal_entity_reference, period_code) + trial_balance = self.load_period_trial_balance( + legal_entity_reference=legal_entity_reference, + accounting_book_reference=book_reference, + period_code=period_code, + ) + income_statement = self.load_financial_statement( + legal_entity_reference, + book_reference, + period_code, + "income_statement", + comparison_period_code, + statement_scope_code, + ) + balance_sheet = self.load_financial_statement( + legal_entity_reference, + book_reference, + period_code, + "balance_sheet", + comparison_period_code, + statement_scope_code, + ) + changes_in_equity = self.load_financial_statement( + legal_entity_reference, + book_reference, + period_code, + "changes_in_equity", + comparison_period_code, + statement_scope_code, + ) + cash_flow = self.load_financial_statement( + legal_entity_reference, + book_reference, + period_code, + "cash_flow", + comparison_period_code, + statement_scope_code, + ) + financial_statement_package: dict[str, object] = { + "tenant_reference": income_statement["tenant_reference"], + "legal_entity_reference": income_statement["legal_entity_reference"], + "accounting_book_reference": income_statement["accounting_book_reference"], + "book_reference": income_statement["book_reference"], + "fiscal_period_reference": income_statement["fiscal_period_reference"], + "income_statement": income_statement, + "balance_sheet": balance_sheet, + "changes_in_equity": changes_in_equity, + "cash_flow": cash_flow, + } + if statement_scope_code == "year_to_date": + financial_statement_package["statement_scope_code"] = "year_to_date" + receivable_aging = self.load_receivable_aging( + legal_entity_reference, + book_reference, + period_code, + ) + payable_aging = self.load_payable_aging( + legal_entity_reference, + book_reference, + period_code, + ) + unapplied_cash_rollforward = self.load_unapplied_cash_rollforward( + legal_entity_reference, + book_reference, + period_code, + ) + vat_period_register = self.load_vat_period_register( + legal_entity_reference, + book_reference, + period_code, + ) + close_page = self.load_period_closes(legal_entity_reference, period_code) + stored_closes = close_page["period_closes"] + period_close = stored_closes[-1] if stored_closes else None + return { + "tenant_reference": trial_balance["tenant_reference"], + "legal_entity_reference": trial_balance["legal_entity_reference"], + "accounting_book_reference": trial_balance["accounting_book_reference"], + "book_reference": trial_balance["book_reference"], + "fiscal_period_reference": trial_balance["fiscal_period_reference"], + "fiscal_period": fiscal_period, + "trial_balance": trial_balance, + "financial_statement_package": financial_statement_package, + "receivable_aging": receivable_aging, + "payable_aging": payable_aging, + "unapplied_cash_rollforward": unapplied_cash_rollforward, + "vat_period_register": vat_period_register, + "period_close": period_close, + } + + def _require_closeable_package(self, package: Mapping[str, object]) -> None: + trial_balance = package["trial_balance"] + lines = trial_balance["lines"] + debit_total = sum( + (Decimal(str(line["debit_amount"])) for line in lines), + Decimal("0"), + ) + credit_total = sum( + (Decimal(str(line["credit_amount"])) for line in lines), + Decimal("0"), + ) + if debit_total != credit_total: + raise AccountingValidationError( + "trial balance does not balance. " + "Correct the posted journals so debit totals equal credit totals, " + "then retry the close." + ) + + def _load_statement_account_facts( + self, legal_entity_reference: str, accounting_book_reference: str + ) -> dict[str, tuple[str, str]]: + with self._session() as connection: + tenant_id = self._require_tenant(connection) + legal_entity_id = self._require_legal_entity( + connection, + tenant_id, + legal_entity_reference, + next_action="the financial-statement read", + ) + book_id = self._require_book_for_close( + connection, + tenant_id, + legal_entity_id, + accounting_book_reference, + next_action="the financial-statement read", + )[0] + rows = connection.execute( + """ + SELECT chart_account.chart_account_code, + account_role_mapping.account_role_code, + chart_account.account_class_code + FROM accounting_core.chart_account + JOIN accounting_core.account_role_mapping + ON account_role_mapping.tenant_account_id = chart_account.tenant_account_id + AND account_role_mapping.chart_account_id = chart_account.chart_account_id + AND account_role_mapping.valid_to IS NULL + WHERE chart_account.tenant_account_id = %s + AND chart_account.accounting_book_id = %s + AND chart_account.valid_to IS NULL + """, + (tenant_id, book_id), + ).fetchall() + return { + str(account_code): (str(account_role_code), str(account_class_code)) + for account_code, account_role_code, account_class_code in rows + } + + def _load_changes_in_equity_lines( + self, + *, + legal_entity_reference: str, + accounting_book_reference: str, + period_code: str, + statement_scope_code: str, + ) -> list[dict[str, object]]: + income_lines = self._load_operational_income_lines( + legal_entity_reference=legal_entity_reference, + accounting_book_reference=accounting_book_reference, + period_code=period_code, + statement_scope_code=statement_scope_code, + ) + period_net_income = sum( + ( + Decimal(str(line["credit_amount"])) - Decimal(str(line["debit_amount"])) + for line in income_lines + ), + Decimal("0"), + ) + with self._session() as connection: + tenant_id = self._require_tenant(connection) + legal_entity_id = self._require_legal_entity( + connection, + tenant_id, + legal_entity_reference, + next_action="the financial-statement read", + ) + book_id = self._require_book_for_close( + connection, + tenant_id, + legal_entity_id, + accounting_book_reference, + next_action="the financial-statement read", + )[0] + period_ids = self._statement_period_ids( + connection, + tenant_id, + period_code, + statement_scope_code, + ) + scope_start = connection.execute( + """ + SELECT MIN(period_start_date) + FROM accounting_core.fiscal_period + WHERE tenant_account_id = %s + AND fiscal_period_id = ANY(%s) + """, + (tenant_id, period_ids), + ).fetchone()[0] + opening_equity = self._opening_equity_amount( + connection, + tenant_id, + legal_entity_id, + book_id, + scope_start, + ) + other_equity_movements = self._other_equity_movement_amount( + connection, + tenant_id, + legal_entity_id, + book_id, + period_ids, + ) + closing_equity = opening_equity + period_net_income + other_equity_movements + return [ + self._equity_movement_line("opening_equity", opening_equity), + self._equity_movement_line("period_net_income", period_net_income), + self._equity_movement_line("other_equity_movements", other_equity_movements), + self._equity_movement_line("closing_equity", closing_equity), + ] + + def _opening_equity_amount( + self, + connection: object, + tenant_id: UUID, + legal_entity_id: UUID, + book_id: UUID, + scope_start: date, + ) -> Decimal: + prior_snapshot = connection.execute( + """ + SELECT trial_balance_snapshot.trial_balance_snapshot_id + FROM accounting_core.fiscal_period + JOIN accounting_reporting.trial_balance_snapshot + ON trial_balance_snapshot.tenant_account_id = fiscal_period.tenant_account_id + AND trial_balance_snapshot.fiscal_period_id = fiscal_period.fiscal_period_id + AND trial_balance_snapshot.legal_entity_id = %s + AND trial_balance_snapshot.accounting_book_id = %s + WHERE fiscal_period.tenant_account_id = %s + AND fiscal_period.period_end_date < %s + AND fiscal_period.period_status_code = 'hard_closed' + ORDER BY fiscal_period.period_end_date DESC, fiscal_period.period_code DESC + LIMIT 1 + """, + (legal_entity_id, book_id, tenant_id, scope_start), + ).fetchone() + if prior_snapshot is not None: + amount = connection.execute( + """ + SELECT COALESCE( + SUM( + trial_balance_line.credit_total_amount + - trial_balance_line.debit_total_amount + ), + 0 + ) + FROM accounting_reporting.trial_balance_line + JOIN accounting_core.chart_account + ON chart_account.tenant_account_id = trial_balance_line.tenant_account_id + AND chart_account.chart_account_id = trial_balance_line.chart_account_id + WHERE trial_balance_line.tenant_account_id = %s + AND trial_balance_line.trial_balance_snapshot_id = %s + AND chart_account.account_class_code = 'equity' + """, + (tenant_id, prior_snapshot[0]), + ).fetchone()[0] + return Decimal(amount) + amount = connection.execute( + """ + SELECT COALESCE( + SUM(journal_entry_line.credit_amount - journal_entry_line.debit_amount), + 0 + ) + FROM accounting_core.journal_entry_line + JOIN accounting_core.general_journal + ON general_journal.tenant_account_id = journal_entry_line.tenant_account_id + AND general_journal.general_journal_id = journal_entry_line.general_journal_id + JOIN accounting_core.chart_account + ON chart_account.tenant_account_id = journal_entry_line.tenant_account_id + AND chart_account.chart_account_id = journal_entry_line.chart_account_id + WHERE general_journal.tenant_account_id = %s + AND general_journal.legal_entity_id = %s + AND general_journal.accounting_book_id = %s + AND general_journal.accounting_date <= %s + AND chart_account.account_class_code = 'equity' + """, + ( + tenant_id, + legal_entity_id, + book_id, + scope_start - timedelta(days=1), + ), + ).fetchone()[0] + return Decimal(amount) + + def _other_equity_movement_amount( + self, + connection: object, + tenant_id: UUID, + legal_entity_id: UUID, + book_id: UUID, + period_ids: list[UUID], + ) -> Decimal: + amount = connection.execute( + """ + SELECT COALESCE( + SUM(journal_entry_line.credit_amount - journal_entry_line.debit_amount), + 0 + ) + FROM accounting_core.journal_entry_line + JOIN accounting_core.general_journal + ON general_journal.tenant_account_id = journal_entry_line.tenant_account_id + AND general_journal.general_journal_id = journal_entry_line.general_journal_id + JOIN accounting_core.chart_account + ON chart_account.tenant_account_id = journal_entry_line.tenant_account_id + AND chart_account.chart_account_id = journal_entry_line.chart_account_id + WHERE general_journal.tenant_account_id = %s + AND general_journal.legal_entity_id = %s + AND general_journal.accounting_book_id = %s + AND general_journal.fiscal_period_id = ANY(%s) + AND chart_account.account_class_code = 'equity' + AND general_journal.journal_reference NOT LIKE %s + """, + ( + tenant_id, + legal_entity_id, + book_id, + period_ids, + _CLOSING_JOURNAL_PATTERN, + ), + ).fetchone()[0] + return Decimal(amount) + + def _equity_movement_line( + self, + account_role_code: str, + amount: Decimal, + account_class_code: str = "equity", + ) -> dict[str, object]: + debit_amount = Decimal("0") if amount >= 0 else -amount + credit_amount = amount if amount >= 0 else Decimal("0") + return { + "chart_account_code": "", + "account_role_code": account_role_code, + "account_class_code": account_class_code, + "debit_amount": debit_amount, + "credit_amount": credit_amount, + } + + def _load_cash_flow_lines( + self, + *, + legal_entity_reference: str, + accounting_book_reference: str, + period_code: str, + statement_scope_code: str, + ) -> list[dict[str, object]]: + income_lines = self._load_operational_income_lines( + legal_entity_reference=legal_entity_reference, + accounting_book_reference=accounting_book_reference, + period_code=period_code, + statement_scope_code=statement_scope_code, + ) + period_net_income = sum( + ( + Decimal(str(line["credit_amount"])) - Decimal(str(line["debit_amount"])) + for line in income_lines + ), + Decimal("0"), + ) + with self._session() as connection: + tenant_id = self._require_tenant(connection) + legal_entity_id = self._require_legal_entity( + connection, + tenant_id, + legal_entity_reference, + next_action="the financial-statement read", + ) + book_id = self._require_book_for_close( + connection, + tenant_id, + legal_entity_id, + accounting_book_reference, + next_action="the financial-statement read", + )[0] + period_ids = self._statement_period_ids( + connection, + tenant_id, + period_code, + statement_scope_code, + ) + scope_start = connection.execute( + """ + SELECT MIN(period_start_date) + FROM accounting_core.fiscal_period + WHERE tenant_account_id = %s + AND fiscal_period_id = ANY(%s) + """, + (tenant_id, period_ids), + ).fetchone()[0] + opening_cash = self._opening_cash_amount( + connection, + tenant_id, + legal_entity_id, + book_id, + scope_start, + ) + operating_working_capital = self._operating_working_capital_amount( + connection, + tenant_id, + legal_entity_id, + book_id, + period_ids, + ) + cash_from_financing = self._other_equity_movement_amount( + connection, + tenant_id, + legal_entity_id, + book_id, + period_ids, + ) + cash_from_investing = Decimal("0") + cash_from_operations = period_net_income + operating_working_capital + net_cash_change = cash_from_operations + cash_from_investing + cash_from_financing + closing_cash = opening_cash + net_cash_change + return [ + self._equity_movement_line("period_net_income", period_net_income, ""), + self._equity_movement_line( + "operating_working_capital", operating_working_capital, "" + ), + self._equity_movement_line("cash_from_operations", cash_from_operations, ""), + self._equity_movement_line("cash_from_investing", cash_from_investing, ""), + self._equity_movement_line("cash_from_financing", cash_from_financing, ""), + self._equity_movement_line("net_cash_change", net_cash_change, ""), + self._equity_movement_line("opening_cash", opening_cash, ""), + self._equity_movement_line("closing_cash", closing_cash, ""), + ] + + def _opening_cash_amount( + self, + connection: object, + tenant_id: UUID, + legal_entity_id: UUID, + book_id: UUID, + scope_start: date, + ) -> Decimal: + prior_snapshot = connection.execute( + """ + SELECT trial_balance_snapshot.trial_balance_snapshot_id + FROM accounting_core.fiscal_period + JOIN accounting_reporting.trial_balance_snapshot + ON trial_balance_snapshot.tenant_account_id = fiscal_period.tenant_account_id + AND trial_balance_snapshot.fiscal_period_id = fiscal_period.fiscal_period_id + AND trial_balance_snapshot.legal_entity_id = %s + AND trial_balance_snapshot.accounting_book_id = %s + WHERE fiscal_period.tenant_account_id = %s + AND fiscal_period.period_end_date < %s + AND fiscal_period.period_status_code = 'hard_closed' + ORDER BY fiscal_period.period_end_date DESC, fiscal_period.period_code DESC + LIMIT 1 + """, + (legal_entity_id, book_id, tenant_id, scope_start), + ).fetchone() + if prior_snapshot is not None: + amount = connection.execute( + """ + SELECT COALESCE( + SUM( + trial_balance_line.debit_total_amount + - trial_balance_line.credit_total_amount + ), + 0 + ) + FROM accounting_reporting.trial_balance_line + JOIN accounting_core.account_role_mapping + ON account_role_mapping.tenant_account_id = trial_balance_line.tenant_account_id + AND account_role_mapping.chart_account_id = trial_balance_line.chart_account_id + AND account_role_mapping.accounting_book_id = %s + AND account_role_mapping.account_role_code = 'cash_receipt' + AND account_role_mapping.valid_to IS NULL + WHERE trial_balance_line.tenant_account_id = %s + AND trial_balance_line.trial_balance_snapshot_id = %s + """, + (book_id, tenant_id, prior_snapshot[0]), + ).fetchone()[0] + return Decimal(amount) + amount = connection.execute( + """ + SELECT COALESCE( + SUM(journal_entry_line.debit_amount - journal_entry_line.credit_amount), + 0 + ) + FROM accounting_core.journal_entry_line + JOIN accounting_core.general_journal + ON general_journal.tenant_account_id = journal_entry_line.tenant_account_id + AND general_journal.general_journal_id = journal_entry_line.general_journal_id + JOIN accounting_core.account_role_mapping + ON account_role_mapping.tenant_account_id = journal_entry_line.tenant_account_id + AND account_role_mapping.chart_account_id = journal_entry_line.chart_account_id + AND account_role_mapping.accounting_book_id = %s + AND account_role_mapping.account_role_code = 'cash_receipt' + AND account_role_mapping.valid_to IS NULL + WHERE general_journal.tenant_account_id = %s + AND general_journal.legal_entity_id = %s + AND general_journal.accounting_book_id = %s + AND general_journal.accounting_date <= %s + """, + ( + book_id, + tenant_id, + legal_entity_id, + book_id, + scope_start - timedelta(days=1), + ), + ).fetchone()[0] + return Decimal(amount) + + def _operating_working_capital_amount( + self, + connection: object, + tenant_id: UUID, + legal_entity_id: UUID, + book_id: UUID, + period_ids: list[UUID], + ) -> Decimal: + amount = connection.execute( + """ + SELECT COALESCE( + SUM(journal_entry_line.credit_amount - journal_entry_line.debit_amount), + 0 + ) + FROM accounting_core.journal_entry_line + JOIN accounting_core.general_journal + ON general_journal.tenant_account_id = journal_entry_line.tenant_account_id + AND general_journal.general_journal_id = journal_entry_line.general_journal_id + JOIN accounting_core.chart_account + ON chart_account.tenant_account_id = journal_entry_line.tenant_account_id + AND chart_account.chart_account_id = journal_entry_line.chart_account_id + WHERE general_journal.tenant_account_id = %s + AND general_journal.legal_entity_id = %s + AND general_journal.accounting_book_id = %s + AND general_journal.fiscal_period_id = ANY(%s) + AND chart_account.account_class_code IN ('asset', 'liability') + AND chart_account.chart_account_id NOT IN ( + SELECT account_role_mapping.chart_account_id + FROM accounting_core.account_role_mapping + WHERE account_role_mapping.tenant_account_id = %s + AND account_role_mapping.accounting_book_id = %s + AND account_role_mapping.account_role_code = 'cash_receipt' + AND account_role_mapping.valid_to IS NULL + ) + AND general_journal.journal_reference NOT LIKE %s + """, + ( + tenant_id, + legal_entity_id, + book_id, + period_ids, + tenant_id, + book_id, + _CLOSING_JOURNAL_PATTERN, + ), + ).fetchone()[0] + return Decimal(amount) + + def _load_operational_income_lines( + self, + *, + legal_entity_reference: str, + accounting_book_reference: str, + period_code: str, + statement_scope_code: str = "", + ) -> list[dict[str, object]]: + with self._session() as connection: + tenant_id = self._require_tenant(connection) + legal_entity_id = self._require_legal_entity( + connection, + tenant_id, + legal_entity_reference, + next_action="the financial-statement read", + ) + book_id = self._require_book_for_close( + connection, + tenant_id, + legal_entity_id, + accounting_book_reference, + next_action="the financial-statement read", + )[0] + period_ids = self._statement_period_ids( + connection, + tenant_id, + period_code, + statement_scope_code, + ) + rows = connection.execute( + """ + SELECT chart_account.chart_account_code, + account_role_mapping.account_role_code, + chart_account.account_class_code, + SUM(journal_entry_line.debit_amount), + SUM(journal_entry_line.credit_amount) + FROM accounting_core.journal_entry_line + JOIN accounting_core.general_journal + ON general_journal.tenant_account_id = journal_entry_line.tenant_account_id + AND general_journal.general_journal_id = journal_entry_line.general_journal_id + JOIN accounting_core.chart_account + ON chart_account.tenant_account_id = journal_entry_line.tenant_account_id + AND chart_account.chart_account_id = journal_entry_line.chart_account_id + LEFT JOIN accounting_core.account_role_mapping + ON account_role_mapping.tenant_account_id = chart_account.tenant_account_id + AND account_role_mapping.chart_account_id = chart_account.chart_account_id + AND account_role_mapping.valid_to IS NULL + WHERE general_journal.tenant_account_id = %s + AND general_journal.legal_entity_id = %s + AND general_journal.accounting_book_id = %s + AND general_journal.fiscal_period_id = ANY(%s) + AND chart_account.account_class_code IN ('revenue', 'expense') + AND general_journal.journal_reference NOT LIKE %s + GROUP BY chart_account.chart_account_code, + account_role_mapping.account_role_code, + chart_account.account_class_code + ORDER BY chart_account.chart_account_code + """, + ( + tenant_id, + legal_entity_id, + book_id, + period_ids, + _CLOSING_JOURNAL_PATTERN, + ), + ).fetchall() + lines: list[dict[str, object]] = [] + for account_code, account_role_code, account_class_code, debit_total, credit_total in rows: + if account_role_code is None: + raise AccountingValidationError( + f"account_role_mapping is missing for chart account {account_code}. " + "Create the account_role_mapping row, then retry the financial-statement read." + ) + lines.append( + { + "chart_account_code": str(account_code), + "account_role_code": str(account_role_code), + "account_class_code": str(account_class_code), + "debit_amount": Decimal(debit_total), + "credit_amount": Decimal(credit_total), + } + ) + return lines + + def _statement_period_ids( + self, + connection: object, + tenant_id: UUID, + period_code: str, + statement_scope_code: str, + ) -> list[UUID]: + period_id, calendar_id, requested_code, period_start_date = connection.execute( + """ + SELECT fiscal_period_id, fiscal_calendar_id, period_code, period_start_date + FROM accounting_core.fiscal_period + WHERE tenant_account_id = %s AND period_code = %s + """, + (tenant_id, period_code), + ).fetchone() + if statement_scope_code in {"", "period"}: + return [period_id] + fiscal_year = _fiscal_year_identity(str(requested_code), period_start_date) + peers = connection.execute( + """ + SELECT fiscal_period_id, period_code, period_start_date + FROM accounting_core.fiscal_period + WHERE tenant_account_id = %s + AND fiscal_calendar_id = %s + AND period_start_date <= %s + ORDER BY period_start_date, period_code + """, + (tenant_id, calendar_id, period_start_date), + ).fetchall() + return [ + peer_id + for peer_id, peer_code, peer_start in peers + if _fiscal_year_identity(str(peer_code), peer_start) == fiscal_year + ] + + @contextmanager + def _consistent_read_session(self) -> Iterator[object]: + with self._session() as connection: + connection.execute("SET TRANSACTION ISOLATION LEVEL REPEATABLE READ") + self._active_connection = connection + try: + yield connection + finally: + self._active_connection = None + + @contextmanager + def _session(self) -> Iterator[object]: + if self._active_connection is not None: + yield self._active_connection + return + psycopg = _import_psycopg() + try: + connection = psycopg.connect(self._database_url) + except Exception as error: + raise AccountingValidationError( + "PostgreSQL is not reachable. Start PostgreSQL 18, set ACCOUNTING_DATABASE_URL " + "to that server, then retry posting." + ) from error + try: + connection.execute("SET lock_timeout = '5s'") + connection.execute("SET idle_in_transaction_session_timeout = '60s'") + yield connection + except Exception: + connection.rollback() + raise + else: + connection.commit() + finally: + connection.close() + + def _acquire_command_lock(self, connection: object, command_scope: str) -> None: + """Serialize one tenant command scope until the current transaction ends.""" + connection.execute( + "SELECT pg_advisory_xact_lock(hashtext(%s), hashtext(%s))", + (self._tenant_reference, command_scope), + ) + + def _require_tenant(self, connection: object) -> UUID: + row = connection.execute( + """ + SELECT tenant_account_id + FROM accounting_core.tenant_account + WHERE tenant_account_code = %s + """, + (self._tenant_reference,), + ).fetchone() + if row is None: + raise AccountingValidationError( + f"Tenant {self._tenant_reference} is not recorded. Create the tenant_account row, then retry posting." + ) + requested_tenant_id = row[0] + bound_tenant_id = connection.execute( + "SELECT accounting_core.current_tenant_account_id()" + ).fetchone()[0] + if bound_tenant_id is not None: + if bound_tenant_id != requested_tenant_id: + raise AccountingValidationError( + "the database session is not provisioned for this tenant. " + "Ask the platform operator to verify tenant provisioning, " + "then retry the request." + ) + return requested_tenant_id + rolsuper, rolbypassrls = connection.execute( + """ + SELECT rolsuper, rolbypassrls + FROM pg_catalog.pg_roles + WHERE rolname = session_user + """ + ).fetchone() + if rolsuper or rolbypassrls: + return requested_tenant_id + raise AccountingValidationError( + "this request cannot be authorized for the requested tenant. " + "Ask the platform operator to verify tenant provisioning, then retry." + ) + + def _require_legal_entity( + self, + connection: object, + tenant_id: UUID, + legal_entity_reference: str, + next_action: str = "posting", + ) -> UUID: + return self._load_legal_entity(connection, tenant_id, legal_entity_reference, next_action)[0] + + def _load_legal_entity( + self, + connection: object, + tenant_id: UUID, + legal_entity_reference: str, + next_action: str = "posting", + ) -> tuple[UUID, str]: + row = connection.execute( + """ + SELECT legal_entity_id, functional_currency_code + FROM accounting_core.legal_entity_record + WHERE tenant_account_id = %s AND legal_entity_code = %s AND valid_to IS NULL + """, + (tenant_id, legal_entity_reference), + ).fetchone() + if row is None: + raise AccountingValidationError( + f"Legal entity {legal_entity_reference} is not recorded for this tenant. " + f"Create the legal_entity_record row, then retry {next_action}." + ) + return row[0], row[1] + + def _require_book( + self, + connection: object, + tenant_id: UUID, + legal_entity_id: UUID, + book_role_code: str, + accounting_book_reference: str, + ) -> UUID: + row = connection.execute( + """ + SELECT accounting_book_id + FROM accounting_core.accounting_book + WHERE tenant_account_id = %s + AND legal_entity_id = %s + AND book_role_code = %s + AND valid_to IS NULL + """, + (tenant_id, legal_entity_id, book_role_code), + ).fetchone() + if row is None: + raise AccountingValidationError( + f"Accounting book {accounting_book_reference} is not recorded for this legal entity. " + "Create the accounting_book row, then retry posting." + ) + return row[0] + + def _require_open_book_period( + self, + connection: object, + tenant_id: UUID, + book_id: UUID, + accounting_date: date, + ) -> UUID: + """Require an open fiscal period for the selected accounting book.""" + return self._require_open_book_period_bounds( + connection, tenant_id, book_id, accounting_date + )[0] + + def _require_open_book_period_bounds( + self, + connection: object, + tenant_id: UUID, + book_id: UUID, + accounting_date: date, + ) -> tuple[UUID, date, date]: + """Return period identity and bounds when this accounting book is open.""" + row = connection.execute( + """ + SELECT fiscal_period.fiscal_period_id, + fiscal_period.period_code, + COALESCE( + accounting_book_period_control.period_status_code, + fiscal_period.period_status_code + ), + fiscal_period.period_start_date, + fiscal_period.period_end_date + FROM accounting_core.fiscal_period + LEFT JOIN accounting_core.accounting_book_period_control + ON accounting_book_period_control.tenant_account_id + = fiscal_period.tenant_account_id + AND accounting_book_period_control.fiscal_period_id + = fiscal_period.fiscal_period_id + AND accounting_book_period_control.accounting_book_id = %s + WHERE fiscal_period.tenant_account_id = %s + AND fiscal_period.period_start_date <= %s + AND fiscal_period.period_end_date >= %s + """, + (book_id, tenant_id, accounting_date, accounting_date), + ).fetchone() + if row is None: + raise AccountingValidationError( + f"No fiscal period covers accounting date {accounting_date.isoformat()}. " + "Create an open fiscal period on the tenant calendar, then retry posting." + ) + period_id, period_code = row[0], row[1] + row = connection.execute( + """ + SELECT fiscal_period.fiscal_period_id, + fiscal_period.period_code, + COALESCE( + accounting_book_period_control.period_status_code, + fiscal_period.period_status_code + ), + fiscal_period.period_start_date, + fiscal_period.period_end_date + FROM accounting_core.fiscal_period + LEFT JOIN accounting_core.accounting_book_period_control + ON accounting_book_period_control.tenant_account_id + = fiscal_period.tenant_account_id + AND accounting_book_period_control.fiscal_period_id + = fiscal_period.fiscal_period_id + AND accounting_book_period_control.accounting_book_id = %s + WHERE fiscal_period.tenant_account_id = %s + AND fiscal_period.fiscal_period_id = %s + """, + (book_id, tenant_id, period_id), + ).fetchone() + if row is None: + raise AccountingValidationError( + f"No fiscal period covers accounting date {accounting_date.isoformat()}. " + "Create an open fiscal period on the tenant calendar, then retry posting." + ) + if row[2] != "open": + locked_marker = " (period_closed)" if row[2] == "hard_closed" else "" + raise AccountingValidationError( + f"Fiscal period {row[1]} is {row[2]}{locked_marker}. " + "Open that period or post into an open period for this accounting book; " + "no journal was written." + ) + return row[0], row[3], row[4] + + def _require_adjusting_period( + self, connection: object, tenant_id: UUID, accounting_date: date + ) -> UUID: + return self._require_adjusting_period_bounds(connection, tenant_id, accounting_date)[0] + + def _require_adjusting_period_bounds( + self, connection: object, tenant_id: UUID, accounting_date: date + ) -> tuple[UUID, date, date]: + return self._require_period_bounds( + connection, + tenant_id, + accounting_date, + allowed_status_codes=frozenset({"open", "soft_closed"}), + next_action="Reverse into an open or soft-closed period", + ) + + def _require_period_bounds( + self, + connection: object, + tenant_id: UUID, + accounting_date: date, + *, + allowed_status_codes: frozenset[str], + next_action: str, + ) -> tuple[UUID, date, date]: + row = connection.execute( + """ + SELECT fiscal_period_id, period_code, period_status_code, + period_start_date, period_end_date + FROM accounting_core.fiscal_period + WHERE tenant_account_id = %s + AND period_start_date <= %s + AND period_end_date >= %s + """, + (tenant_id, accounting_date, accounting_date), + ).fetchone() + if row is None: + raise AccountingValidationError( + f"No fiscal period covers accounting date {accounting_date.isoformat()}. " + "Create an open fiscal period on the tenant calendar, then retry posting." + ) + period_id, period_code = row[0], row[1] + self._acquire_command_lock(connection, f"period:{period_code}") + row = connection.execute( + """ + SELECT fiscal_period_id, period_code, period_status_code, + period_start_date, period_end_date + FROM accounting_core.fiscal_period + WHERE tenant_account_id = %s + AND fiscal_period_id = %s + """, + (tenant_id, period_id), + ).fetchone() + if row is None: + raise AccountingValidationError( + f"No fiscal period covers accounting date {accounting_date.isoformat()}. " + "Create an open fiscal period on the tenant calendar, then retry posting." + ) + if row[2] not in allowed_status_codes: + locked_marker = " (period_closed)" if row[2] == "hard_closed" else "" + raise AccountingValidationError( + f"Fiscal period {row[1]} is {row[2]}{locked_marker}. {next_action}; " + "no journal was written." + ) + return row[0], row[3], row[4] + + def _resolve_accounting_policy( + self, connection: object, tenant_id: UUID, proposal: JournalProposal + ) -> AccountingPolicy: + if proposal.tenant_reference != self._tenant_reference: + raise AccountingValidationError( + "proposal tenant scope does not match this deployment. " + "Send the proposal to that tenant's accounting endpoint, then retry posting." + ) + legal_entity_id, functional_currency = self._load_legal_entity( + connection, tenant_id, proposal.legal_entity_reference + ) + book_id, book_name = self._require_book_for_role( + connection, + tenant_id, + legal_entity_id, + proposal.intended_book_role_code, + ) + _period_id, period_start, period_end = self._require_open_book_period_bounds( + connection, tenant_id, book_id, proposal.accounting_date + ) + mapping, policy_version, rule_version = self._load_role_mapping( + connection, tenant_id, book_id, proposal + ) + return AccountingPolicy( + tenant_reference=proposal.tenant_reference, + legal_entity_reference=proposal.legal_entity_reference, + accounting_book_reference=book_name, + intended_book_role_code=proposal.intended_book_role_code, + transaction_currency=proposal.transaction_currency, + functional_currency=functional_currency, + open_period_start=period_start, + open_period_end=period_end, + chart_account_mapping=mapping, + accounting_policy_version=policy_version, + posting_rule_version=rule_version, + ) + + def _require_book_for_role( + self, + connection: object, + tenant_id: UUID, + legal_entity_id: UUID, + book_role_code: str, + ) -> tuple[UUID, str]: + row = connection.execute( + """ + SELECT accounting_book_id, book_name + FROM accounting_core.accounting_book + WHERE tenant_account_id = %s + AND legal_entity_id = %s + AND book_role_code = %s + AND valid_to IS NULL + """, + (tenant_id, legal_entity_id, book_role_code), + ).fetchone() + if row is None: + raise AccountingValidationError( + f"Accounting book for role {book_role_code} is not recorded for this legal entity. " + "Create the accounting_book row, then retry posting." + ) + return row[0], row[1] + + def _load_role_mapping( + self, + connection: object, + tenant_id: UUID, + book_id: UUID, + proposal: JournalProposal, + ) -> tuple[dict[str, str], str, str]: + role_codes = tuple(dict.fromkeys(line.account_role_code for line in proposal.lines)) + as_of = datetime.combine( + proposal.accounting_date, datetime.min.time(), tzinfo=timezone.utc + ) + rows = connection.execute( + """ + SELECT account_role_mapping.account_role_code, + chart_account.chart_account_code, + account_role_mapping.accounting_policy_version, + account_role_mapping.posting_rule_version + FROM accounting_core.account_role_mapping + JOIN accounting_core.chart_account + ON chart_account.tenant_account_id = account_role_mapping.tenant_account_id + AND chart_account.chart_account_id = account_role_mapping.chart_account_id + WHERE account_role_mapping.tenant_account_id = %s + AND account_role_mapping.accounting_book_id = %s + AND account_role_mapping.account_role_code = ANY(%s) + AND account_role_mapping.valid_from <= %s + AND ( + account_role_mapping.valid_to IS NULL + OR account_role_mapping.valid_to > %s + ) + """, + (tenant_id, book_id, list(role_codes), as_of, as_of), + ).fetchall() + if not rows: + raise AccountingValidationError( + "No account_role_mapping is effective for this book and accounting date. " + "Create the account_role_mapping rows, then retry posting." + ) + seen_roles: dict[str, tuple[str, str, str]] = {} + for role_code, account_code, policy_version, rule_version in rows: + if role_code in seen_roles: + raise AccountingValidationError( + f"More than one effective account_role_mapping applies for role {role_code}. " + "Close the superseded mapping, then retry posting." + ) + seen_roles[role_code] = (account_code, policy_version, rule_version) + missing_roles = [role_code for role_code in role_codes if role_code not in seen_roles] + if missing_roles: + raise AccountingValidationError( + f"Account role {missing_roles[0]} is not mapped on this book. " + "Create the account_role_mapping row, then retry posting." + ) + versions = {(policy_version, rule_version) for _code, policy_version, rule_version in seen_roles.values()} + if len(versions) != 1: + raise AccountingValidationError( + "Account role mappings use more than one policy version. " + "Approve a single effective mapping set, then retry posting." + ) + policy_version, rule_version = next(iter(versions)) + return ( + {role_code: account_code for role_code, (account_code, _, _) in seen_roles.items()}, + policy_version, + rule_version, + ) + + def _require_book_for_close( + self, + connection: object, + tenant_id: UUID, + legal_entity_id: UUID, + accounting_book_reference: str, + next_action: str = "the close", + ) -> tuple[UUID, str]: + row = connection.execute( + """ + SELECT accounting_book_id, reporting_currency_code + FROM accounting_core.accounting_book + WHERE tenant_account_id = %s + AND legal_entity_id = %s + AND book_name = %s + AND valid_to IS NULL + """, + (tenant_id, legal_entity_id, accounting_book_reference), + ).fetchone() + if row is None: + raise AccountingValidationError( + f"Accounting book {accounting_book_reference} is not recorded for this legal entity. " + f"Create the accounting_book row, then retry {next_action}." + ) + return row[0], row[1] + + def _require_fiscal_period( + self, + connection: object, + tenant_id: UUID, + period_code: str, + next_action: str = "the close", + ) -> tuple[UUID, str, date]: + row = connection.execute( + """ + SELECT fiscal_period_id, period_status_code, period_end_date + FROM accounting_core.fiscal_period + WHERE tenant_account_id = %s AND period_code = %s + """, + (tenant_id, period_code), + ).fetchone() + if row is None: + raise AccountingValidationError( + f"Fiscal period {period_code} is not recorded for this tenant. " + f"Create the fiscal_period row, then retry {next_action}." + ) + return row[0], row[1], row[2] + + def _lock_book_period( + self, + connection: object, + tenant_id: UUID, + book_id: UUID, + period_code: str, + ) -> tuple[UUID, str, date]: + """Materialize and lock close state independently for one accounting book.""" + period_row = connection.execute( + """ + SELECT fiscal_period_id, period_status_code, period_closed_at + FROM accounting_core.fiscal_period + WHERE tenant_account_id = %s AND period_code = %s + """, + (tenant_id, period_code), + ).fetchone() + if period_row is None: + raise AccountingValidationError( + f"Fiscal period {period_code} is not recorded for this tenant. " + "Create the fiscal_period row, then retry the close." + ) + period_id = period_row[0] + connection.execute( + """ + INSERT INTO accounting_core.accounting_book_period_control ( + tenant_account_id, accounting_book_id, fiscal_period_id, + period_status_code, period_closed_at + ) + SELECT accounting_book.tenant_account_id, + accounting_book.accounting_book_id, + fiscal_period.fiscal_period_id, + fiscal_period.period_status_code, + fiscal_period.period_closed_at + FROM accounting_core.accounting_book + JOIN accounting_core.fiscal_period + ON fiscal_period.tenant_account_id = accounting_book.tenant_account_id + WHERE accounting_book.tenant_account_id = %s + AND accounting_book.valid_to IS NULL + AND fiscal_period.fiscal_period_id = %s + ON CONFLICT (tenant_account_id, accounting_book_id, fiscal_period_id) + DO NOTHING + """, + (tenant_id, period_id), + ) + row = connection.execute( + """ + SELECT fiscal_period.fiscal_period_id, + accounting_book_period_control.period_status_code, + fiscal_period.period_end_date + FROM accounting_core.fiscal_period + JOIN accounting_core.accounting_book_period_control + ON accounting_book_period_control.tenant_account_id + = fiscal_period.tenant_account_id + AND accounting_book_period_control.fiscal_period_id + = fiscal_period.fiscal_period_id + AND accounting_book_period_control.accounting_book_id = %s + WHERE fiscal_period.tenant_account_id = %s + AND fiscal_period.fiscal_period_id = %s + FOR UPDATE OF accounting_book_period_control + """, + (book_id, tenant_id, period_id), + ).fetchone() + if row is None: + raise AccountingValidationError( + f"Fiscal period {period_code} has no control row for this accounting book. " + "Repair the fiscal-period control data for this book, then retry the close." + ) + return row[0], row[1], row[2] + + def _load_book_period_state( + self, + connection: object, + tenant_id: UUID, + book_id: UUID, + period_code: str, + ) -> tuple[UUID, str, date, date] | None: + """Return the selected book's period state, falling back to legacy calendar state.""" + row = connection.execute( + """ + SELECT fiscal_period.fiscal_period_id, + COALESCE( + accounting_book_period_control.period_status_code, + fiscal_period.period_status_code + ), + fiscal_period.period_start_date, + fiscal_period.period_end_date + FROM accounting_core.fiscal_period + LEFT JOIN accounting_core.accounting_book_period_control + ON accounting_book_period_control.tenant_account_id + = fiscal_period.tenant_account_id + AND accounting_book_period_control.fiscal_period_id + = fiscal_period.fiscal_period_id + AND accounting_book_period_control.accounting_book_id = %s + WHERE fiscal_period.tenant_account_id = %s + AND fiscal_period.period_code = %s + """, + (book_id, tenant_id, period_code), + ).fetchone() + if row is None: + return None + return row[0], row[1], row[2], row[3] + + def _load_period_state( + self, connection: object, tenant_id: UUID, period_code: str + ) -> tuple[UUID, str, date, date] | None: + row = connection.execute( + """ + SELECT fiscal_period_id, period_status_code, period_start_date, period_end_date + FROM accounting_core.fiscal_period + WHERE tenant_account_id = %s AND period_code = %s + """, + (tenant_id, period_code), + ).fetchone() + if row is None: + return None + return row[0], row[1], row[2], row[3] + + def _require_tenant_calendar(self, connection: object, tenant_id: UUID) -> UUID: + row = connection.execute( + """ + SELECT fiscal_calendar_id + FROM accounting_core.fiscal_calendar + WHERE tenant_account_id = %s + ORDER BY calendar_code + LIMIT 1 + """, + (tenant_id,), + ).fetchone() + if row is None: + raise AccountingValidationError( + "No fiscal_calendar is recorded for this tenant. " + "Create the fiscal_calendar row, then retry the period open." + ) + return row[0] + + def _period_open_document( + self, + legal_entity_reference: str, + period_code: str, + period_start_date: date, + period_end_date: date, + *, + replayed: bool, + ) -> dict[str, object]: + return { + "tenant_reference": self._tenant_reference, + "legal_entity_reference": legal_entity_reference, + "fiscal_period_reference": f"urn:cwl:accounting:fiscal_period:{period_code}", + "period_code": period_code, + "period_status_code": "open", + "period_start_date": period_start_date.isoformat(), + "period_end_date": period_end_date.isoformat(), + "replayed": replayed, + } + + def _aggregate_trial_balance( + self, + connection: object, + tenant_id: UUID, + legal_entity_id: UUID, + book_id: UUID, + through_date: date, + ) -> tuple[tuple[UUID, str, Decimal, Decimal], ...]: + rows = connection.execute( + """ + SELECT chart_account.chart_account_id, + chart_account.chart_account_code, + SUM(journal_entry_line.debit_amount), + SUM(journal_entry_line.credit_amount) + FROM accounting_core.journal_entry_line + JOIN accounting_core.general_journal + ON general_journal.tenant_account_id = journal_entry_line.tenant_account_id + AND general_journal.general_journal_id = journal_entry_line.general_journal_id + JOIN accounting_core.chart_account + ON chart_account.tenant_account_id = journal_entry_line.tenant_account_id + AND chart_account.chart_account_id = journal_entry_line.chart_account_id + WHERE general_journal.tenant_account_id = %s + AND general_journal.legal_entity_id = %s + AND general_journal.accounting_book_id = %s + AND general_journal.accounting_date <= %s + GROUP BY chart_account.chart_account_id, chart_account.chart_account_code + ORDER BY chart_account.chart_account_code + """, + (tenant_id, legal_entity_id, book_id, through_date), + ).fetchall() + return tuple( + (row[0], row[1], Decimal(row[2]), Decimal(row[3])) for row in rows + ) + + def _aggregate_worksheet_trial_balance( + self, + connection: object, + tenant_id: UUID, + legal_entity_id: UUID, + book_id: UUID, + through_date: date, + *, + exclude_adjusting: bool, + ) -> tuple[tuple[UUID, str, Decimal, Decimal], ...]: + rows = connection.execute( + """ + SELECT chart_account.chart_account_id, + chart_account.chart_account_code, + SUM(journal_entry_line.debit_amount), + SUM(journal_entry_line.credit_amount) + FROM accounting_core.journal_entry_line + JOIN accounting_core.general_journal + ON general_journal.tenant_account_id = journal_entry_line.tenant_account_id + AND general_journal.general_journal_id = journal_entry_line.general_journal_id + JOIN accounting_core.chart_account + ON chart_account.tenant_account_id = journal_entry_line.tenant_account_id + AND chart_account.chart_account_id = journal_entry_line.chart_account_id + WHERE general_journal.tenant_account_id = %s + AND general_journal.legal_entity_id = %s + AND general_journal.accounting_book_id = %s + AND general_journal.accounting_date <= %s + AND general_journal.journal_reference NOT LIKE %s + AND ( + %s + OR journal_entry_line.account_role_code IS DISTINCT FROM %s + ) + GROUP BY chart_account.chart_account_id, chart_account.chart_account_code + ORDER BY chart_account.chart_account_code + """, + ( + tenant_id, + legal_entity_id, + book_id, + through_date, + _CLOSING_JOURNAL_PATTERN, + not exclude_adjusting, + "adjusting", + ), + ).fetchall() + return tuple( + (row[0], row[1], Decimal(row[2]), Decimal(row[3])) for row in rows + ) + + def _count_source_journals( + self, + connection: object, + tenant_id: UUID, + legal_entity_id: UUID, + book_id: UUID, + through_date: date, + ) -> int: + return int( + connection.execute( + """ + SELECT COUNT(*) + FROM accounting_core.general_journal + WHERE tenant_account_id = %s + AND legal_entity_id = %s + AND accounting_book_id = %s + AND accounting_date <= %s + """, + (tenant_id, legal_entity_id, book_id, through_date), + ).fetchone()[0] + ) + + def _latest_close_snapshot( + self, + connection: object, + tenant_id: UUID, + legal_entity_id: UUID, + book_id: UUID, + period_id: UUID, + ) -> tuple[UUID, datetime, int, str, str] | None: + row = connection.execute( + """ + SELECT trial_balance_snapshot_id, snapshot_generated_at, + source_journal_count, source_payload_hash, close_idempotency_key + FROM accounting_reporting.trial_balance_snapshot + WHERE tenant_account_id = %s + AND legal_entity_id = %s + AND accounting_book_id = %s + AND fiscal_period_id = %s + ORDER BY snapshot_generated_at DESC + LIMIT 1 + """, + (tenant_id, legal_entity_id, book_id, period_id), + ).fetchone() + if row is None: + return None + return row[0], row[1], int(row[2]), row[3], str(row[4]) + + def _load_snapshot_balance_lines( + self, connection: object, tenant_id: UUID, snapshot_id: UUID + ) -> tuple[tuple[str, Decimal, Decimal], ...]: + rows = connection.execute( + """ + SELECT chart_account.chart_account_code, + trial_balance_line.debit_total_amount, + trial_balance_line.credit_total_amount + FROM accounting_reporting.trial_balance_line + JOIN accounting_core.chart_account + ON chart_account.tenant_account_id = trial_balance_line.tenant_account_id + AND chart_account.chart_account_id = trial_balance_line.chart_account_id + WHERE trial_balance_line.tenant_account_id = %s + AND trial_balance_line.trial_balance_snapshot_id = %s + ORDER BY chart_account.chart_account_code + """, + (tenant_id, snapshot_id), + ).fetchall() + return tuple((row[0], Decimal(row[1]), Decimal(row[2])) for row in rows) + + def _replay_close_receipt( + self, + connection: object, + *, + tenant_id: UUID, + legal_entity_id: UUID, + book_id: UUID, + period_id: UUID, + period_code: str, + current_status: str, + legal_entity_reference: str, + accounting_book_reference: str, + idempotency_key: str, + ) -> PeriodCloseReceipt: + snapshot = self._latest_close_snapshot( + connection, tenant_id, legal_entity_id, book_id, period_id + ) + if snapshot is None: + raise AccountingValidationError( + f"Fiscal period {period_code} is {current_status} without a trial-balance snapshot. " + "Restore the trial_balance_snapshot for this book from the journal population, " + "then retry the close." + ) + stored_close_key = snapshot[4] + if stored_close_key != idempotency_key: + raise AccountingValidationError( + f"Fiscal period {period_code} is hard_closed (period_closed). " + "Replay the original period-close idempotency key; " + "a second close of a locked period is rejected." + ) + return self._close_receipt_from_snapshot( + snapshot, + period_code=period_code, + period_status_code=current_status, + legal_entity_reference=legal_entity_reference, + accounting_book_reference=accounting_book_reference, + replayed=True, + ) + + def _replay_soft_close_receipt( + self, + connection: object, + *, + tenant_id: UUID, + legal_entity_id: UUID, + book_id: UUID, + period_id: UUID, + period_code: str, + period_end_date: date, + snapshot_currency_code: str, + legal_entity_reference: str, + accounting_book_reference: str, + idempotency_key: str, + ) -> PeriodCloseReceipt: + ( + period_closed_at, + stored_idempotency_key, + source_journal_count, + source_payload_hash, + evidence_complete, + ) = connection.execute( + """ + SELECT COALESCE(period_closed_at, clock_timestamp()), + soft_close_idempotency_key, + soft_close_source_journal_count, + soft_close_source_payload_hash, + ( + soft_close_idempotency_key IS NOT NULL + AND soft_close_source_journal_count IS NOT NULL + AND soft_close_source_payload_hash IS NOT NULL + ) + FROM accounting_core.accounting_book_period_control + WHERE tenant_account_id = %s + AND accounting_book_id = %s + AND fiscal_period_id = %s + """, + (tenant_id, book_id, period_id), + ).fetchone() + if not evidence_complete: + raise AccountingValidationError( + f"Fiscal period {period_code} is soft_closed without durable close-command evidence. " + "Restore the original evidence through an audited migration, then retry; " + "do not reconstruct it from later ledger state." + ) + if stored_idempotency_key != idempotency_key: + raise IdempotencyConflictError( + "period-close idempotency key was already used by the soft-close command. Replay the original close idempotency key, then retry the close." + ) + return PeriodCloseReceipt( + tenant_reference=self._tenant_reference, + legal_entity_reference=legal_entity_reference, + accounting_book_reference=accounting_book_reference, + period_code=period_code, + period_status_code="soft_closed", + snapshot_record_id="", + snapshot_generated_at=period_closed_at, + source_journal_count=source_journal_count, + source_payload_hash=source_payload_hash, + replayed=True, + ) + + def _persist_soft_close( + self, + connection: object, + *, + tenant_id: UUID, + legal_entity_id: UUID, + book_id: UUID, + period_id: UUID, + period_code: str, + period_end_date: date, + snapshot_currency_code: str, + legal_entity_reference: str, + accounting_book_reference: str, + idempotency_key: str, + ) -> PeriodCloseReceipt: + _lines, source_journal_count, source_payload_hash = self._live_close_source( + connection, + tenant_id=tenant_id, + legal_entity_id=legal_entity_id, + book_id=book_id, + period_end_date=period_end_date, + period_code=period_code, + snapshot_currency_code=snapshot_currency_code, + legal_entity_reference=legal_entity_reference, + accounting_book_reference=accounting_book_reference, + ) + period_closed_at = self._set_book_period_closed( + connection, tenant_id, book_id, period_id, "soft_closed" + ) + connection.execute( + """ + UPDATE accounting_core.accounting_book_period_control + SET soft_close_idempotency_key = %s, + soft_close_source_payload_hash = %s, + soft_close_source_journal_count = %s + WHERE tenant_account_id = %s + AND accounting_book_id = %s + AND fiscal_period_id = %s + """, + ( + idempotency_key, + source_payload_hash, + source_journal_count, + tenant_id, + book_id, + period_id, + ), + ) + self._insert_period_close_event( + connection, + tenant_id, + period_code, + accounting_book_reference, + None, + source_payload_hash, + ) + return PeriodCloseReceipt( + tenant_reference=self._tenant_reference, + legal_entity_reference=legal_entity_reference, + accounting_book_reference=accounting_book_reference, + period_code=period_code, + period_status_code="soft_closed", + snapshot_record_id="", + snapshot_generated_at=period_closed_at, + source_journal_count=source_journal_count, + source_payload_hash=source_payload_hash, + replayed=False, + ) - def _require_open_book_period( + def _live_close_source( self, connection: object, + *, tenant_id: UUID, + legal_entity_id: UUID, book_id: UUID, - accounting_date: date, - ) -> UUID: - """Require an open fiscal period for the selected accounting book.""" - return self._require_open_book_period_bounds( - connection, tenant_id, book_id, accounting_date - )[0] + period_end_date: date, + period_code: str, + snapshot_currency_code: str, + legal_entity_reference: str, + accounting_book_reference: str, + ) -> tuple[tuple[tuple[UUID, str, Decimal, Decimal], ...], int, str]: + lines = self._aggregate_trial_balance( + connection, tenant_id, legal_entity_id, book_id, period_end_date + ) + source_journal_count = self._count_source_journals( + connection, tenant_id, legal_entity_id, book_id, period_end_date + ) + source_payload_hash = _canonical_snapshot_hash( + tenant_reference=self._tenant_reference, + legal_entity_reference=legal_entity_reference, + accounting_book_reference=accounting_book_reference, + period_code=period_code, + snapshot_currency_code=snapshot_currency_code, + source_journal_count=source_journal_count, + lines=lines, + ) + return lines, source_journal_count, source_payload_hash - def _require_open_book_period_bounds( + def _persist_period_close( self, connection: object, + *, tenant_id: UUID, + legal_entity_id: UUID, book_id: UUID, - accounting_date: date, - ) -> tuple[UUID, date, date]: - """Return period identity and bounds when this accounting book is open.""" - period_row = connection.execute( + period_id: UUID, + period_code: str, + period_end_date: date, + period_status_code: str, + snapshot_currency_code: str, + legal_entity_reference: str, + accounting_book_reference: str, + idempotency_key: str, + ) -> PeriodCloseReceipt: + self._post_closing_journal( + connection, + tenant_id=tenant_id, + legal_entity_id=legal_entity_id, + book_id=book_id, + period_id=period_id, + period_code=period_code, + period_end_date=period_end_date, + snapshot_currency_code=snapshot_currency_code, + legal_entity_reference=legal_entity_reference, + accounting_book_reference=accounting_book_reference, + ) + lines, source_journal_count, source_payload_hash = self._live_close_source( + connection, + tenant_id=tenant_id, + legal_entity_id=legal_entity_id, + book_id=book_id, + period_end_date=period_end_date, + period_code=period_code, + snapshot_currency_code=snapshot_currency_code, + legal_entity_reference=legal_entity_reference, + accounting_book_reference=accounting_book_reference, + ) + snapshot_id, snapshot_generated_at = connection.execute( """ - SELECT fiscal_period_id, period_code, - period_start_date, period_end_date - FROM accounting_core.fiscal_period - WHERE tenant_account_id = %s - AND period_start_date <= %s - AND period_end_date >= %s - """, - (tenant_id, accounting_date, accounting_date), - ).fetchone() - if period_row is None: - raise AccountingValidationError( - f"No fiscal period covers accounting date {accounting_date.isoformat()}. " - "Create an open fiscal period on the tenant calendar, then retry posting." + INSERT INTO accounting_reporting.trial_balance_snapshot ( + tenant_account_id, legal_entity_id, accounting_book_id, fiscal_period_id, + snapshot_currency_code, source_journal_count, source_payload_hash, + close_idempotency_key ) - period_id, period_code, period_start_date, period_end_date = period_row - control_row = connection.execute( - """ - SELECT accounting_book_period_control.period_status_code - FROM accounting_core.fiscal_period - JOIN accounting_core.accounting_book_period_control - ON accounting_book_period_control.tenant_account_id - = fiscal_period.tenant_account_id - AND accounting_book_period_control.fiscal_period_id - = fiscal_period.fiscal_period_id - AND accounting_book_period_control.accounting_book_id = %s - WHERE fiscal_period.tenant_account_id = %s - AND fiscal_period.fiscal_period_id = %s + VALUES (%s, %s, %s, %s, %s, %s, %s, %s) + RETURNING trial_balance_snapshot_id, snapshot_generated_at """, - (book_id, tenant_id, period_id), + ( + tenant_id, + legal_entity_id, + book_id, + period_id, + snapshot_currency_code, + source_journal_count, + source_payload_hash, + idempotency_key, + ), ).fetchone() - if control_row is None: - raise AccountingValidationError( - f"Fiscal period {period_code} has no control row for this accounting book. " - "Repair the fiscal-period control data for this book, then retry posting." - ) - period_status_code = control_row[0] - if period_status_code != "open": - locked_marker = " (period_closed)" if period_status_code == "hard_closed" else "" - raise AccountingValidationError( - f"Fiscal period {period_code} is {period_status_code}{locked_marker}. " - "Open that period or post into an open period for this accounting book; " - "no journal was written." + for account_id, _account_code, debit_total, credit_total in lines: + connection.execute( + """ + INSERT INTO accounting_reporting.trial_balance_line ( + tenant_account_id, trial_balance_snapshot_id, chart_account_id, + debit_total_amount, credit_total_amount, net_balance_amount + ) + VALUES (%s, %s, %s, %s, %s, %s) + """, + ( + tenant_id, + snapshot_id, + account_id, + debit_total, + credit_total, + debit_total - credit_total, + ), ) - return period_id, period_start_date, period_end_date - - def _require_adjusting_period( - self, connection: object, tenant_id: UUID, accounting_date: date - ) -> UUID: - return self._require_adjusting_period_bounds(connection, tenant_id, accounting_date)[0] - - def _require_adjusting_period_bounds( - self, connection: object, tenant_id: UUID, accounting_date: date - ) -> tuple[UUID, date, date]: - return self._require_period_bounds( + self._set_book_period_closed( + connection, tenant_id, book_id, period_id, period_status_code + ) + self._insert_period_close_event( connection, tenant_id, - accounting_date, - allowed_status_codes=frozenset({"open", "soft_closed"}), - next_action="Reverse into an open or soft-closed period", + period_code, + accounting_book_reference, + snapshot_id, + source_payload_hash, + ) + return PeriodCloseReceipt( + tenant_reference=self._tenant_reference, + legal_entity_reference=legal_entity_reference, + accounting_book_reference=accounting_book_reference, + period_code=period_code, + period_status_code=period_status_code, + snapshot_record_id=str(snapshot_id), + snapshot_generated_at=snapshot_generated_at, + source_journal_count=source_journal_count, + source_payload_hash=source_payload_hash, + replayed=False, ) - def _require_period_bounds( + def _post_closing_journal( self, connection: object, - tenant_id: UUID, - accounting_date: date, *, - allowed_status_codes: frozenset[str], - next_action: str, - ) -> tuple[UUID, date, date]: + tenant_id: UUID, + legal_entity_id: UUID, + book_id: UUID, + period_id: UUID, + period_code: str, + period_end_date: date, + snapshot_currency_code: str, + legal_entity_reference: str, + accounting_book_reference: str, + ) -> None: + closing_reference = ( + "urn:cwl:accounting:general_journal:period_closing:" + f"{period_code}:{accounting_book_reference}" + ) + income_rows = connection.execute( + """ + SELECT chart_account.chart_account_code, + account_role_mapping.account_role_code, + SUM(journal_entry_line.debit_amount), + SUM(journal_entry_line.credit_amount) + FROM accounting_core.journal_entry_line + JOIN accounting_core.general_journal + ON general_journal.tenant_account_id = journal_entry_line.tenant_account_id + AND general_journal.general_journal_id = journal_entry_line.general_journal_id + JOIN accounting_core.chart_account + ON chart_account.tenant_account_id = journal_entry_line.tenant_account_id + AND chart_account.chart_account_id = journal_entry_line.chart_account_id + JOIN accounting_core.account_role_mapping + ON account_role_mapping.tenant_account_id = chart_account.tenant_account_id + AND account_role_mapping.chart_account_id = chart_account.chart_account_id + AND account_role_mapping.valid_to IS NULL + WHERE general_journal.tenant_account_id = %s + AND general_journal.legal_entity_id = %s + AND general_journal.accounting_book_id = %s + AND general_journal.accounting_date <= %s + AND account_role_mapping.account_role_code IN ( + 'usage_revenue', 'write_off_expense' + ) + GROUP BY chart_account.chart_account_code, account_role_mapping.account_role_code + ORDER BY chart_account.chart_account_code + """, + (tenant_id, legal_entity_id, book_id, period_end_date), + ).fetchall() + closing_lines: list[PostedJournalLine] = [] + retained_earnings_amount = Decimal("0") + for account_code, role_code, debit_total, credit_total in income_rows: + net_amount = Decimal(credit_total) - Decimal(debit_total) + if net_amount == 0: + continue + line_number = len(closing_lines) + 1 + if net_amount > 0: + closing_lines.append( + PostedJournalLine( + line_number=line_number, + chart_account_code=str(account_code), + account_role_code=str(role_code), + debit_amount=net_amount, + credit_amount=Decimal("0"), + ) + ) + else: + closing_lines.append( + PostedJournalLine( + line_number=line_number, + chart_account_code=str(account_code), + account_role_code=str(role_code), + debit_amount=Decimal("0"), + credit_amount=-net_amount, + ) + ) + retained_earnings_amount += net_amount + if not closing_lines: + return + policy_version, rule_version = self._require_retained_earnings_mapping( + connection, tenant_id, book_id + ) + if retained_earnings_amount > 0: + closing_lines.append( + PostedJournalLine( + line_number=len(closing_lines) + 1, + chart_account_code="310100", + account_role_code="retained_earnings", + debit_amount=Decimal("0"), + credit_amount=retained_earnings_amount, + ) + ) + elif retained_earnings_amount < 0: + closing_lines.append( + PostedJournalLine( + line_number=len(closing_lines) + 1, + chart_account_code="310100", + account_role_code="retained_earnings", + debit_amount=-retained_earnings_amount, + credit_amount=Decimal("0"), + ) + ) + source_payload_hash = _canonical_closing_hash( + tenant_reference=self._tenant_reference, + legal_entity_reference=legal_entity_reference, + accounting_book_reference=accounting_book_reference, + period_code=period_code, + lines=tuple(closing_lines), + ) + proposal_record_id = connection.execute( + """ + INSERT INTO accounting_integration.journal_proposal_record ( + tenant_account_id, external_proposal_id, proposal_contract_version, + idempotency_key, source_payload_hash, proposal_status_code, processed_at + ) + VALUES (%s, uuidv7(), 1, %s, %s, 'posted', clock_timestamp()) + RETURNING proposal_record_id + """, + ( + tenant_id, + f"{self._tenant_reference}:period_closing:{period_code}:" + f"{accounting_book_reference}", + source_payload_hash, + ), + ).fetchone()[0] + policy = AccountingPolicy( + tenant_reference=self._tenant_reference, + legal_entity_reference=legal_entity_reference, + accounting_book_reference=accounting_book_reference, + intended_book_role_code=self._book_role_code(connection, tenant_id, book_id), + transaction_currency=snapshot_currency_code, + functional_currency=snapshot_currency_code, + open_period_start=period_end_date, + open_period_end=period_end_date, + chart_account_mapping={"retained_earnings": "310100"}, + accounting_policy_version=policy_version, + posting_rule_version=rule_version, + ) + self._insert_journal( + connection, + tenant_id=tenant_id, + legal_entity_id=legal_entity_id, + book_id=book_id, + period_id=period_id, + journal_reference=closing_reference, + proposal=_ClosingProposal( + source_payload_hash=source_payload_hash, + transaction_currency=snapshot_currency_code, + transaction_date=period_end_date, + accounting_date=period_end_date, + source_event_references=(), + ), + policy=policy, + proposal_record_id=proposal_record_id, + lines=tuple(closing_lines), + ) + + def _require_retained_earnings_mapping( + self, connection: object, tenant_id: UUID, book_id: UUID + ) -> tuple[str, str]: row = connection.execute( """ - SELECT fiscal_period_id, period_code, period_status_code, - period_start_date, period_end_date - FROM accounting_core.fiscal_period - WHERE tenant_account_id = %s - AND period_start_date <= %s - AND period_end_date >= %s + SELECT account_role_mapping.accounting_policy_version, + account_role_mapping.posting_rule_version + FROM accounting_core.account_role_mapping + JOIN accounting_core.chart_account + ON chart_account.tenant_account_id = account_role_mapping.tenant_account_id + AND chart_account.chart_account_id = account_role_mapping.chart_account_id + WHERE account_role_mapping.tenant_account_id = %s + AND account_role_mapping.accounting_book_id = %s + AND account_role_mapping.account_role_code = 'retained_earnings' + AND chart_account.chart_account_code = '310100' + AND account_role_mapping.valid_to IS NULL + AND chart_account.valid_to IS NULL """, - (tenant_id, accounting_date, accounting_date), + (tenant_id, book_id), ).fetchone() if row is None: raise AccountingValidationError( - f"No fiscal period covers accounting date {accounting_date.isoformat()}. " - "Create an open fiscal period on the tenant calendar, then retry posting." + "account_role_mapping is missing for retained_earnings → 310100. " + "Create the retained_earnings mapping and chart_account 310100, " + "then retry the close." ) - period_id, period_code = row[0], row[1] - self._acquire_command_lock(connection, f"period:{period_code}") - row = connection.execute( + return str(row[0]), str(row[1]) + + def _book_role_code( + self, connection: object, tenant_id: UUID, book_id: UUID + ) -> str: + return str( + connection.execute( + """ + SELECT book_role_code + FROM accounting_core.accounting_book + WHERE tenant_account_id = %s AND accounting_book_id = %s + """, + (tenant_id, book_id), + ).fetchone()[0] + ) + + def _set_book_period_closed( + self, + connection: object, + tenant_id: UUID, + book_id: UUID, + period_id: UUID, + period_status_code: str, + ) -> datetime: + """Close one book and retain aggregate calendar status only for compatibility.""" + period_closed_at = connection.execute( """ - SELECT fiscal_period_id, period_code, period_status_code, - period_start_date, period_end_date - FROM accounting_core.fiscal_period + UPDATE accounting_core.accounting_book_period_control + SET period_status_code = %s, + period_closed_at = clock_timestamp() WHERE tenant_account_id = %s + AND accounting_book_id = %s AND fiscal_period_id = %s + RETURNING period_closed_at + """, + (period_status_code, tenant_id, book_id, period_id), + ).fetchone()[0] + aggregate_row = connection.execute( + """ + SELECT CASE + WHEN bool_and( + accounting_book_period_control.period_status_code = 'hard_closed' + ) THEN 'hard_closed' + WHEN bool_and( + accounting_book_period_control.period_status_code <> 'open' + ) THEN 'soft_closed' + ELSE 'open' + END, + max(accounting_book_period_control.period_closed_at) + FROM accounting_core.accounting_book_period_control + JOIN accounting_core.accounting_book + ON accounting_book.tenant_account_id + = accounting_book_period_control.tenant_account_id + AND accounting_book.accounting_book_id + = accounting_book_period_control.accounting_book_id + WHERE accounting_book_period_control.tenant_account_id = %s + AND accounting_book_period_control.fiscal_period_id = %s + AND accounting_book.valid_to IS NULL """, (tenant_id, period_id), ).fetchone() - if row is None: - raise AccountingValidationError( - f"No fiscal period covers accounting date {accounting_date.isoformat()}. " - "Create an open fiscal period on the tenant calendar, then retry posting." + aggregate_status = aggregate_row[0] or "open" + aggregate_closed_at = None if aggregate_status == "open" else aggregate_row[1] + connection.execute( + """ + UPDATE accounting_core.fiscal_period + SET period_status_code = %s, + period_closed_at = %s + WHERE tenant_account_id = %s AND fiscal_period_id = %s + """, + (aggregate_status, aggregate_closed_at, tenant_id, period_id), + ) + return period_closed_at + + def _insert_period_close_event( + self, + connection: object, + tenant_id: UUID, + period_code: str, + accounting_book_reference: str, + snapshot_id: UUID | None, + payload_hash: str, + ) -> None: + payload_reference = ( + f"urn:cwl:accounting:trial_balance_snapshot:{snapshot_id}" + if snapshot_id is not None + else f"urn:cwl:accounting:fiscal_period:{period_code}" + ) + connection.execute( + """ + INSERT INTO accounting_integration.outbox_event ( + tenant_account_id, event_type_code, aggregate_reference, + payload_reference, payload_hash ) - if row[2] not in allowed_status_codes: - locked_marker = " (period_closed)" if row[2] == "hard_closed" else "" - raise AccountingValidationError( - f"Fiscal period {row[1]} is {row[2]}{locked_marker}. {next_action}; " - "no journal was written." + VALUES (%s, 'period_close', %s, %s, %s) + """, + ( + tenant_id, + f"{accounting_book_reference}:fiscal_period:{period_code}", + payload_reference, + payload_hash, + ), + ) + + def _close_receipt_from_snapshot( + self, + snapshot: tuple[UUID, datetime, int, str, str], + *, + period_code: str, + period_status_code: str, + legal_entity_reference: str, + accounting_book_reference: str, + replayed: bool, + ) -> PeriodCloseReceipt: + snapshot_id, snapshot_generated_at, source_journal_count, source_payload_hash, _close_key = ( + snapshot + ) + return PeriodCloseReceipt( + tenant_reference=self._tenant_reference, + legal_entity_reference=legal_entity_reference, + accounting_book_reference=accounting_book_reference, + period_code=period_code, + period_status_code=period_status_code, + snapshot_record_id=str(snapshot_id), + snapshot_generated_at=snapshot_generated_at, + source_journal_count=source_journal_count, + source_payload_hash=source_payload_hash, + replayed=replayed, + ) + + def _insert_journal( + self, + connection: object, + *, + tenant_id: UUID, + legal_entity_id: UUID, + book_id: UUID, + period_id: UUID, + journal_reference: str, + proposal: JournalProposal | _ReversalProposal | _ClosingProposal | _AdjustingProposal, + policy: AccountingPolicy, + proposal_record_id: UUID, + lines: tuple[PostedJournalLine, ...], + ) -> UUID: + connection.execute( + "SELECT set_config('accounting_core.journal_write_role', %s, true)", + (_journal_write_role(proposal),), + ) + journal_id = connection.execute( + """ + INSERT INTO accounting_core.general_journal ( + tenant_account_id, legal_entity_id, accounting_book_id, fiscal_period_id, + journal_reference, journal_status_code, transaction_currency_code, + functional_currency_code, transaction_date, accounting_date, + source_proposal_record_id, accounting_policy_version, posting_rule_version ) - return row[0], row[3], row[4] + VALUES (%s, %s, %s, %s, %s, 'posted', %s, %s, %s, %s, %s, %s, %s) + RETURNING general_journal_id + """, + ( + tenant_id, + legal_entity_id, + book_id, + period_id, + journal_reference, + proposal.transaction_currency, + policy.functional_currency, + proposal.transaction_date, + proposal.accounting_date, + proposal_record_id, + policy.accounting_policy_version, + policy.posting_rule_version, + ), + ).fetchone()[0] + for line in lines: + chart_account_id = connection.execute( + """ + SELECT chart_account_id + FROM accounting_core.chart_account + WHERE tenant_account_id = %s + AND accounting_book_id = %s + AND chart_account_code = %s + AND valid_to IS NULL + """, + (tenant_id, book_id, line.chart_account_code), + ).fetchone() + if chart_account_id is None: + raise AccountingValidationError( + f"Chart account {line.chart_account_code} is not recorded on this book. " + "Create the chart_account row, then retry posting." + ) + connection.execute( + """ + INSERT INTO accounting_core.journal_entry_line ( + tenant_account_id, general_journal_id, line_number, chart_account_id, + account_role_code, debit_amount, credit_amount + ) + VALUES (%s, %s, %s, %s, %s, %s, %s) + """, + ( + tenant_id, + journal_id, + line.line_number, + chart_account_id[0], + line.account_role_code, + line.debit_amount, + line.credit_amount, + ), + ) + for reference in proposal.source_event_references: + connection.execute( + """ + INSERT INTO accounting_core.journal_source_reference ( + tenant_account_id, general_journal_id, source_reference, source_payload_hash + ) + VALUES (%s, %s, %s, %s) + """, + (tenant_id, journal_id, reference, proposal.source_payload_hash), + ) + return journal_id - def _require_fiscal_period( + def _insert_receipt( self, connection: object, tenant_id: UUID, - period_code: str, - next_action: str = "the close", - ) -> tuple[UUID, str, date]: + proposal_record_id: UUID, + journal_id: UUID, + receipt: PostingReceipt, + ) -> None: + connection.execute( + """ + INSERT INTO accounting_integration.posting_receipt ( + tenant_account_id, proposal_record_id, general_journal_id, + receipt_status_code, receipt_payload_hash + ) + VALUES (%s, %s, %s, %s, %s) + """, + ( + tenant_id, + proposal_record_id, + journal_id, + receipt.posting_status_code, + _canonical_receipt_hash(receipt), + ), + ) + + def _insert_outbox( + self, + connection: object, + tenant_id: UUID, + event_type_code: str, + aggregate_reference: str, + payload_reference: str, + receipt: PostingReceipt, + ) -> None: + connection.execute( + """ + INSERT INTO accounting_integration.outbox_event ( + tenant_account_id, event_type_code, aggregate_reference, + payload_reference, payload_hash + ) + VALUES (%s, %s, %s, %s, %s) + """, + ( + tenant_id, + event_type_code, + aggregate_reference, + payload_reference, + _canonical_receipt_hash(receipt), + ), + ) + + def _receipt_for_idempotency_key( + self, connection: object, tenant_id: UUID, proposal: JournalProposal + ) -> PostingReceipt: + return PostingReceipt( + receipt_reference=f"urn:cwl:accounting:posting_receipt:{proposal.proposal_id}", + journal_reference=f"urn:cwl:accounting:general_journal:{proposal.proposal_id}", + posting_status_code="posted", + source_proposal_id=proposal.proposal_id, + source_payload_hash=proposal.source_payload_hash, + tenant_reference=proposal.tenant_reference, + legal_entity_reference=proposal.legal_entity_reference, + accounting_book_reference=self._book_name_for_proposal( + connection, tenant_id, proposal.idempotency_key + ), + accounting_policy_version=self._policy_version_for_proposal( + connection, tenant_id, proposal.idempotency_key + )[0], + posting_rule_version=self._policy_version_for_proposal( + connection, tenant_id, proposal.idempotency_key + )[1], + line_count=self._line_count_for_proposal( + connection, tenant_id, proposal.idempotency_key + ), + ) + + def _receipt_for_journal( + self, connection: object, tenant_id: UUID, journal_reference: str + ) -> PostingReceipt: row = connection.execute( """ - SELECT fiscal_period_id, period_status_code, period_end_date - FROM accounting_core.fiscal_period - WHERE tenant_account_id = %s AND period_code = %s + SELECT general_journal.journal_reference, + journal_proposal_record.source_payload_hash, + journal_proposal_record.external_proposal_id, + general_journal.accounting_policy_version, + general_journal.posting_rule_version, + accounting_book.book_name, + legal_entity_record.legal_entity_code, + ( + SELECT COUNT(*) + FROM accounting_core.journal_entry_line + WHERE tenant_account_id = general_journal.tenant_account_id + AND general_journal_id = general_journal.general_journal_id + ), + original_journal.journal_reference + FROM accounting_core.general_journal + JOIN accounting_integration.journal_proposal_record + ON journal_proposal_record.tenant_account_id = general_journal.tenant_account_id + AND journal_proposal_record.proposal_record_id = general_journal.source_proposal_record_id + JOIN accounting_core.accounting_book + ON accounting_book.tenant_account_id = general_journal.tenant_account_id + AND accounting_book.accounting_book_id = general_journal.accounting_book_id + JOIN accounting_core.legal_entity_record + ON legal_entity_record.tenant_account_id = general_journal.tenant_account_id + AND legal_entity_record.legal_entity_id = general_journal.legal_entity_id + LEFT JOIN accounting_core.journal_reversal + ON journal_reversal.tenant_account_id = general_journal.tenant_account_id + AND journal_reversal.reversal_journal_id = general_journal.general_journal_id + LEFT JOIN accounting_core.general_journal AS original_journal + ON original_journal.tenant_account_id = journal_reversal.tenant_account_id + AND original_journal.general_journal_id = journal_reversal.original_journal_id + WHERE general_journal.tenant_account_id = %s + AND general_journal.journal_reference = %s + """, + (tenant_id, journal_reference), + ).fetchone() + source_proposal_id = journal_reference.removeprefix( + "urn:cwl:accounting:general_journal:" + ).removesuffix(":reversal") + return PostingReceipt( + receipt_reference=f"{journal_reference}:receipt", + journal_reference=row[0], + posting_status_code="posted", + source_proposal_id=source_proposal_id, + source_payload_hash=row[1], + tenant_reference=self._tenant_reference, + legal_entity_reference=row[6], + accounting_book_reference=row[5], + accounting_policy_version=row[3], + posting_rule_version=row[4], + line_count=int(row[7]), + reversal_of_journal_reference=row[8], + ) + + def _book_name_for_proposal( + self, connection: object, tenant_id: UUID, idempotency_key: str + ) -> str: + return connection.execute( + """ + SELECT accounting_book.book_name + FROM accounting_integration.journal_proposal_record + JOIN accounting_core.general_journal + ON general_journal.tenant_account_id = journal_proposal_record.tenant_account_id + AND general_journal.source_proposal_record_id = journal_proposal_record.proposal_record_id + JOIN accounting_core.accounting_book + ON accounting_book.tenant_account_id = general_journal.tenant_account_id + AND accounting_book.accounting_book_id = general_journal.accounting_book_id + WHERE journal_proposal_record.tenant_account_id = %s + AND journal_proposal_record.idempotency_key = %s + """, + (tenant_id, idempotency_key), + ).fetchone()[0] + + def _policy_version_for_proposal( + self, connection: object, tenant_id: UUID, idempotency_key: str + ) -> tuple[str, str]: + return connection.execute( + """ + SELECT general_journal.accounting_policy_version, + general_journal.posting_rule_version + FROM accounting_integration.journal_proposal_record + JOIN accounting_core.general_journal + ON general_journal.tenant_account_id = journal_proposal_record.tenant_account_id + AND general_journal.source_proposal_record_id = journal_proposal_record.proposal_record_id + WHERE journal_proposal_record.tenant_account_id = %s + AND journal_proposal_record.idempotency_key = %s """, - (tenant_id, period_code), + (tenant_id, idempotency_key), ).fetchone() - if row is None: - raise AccountingValidationError( - f"Fiscal period {period_code} is not recorded for this tenant. " - f"Create the fiscal_period row, then retry {next_action}." - ) - return row[0], row[1], row[2] - def _lock_book_period( + def _line_count_for_proposal( + self, connection: object, tenant_id: UUID, idempotency_key: str + ) -> int: + return int( + connection.execute( + """ + SELECT COUNT(*) + FROM accounting_integration.journal_proposal_record + JOIN accounting_core.general_journal + ON general_journal.tenant_account_id = journal_proposal_record.tenant_account_id + AND general_journal.source_proposal_record_id = journal_proposal_record.proposal_record_id + JOIN accounting_core.journal_entry_line + ON journal_entry_line.tenant_account_id = general_journal.tenant_account_id + AND journal_entry_line.general_journal_id = general_journal.general_journal_id + WHERE journal_proposal_record.tenant_account_id = %s + AND journal_proposal_record.idempotency_key = %s + """, + (tenant_id, idempotency_key), + ).fetchone()[0] + ) + + def _load_journal_row( self, connection: object, tenant_id: UUID, - book_id: UUID, - period_code: str, - ) -> tuple[UUID, str, date]: - """Lock authoritative close state for one accounting book.""" - period_row = connection.execute( + *, + idempotency_key: str = "", + journal_reference: str = "", + ) -> tuple[object, ...] | None: + return connection.execute( """ - SELECT fiscal_period_id - FROM accounting_core.fiscal_period - WHERE tenant_account_id = %s AND period_code = %s + SELECT general_journal.general_journal_id, + general_journal.journal_reference, + general_journal.journal_status_code, + general_journal.accounting_date, + general_journal.transaction_currency_code, + general_journal.functional_currency_code, + general_journal.accounting_policy_version, + general_journal.posting_rule_version, + legal_entity_record.legal_entity_code, + accounting_book.book_name, + journal_proposal_record.idempotency_key, + journal_proposal_record.source_payload_hash, + journal_proposal_record.external_proposal_id, + original_journal.journal_reference, + journal_reversal.reversal_reason_code + FROM accounting_core.general_journal + JOIN accounting_integration.journal_proposal_record + ON journal_proposal_record.tenant_account_id = general_journal.tenant_account_id + AND journal_proposal_record.proposal_record_id = general_journal.source_proposal_record_id + JOIN accounting_core.accounting_book + ON accounting_book.tenant_account_id = general_journal.tenant_account_id + AND accounting_book.accounting_book_id = general_journal.accounting_book_id + JOIN accounting_core.legal_entity_record + ON legal_entity_record.tenant_account_id = general_journal.tenant_account_id + AND legal_entity_record.legal_entity_id = general_journal.legal_entity_id + LEFT JOIN accounting_core.journal_reversal + ON journal_reversal.tenant_account_id = general_journal.tenant_account_id + AND journal_reversal.reversal_journal_id = general_journal.general_journal_id + LEFT JOIN accounting_core.general_journal AS original_journal + ON original_journal.tenant_account_id = journal_reversal.tenant_account_id + AND original_journal.general_journal_id = journal_reversal.original_journal_id + WHERE general_journal.tenant_account_id = %s + AND (%s OR journal_proposal_record.idempotency_key = %s) + AND (%s OR general_journal.journal_reference = %s) """, - (tenant_id, period_code), + ( + tenant_id, + not idempotency_key, + idempotency_key, + not journal_reference, + journal_reference, + ), ).fetchone() - if period_row is None: - raise AccountingValidationError( - f"Fiscal period {period_code} is not recorded for this tenant. " - "Create the fiscal_period row, then retry the close." - ) - period_id = period_row[0] + + def _load_published_receipt( + self, connection: object, tenant_id: UUID, idempotency_key: str + ) -> dict[str, object]: row = connection.execute( """ - SELECT fiscal_period.fiscal_period_id, - accounting_book_period_control.period_status_code, - fiscal_period.period_end_date - FROM accounting_core.fiscal_period - JOIN accounting_core.accounting_book_period_control - ON accounting_book_period_control.tenant_account_id - = fiscal_period.tenant_account_id - AND accounting_book_period_control.fiscal_period_id - = fiscal_period.fiscal_period_id - AND accounting_book_period_control.accounting_book_id = %s - WHERE fiscal_period.tenant_account_id = %s - AND fiscal_period.fiscal_period_id = %s - FOR UPDATE OF accounting_book_period_control + SELECT posting_receipt.posting_receipt_id, + posting_receipt.created_at, + posting_receipt.receipt_status_code, + general_journal.journal_reference, + general_journal.transaction_currency_code, + general_journal.functional_currency_code, + general_journal.accounting_policy_version, + general_journal.posting_rule_version, + accounting_book.book_name, + legal_entity_record.legal_entity_code, + fiscal_period.period_code, + ( + SELECT COUNT(*) + FROM accounting_core.journal_entry_line + WHERE tenant_account_id = general_journal.tenant_account_id + AND general_journal_id = general_journal.general_journal_id + ), + journal_proposal_record.idempotency_key, + journal_proposal_record.external_proposal_id, + journal_proposal_record.source_payload_hash + FROM accounting_integration.posting_receipt + JOIN accounting_integration.journal_proposal_record + ON journal_proposal_record.tenant_account_id = posting_receipt.tenant_account_id + AND journal_proposal_record.proposal_record_id = posting_receipt.proposal_record_id + JOIN accounting_core.general_journal + ON general_journal.tenant_account_id = posting_receipt.tenant_account_id + AND general_journal.general_journal_id = posting_receipt.general_journal_id + JOIN accounting_core.accounting_book + ON accounting_book.tenant_account_id = general_journal.tenant_account_id + AND accounting_book.accounting_book_id = general_journal.accounting_book_id + JOIN accounting_core.legal_entity_record + ON legal_entity_record.tenant_account_id = general_journal.tenant_account_id + AND legal_entity_record.legal_entity_id = general_journal.legal_entity_id + JOIN accounting_core.fiscal_period + ON fiscal_period.tenant_account_id = general_journal.tenant_account_id + AND fiscal_period.fiscal_period_id = general_journal.fiscal_period_id + WHERE posting_receipt.tenant_account_id = %s + AND journal_proposal_record.idempotency_key = %s """, - (book_id, tenant_id, period_id), + (tenant_id, idempotency_key), ).fetchone() if row is None: raise AccountingValidationError( - f"Fiscal period {period_code} has no control row for this accounting book. " - "Repair the fiscal-period control data for this book, then retry the close." + "posting receipt is missing for this idempotency key. " + "Accept the proposal, then retry the receipt read." ) - return row[0], row[1], row[2] + recorded_at = _format_timestamp(row[1]) + return { + "receipt_id": str(row[0]), + "receipt_contract_version": 1, + "idempotency_key": row[12], + "source_proposal_id": str(row[13]), + "source_payload_hash": row[14], + "tenant_reference": self._tenant_reference, + "legal_entity_reference": row[9], + "accounting_book_reference": row[8], + "fiscal_period_reference": f"urn:cwl:accounting:fiscal_period:{row[10]}", + "journal_reference": row[3], + "accounting_policy_version": row[6], + "posting_rule_version": row[7], + "posting_status_code": row[2], + "recorded_at": recorded_at, + "posted_at": recorded_at, + "line_count": int(row[11]), + "transaction_currency": row[4], + "functional_currency": row[5], + } - def _load_book_period_state( - self, - connection: object, - tenant_id: UUID, - book_id: UUID, - period_code: str, - ) -> tuple[UUID, str, date, date] | None: - """Return the selected book's authoritative period state.""" - row = connection.execute( + def _load_lines( + self, connection: object, tenant_id: UUID, journal_id: UUID + ) -> tuple[PostedJournalLine, ...]: + rows = connection.execute( """ - SELECT fiscal_period.fiscal_period_id, - accounting_book_period_control.period_status_code, - fiscal_period.period_start_date, - fiscal_period.period_end_date - FROM accounting_core.fiscal_period - JOIN accounting_core.accounting_book_period_control - ON accounting_book_period_control.tenant_account_id - = fiscal_period.tenant_account_id - AND accounting_book_period_control.fiscal_period_id - = fiscal_period.fiscal_period_id - AND accounting_book_period_control.accounting_book_id = %s - WHERE fiscal_period.tenant_account_id = %s - AND fiscal_period.period_code = %s + SELECT journal_entry_line.line_number, + chart_account.chart_account_code, + journal_entry_line.account_role_code, + journal_entry_line.debit_amount, + journal_entry_line.credit_amount + FROM accounting_core.journal_entry_line + JOIN accounting_core.chart_account + ON chart_account.tenant_account_id = journal_entry_line.tenant_account_id + AND chart_account.chart_account_id = journal_entry_line.chart_account_id + WHERE journal_entry_line.tenant_account_id = %s + AND journal_entry_line.general_journal_id = %s + ORDER BY journal_entry_line.line_number """, - (book_id, tenant_id, period_code), - ).fetchone() - if row is None: - return None - return row[0], row[1], row[2], row[3] + (tenant_id, journal_id), + ).fetchall() + return tuple( + PostedJournalLine( + line_number=row[0], + chart_account_code=row[1], + account_role_code=row[2], + debit_amount=Decimal(row[3]), + credit_amount=Decimal(row[4]), + ) + for row in rows + ) - def _load_period_state( - self, connection: object, tenant_id: UUID, period_code: str - ) -> tuple[UUID, str, date, date] | None: + def _proposal_identity( + self, connection: object, tenant_id: UUID, proposal_record_id: UUID + ) -> tuple[str, str]: row = connection.execute( """ - SELECT fiscal_period_id, period_status_code, period_start_date, period_end_date - FROM accounting_core.fiscal_period - WHERE tenant_account_id = %s AND period_code = %s + SELECT source_payload_hash, external_proposal_id + FROM accounting_integration.journal_proposal_record + WHERE tenant_account_id = %s AND proposal_record_id = %s """, - (tenant_id, period_code), + (tenant_id, proposal_record_id), ).fetchone() - if row is None: - return None - return row[0], row[1], row[2], row[3] + return row[0], str(row[1]) + + def _legal_entity_code( + self, connection: object, tenant_id: UUID, legal_entity_id: UUID + ) -> str: + return connection.execute( + """ + SELECT legal_entity_code + FROM accounting_core.legal_entity_record + WHERE tenant_account_id = %s AND legal_entity_id = %s + """, + (tenant_id, legal_entity_id), + ).fetchone()[0] + + def _book_name(self, connection: object, tenant_id: UUID, book_id: UUID) -> str: + return connection.execute( + """ + SELECT book_name + FROM accounting_core.accounting_book + WHERE tenant_account_id = %s AND accounting_book_id = %s + """, + (tenant_id, book_id), + ).fetchone()[0] + + +class _ClosingProposal: + """Minimal proposal shape used when persisting an AIS period-closing journal.""" + + def __init__( + self, + *, + source_payload_hash: str, + transaction_currency: str, + transaction_date: date, + accounting_date: date, + source_event_references: tuple[str, ...], + ) -> None: + self.source_payload_hash = source_payload_hash + self.transaction_currency = transaction_currency + self.transaction_date = transaction_date + self.accounting_date = accounting_date + self.source_event_references = source_event_references + + +class _AdjustingProposal: + """Minimal proposal shape used when persisting an AIS-owned adjusting journal.""" + + def __init__( + self, + *, + source_payload_hash: str, + transaction_currency: str, + transaction_date: date, + accounting_date: date, + source_event_references: tuple[str, ...], + ) -> None: + self.source_payload_hash = source_payload_hash + self.transaction_currency = transaction_currency + self.transaction_date = transaction_date + self.accounting_date = accounting_date + self.source_event_references = source_event_references + + +class _ReversalProposal: + """Minimal proposal shape used when persisting an equal-and-opposite journal.""" + + def __init__( + self, + *, + source_payload_hash: str, + transaction_currency: str, + transaction_date: date, + accounting_date: date, + source_event_references: tuple[str, ...], + ) -> None: + self.source_payload_hash = source_payload_hash + self.transaction_currency = transaction_currency + self.transaction_date = transaction_date + self.accounting_date = accounting_date + self.source_event_references = source_event_references + + +def _journal_write_role( + proposal: JournalProposal | _ReversalProposal | _ClosingProposal | _AdjustingProposal, +) -> str: + """Return the session-local role AIS sets before a journal INSERT.""" + if isinstance(proposal, _ClosingProposal): + return "period_closing" + if isinstance(proposal, _AdjustingProposal): + return "adjusting" + if isinstance(proposal, _ReversalProposal): + return "reversal" + return "" def apply_foundation_migration(database_url: str, migration_path: Path) -> None: """Apply the checked-in PostgreSQL 18 accounting foundation in migration order.""" - from .migration_install import apply_foundation_migration as _install - - _install(database_url, migration_path) + if not migration_path.is_file(): + raise AccountingValidationError( + f"Foundation migration is missing at {migration_path}. " + "Restore database/migrations/0001_accounting_foundation.sql, then retry." + ) + class_migration_path = migration_path.parent / "0002_chart_account_class.sql" + if not class_migration_path.is_file(): + raise AccountingValidationError( + f"Chart-account class migration is missing at {class_migration_path}. " + "Restore database/migrations/0002_chart_account_class.sql, then retry." + ) + submission_migration_path = migration_path.parent / "0003_home_tax_submission.sql" + if not submission_migration_path.is_file(): + raise AccountingValidationError( + f"Home-tax submission migration is missing at {submission_migration_path}. " + "Restore database/migrations/0003_home_tax_submission.sql, then retry." + ) + close_key_migration_path = migration_path.parent / "0004_close_idempotency_key.sql" + if not close_key_migration_path.is_file(): + raise AccountingValidationError( + f"Close-idempotency-key migration is missing at {close_key_migration_path}. " + "Restore database/migrations/0004_close_idempotency_key.sql, then retry." + ) + period_guard_migration_path = migration_path.parent / "0005_closed_period_guard.sql" + if not period_guard_migration_path.is_file(): + raise AccountingValidationError( + f"Closed-period guard migration is missing at {period_guard_migration_path}. " + "Restore database/migrations/0005_closed_period_guard.sql, then retry." + ) + concurrency_migration_path = migration_path.parent / "0006_concurrency_hot_partition.sql" + if not concurrency_migration_path.is_file(): + raise AccountingValidationError( + f"Concurrency and hot-partition migration is missing at {concurrency_migration_path}. " + "Restore database/migrations/0006_concurrency_hot_partition.sql, then retry." + ) + runtime_binding_migration_path = migration_path.parent / "0007_runtime_tenant_binding.sql" + if not runtime_binding_migration_path.is_file(): + raise AccountingValidationError( + f"Runtime-tenant binding migration is missing at {runtime_binding_migration_path}. " + "Restore database/migrations/0007_runtime_tenant_binding.sql, then retry." + ) + period_open_command_migration_path = ( + migration_path.parent / "0008_fiscal_period_open_command.sql" + ) + if not period_open_command_migration_path.is_file(): + raise AccountingValidationError( + f"Fiscal-period-open command migration is missing at {period_open_command_migration_path}. " + "Restore database/migrations/0008_fiscal_period_open_command.sql, then retry." + ) + book_period_control_migration_path = ( + migration_path.parent / "0009_accounting_book_period_control.sql" + ) + if not book_period_control_migration_path.is_file(): + raise AccountingValidationError( + f"Accounting-book-period control migration is missing at {book_period_control_migration_path}. " + "Restore database/migrations/0009_accounting_book_period_control.sql, then retry." + ) + soft_close_evidence_migration_path = ( + migration_path.parent / "0010_soft_close_command_evidence.sql" + ) + if not soft_close_evidence_migration_path.is_file(): + raise AccountingValidationError( + f"Soft-close command-evidence migration is missing at {soft_close_evidence_migration_path}. " + "Restore database/migrations/0010_soft_close_command_evidence.sql, then retry." + ) + bank_statement_migration_path = ( + migration_path.parent / "0011_bank_statement_evidence.sql" + ) + if not bank_statement_migration_path.is_file(): + raise AccountingValidationError( + f"Bank-statement evidence migration is missing at {bank_statement_migration_path}. " + "Restore database/migrations/0011_bank_statement_evidence.sql, then retry." + ) + assignment_identity_migration_path = ( + migration_path.parent / "0012_bank_assignment_command_identity.sql" + ) + if not assignment_identity_migration_path.is_file(): + raise AccountingValidationError( + "Bank-account assignment command-identity migration is missing at " + f"{assignment_identity_migration_path}. Restore " + "database/migrations/0012_bank_assignment_command_identity.sql, then retry." + ) + reconciliation_control_migration_path = ( + migration_path.parent / "0013_reconciliation_run_exception_evidence.sql" + ) + if not reconciliation_control_migration_path.is_file(): + raise AccountingValidationError( + "Reconciliation run/exception evidence migration is missing at " + f"{reconciliation_control_migration_path}. Restore " + "database/migrations/0013_reconciliation_run_exception_evidence.sql, then retry." + ) + allocation_control_migration_path = ( + migration_path.parent / "0014_reconciliation_candidate_allocation.sql" + ) + if not allocation_control_migration_path.is_file(): + raise AccountingValidationError( + "Reconciliation candidate/allocation migration is missing at " + f"{allocation_control_migration_path}. Restore " + "database/migrations/0014_reconciliation_candidate_allocation.sql, then retry." + ) + conservation_migration_path = ( + migration_path.parent / "0015_reconciliation_multi_match_conservation.sql" + ) + if not conservation_migration_path.is_file(): + raise AccountingValidationError( + "Reconciliation multi-match conservation migration is missing at " + f"{conservation_migration_path}. Restore " + "database/migrations/0015_reconciliation_multi_match_conservation.sql, then retry." + ) + approval_migration_path = ( + migration_path.parent / "0016_reconciliation_approval_evidence.sql" + ) + if not approval_migration_path.is_file(): + raise AccountingValidationError( + "Reconciliation approval-evidence migration is missing at " + f"{approval_migration_path}. Restore " + "database/migrations/0016_reconciliation_approval_evidence.sql, then retry." + ) + approval_lock_order_migration_path = ( + migration_path.parent / "0017_reconciliation_approval_lock_order.sql" + ) + if not approval_lock_order_migration_path.is_file(): + raise AccountingValidationError( + "Reconciliation approval lock-order migration is missing at " + f"{approval_lock_order_migration_path}. Restore " + "database/migrations/0017_reconciliation_approval_lock_order.sql, then retry." + ) + balance_evidence_migration_path = ( + migration_path.parent / "0018_bank_statement_balance_evidence.sql" + ) + if not balance_evidence_migration_path.is_file(): + raise AccountingValidationError( + "Bank-statement balance-evidence migration is missing at " + f"{balance_evidence_migration_path}. Restore " + "database/migrations/0018_bank_statement_balance_evidence.sql, then retry." + ) + run_command_migration_path = ( + migration_path.parent / "0019_reconciliation_run_command_evidence.sql" + ) + if not run_command_migration_path.is_file(): + raise AccountingValidationError( + "Reconciliation run-command evidence migration is missing at " + f"{run_command_migration_path}. Restore " + "database/migrations/0019_reconciliation_run_command_evidence.sql, then retry." + ) + psycopg = _import_psycopg() + try: + with psycopg.connect( + database_url, autocommit=True, cursor_factory=psycopg.ClientCursor + ) as connection: + connection.execute(migration_path.read_text(encoding="utf-8")) + connection.execute(class_migration_path.read_text(encoding="utf-8")) + connection.execute(submission_migration_path.read_text(encoding="utf-8")) + connection.execute(close_key_migration_path.read_text(encoding="utf-8")) + connection.execute(period_guard_migration_path.read_text(encoding="utf-8")) + connection.execute(concurrency_migration_path.read_text(encoding="utf-8")) + connection.execute(runtime_binding_migration_path.read_text(encoding="utf-8")) + connection.execute(period_open_command_migration_path.read_text(encoding="utf-8")) + connection.execute(book_period_control_migration_path.read_text(encoding="utf-8")) + connection.execute(soft_close_evidence_migration_path.read_text(encoding="utf-8")) + connection.execute(bank_statement_migration_path.read_text(encoding="utf-8")) + connection.execute( + assignment_identity_migration_path.read_text(encoding="utf-8") + ) + connection.execute( + reconciliation_control_migration_path.read_text(encoding="utf-8") + ) + connection.execute( + allocation_control_migration_path.read_text(encoding="utf-8") + ) + connection.execute( + conservation_migration_path.read_text(encoding="utf-8") + ) + connection.execute(approval_migration_path.read_text(encoding="utf-8")) + connection.execute( + approval_lock_order_migration_path.read_text(encoding="utf-8") + ) + connection.execute( + balance_evidence_migration_path.read_text(encoding="utf-8") + ) + connection.execute( + run_command_migration_path.read_text(encoding="utf-8") + ) + except Exception as error: + raise AccountingValidationError( + "Foundation migration failed. Inspect the PostgreSQL error, restore a clean " + "database, then retry the migration." + ) from error def _import_psycopg(): @@ -1732,12 +6399,157 @@ def _require_proposal_uuid(proposal_id: str) -> UUID: return uuid.UUID(_require_proposal_id(proposal_id)) +def _canonical_snapshot_hash( + *, + tenant_reference: str, + legal_entity_reference: str, + accounting_book_reference: str, + period_code: str, + snapshot_currency_code: str, + source_journal_count: int, + lines: tuple[tuple[UUID, str, Decimal, Decimal], ...], +) -> str: + payload = json.dumps( + { + "accounting_book_reference": accounting_book_reference, + "legal_entity_reference": legal_entity_reference, + "lines": [ + { + "chart_account_code": account_code, + "credit_total_amount": format(credit_total, "f"), + "debit_total_amount": format(debit_total, "f"), + "net_balance_amount": format(debit_total - credit_total, "f"), + } + for _account_id, account_code, debit_total, credit_total in lines + ], + "period_code": period_code, + "snapshot_currency_code": snapshot_currency_code, + "source_journal_count": source_journal_count, + "tenant_reference": tenant_reference, + }, + separators=(",", ":"), + sort_keys=True, + ) + return "sha256:" + hashlib.sha256(payload.encode("utf-8")).hexdigest() + + +def _canonical_closing_hash( + *, + tenant_reference: str, + legal_entity_reference: str, + accounting_book_reference: str, + period_code: str, + lines: tuple[PostedJournalLine, ...], +) -> str: + payload = json.dumps( + { + "accounting_book_reference": accounting_book_reference, + "legal_entity_reference": legal_entity_reference, + "lines": [ + { + "account_role_code": line.account_role_code, + "chart_account_code": line.chart_account_code, + "credit_amount": format(line.credit_amount, "f"), + "debit_amount": format(line.debit_amount, "f"), + "line_number": line.line_number, + } + for line in lines + ], + "period_code": period_code, + "tenant_reference": tenant_reference, + }, + separators=(",", ":"), + sort_keys=True, + ) + return "sha256:" + hashlib.sha256(payload.encode("utf-8")).hexdigest() + + +def _canonical_receipt_hash(receipt: PostingReceipt) -> str: + payload = json.dumps( + { + "journal_reference": receipt.journal_reference, + "line_count": receipt.line_count, + "posting_status_code": receipt.posting_status_code, + "receipt_reference": receipt.receipt_reference, + "reversal_of_journal_reference": receipt.reversal_of_journal_reference, + "source_payload_hash": receipt.source_payload_hash, + "source_proposal_id": receipt.source_proposal_id, + }, + separators=(",", ":"), + sort_keys=True, + ) + return "sha256:" + hashlib.sha256(payload.encode("utf-8")).hexdigest() + + +def _fiscal_year_identity(period_code: str, period_start_date: date | None) -> str: + matched = re.match(r"^(\d{4})", period_code) + if matched: + return matched.group(1) + if period_start_date is not None: + return f"{period_start_date.year:04d}" + raise AccountingValidationError( + "fiscal year identity is missing for this period. " + "Use a period_code that starts with the four-digit year, then retry the financial-statement read." + ) + + def _format_timestamp(value: datetime) -> str: return value.astimezone(timezone.utc).isoformat().replace("+00:00", "Z") -def _vat_register_is_loadable(register_document: dict[str, object]) -> bool: - return { +def _vat_period_movement_kind( + idempotency_key: str, + debit_roles: set[str], + credit_roles: set[str], +) -> str | None: + if ":issued_invoice_void:" in idempotency_key or ( + "tax_payable" in debit_roles + and "usage_revenue" in debit_roles + and "accounts_receivable" in credit_roles + ): + return "voided" + if ":invoice_draft:" in idempotency_key or ( + "tax_payable" in credit_roles + and "usage_revenue" in credit_roles + and "accounts_receivable" in debit_roles + ): + return "issued" + return None + + +def _unapplied_cash_movement_kind( + idempotency_key: str, + debit_roles: set[str], + credit_roles: set[str], +) -> str | None: + if ":unapplied_cash_application:" in idempotency_key or ( + "unapplied_cash" in debit_roles and "accounts_receivable" in credit_roles + ): + return "applied" + if ":unapplied_cash_refund:" in idempotency_key or ( + "unapplied_cash" in debit_roles and "cash_receipt" in credit_roles + ): + return "refunded" + if ":unapplied_cash:" in idempotency_key or ( + "unapplied_cash" in credit_roles and "cash_receipt" in debit_roles + ): + return "parked" + return None + + +def _exact_amount_text(value: Decimal) -> str: + return format(value, "f") + + +def _unsigned_aging_amount_text(value: Decimal) -> str: + amount_text = format(value, "f") + if "." not in amount_text: + return amount_text + return amount_text.rstrip("0").rstrip(".") + + +_VAT_REGISTER_REQUIRED_KEYS = frozenset( + { "tenant_reference", "legal_entity_reference", "accounting_book_reference", @@ -1749,16 +6561,76 @@ def _vat_register_is_loadable(register_document: dict[str, object]) -> bool: "issued_amount", "voided_amount", "closing_amount", - }.issubset(register_document.keys()) + } +) -def _fiscal_year_identity(period_code: str, period_start_date: date | None) -> str: - matched = re.match(r"^(\d{4})", period_code) - if matched: - return matched.group(1) - if period_start_date is not None: - return f"{period_start_date.year:04d}" - raise AccountingValidationError( - "fiscal year identity is missing for this period. " - "Use a period_code that starts with the four-digit year, then retry the financial-statement read." - ) +def _vat_register_is_loadable(register_document: dict[str, object]) -> bool: + return _VAT_REGISTER_REQUIRED_KEYS.issubset(register_document.keys()) + + +def _home_tax_register_view(register_document: dict[str, object]) -> dict[str, object]: + if _vat_register_is_loadable(register_document): + return dict(register_document) + return { + "as_of_date": str(register_document.get("as_of_date") or ""), + "closing_amount": str(register_document.get("closing_amount") or "0"), + } + + +def _home_tax_submission_document( + *, + home_tax_submission_id: str, + tenant_reference: str, + legal_entity_reference: str, + book_reference: str, + period_code: str, + vat_period_register: dict[str, object], + rejection_reason_code: str, + submission_status_code: str = "rejected", +) -> dict[str, object]: + return { + "home_tax_submission_id": home_tax_submission_id, + "tenant_reference": tenant_reference, + "legal_entity_reference": legal_entity_reference, + "book_reference": book_reference, + "fiscal_period_reference": f"urn:cwl:accounting:fiscal_period:{period_code}", + "vat_period_register": vat_period_register, + "submission_status_code": submission_status_code, + "rejection_reason_code": rejection_reason_code, + } + + +def _fifo_aging_open_items( + line_rows: list[tuple[object, ...]], + *, + increase_is_debit: bool, +) -> list[list[object]]: + open_items: list[list[object]] = [] + for accounting_date, _journal_reference, _line_number, debit_amount, credit_amount in line_rows: + increase_amount = Decimal(str(debit_amount)) if increase_is_debit else Decimal( + str(credit_amount) + ) + decrease_amount = Decimal(str(credit_amount)) if increase_is_debit else Decimal( + str(debit_amount) + ) + if increase_amount > 0: + open_items.append([accounting_date, increase_amount]) + continue + remaining_decrease = decrease_amount + for open_item in open_items: + applied_amount = min(open_item[1], remaining_decrease) + open_item[1] = open_item[1] - applied_amount + remaining_decrease = remaining_decrease - applied_amount + open_items = [open_item for open_item in open_items if open_item[1] > 0] + return open_items + + +def _receivable_aging_bucket(outstanding_days: int) -> str: + if outstanding_days <= 30: + return "current" + if outstanding_days <= 60: + return "days_31_60" + if outstanding_days <= 90: + return "days_61_90" + return "days_over_90" From 6a0fd167df70770590e76d00f62c70deb5030e8a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 19:41:35 +0900 Subject: [PATCH 150/224] docs(close): record application authority repair contract --- ...K_PERIOD_CONTROL_INSERT_AUTHORITY_TRACEABILITY.md | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/docs/doctoring/BOOK_PERIOD_CONTROL_INSERT_AUTHORITY_TRACEABILITY.md b/docs/doctoring/BOOK_PERIOD_CONTROL_INSERT_AUTHORITY_TRACEABILITY.md index 020ab32a..576a4754 100644 --- a/docs/doctoring/BOOK_PERIOD_CONTROL_INSERT_AUTHORITY_TRACEABILITY.md +++ b/docs/doctoring/BOOK_PERIOD_CONTROL_INSERT_AUTHORITY_TRACEABILITY.md @@ -29,13 +29,23 @@ Real-PostgreSQL RED `614d1164f3abf1f7bab3fe77d520e5b7108e4c69` creates a tenant Production candidate `610d77082eb01c80d2e9e74521e48a3b06e1375a` installs the post-repair direct-insert guard in migration 0034. Static ratchet `ee233b5c40c942008c7ec034917fd49f1fcf9976` pins the structural nesting check, open/NULL-only invariant, `BEFORE INSERT` placement, and migration-repair ordering. +Application-source RED `tests/test_book_period_application_authority_contract.py` separately requires the persistence adapter to remove the stale `INSERT ... SELECT` writer, replace both shared-state `LEFT JOIN`/`COALESCE` fallbacks with an exact `accounting_book_period_control` join, and lock the authoritative control row with `FOR UPDATE OF accounting_book_period_control`. Real-PostgreSQL `tests/test_postgres_book_period_control_no_projection_red.py` supplies the buyer-relevant missing-non-open-control case. Database containment is therefore not treated as permission to leave the application authority model permanently divergent. + These SHAs are development lineage, not release evidence. The RED was authored before the causal repair, but it was not observed failing on a GitHub runner in this run. Exact-head real-PostgreSQL execution, security/SAST/dependency evidence, independent review, protected-stack prerequisites, migration/recovery evidence, and immutable release evidence remain separate gates. +## Scope-preservation repair + +A source-cleanup candidate at `952bb1b2a014db823f8ee452ebdfb9bc3980e733` attempted to replace the three stale helper paths together. Exact-blob verification immediately found that the replacement did not preserve unrelated methods in the large persistence adapter, so that candidate is invalid development evidence and must not be used as a GREEN or release input. + +Normal descendant `a8c5abe7520cb0a50708127726bcf0dfb420dc60` restores `src/accounting_information_platform/persistence.py` byte-for-byte to prior complete blob `1d27c2399b0adca1aead3a2f3a141a8eb6a95435`. No force-push, reset, destructive rebase, or selective loss of concurrent delta was used. The application-source REDs therefore remain intentionally RED until a scope-preserving causal edit changes only the three authority helpers on a freshly read exact head. + +This recovery is part of the verification record: changing an authority boundary is not acceptable if the patch silently deletes unrelated reporting, reconciliation, integration, or audit behavior. The next implementation must prove both the authority assertions and preservation of the rest of the persistence module before it can be called GREEN. + ## Recovery and follow-up A rejected direct control INSERT writes no authoritative row and therefore seeds no 64-row journal-population fence. The surrounding close transaction remains free to roll back normally. Operators must not repair a missing non-open pair by copying `fiscal_period` status or by manual SQL. If a later-created book must become applicable to an already non-open period, that requires an explicit book-period lifecycle/adoption/reopen command with authenticated capability, idempotency, maker-checker policy where applicable, and retained chronology. -The stale application-side `INSERT ... SELECT` is now behaviorally contained by the database single-writer boundary, but its source expression remains a cleanup finding: it should be removed from `_lock_book_period()` when that large persistence module is edited on a current exact head, leaving the helper as read/lock/fail-closed only. Until then, the database guard is authoritative and the branch must not claim the application source is conceptually clean. +The stale application-side `INSERT ... SELECT` is now behaviorally contained by the database single-writer boundary, but its source expression remains a cleanup finding: it should be removed from `_lock_book_period()` on a current exact head, leaving the helper as read/lock/fail-closed only. `_require_open_book_period_bounds()` and `_load_book_period_state()` must likewise read book-owned status only from `accounting_book_period_control`; a missing control must fail closed rather than inherit the shared calendar projection. Until those REDs are satisfied, the database guard is authoritative and the branch must not claim the application source is conceptually clean. ## References From 9086422c2cd801c3be76069114ee0e6753c47f92 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 19:42:45 +0900 Subject: [PATCH 151/224] test(close): preserve unrelated persistence surfaces --- ...k_period_application_authority_contract.py | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/tests/test_book_period_application_authority_contract.py b/tests/test_book_period_application_authority_contract.py index 199a3bd5..08eb82aa 100644 --- a/tests/test_book_period_application_authority_contract.py +++ b/tests/test_book_period_application_authority_contract.py @@ -47,6 +47,28 @@ def test_open_posting_has_no_shared_calendar_fallback(self) -> None: self.assertIn("JOIN accounting_core.accounting_book_period_control", source) self.assertIn("accounting_book_period_control.period_status_code", source) + def test_authority_cleanup_preserves_unrelated_persistence_surfaces(self) -> None: + """A focused period-authority edit must not erase unrelated accounting reads.""" + required_methods = ( + "load_trial_balance", + "load_account_ledger", + "load_financial_statement", + "load_financial_statement_package", + "load_vat_period_register", + "load_home_tax_submissions", + ) + + missing = [ + method_name + for method_name in required_methods + if not hasattr(PostgresPostingLedger, method_name) + ] + self.assertEqual( + missing, + [], + f"period-authority cleanup removed unrelated persistence methods: {missing}", + ) + if __name__ == "__main__": unittest.main() From 048671fe7243b6bf8c730c349b46d4f3bfc79dde Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 20:18:31 +0900 Subject: [PATCH 152/224] fix: make book-period authority application-read-only --- .../persistence.py | 49 ++++--------------- 1 file changed, 9 insertions(+), 40 deletions(-) diff --git a/src/accounting_information_platform/persistence.py b/src/accounting_information_platform/persistence.py index 1d27c239..503877c4 100644 --- a/src/accounting_information_platform/persistence.py +++ b/src/accounting_information_platform/persistence.py @@ -4426,14 +4426,11 @@ def _require_open_book_period_bounds( """ SELECT fiscal_period.fiscal_period_id, fiscal_period.period_code, - COALESCE( - accounting_book_period_control.period_status_code, - fiscal_period.period_status_code - ), + accounting_book_period_control.period_status_code, fiscal_period.period_start_date, fiscal_period.period_end_date FROM accounting_core.fiscal_period - LEFT JOIN accounting_core.accounting_book_period_control + JOIN accounting_core.accounting_book_period_control ON accounting_book_period_control.tenant_account_id = fiscal_period.tenant_account_id AND accounting_book_period_control.fiscal_period_id @@ -4455,14 +4452,11 @@ def _require_open_book_period_bounds( """ SELECT fiscal_period.fiscal_period_id, fiscal_period.period_code, - COALESCE( - accounting_book_period_control.period_status_code, - fiscal_period.period_status_code - ), + accounting_book_period_control.period_status_code, fiscal_period.period_start_date, fiscal_period.period_end_date FROM accounting_core.fiscal_period - LEFT JOIN accounting_core.accounting_book_period_control + JOIN accounting_core.accounting_book_period_control ON accounting_book_period_control.tenant_account_id = fiscal_period.tenant_account_id AND accounting_book_period_control.fiscal_period_id @@ -4734,10 +4728,10 @@ def _lock_book_period( book_id: UUID, period_code: str, ) -> tuple[UUID, str, date]: - """Materialize and lock close state independently for one accounting book.""" + """Lock the authoritative close state for one accounting book, failing closed if absent.""" period_row = connection.execute( """ - SELECT fiscal_period_id, period_status_code, period_closed_at + SELECT fiscal_period_id FROM accounting_core.fiscal_period WHERE tenant_account_id = %s AND period_code = %s """, @@ -4749,28 +4743,6 @@ def _lock_book_period( "Create the fiscal_period row, then retry the close." ) period_id = period_row[0] - connection.execute( - """ - INSERT INTO accounting_core.accounting_book_period_control ( - tenant_account_id, accounting_book_id, fiscal_period_id, - period_status_code, period_closed_at - ) - SELECT accounting_book.tenant_account_id, - accounting_book.accounting_book_id, - fiscal_period.fiscal_period_id, - fiscal_period.period_status_code, - fiscal_period.period_closed_at - FROM accounting_core.accounting_book - JOIN accounting_core.fiscal_period - ON fiscal_period.tenant_account_id = accounting_book.tenant_account_id - WHERE accounting_book.tenant_account_id = %s - AND accounting_book.valid_to IS NULL - AND fiscal_period.fiscal_period_id = %s - ON CONFLICT (tenant_account_id, accounting_book_id, fiscal_period_id) - DO NOTHING - """, - (tenant_id, period_id), - ) row = connection.execute( """ SELECT fiscal_period.fiscal_period_id, @@ -4803,18 +4775,15 @@ def _load_book_period_state( book_id: UUID, period_code: str, ) -> tuple[UUID, str, date, date] | None: - """Return the selected book's period state, falling back to legacy calendar state.""" + """Return the selected book's authoritative period-control state when recorded.""" row = connection.execute( """ SELECT fiscal_period.fiscal_period_id, - COALESCE( - accounting_book_period_control.period_status_code, - fiscal_period.period_status_code - ), + accounting_book_period_control.period_status_code, fiscal_period.period_start_date, fiscal_period.period_end_date FROM accounting_core.fiscal_period - LEFT JOIN accounting_core.accounting_book_period_control + JOIN accounting_core.accounting_book_period_control ON accounting_book_period_control.tenant_account_id = fiscal_period.tenant_account_id AND accounting_book_period_control.fiscal_period_id From 4092ce2174352d3d05156cb7be6d192671e44ad2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 20:20:33 +0900 Subject: [PATCH 153/224] docs: trace application book-period authority cleanup --- ...OD_CONTROL_INSERT_AUTHORITY_TRACEABILITY.md | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/docs/doctoring/BOOK_PERIOD_CONTROL_INSERT_AUTHORITY_TRACEABILITY.md b/docs/doctoring/BOOK_PERIOD_CONTROL_INSERT_AUTHORITY_TRACEABILITY.md index 576a4754..2bffa96b 100644 --- a/docs/doctoring/BOOK_PERIOD_CONTROL_INSERT_AUTHORITY_TRACEABILITY.md +++ b/docs/doctoring/BOOK_PERIOD_CONTROL_INSERT_AUTHORITY_TRACEABILITY.md @@ -6,18 +6,20 @@ That path could synthesize `soft_closed` or `hard_closed` authority for a later-created book without that book's close command, maker-checker evidence, retained trial-balance snapshot, close event, or chronology. It also selected every active book for the tenant rather than only the requested book. The resulting state contradicted migration 0034's open-only master-data lifecycle and the domain model's single-writer boundary. +The same authority leak remained on the read side: `_load_book_period_state()` and `_require_open_book_period_bounds()` used an outer join plus `COALESCE` to inherit the shared `fiscal_period.period_status_code` whenever the book-owned control was absent. Database containment therefore prevented a bad control write but did not make the application authority model conceptually correct. + This is a DDD/data-authority defect, not an IFRS interpretation. IFRS does not prescribe PostgreSQL trigger nesting or book-period control rows. ## Selected control -Migration `0034_book_period_control_seed.sql` now makes the database relation itself reject direct post-install control creation. The one-time migration repair runs first. After that repair, `book_period_control_insert_authority_guard` is installed as a row-level `BEFORE INSERT` trigger on `accounting_book_period_control`. +Migration `0034_book_period_control_seed.sql` makes the database relation itself reject direct post-install control creation. The one-time migration repair runs first. After that repair, `book_period_control_insert_authority_guard` is installed as a row-level `BEFORE INSERT` trigger on `accounting_book_period_control`. The guard admits a new row only when both conditions hold: - the control INSERT is nested under one of migration 0034's canonical master-data seed triggers (`pg_trigger_depth() >= 2`); and - the new control is literal `open` with `period_closed_at IS NULL`. -A direct runtime/application INSERT therefore returns no row. `_lock_book_period()` immediately performs its authoritative control lookup; when the requested pair is legitimately absent, the existing domain validation path reports that the accounting book has no control row and requires control-data repair. No shared `fiscal_period` status becomes book close authority. +A direct runtime/application INSERT therefore returns no row. The application now matches that database single-writer boundary instead of relying on the guard as a compensating control. `_lock_book_period()` performs a diagnostic fiscal-period existence lookup, then reads and locks only the requested `accounting_book_period_control` row with `FOR UPDATE OF accounting_book_period_control`; it no longer creates controls. `_load_book_period_state()` and `_require_open_book_period_bounds()` use an inner join to the same book-owned control and read its `period_status_code` directly. A missing control therefore fails closed instead of inheriting the shared calendar projection. A caller-controlled custom GUC was rejected as an authority marker because an arbitrary session setting would itself become a spoofable mutable capability. PostgreSQL's trigger-depth signal is structural: PostgreSQL 18.6 documents `pg_trigger_depth()` as the current trigger nesting level, returning zero outside trigger execution. PostgreSQL also documents that a row-level `BEFORE` trigger can skip the current row operation, which is the fail-closed mechanism used here. @@ -29,23 +31,25 @@ Real-PostgreSQL RED `614d1164f3abf1f7bab3fe77d520e5b7108e4c69` creates a tenant Production candidate `610d77082eb01c80d2e9e74521e48a3b06e1375a` installs the post-repair direct-insert guard in migration 0034. Static ratchet `ee233b5c40c942008c7ec034917fd49f1fcf9976` pins the structural nesting check, open/NULL-only invariant, `BEFORE INSERT` placement, and migration-repair ordering. -Application-source RED `tests/test_book_period_application_authority_contract.py` separately requires the persistence adapter to remove the stale `INSERT ... SELECT` writer, replace both shared-state `LEFT JOIN`/`COALESCE` fallbacks with an exact `accounting_book_period_control` join, and lock the authoritative control row with `FOR UPDATE OF accounting_book_period_control`. Real-PostgreSQL `tests/test_postgres_book_period_control_no_projection_red.py` supplies the buyer-relevant missing-non-open-control case. Database containment is therefore not treated as permission to leave the application authority model permanently divergent. +Application-source RED `tests/test_book_period_application_authority_contract.py` separately requires the persistence adapter to remove the stale `INSERT ... SELECT` writer, replace both shared-state `LEFT JOIN`/`COALESCE` fallbacks with an exact `accounting_book_period_control` join, lock the authoritative control row with `FOR UPDATE OF accounting_book_period_control`, and preserve unrelated reporting/integration surfaces in the large adapter. Real-PostgreSQL `tests/test_postgres_book_period_control_no_projection_red.py` supplies the buyer-relevant missing-non-open-control case. + +Production source repair `048671fe7243b6bf8c730c349b46d4f3bfc79dde` is a normal descendant of `9086422c2cd801c3be76069114ee0e6753c47f92`. Exact commit comparison reports one modified path, `src/accounting_information_platform/persistence.py`, with 9 additions and 40 deletions. The patch is limited to the three authority helpers: removing the runtime control INSERT, replacing the two shared-state fallbacks with inner book-control joins, and updating helper documentation. It does not delete or rewrite the unrelated reporting, reconciliation, HomeTax, VAT, or ledger surfaces that the preservation contract protects. -These SHAs are development lineage, not release evidence. The RED was authored before the causal repair, but it was not observed failing on a GitHub runner in this run. Exact-head real-PostgreSQL execution, security/SAST/dependency evidence, independent review, protected-stack prerequisites, migration/recovery evidence, and immutable release evidence remain separate gates. +These SHAs are development lineage, not release evidence. The REDs were authored before the causal repair, but they were not observed failing on a GitHub runner in this run. Exact-head real-PostgreSQL execution, security/SAST/dependency evidence, independent review, protected-stack prerequisites, migration/recovery evidence, and immutable release evidence remain separate gates. ## Scope-preservation repair A source-cleanup candidate at `952bb1b2a014db823f8ee452ebdfb9bc3980e733` attempted to replace the three stale helper paths together. Exact-blob verification immediately found that the replacement did not preserve unrelated methods in the large persistence adapter, so that candidate is invalid development evidence and must not be used as a GREEN or release input. -Normal descendant `a8c5abe7520cb0a50708127726bcf0dfb420dc60` restores `src/accounting_information_platform/persistence.py` byte-for-byte to prior complete blob `1d27c2399b0adca1aead3a2f3a141a8eb6a95435`. No force-push, reset, destructive rebase, or selective loss of concurrent delta was used. The application-source REDs therefore remain intentionally RED until a scope-preserving causal edit changes only the three authority helpers on a freshly read exact head. +Normal descendant `a8c5abe7520cb0a50708127726bcf0dfb420dc60` restored `src/accounting_information_platform/persistence.py` byte-for-byte to prior complete blob `1d27c2399b0adca1aead3a2f3a141a8eb6a95435`. No force-push, reset, destructive rebase, or selective loss of concurrent delta was used. Scope-preservation ratchet `9086422c2cd801c3be76069114ee0e6753c47f92` then fixed the acceptance before another production edit was attempted. -This recovery is part of the verification record: changing an authority boundary is not acceptable if the patch silently deletes unrelated reporting, reconciliation, integration, or audit behavior. The next implementation must prove both the authority assertions and preservation of the rest of the persistence module before it can be called GREEN. +The successful source repair at `048671fe7243b6bf8c730c349b46d4f3bfc79dde` was applied against that exact restored blob. A post-write compare against `9086422c2cd801c3be76069114ee0e6753c47f92` shows `ahead_by=1`, `behind_by=0`, a single modified file, and 49 changed lines. This scope check is part of the verification record: changing an authority boundary is not acceptable if the patch silently deletes unrelated accounting behavior. ## Recovery and follow-up A rejected direct control INSERT writes no authoritative row and therefore seeds no 64-row journal-population fence. The surrounding close transaction remains free to roll back normally. Operators must not repair a missing non-open pair by copying `fiscal_period` status or by manual SQL. If a later-created book must become applicable to an already non-open period, that requires an explicit book-period lifecycle/adoption/reopen command with authenticated capability, idempotency, maker-checker policy where applicable, and retained chronology. -The stale application-side `INSERT ... SELECT` is now behaviorally contained by the database single-writer boundary, but its source expression remains a cleanup finding: it should be removed from `_lock_book_period()` on a current exact head, leaving the helper as read/lock/fail-closed only. `_require_open_book_period_bounds()` and `_load_book_period_state()` must likewise read book-owned status only from `accounting_book_period_control`; a missing control must fail closed rather than inherit the shared calendar projection. Until those REDs are satisfied, the database guard is authoritative and the branch must not claim the application source is conceptually clean. +With `048671fe7243b6bf8c730c349b46d4f3bfc79dde`, the application and database now share the same writer model: master-data seed triggers may create literal-open controls, while posting/adjusting/close helpers only consume existing book-period authority. The source cleanup is not considered execution-GREEN until an unchanged exact head runs the static authority contract and real-PostgreSQL regressions successfully. Missing-control diagnostic wording for an adjusting journal remains a possible buyer-facing refinement, but it must not reintroduce authority synthesis. ## References From 3ff7ac365bf2e8d15c44f6a47a5f6b568906874b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 20:34:42 +0900 Subject: [PATCH 154/224] test(reporting): require book-scoped trial-balance close state --- ..._book_period_application_authority_contract.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tests/test_book_period_application_authority_contract.py b/tests/test_book_period_application_authority_contract.py index 08eb82aa..7597d49e 100644 --- a/tests/test_book_period_application_authority_contract.py +++ b/tests/test_book_period_application_authority_contract.py @@ -47,6 +47,21 @@ def test_open_posting_has_no_shared_calendar_fallback(self) -> None: self.assertIn("JOIN accounting_core.accounting_book_period_control", source) self.assertIn("accounting_book_period_control.period_status_code", source) + def test_trial_balance_read_uses_book_scoped_close_authority(self) -> None: + """A hard-closed book must not inherit an open sibling's calendar projection.""" + source = inspect.getsource(PostgresPostingLedger.load_period_trial_balance) + + self.assertIn( + "_load_book_period_state(", + source, + "trial balance must select live versus retained evidence from book-period authority", + ) + self.assertNotIn( + "_require_fiscal_period(", + source, + "trial balance must not infer one book's close state from fiscal_period", + ) + def test_authority_cleanup_preserves_unrelated_persistence_surfaces(self) -> None: """A focused period-authority edit must not erase unrelated accounting reads.""" required_methods = ( From a26e35a7a5782ba1d3401f274e61f8ac168da0d6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 20:38:44 +0900 Subject: [PATCH 155/224] test(reporting): reproduce sibling-open hard-close read drift --- tests/test_period_close_book_scope.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/tests/test_period_close_book_scope.py b/tests/test_period_close_book_scope.py index 0c8a7a96..6115d3f4 100644 --- a/tests/test_period_close_book_scope.py +++ b/tests/test_period_close_book_scope.py @@ -263,6 +263,28 @@ def test_soft_close_one_book_does_not_block_sibling_book_posting(self) -> None: self.mgmt_book_reference, ) + def test_hard_closed_book_default_trial_balance_uses_retained_snapshot(self) -> None: + """An open sibling must not make a hard-closed book read mutable live totals.""" + close_receipt = self.ledger.close_fiscal_period( + self.legal_entity_reference, + self.stat_book_reference, + "2026-08", + "KRW", + period_status_code="hard_closed", + idempotency_key=f"{self.tenant_reference}:statutory:2026-08:hard-close", + ) + + trial_balance = self.ledger.load_period_trial_balance( + self.legal_entity_reference, + self.stat_book_reference, + "2026-08", + ) + + self.assertEqual(close_receipt.period_status_code, "hard_closed") + self.assertEqual(trial_balance["period_status_code"], "hard_closed") + self.assertEqual(trial_balance["balance_source_code"], "snapshot") + self.assertEqual(trial_balance["snapshot_record_id"], close_receipt.snapshot_record_id) + def test_soft_close_replay_rejects_a_different_command_key(self) -> None: """A soft-close replay is exact and cannot accept a different command identity.""" original_key = f"{self.tenant_reference}:statutory:2026-08:soft-close" From d6acfec4b8754d2a21fa3afb2c53e6b0cba2c06d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 20:45:57 +0900 Subject: [PATCH 156/224] docs(close): trace book-scoped snapshot read authority --- .../TRIAL_BALANCE_SNAPSHOT_AUTHORITY_TRACEABILITY.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/docs/doctoring/TRIAL_BALANCE_SNAPSHOT_AUTHORITY_TRACEABILITY.md b/docs/doctoring/TRIAL_BALANCE_SNAPSHOT_AUTHORITY_TRACEABILITY.md index 516094ea..4b819dc9 100644 --- a/docs/doctoring/TRIAL_BALANCE_SNAPSHOT_AUTHORITY_TRACEABILITY.md +++ b/docs/doctoring/TRIAL_BALANCE_SNAPSHOT_AUTHORITY_TRACEABILITY.md @@ -54,6 +54,18 @@ The governed hard-close regressions must still prove that: If lock-identity logic changes, update the application lock acquisition, both migration guards, PostgreSQL acceptance tests, ADR 0006, and this trace together. Do not recover by manually inserting or relabeling retained snapshots. +## Buyer read authority follow-up + +Snapshot admission and snapshot selection are separate authority boundaries. A retained snapshot can be correctly created and immutable while a buyer-facing read still bypasses it. + +`accounting_book_period_control` is the authoritative tenant/book/period close state. `fiscal_period.period_status_code` is only the aggregate compatibility projection across active books. When a statutory book is hard-closed while a management sibling remains open, the aggregate calendar projection is intentionally still `open`. A default read for the hard-closed statutory book must nevertheless return its retained snapshot; otherwise a closed-book financial report can be rebuilt from mutable live journals. + +Static RED `3ff7ac365bf2e8d15c44f6a47a5f6b568906874b` therefore requires `PostgresPostingLedger.load_period_trial_balance()` to consume `_load_book_period_state()` and rejects `_require_fiscal_period()` as the source of close-state selection. Real-PostgreSQL RED `a26e35a7a5782ba1d3401f274e61f8ac168da0d6` hard-closes one statutory book while an active sibling remains open, then requires the default statutory read to report `period_status_code=hard_closed`, `balance_source_code=snapshot`, and the exact retained `snapshot_record_id`. + +The minimal production repair is deliberately narrow: resolve the requested book first, load its exact `accounting_book_period_control` state, fail closed when that control is absent, and use that selected-book status to choose retained snapshot versus live aggregation. Do not change aggregate calendar semantics, synthesize missing control rows, add a Reporting-owned close-state copy, or weaken explicit `unadjusted`/`adjusted` worksheet semantics. + +The two RED commits are not production GREEN or release evidence. Their exact-head PostgreSQL/Accounting Foundation execution and the production helper repair are still required before this read boundary is complete. + ## References PostgreSQL Global Development Group. (2026). *PostgreSQL 18 documentation: System administration functions*. https://www.postgresql.org/docs/18/functions-admin.html From 9af6fe8aa534195ca040cfc3f1b5d7c85612650a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 21:21:38 +0900 Subject: [PATCH 157/224] fix(reporting): use book-period close authority for trial balance --- src/accounting_information_platform/persistence.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/accounting_information_platform/persistence.py b/src/accounting_information_platform/persistence.py index 503877c4..846a7389 100644 --- a/src/accounting_information_platform/persistence.py +++ b/src/accounting_information_platform/persistence.py @@ -3225,12 +3225,15 @@ def load_period_trial_balance( accounting_book_reference, next_action="the trial-balance read", ) - period_id, period_status_code, period_end_date = self._require_fiscal_period( - connection, - tenant_id, - period_code, - next_action="the trial-balance read", + period_state = self._load_book_period_state( + connection, tenant_id, book_id, period_code ) + if period_state is None: + raise AccountingValidationError( + f"Fiscal period {period_code} has no control row for this accounting book. " + "Repair the fiscal-period control data for this book, then retry the trial-balance read." + ) + period_id, period_status_code, _period_start_date, period_end_date = period_state snapshot_record_id = None if balance_basis_code == "post_close": snapshot = self._latest_close_snapshot( From 1c1360ebf9d0ab0ece0237b820567ff834999abe Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 22:09:20 +0900 Subject: [PATCH 158/224] test(close): require snapshot and hard-close commit pairing --- ..._trial_balance_snapshot_commit_pair_red.py | 150 ++++++++++++++++++ 1 file changed, 150 insertions(+) create mode 100644 tests/test_postgres_trial_balance_snapshot_commit_pair_red.py diff --git a/tests/test_postgres_trial_balance_snapshot_commit_pair_red.py b/tests/test_postgres_trial_balance_snapshot_commit_pair_red.py new file mode 100644 index 00000000..63af2aba --- /dev/null +++ b/tests/test_postgres_trial_balance_snapshot_commit_pair_red.py @@ -0,0 +1,150 @@ +"""Real PostgreSQL RED/GREEN for commit-time hard-close snapshot pairing.""" + +from __future__ import annotations + +import unittest + +import psycopg + +from accounting_information_platform.persistence import apply_foundation_migration +from tests import test_postgres_posting as posting + + +class TrialBalanceSnapshotCommitPairPostgresTests(unittest.TestCase): + """A retained snapshot may commit only with the matching hard-closed book-period fact.""" + + @classmethod + def setUpClass(cls) -> None: + """Install the complete current accounting migration chain.""" + posting.PostgresPostingTests.setUpClass() + + def setUp(self) -> None: + """Create one governed book-period and leave it soft-closed.""" + self.case = posting.PostgresPostingTests("setUp") + self.case.setUp() + self.addCleanup(self.case.doCleanups) + self.addCleanup(self.case.tearDown) + self.case.ledger.close_fiscal_period( + self.case.policy.legal_entity_reference, + self.case.policy.accounting_book_reference, + "2026-08", + "KRW", + period_status_code="soft_closed", + idempotency_key=f"{self.case.policy.tenant_reference}:snapshot-pair:soft", + ) + + def _scope(self, connection: psycopg.Connection[object]) -> tuple[object, object, object]: + row = connection.execute( + """ + SELECT legal_entity_record.legal_entity_id, + accounting_book.accounting_book_id, + fiscal_period.fiscal_period_id + FROM accounting_core.legal_entity_record + JOIN accounting_core.accounting_book + ON accounting_book.tenant_account_id = legal_entity_record.tenant_account_id + AND accounting_book.legal_entity_id = legal_entity_record.legal_entity_id + JOIN accounting_core.accounting_book_period_control + ON accounting_book_period_control.tenant_account_id = accounting_book.tenant_account_id + AND accounting_book_period_control.accounting_book_id = accounting_book.accounting_book_id + JOIN accounting_core.fiscal_period + ON fiscal_period.tenant_account_id = accounting_book_period_control.tenant_account_id + AND fiscal_period.fiscal_period_id = accounting_book_period_control.fiscal_period_id + WHERE legal_entity_record.tenant_account_id = %s + AND legal_entity_record.legal_entity_code = %s + AND accounting_book.book_name = %s + AND fiscal_period.period_code = '2026-08' + """, + ( + self.case.tenant_id, + self.case.policy.legal_entity_reference, + self.case.policy.accounting_book_reference, + ), + ).fetchone() + self.assertIsNotNone(row) + assert row is not None + return row + + def test_soft_closed_snapshot_cannot_commit_without_hard_close_pair(self) -> None: + """The closing capability cannot retain a snapshot while book authority stays soft-closed.""" + with psycopg.connect(posting.DATABASE_URL) as connection: + legal_entity_id, accounting_book_id, fiscal_period_id = self._scope(connection) + connection.execute( + "SELECT pg_advisory_xact_lock(hashtext(%s), hashtext(%s))", + ( + self.case.policy.tenant_reference, + f"period:{accounting_book_id}:2026-08", + ), + ) + connection.execute( + """ + INSERT INTO accounting_reporting.trial_balance_snapshot ( + tenant_account_id, + legal_entity_id, + accounting_book_id, + fiscal_period_id, + snapshot_currency_code, + source_journal_count, + source_payload_hash, + close_idempotency_key + ) + VALUES (%s, %s, %s, %s, 'KRW', 0, %s, %s) + """, + ( + self.case.tenant_id, + legal_entity_id, + accounting_book_id, + fiscal_period_id, + "sha256:" + "7" * 64, + f"{self.case.policy.tenant_reference}:snapshot-pair:forged", + ), + ) + + with self.assertRaisesRegex( + psycopg.errors.CheckViolation, + "trial_balance_snapshot_hard_close_pair_required", + ): + connection.commit() + connection.rollback() + + with psycopg.connect(posting.DATABASE_URL) as connection: + snapshot_count = connection.execute( + """ + SELECT count(*) + FROM accounting_reporting.trial_balance_snapshot + WHERE tenant_account_id = %s + AND accounting_book_id = ( + SELECT accounting_book_id + FROM accounting_core.accounting_book + WHERE tenant_account_id = %s + AND book_name = %s + ) + """, + ( + self.case.tenant_id, + self.case.tenant_id, + self.case.policy.accounting_book_reference, + ), + ).fetchone()[0] + period_status = connection.execute( + """ + SELECT accounting_book_period_control.period_status_code + FROM accounting_core.accounting_book_period_control + JOIN accounting_core.accounting_book + ON accounting_book.tenant_account_id = accounting_book_period_control.tenant_account_id + AND accounting_book.accounting_book_id = accounting_book_period_control.accounting_book_id + JOIN accounting_core.fiscal_period + ON fiscal_period.tenant_account_id = accounting_book_period_control.tenant_account_id + AND fiscal_period.fiscal_period_id = accounting_book_period_control.fiscal_period_id + WHERE accounting_book_period_control.tenant_account_id = %s + AND accounting_book.book_name = %s + AND fiscal_period.period_code = '2026-08' + """, + (self.case.tenant_id, self.case.policy.accounting_book_reference), + ).fetchone()[0] + + self.assertEqual(snapshot_count, 0) + self.assertEqual(period_status, "soft_closed") + + +if __name__ == "__main__": + unittest.main() From 9c810ea3f96fe0a79c94128a8569e0c7472be665 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 22:09:54 +0900 Subject: [PATCH 159/224] fix(close): pair retained snapshot with hard-close commit --- ...trial_balance_snapshot_hard_close_pair.sql | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 database/migrations/0035_trial_balance_snapshot_hard_close_pair.sql diff --git a/database/migrations/0035_trial_balance_snapshot_hard_close_pair.sql b/database/migrations/0035_trial_balance_snapshot_hard_close_pair.sql new file mode 100644 index 00000000..242da675 --- /dev/null +++ b/database/migrations/0035_trial_balance_snapshot_hard_close_pair.sql @@ -0,0 +1,46 @@ +BEGIN; + +-- A retained trial-balance snapshot is created while the authoritative +-- book-period control is still soft_closed, then the canonical hard-close +-- command advances that same control to hard_closed before commit. The +-- immediate admission trigger therefore cannot prove the final pairing. +-- Enforce the invariant at transaction end so a purpose-limited closing +-- session cannot retain snapshot evidence while leaving book-period authority +-- soft-closed. This is a database consistency control, not an IFRS rule. +CREATE OR REPLACE FUNCTION accounting_reporting.require_trial_balance_snapshot_hard_close_pair() +RETURNS trigger +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = pg_catalog, pg_temp +AS $$ +DECLARE + period_status_value text; +BEGIN + SELECT accounting_book_period_control.period_status_code + INTO period_status_value + FROM accounting_core.accounting_book_period_control + WHERE accounting_book_period_control.tenant_account_id = NEW.tenant_account_id + AND accounting_book_period_control.accounting_book_id = NEW.accounting_book_id + AND accounting_book_period_control.fiscal_period_id = NEW.fiscal_period_id; + + IF period_status_value IS DISTINCT FROM 'hard_closed' THEN + RAISE EXCEPTION + 'retained trial balance snapshot must commit with hard-closed book-period authority (trial_balance_snapshot_hard_close_pair_required)' + USING ERRCODE = 'check_violation'; + END IF; + + RETURN NULL; +END; +$$; + +REVOKE ALL ON FUNCTION accounting_reporting.require_trial_balance_snapshot_hard_close_pair() + FROM PUBLIC; + +CREATE CONSTRAINT TRIGGER trial_balance_snapshot_hard_close_pair_guard + AFTER INSERT + ON accounting_reporting.trial_balance_snapshot + DEFERRABLE INITIALLY DEFERRED + FOR EACH ROW + EXECUTE FUNCTION accounting_reporting.require_trial_balance_snapshot_hard_close_pair(); + +COMMIT; From 1a9f28c56102f1eda617a49e9880d31025f3caca Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 22:10:31 +0900 Subject: [PATCH 160/224] build(close): install snapshot hard-close pairing guard --- src/accounting_information_platform/migration_install.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/accounting_information_platform/migration_install.py b/src/accounting_information_platform/migration_install.py index 825198fc..98888c28 100644 --- a/src/accounting_information_platform/migration_install.py +++ b/src/accounting_information_platform/migration_install.py @@ -31,6 +31,7 @@ def apply_foundation_migration(database_url: str, migration_path: Path) -> None: migration_path.parent / "0032_period_close_journal_population_fence.sql", migration_path.parent / "0033_open_period_journal_population_fence.sql", migration_path.parent / "0034_book_period_control_seed.sql", + migration_path.parent / "0035_trial_balance_snapshot_hard_close_pair.sql", ) for forward_migration_path in forward_migration_paths: if not forward_migration_path.is_file(): From 9c17dab313870e851c484a4f214d7609364e6c30 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 22:10:44 +0900 Subject: [PATCH 161/224] test(close): ratchet snapshot hard-close pairing guard --- ...l_balance_snapshot_commit_pair_contract.py | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 tests/test_trial_balance_snapshot_commit_pair_contract.py diff --git a/tests/test_trial_balance_snapshot_commit_pair_contract.py b/tests/test_trial_balance_snapshot_commit_pair_contract.py new file mode 100644 index 00000000..56d9640a --- /dev/null +++ b/tests/test_trial_balance_snapshot_commit_pair_contract.py @@ -0,0 +1,34 @@ +"""Static contract for retained-snapshot and hard-close commit pairing.""" + +from __future__ import annotations + +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +MIGRATION_PATH = ROOT / "database/migrations/0035_trial_balance_snapshot_hard_close_pair.sql" +INSTALLER_PATH = ROOT / "src/accounting_information_platform/migration_install.py" + + +def test_snapshot_pair_guard_is_deferred_and_fail_closed() -> None: + """Snapshot admission may be temporary soft-close state, but commit may not be.""" + sql = MIGRATION_PATH.read_text(encoding="utf-8") + + assert "CREATE CONSTRAINT TRIGGER trial_balance_snapshot_hard_close_pair_guard" in sql + assert "DEFERRABLE INITIALLY DEFERRED" in sql + assert "AFTER INSERT" in sql + assert "period_status_value IS DISTINCT FROM 'hard_closed'" in sql + assert "trial_balance_snapshot_hard_close_pair_required" in sql + assert "SECURITY DEFINER" in sql + assert "SET search_path = pg_catalog, pg_temp" in sql + assert ( + "REVOKE ALL ON FUNCTION accounting_reporting.require_trial_balance_snapshot_hard_close_pair()" + in sql + ) + + +def test_canonical_installer_cannot_stop_before_snapshot_pair_guard() -> None: + """Every supported foundation install reaches migration 0035.""" + installer = INSTALLER_PATH.read_text(encoding="utf-8") + + assert '"0035_trial_balance_snapshot_hard_close_pair.sql"' in installer From 58175fe0ea19c355b1885ba2101548a9aa84b7d2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 22:14:24 +0900 Subject: [PATCH 162/224] docs(close): trace deferred snapshot hard-close pairing --- ...BALANCE_SNAPSHOT_AUTHORITY_TRACEABILITY.md | 20 ++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/docs/doctoring/TRIAL_BALANCE_SNAPSHOT_AUTHORITY_TRACEABILITY.md b/docs/doctoring/TRIAL_BALANCE_SNAPSHOT_AUTHORITY_TRACEABILITY.md index 4b819dc9..6e96987c 100644 --- a/docs/doctoring/TRIAL_BALANCE_SNAPSHOT_AUTHORITY_TRACEABILITY.md +++ b/docs/doctoring/TRIAL_BALANCE_SNAPSHOT_AUTHORITY_TRACEABILITY.md @@ -62,14 +62,24 @@ Snapshot admission and snapshot selection are separate authority boundaries. A r Static RED `3ff7ac365bf2e8d15c44f6a47a5f6b568906874b` therefore requires `PostgresPostingLedger.load_period_trial_balance()` to consume `_load_book_period_state()` and rejects `_require_fiscal_period()` as the source of close-state selection. Real-PostgreSQL RED `a26e35a7a5782ba1d3401f274e61f8ac168da0d6` hard-closes one statutory book while an active sibling remains open, then requires the default statutory read to report `period_status_code=hard_closed`, `balance_source_code=snapshot`, and the exact retained `snapshot_record_id`. -The minimal production repair is deliberately narrow: resolve the requested book first, load its exact `accounting_book_period_control` state, fail closed when that control is absent, and use that selected-book status to choose retained snapshot versus live aggregation. Do not change aggregate calendar semantics, synthesize missing control rows, add a Reporting-owned close-state copy, or weaken explicit `unadjusted`/`adjusted` worksheet semantics. +Production repair `9af6fe8aa534195ca040cfc3f1b5d7c85612650a` resolves the requested book first, loads its exact `accounting_book_period_control` state, fails closed when that control is absent, and uses that selected-book status to choose retained snapshot versus live aggregation. It does not change aggregate calendar semantics, synthesize missing control rows, add a Reporting-owned close-state copy, or weaken explicit `unadjusted`/`adjusted` worksheet semantics. -The two RED commits are not production GREEN or release evidence. Their exact-head PostgreSQL/Accounting Foundation execution and the production helper repair are still required before this read boundary is complete. +## Commit-time snapshot/status pairing + +Insert-time snapshot admission and commit-time Period Close authority are different checks. The canonical hard-close transaction inserts the retained snapshot while the selected `accounting_book_period_control` is still `soft_closed`, writes the retained lines, and only then advances that exact control to `hard_closed`. Consequently an immediate insert trigger cannot require `hard_closed` without breaking the valid command, but the previous database contract also allowed a purpose-limited closing session holding the exact close lock to insert a snapshot and commit while leaving the book-period `soft_closed`. + +Real-PostgreSQL RED `1c1360ebf9d0ab0ece0237b820567ff834999abe` reproduces that boundary: it acquires the exact tenant/book/period close lock, inserts a valid retained snapshot into a soft-closed book-period, and requires transaction commit to fail rather than retain unpaired close evidence. Migration `0035_trial_balance_snapshot_hard_close_pair.sql` in repair `9c810ea3f96fe0a79c94128a8569e0c7472be665` adds an `AFTER INSERT` constraint trigger declared `DEFERRABLE INITIALLY DEFERRED`. At deferred execution it resolves the exact tenant/book/period control and raises `trial_balance_snapshot_hard_close_pair_required` unless the final status is `hard_closed`. Installer commit `1a9f28c56102f1eda617a49e9880d31025f3caca` makes 0035 part of every supported foundation install, and static ratchet `9c17dab313870e851c484a4f214d7609364e6c30` pins the deferred timing, hardened function boundary, diagnostic, and installer membership. + +PostgreSQL 18 explicitly permits constraint triggers to run at the end of the containing transaction and requires them to be `AFTER ROW` triggers; `DEFERRABLE INITIALLY DEFERRED` therefore matches the transaction shape rather than inventing an application-side second authority. If the deferred check fails, PostgreSQL aborts the transaction, so the snapshot, its lines, the later status change, and close outbox work do not become a partially committed accounting fact. Recovery is a whole-command retry after the defect is corrected; operators must not relabel the book-period or manually insert/delete retained evidence to satisfy the guard. + +This commit-pair invariant is an AIP DDD/database consistency decision. IFRS does not prescribe PostgreSQL constraint-trigger timing. ADR 0006 already defines hard close as one snapshot-and-status transaction; migration 0035 makes that existing decision enforceable at the commit boundary. ## References -PostgreSQL Global Development Group. (2026). *PostgreSQL 18 documentation: System administration functions*. https://www.postgresql.org/docs/18/functions-admin.html +PostgreSQL Global Development Group. (2026a). *PostgreSQL 18 documentation: System administration functions*. https://www.postgresql.org/docs/18/functions-admin.html + +PostgreSQL Global Development Group. (2026b). *PostgreSQL 18 documentation: pg_locks*. https://www.postgresql.org/docs/18/view-pg-locks.html -PostgreSQL Global Development Group. (2026). *PostgreSQL 18 documentation: pg_locks*. https://www.postgresql.org/docs/18/view-pg-locks.html +PostgreSQL Global Development Group. (2026c). *PostgreSQL 18 documentation: Setting parameters*. https://www.postgresql.org/docs/18/config-setting.html -PostgreSQL Global Development Group. (2026). *PostgreSQL 18 documentation: Setting parameters*. https://www.postgresql.org/docs/18/config-setting.html +PostgreSQL Global Development Group. (2026d). *PostgreSQL 18 documentation: CREATE TRIGGER*. https://www.postgresql.org/docs/18/sql-createtrigger.html From c89d710e90f3ecf419375a76fff69b941b5afab3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 22:21:35 +0900 Subject: [PATCH 163/224] test(close): remove unused commit-pair import --- tests/test_postgres_trial_balance_snapshot_commit_pair_red.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/test_postgres_trial_balance_snapshot_commit_pair_red.py b/tests/test_postgres_trial_balance_snapshot_commit_pair_red.py index 63af2aba..8dd47fd6 100644 --- a/tests/test_postgres_trial_balance_snapshot_commit_pair_red.py +++ b/tests/test_postgres_trial_balance_snapshot_commit_pair_red.py @@ -6,7 +6,6 @@ import psycopg -from accounting_information_platform.persistence import apply_foundation_migration from tests import test_postgres_posting as posting From bea21ed65d9c9e8a79cb48a102f7688032baae0a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 22:34:30 +0900 Subject: [PATCH 164/224] test(close): require hard-close snapshot pair --- ...t_postgres_hard_close_snapshot_pair_red.py | 112 ++++++++++++++++++ 1 file changed, 112 insertions(+) create mode 100644 tests/test_postgres_hard_close_snapshot_pair_red.py diff --git a/tests/test_postgres_hard_close_snapshot_pair_red.py b/tests/test_postgres_hard_close_snapshot_pair_red.py new file mode 100644 index 00000000..7c733f9b --- /dev/null +++ b/tests/test_postgres_hard_close_snapshot_pair_red.py @@ -0,0 +1,112 @@ +"""Real PostgreSQL RED/GREEN for the hard-close-to-snapshot commit pair.""" + +from __future__ import annotations + +import unittest + +import psycopg + +from tests import test_postgres_posting as posting + + +class HardCloseSnapshotPairPostgresTests(unittest.TestCase): + """A hard-closed book period may commit only with its retained trial balance.""" + + @classmethod + def setUpClass(cls) -> None: + """Install the complete current accounting migration chain.""" + posting.PostgresPostingTests.setUpClass() + + def setUp(self) -> None: + """Create one governed book period and leave it soft-closed without a snapshot.""" + self.case = posting.PostgresPostingTests("setUp") + self.case.setUp() + self.addCleanup(self.case.doCleanups) + self.addCleanup(self.case.tearDown) + self.case.ledger.close_fiscal_period( + self.case.policy.legal_entity_reference, + self.case.policy.accounting_book_reference, + "2026-08", + "KRW", + period_status_code="soft_closed", + idempotency_key=f"{self.case.policy.tenant_reference}:hard-close-pair:soft", + ) + + def _scope(self, connection: psycopg.Connection[object]) -> tuple[object, object]: + row = connection.execute( + """ + SELECT accounting_book.accounting_book_id, + fiscal_period.fiscal_period_id + FROM accounting_core.accounting_book + JOIN accounting_core.accounting_book_period_control + ON accounting_book_period_control.tenant_account_id + = accounting_book.tenant_account_id + AND accounting_book_period_control.accounting_book_id + = accounting_book.accounting_book_id + JOIN accounting_core.fiscal_period + ON fiscal_period.tenant_account_id + = accounting_book_period_control.tenant_account_id + AND fiscal_period.fiscal_period_id + = accounting_book_period_control.fiscal_period_id + WHERE accounting_book.tenant_account_id = %s + AND accounting_book.book_name = %s + AND fiscal_period.period_code = '2026-08' + """, + (self.case.tenant_id, self.case.policy.accounting_book_reference), + ).fetchone() + self.assertIsNotNone(row) + assert row is not None + return row[0], row[1] + + def test_hard_close_cannot_commit_without_matching_snapshot(self) -> None: + """A database writer cannot retain hard-closed authority without retained evidence.""" + with psycopg.connect(posting.DATABASE_URL) as connection: + accounting_book_id, fiscal_period_id = self._scope(connection) + connection.execute( + """ + UPDATE accounting_core.accounting_book_period_control + SET period_status_code = 'hard_closed', + period_closed_at = clock_timestamp() + WHERE tenant_account_id = %s + AND accounting_book_id = %s + AND fiscal_period_id = %s + """, + (self.case.tenant_id, accounting_book_id, fiscal_period_id), + ) + + with self.assertRaisesRegex( + psycopg.errors.CheckViolation, + "hard_close_snapshot_pair_required", + ): + connection.commit() + connection.rollback() + + with psycopg.connect(posting.DATABASE_URL) as connection: + accounting_book_id, fiscal_period_id = self._scope(connection) + period_status, snapshot_count = connection.execute( + """ + SELECT accounting_book_period_control.period_status_code, + ( + SELECT count(*) + FROM accounting_reporting.trial_balance_snapshot + WHERE trial_balance_snapshot.tenant_account_id + = accounting_book_period_control.tenant_account_id + AND trial_balance_snapshot.accounting_book_id + = accounting_book_period_control.accounting_book_id + AND trial_balance_snapshot.fiscal_period_id + = accounting_book_period_control.fiscal_period_id + ) + FROM accounting_core.accounting_book_period_control + WHERE accounting_book_period_control.tenant_account_id = %s + AND accounting_book_period_control.accounting_book_id = %s + AND accounting_book_period_control.fiscal_period_id = %s + """, + (self.case.tenant_id, accounting_book_id, fiscal_period_id), + ).fetchone() + + self.assertEqual(period_status, "soft_closed") + self.assertEqual(snapshot_count, 0) + + +if __name__ == "__main__": + unittest.main() From 26e71eb4e5a8450159f5ced482de43176c80e0f6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 22:34:49 +0900 Subject: [PATCH 165/224] fix(close): require retained snapshot on hard close --- ...hard_close_trial_balance_snapshot_pair.sql | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 database/migrations/0036_hard_close_trial_balance_snapshot_pair.sql diff --git a/database/migrations/0036_hard_close_trial_balance_snapshot_pair.sql b/database/migrations/0036_hard_close_trial_balance_snapshot_pair.sql new file mode 100644 index 00000000..4d7bb107 --- /dev/null +++ b/database/migrations/0036_hard_close_trial_balance_snapshot_pair.sql @@ -0,0 +1,46 @@ +BEGIN; + +-- Migration 0035 proves snapshot -> hard_closed at commit. The inverse also +-- matters: accounting_book_period_control is the authoritative close fact, so a +-- hard_closed transition must not commit without the retained trial-balance +-- evidence that the supported close command promises to preserve. Keep this +-- check deferred because the canonical command inserts the snapshot before it +-- advances the book-period control in the same transaction. +CREATE OR REPLACE FUNCTION accounting_reporting.require_hard_close_trial_balance_snapshot_pair() +RETURNS trigger +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = pg_catalog, pg_temp +AS $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM accounting_reporting.trial_balance_snapshot + WHERE trial_balance_snapshot.tenant_account_id = NEW.tenant_account_id + AND trial_balance_snapshot.accounting_book_id = NEW.accounting_book_id + AND trial_balance_snapshot.fiscal_period_id = NEW.fiscal_period_id + ) THEN + RAISE EXCEPTION + 'hard-closed book-period authority must commit with retained trial balance evidence (hard_close_snapshot_pair_required)' + USING ERRCODE = 'check_violation'; + END IF; + + RETURN NULL; +END; +$$; + +REVOKE ALL ON FUNCTION accounting_reporting.require_hard_close_trial_balance_snapshot_pair() + FROM PUBLIC; + +CREATE CONSTRAINT TRIGGER hard_close_trial_balance_snapshot_pair_guard + AFTER UPDATE OF period_status_code + ON accounting_core.accounting_book_period_control + DEFERRABLE INITIALLY DEFERRED + FOR EACH ROW + WHEN ( + OLD.period_status_code IS DISTINCT FROM 'hard_closed' + AND NEW.period_status_code = 'hard_closed' + ) + EXECUTE FUNCTION accounting_reporting.require_hard_close_trial_balance_snapshot_pair(); + +COMMIT; From a3c2b250f95bc1232edb305c9fa03904ad22332c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 22:35:06 +0900 Subject: [PATCH 166/224] fix(close): install hard-close snapshot pair guard --- src/accounting_information_platform/migration_install.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/accounting_information_platform/migration_install.py b/src/accounting_information_platform/migration_install.py index 98888c28..2b73564b 100644 --- a/src/accounting_information_platform/migration_install.py +++ b/src/accounting_information_platform/migration_install.py @@ -32,6 +32,7 @@ def apply_foundation_migration(database_url: str, migration_path: Path) -> None: migration_path.parent / "0033_open_period_journal_population_fence.sql", migration_path.parent / "0034_book_period_control_seed.sql", migration_path.parent / "0035_trial_balance_snapshot_hard_close_pair.sql", + migration_path.parent / "0036_hard_close_trial_balance_snapshot_pair.sql", ) for forward_migration_path in forward_migration_paths: if not forward_migration_path.is_file(): From b7056cd969693f9ccdd71f9e2958eedb1d9b133a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 22:35:33 +0900 Subject: [PATCH 167/224] test(close): ratchet bidirectional commit pair --- ...l_balance_snapshot_commit_pair_contract.py | 32 ++++++++++++++++--- 1 file changed, 28 insertions(+), 4 deletions(-) diff --git a/tests/test_trial_balance_snapshot_commit_pair_contract.py b/tests/test_trial_balance_snapshot_commit_pair_contract.py index 56d9640a..5956f0e3 100644 --- a/tests/test_trial_balance_snapshot_commit_pair_contract.py +++ b/tests/test_trial_balance_snapshot_commit_pair_contract.py @@ -6,13 +6,18 @@ ROOT = Path(__file__).resolve().parents[1] -MIGRATION_PATH = ROOT / "database/migrations/0035_trial_balance_snapshot_hard_close_pair.sql" +SNAPSHOT_TO_CLOSE_MIGRATION = ( + ROOT / "database/migrations/0035_trial_balance_snapshot_hard_close_pair.sql" +) +CLOSE_TO_SNAPSHOT_MIGRATION = ( + ROOT / "database/migrations/0036_hard_close_trial_balance_snapshot_pair.sql" +) INSTALLER_PATH = ROOT / "src/accounting_information_platform/migration_install.py" def test_snapshot_pair_guard_is_deferred_and_fail_closed() -> None: """Snapshot admission may be temporary soft-close state, but commit may not be.""" - sql = MIGRATION_PATH.read_text(encoding="utf-8") + sql = SNAPSHOT_TO_CLOSE_MIGRATION.read_text(encoding="utf-8") assert "CREATE CONSTRAINT TRIGGER trial_balance_snapshot_hard_close_pair_guard" in sql assert "DEFERRABLE INITIALLY DEFERRED" in sql @@ -27,8 +32,27 @@ def test_snapshot_pair_guard_is_deferred_and_fail_closed() -> None: ) -def test_canonical_installer_cannot_stop_before_snapshot_pair_guard() -> None: - """Every supported foundation install reaches migration 0035.""" +def test_hard_close_pair_guard_is_deferred_and_fail_closed() -> None: + """Hard-close authority may not commit unless its retained snapshot exists.""" + sql = CLOSE_TO_SNAPSHOT_MIGRATION.read_text(encoding="utf-8") + + assert "CREATE CONSTRAINT TRIGGER hard_close_trial_balance_snapshot_pair_guard" in sql + assert "DEFERRABLE INITIALLY DEFERRED" in sql + assert "AFTER UPDATE OF period_status_code" in sql + assert "NEW.period_status_code = 'hard_closed'" in sql + assert "accounting_reporting.trial_balance_snapshot" in sql + assert "hard_close_snapshot_pair_required" in sql + assert "SECURITY DEFINER" in sql + assert "SET search_path = pg_catalog, pg_temp" in sql + assert ( + "REVOKE ALL ON FUNCTION accounting_reporting.require_hard_close_trial_balance_snapshot_pair()" + in sql + ) + + +def test_canonical_installer_cannot_stop_before_bidirectional_pair_guards() -> None: + """Every supported foundation install reaches both commit-pair migrations.""" installer = INSTALLER_PATH.read_text(encoding="utf-8") assert '"0035_trial_balance_snapshot_hard_close_pair.sql"' in installer + assert '"0036_hard_close_trial_balance_snapshot_pair.sql"' in installer From 80a719fe85239b864993023d4de8aa4e5765b397 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 22:38:08 +0900 Subject: [PATCH 168/224] docs(close): record bidirectional commit pair --- docs/adr/0006-fiscal-period-close-snapshot.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/adr/0006-fiscal-period-close-snapshot.md b/docs/adr/0006-fiscal-period-close-snapshot.md index 25a1c8f6..2411eada 100644 --- a/docs/adr/0006-fiscal-period-close-snapshot.md +++ b/docs/adr/0006-fiscal-period-close-snapshot.md @@ -14,6 +14,8 @@ The implementation also has to preserve posting throughput. `accounting_book_per An exact hard-close replay returns the retained result and creates no second snapshot, journal, or close event. `hard_closed` cannot transition back to `soft_closed`. Soft-close replay remains snapshot-free. +Commit-time pairing is bidirectional. Migration `0035_trial_balance_snapshot_hard_close_pair.sql` rejects a retained snapshot that would commit without matching `hard_closed` book-period authority. Migration `0036_hard_close_trial_balance_snapshot_pair.sql` rejects an `accounting_book_period_control` transition to `hard_closed` that would commit without the matching retained snapshot. Both guards are `DEFERRABLE INITIALLY DEFERRED`, so the canonical snapshot-first/status-second hard-close ordering remains valid while either one-sided terminal state fails closed at commit. The unique tenant/book/period snapshot identity makes the retained counterpart unambiguous. This is an AIP database consistency invariant, not an IFRS-prescribed PostgreSQL mechanism. + ### Retained population identity and immutability Migration `0029_trial_balance_snapshot_population_unique_index.sql` builds the unique `(tenant_account_id, accounting_book_id, fiscal_period_id)` population identity with `CREATE UNIQUE INDEX CONCURRENTLY` outside a transaction block. Migration `0030_trial_balance_snapshot_immutability.sql` attaches that index as `trial_balance_snapshot_one_population_per_book_period`, installs header/line mutation guards, and serializes snapshot and line admission on the exact `accounting_book_period_control` row. @@ -117,7 +119,7 @@ PostgreSQL row locking and same-slot collisions still have measurable cost. Rele Migration 0033 creates a new tenant-scoped table under RLS/FORCE RLS. Its initial cross-tenant fence backfill occurs before FORCE RLS is enabled. Migrations 0009/0034 now also account for the already forced source tables: the migration owner gets only a transactional owner-bypass window on the exact participating relations, while RLS remains enabled for non-owner roles and FORCE is restored before commit. Migration 0034 seeds future and repair rows only where the shared period is open; missing non-open book-period authority remains fail closed rather than being inferred. Future created controls are seeded from the book-period-control INSERT transaction and stay in that tenant scope. Guard functions are `SECURITY DEFINER`, use `search_path = pg_catalog, pg_temp`, and revoke PUBLIC execute. -The migration chain has distinct recovery states. A failed 0029 concurrent index build can leave an invalid index that operators must remove or rebuild before retry. A failed 0031 validation leaves the constraint enforced for subsequent writes but inherited rows uncertified. Failed 0033 or 0034 migration transactions roll back their tables/triggers/functions/policies or owner-force toggles and seed population together. A runtime SQLSTATE `40001` leaves no authoritative close result and requires a whole-command retry. Recovery must never normalize or rewrite posted journals, reconciliation evidence, or retained close facts. +The migration chain has distinct recovery states. A failed 0029 concurrent index build can leave an invalid index that operators must remove or rebuild before retry. A failed 0031 validation leaves the constraint enforced for subsequent writes but inherited rows uncertified. Failed 0033 or 0034 migration transactions roll back their tables/triggers/functions/policies or owner-force toggles and seed population together. A failed 0035 or 0036 installation rolls back its deferred trigger/function pair. A runtime SQLSTATE `40001` leaves no authoritative close result and requires a whole-command retry. A `hard_close_snapshot_pair_required` or `trial_balance_snapshot_hard_close_pair_required` failure rolls back the whole transaction; operators must retry the supported close command rather than patch either side of the pair. Recovery must never normalize or rewrite posted journals, reconciliation evidence, or retained close facts. Future reopen/correction is not implemented here. Any later reopen policy must preserve the prior hard-close population through explicit successor lineage and a replacement population identity/version invariant rather than mutating retained evidence or weakening uniqueness. From 780b66c0fe6b641fcaee15d76c16d78e138ab230 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 22:38:59 +0900 Subject: [PATCH 169/224] docs(close): trace hard-close snapshot pairing --- .../HARD_CLOSE_SNAPSHOT_PAIR_TRACEABILITY.md | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 docs/doctoring/HARD_CLOSE_SNAPSHOT_PAIR_TRACEABILITY.md diff --git a/docs/doctoring/HARD_CLOSE_SNAPSHOT_PAIR_TRACEABILITY.md b/docs/doctoring/HARD_CLOSE_SNAPSHOT_PAIR_TRACEABILITY.md new file mode 100644 index 00000000..f0828a86 --- /dev/null +++ b/docs/doctoring/HARD_CLOSE_SNAPSHOT_PAIR_TRACEABILITY.md @@ -0,0 +1,41 @@ +# Hard-close / retained-snapshot pair traceability + +## Finding + +`accounting_book_period_control` is the authoritative tenant/book/period close-state fact. Migration 0035 already prevented the retained `trial_balance_snapshot` side of a hard close from committing unless the matching control ended `hard_closed`, but the inverse was not enforced: a writer could update an existing control to `hard_closed` and commit without a retained snapshot. The application later detected that damaged state in `_replay_close_receipt()`, but detection after commit is weaker than preventing an impossible accounting-control state. + +This is an Accounting Information Platform database/DDD invariant. IFRS does not prescribe PostgreSQL trigger timing or this physical pairing mechanism. + +## Test-first lineage + +- `bea21ed65d9c9e8a79cb48a102f7688032baae0a` adds `tests/test_postgres_hard_close_snapshot_pair_red.py`. Starting from a legitimate `soft_closed` book-period with no snapshot, it attempts a direct `hard_closed` transition and requires commit to fail with `hard_close_snapshot_pair_required`; after rollback the authoritative status must remain `soft_closed` and snapshot count must remain zero. +- `26e71eb4e5a8450159f5ced482de43176c80e0f6` adds migration `0036_hard_close_trial_balance_snapshot_pair.sql`. +- `a3c2b250f95bc1232edb305c9fa03904ad22332c` puts migration 0036 in the canonical foundation installer. +- `b7056cd969693f9ccdd71f9e2958eedb1d9b133a` extends the static contract so every supported install requires both directions of the pair. +- `80a719fe85239b864993023d4de8aa4e5765b397` records the bidirectional commit invariant and recovery semantics in ADR 0006. + +The first commit is a source RED by construction; it is not called runner-observed RED until the exact commit or a descendant containing that unchanged test executes it in PostgreSQL. Likewise, the migration is a production candidate until an unchanged exact head produces terminal GREEN evidence. + +## Selected control + +Migration 0036 installs an `AFTER UPDATE OF period_status_code` constraint trigger on `accounting_core.accounting_book_period_control`. Only a transition from a non-`hard_closed` state to `hard_closed` is queued. At deferred execution, the trigger requires a `trial_balance_snapshot` with the same tenant, accounting book, and fiscal period. Missing evidence raises `check_violation` with the stable marker `hard_close_snapshot_pair_required`. + +The trigger is `DEFERRABLE INITIALLY DEFERRED`, `FOR EACH ROW`, and uses a `SECURITY DEFINER` function with `search_path = pg_catalog, pg_temp`; PUBLIC execute is revoked. PostgreSQL 18 documents that constraint triggers are `AFTER ROW` triggers whose firing can be deferred to transaction end, and that a constraint-trigger `WHEN` expression is evaluated immediately after the row update before a matching firing is queued. That is the required timing here: the canonical hard-close command inserts its snapshot first and advances the book-period control later in the same transaction, while an unsupported hard-close-only write cannot survive commit. + +Migration 0035 and 0036 therefore enforce both implications at commit: + +`retained snapshot => hard_closed authority` + +`transition to hard_closed authority => retained snapshot` + +The existing unique `(tenant_account_id, accounting_book_id, fiscal_period_id)` snapshot population identity supplies at most one retained counterpart. No second close writer, foreign billing truth, mutable session flag, cross-service SQL, or application-side status synthesis is introduced. + +## Rejected alternatives + +Application-only replay validation was rejected because it allows corrupt `hard_closed` authority to commit and pushes recovery onto the next reader. An immediate trigger was rejected because it would couple correctness to statement order and would conflict with the existing snapshot-first/status-second command transaction. Adding another snapshot writer or synthesizing retained evidence when the status changes was rejected because it would bypass close-package validation, journal-population freshness, exact currency/scope checks, and purpose-limited close authority. + +## Recovery and operability + +A pairing violation aborts the whole transaction. Operators must retry the supported close command from a clean transaction with the original immutable command identity; they must not patch the status or insert retained evidence independently. A failed migration 0036 transaction rolls back its trigger/function pair. Rollback of a successfully deployed migration must first prove that no consumer depends on bidirectional pairing and must not weaken existing retained-evidence immutability. + +PostgreSQL Global Development Group. (2026). *PostgreSQL 18 documentation: CREATE TRIGGER*. https://www.postgresql.org/docs/18/sql-createtrigger.html From 615d2532e5908f23bee9188544ac0c04be9f7790 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 22:42:10 +0900 Subject: [PATCH 170/224] docs(close): align architecture with snapshot pair --- docs/ARCHITECTURE.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 6cbd4105..8c8ef347 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -76,6 +76,8 @@ The PostgreSQL 18 foundation is installed in order: 33. `database/migrations/0032_period_close_journal_population_fence.sql` — invalidates stale `REPEATABLE READ` close attempts when purpose-limited soft-close journal population changes. 34. `database/migrations/0033_open_period_journal_population_fence.sql` — places ordinary open-period journals on a 64-stripe PostgreSQL freshness fence and makes period transitions lock the complete ordered fence set instead of serializing every post on one close-control row. 35. `database/migrations/0034_book_period_control_seed.sql` — materializes missing book-period controls and all 64 freshness rows for newly admitted active books or fiscal periods while restoring FORCE RLS before commit. +36. `database/migrations/0035_trial_balance_snapshot_hard_close_pair.sql` — defers snapshot-side validation to commit and rejects retained trial-balance evidence whose exact book-period control does not end `hard_closed`. +37. `database/migrations/0036_hard_close_trial_balance_snapshot_pair.sql` — defers the inverse status-side validation to commit and rejects a transition to `hard_closed` without the exact retained trial-balance snapshot. `0005_closed_period_guard.sql` makes `accounting_closing_writer` a `NOLOGIN` capability role. A soft-closed insert is admitted only when the session login is a member of that role **and** the transaction-local journal classification is `period_closing`, `adjusting`, or `reversal`. The GUC alone is not authority. Hard-closed periods reject every later journal insert. @@ -83,7 +85,7 @@ Deferred constraint triggers recompute persisted journal lines at commit. A dura `0020_reconciliation_exception_resolution_command.sql` keeps exception review inside the reconciliation aggregate. A direct `open -> resolved/superseded` row update is rejected unless a matching immutable command exists in the same transaction; the command and terminal status must commit as a pair, and terminal exception evidence freezes afterward. `0021_reconciliation_exception_resolution_outbox_pair.sql` extends that pair to the matching accounting outbox event and adds only the child resolution-evidence overlay. `0022_reconciliation_authority_outbox_retention.sql` then protects the committed authority/event relationship from later detachment or ambiguity: the sole matching event cannot be deleted or re-keyed away, a duplicate exact event cannot be inserted, and an unrelated event cannot be re-keyed into the same authority identity. `0023_reconciliation_authority_outbox_orphan_guard.sql` closes the inverse admission gap: the reserved `reconciliation_exception_resolved`, `reconciliation_exception_superseded`, and `reconciliation_run_reconciled` event types cannot exist without the exact immutable command whose tenant, aggregate reference, payload reference and command hash they claim to publish. Its upgrade preflight uses temporary current-user SELECT policies only to inspect forced-RLS history and removes them before the durable runtime guard is installed. `0024_reconciliation_control_recording_time_authority.sql` closes the temporal provenance gap on the maker and retained review artifact themselves: `recorded_at` is overwritten by PostgreSQL at INSERT while `effective_at` remains the business-valid-time fact. `0025_reconciliation_lifecycle_recording_time_authority.sql` applies the same database-clock boundary to the run-finalization command and fails closed when its valid time lies after the recording instant; caller-supplied lifecycle system time is not authority. Publication may still update `published_at`. The parent `accounting_reconciliation_transition_database_authority_guard` derives the bridge and all three transition identities first; child `accounting_reconciliation_transition_evidence_snapshot_guard` composes immutable resolution commands second; `accounting_reconciliation_transition_hash_guard` binds the final snapshot and population identities; the later recording-time trigger assigns only database system time and validates temporal causality. Final reconciliation accepts a terminal exception only when its target status agrees with the retained command. Exception resolution does not post a journal: any correcting journal remains a separate General Ledger command. -Migrations 0029–0034 keep Period Close evidence inside the `close_control` / `trial_balance` boundary. A retained snapshot cannot be relabelled across legal entity, accounting book, reporting currency, period, or chart-account scope, and its exact monetary population cannot be mutated after hard close. Journal-population freshness is a PostgreSQL concurrency control, not an alternate accounting authority: a stale close fails closed and the complete command must retry from a fresh transaction with the same immutable source identity/idempotency key. +Migrations 0029–0036 keep Period Close evidence inside the `close_control` / `trial_balance` boundary. A retained snapshot cannot be relabelled across legal entity, accounting book, reporting currency, period, or chart-account scope, and its exact monetary population cannot be mutated after hard close. Journal-population freshness is a PostgreSQL concurrency control, not an alternate accounting authority: a stale close fails closed and the complete command must retry from a fresh transaction with the same immutable source identity/idempotency key. At commit, the retained snapshot and exact `hard_closed` book-period state are a bidirectional pair: neither side may persist without the other. ## Runtime identity boundary @@ -122,7 +124,7 @@ A reversal accounting date may not precede the original accounting date. Soft-cl Soft-close changes the fiscal-period state but writes no hard-close snapshot. Ordinary posting is blocked; purpose-limited adjusting / closing / reversal paths may remain available under database authorization. -Hard-close loads one repeatable-read close package, posts the AIS-owned period-closing journal when required, stores the hard-close trial-balance snapshot and locks the period. A journal population change that races a stale close must invalidate that close rather than allow retained evidence to omit an admitted journal. Later ordinary or reversal inserts into the hard-closed period are rejected. Close replay is idempotent and does not create a second snapshot or closing journal. +Hard-close loads one repeatable-read close package, posts the AIS-owned period-closing journal when required, stores the hard-close trial-balance snapshot and locks the period. A journal population change that races a stale close must invalidate that close rather than allow retained evidence to omit an admitted journal. The snapshot and exact book-period `hard_closed` state must survive commit together; either one-sided terminal state aborts the transaction. Later ordinary or reversal inserts into the hard-closed period are rejected. Close replay is idempotent and does not create a second snapshot or closing journal. ## Read models From f1935c8515a74967355cfc5ffd96ef4c134500a2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 22:44:29 +0900 Subject: [PATCH 171/224] test(close): reject legacy one-sided hard close --- ...t_postgres_hard_close_snapshot_pair_red.py | 71 +++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/tests/test_postgres_hard_close_snapshot_pair_red.py b/tests/test_postgres_hard_close_snapshot_pair_red.py index 7c733f9b..c4cb5c30 100644 --- a/tests/test_postgres_hard_close_snapshot_pair_red.py +++ b/tests/test_postgres_hard_close_snapshot_pair_red.py @@ -2,6 +2,7 @@ from __future__ import annotations +from pathlib import Path import unittest import psycopg @@ -9,6 +10,10 @@ from tests import test_postgres_posting as posting +ROOT = Path(__file__).resolve().parents[1] +PAIR_MIGRATION = ROOT / "database/migrations/0036_hard_close_trial_balance_snapshot_pair.sql" + + class HardCloseSnapshotPairPostgresTests(unittest.TestCase): """A hard-closed book period may commit only with its retained trial balance.""" @@ -107,6 +112,72 @@ def test_hard_close_cannot_commit_without_matching_snapshot(self) -> None: self.assertEqual(period_status, "soft_closed") self.assertEqual(snapshot_count, 0) + def test_upgrade_refuses_preexisting_hard_close_without_snapshot(self) -> None: + """Migration 0036 may not silently grandfather a one-sided hard-close fact.""" + migration_sql = PAIR_MIGRATION.read_text(encoding="utf-8") + + with psycopg.connect( + posting.DATABASE_URL, + autocommit=True, + cursor_factory=psycopg.ClientCursor, + ) as connection: + accounting_book_id, fiscal_period_id = self._scope(connection) + connection.execute( + """ + DROP TRIGGER hard_close_trial_balance_snapshot_pair_guard + ON accounting_core.accounting_book_period_control + """ + ) + connection.execute("BEGIN") + connection.execute( + """ + UPDATE accounting_core.accounting_book_period_control + SET period_status_code = 'hard_closed', + period_closed_at = clock_timestamp() + WHERE tenant_account_id = %s + AND accounting_book_id = %s + AND fiscal_period_id = %s + """, + (self.case.tenant_id, accounting_book_id, fiscal_period_id), + ) + connection.execute("COMMIT") + + try: + with psycopg.connect( + posting.DATABASE_URL, + autocommit=True, + cursor_factory=psycopg.ClientCursor, + ) as connection: + with self.assertRaisesRegex( + psycopg.errors.CheckViolation, + "hard_close_snapshot_pair_legacy_preflight", + ): + connection.execute(migration_sql) + connection.execute("ROLLBACK") + finally: + with psycopg.connect( + posting.DATABASE_URL, + autocommit=True, + cursor_factory=psycopg.ClientCursor, + ) as connection: + connection.execute( + """ + DROP TRIGGER IF EXISTS hard_close_trial_balance_snapshot_pair_guard + ON accounting_core.accounting_book_period_control + """ + ) + connection.execute( + """ + UPDATE accounting_core.accounting_book_period_control + SET period_status_code = 'soft_closed' + WHERE tenant_account_id = %s + AND accounting_book_id = %s + AND fiscal_period_id = %s + """, + (self.case.tenant_id, accounting_book_id, fiscal_period_id), + ) + connection.execute(migration_sql) + if __name__ == "__main__": unittest.main() From 08c7b0f25bcdae7e5b223108eb68d06a6cf3a29a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 22:45:00 +0900 Subject: [PATCH 172/224] fix(close): refuse legacy one-sided hard close --- ...hard_close_trial_balance_snapshot_pair.sql | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/database/migrations/0036_hard_close_trial_balance_snapshot_pair.sql b/database/migrations/0036_hard_close_trial_balance_snapshot_pair.sql index 4d7bb107..c1c65507 100644 --- a/database/migrations/0036_hard_close_trial_balance_snapshot_pair.sql +++ b/database/migrations/0036_hard_close_trial_balance_snapshot_pair.sql @@ -1,5 +1,63 @@ BEGIN; +-- A deferred trigger protects future transitions but cannot certify rows that +-- already existed before this migration. Both participating relations are FORCE +-- RLS, so give only the current migration role transaction-scoped SELECT +-- visibility for the preflight. The policies disappear before durable trigger +-- installation; an aborted migration rolls them back with the transaction. +CREATE POLICY hard_close_snapshot_pair_control_upgrade_visibility + ON accounting_core.accounting_book_period_control + FOR SELECT + TO current_user + USING (true); + +CREATE POLICY hard_close_snapshot_pair_snapshot_upgrade_visibility + ON accounting_reporting.trial_balance_snapshot + FOR SELECT + TO current_user + USING (true); + +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM accounting_core.accounting_book_period_control AS period_control + WHERE period_control.period_status_code = 'hard_closed' + AND NOT EXISTS ( + SELECT 1 + FROM accounting_reporting.trial_balance_snapshot AS snapshot + WHERE snapshot.tenant_account_id = period_control.tenant_account_id + AND snapshot.accounting_book_id = period_control.accounting_book_id + AND snapshot.fiscal_period_id = period_control.fiscal_period_id + ) + ) THEN + RAISE EXCEPTION + 'pre-0036 hard-closed book-period authority has no retained trial balance; perform audited remediation before migration 0036 (hard_close_snapshot_pair_legacy_preflight)' + USING ERRCODE = '23514'; + END IF; + + IF EXISTS ( + SELECT 1 + FROM accounting_reporting.trial_balance_snapshot AS snapshot + LEFT JOIN accounting_core.accounting_book_period_control AS period_control + ON period_control.tenant_account_id = snapshot.tenant_account_id + AND period_control.accounting_book_id = snapshot.accounting_book_id + AND period_control.fiscal_period_id = snapshot.fiscal_period_id + WHERE period_control.fiscal_period_id IS NULL + OR period_control.period_status_code IS DISTINCT FROM 'hard_closed' + ) THEN + RAISE EXCEPTION + 'pre-0036 retained trial balance lacks matching hard-closed book-period authority; perform audited remediation before migration 0036 (trial_balance_snapshot_hard_close_pair_legacy_preflight)' + USING ERRCODE = '23514'; + END IF; +END; +$$; + +DROP POLICY hard_close_snapshot_pair_snapshot_upgrade_visibility + ON accounting_reporting.trial_balance_snapshot; +DROP POLICY hard_close_snapshot_pair_control_upgrade_visibility + ON accounting_core.accounting_book_period_control; + -- Migration 0035 proves snapshot -> hard_closed at commit. The inverse also -- matters: accounting_book_period_control is the authoritative close fact, so a -- hard_closed transition must not commit without the retained trial-balance From 4536a84c35371926fb6e128fa92e5c3d109d7e07 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 22:45:21 +0900 Subject: [PATCH 173/224] test(close): ratchet pair upgrade preflight --- ...t_trial_balance_snapshot_commit_pair_contract.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/tests/test_trial_balance_snapshot_commit_pair_contract.py b/tests/test_trial_balance_snapshot_commit_pair_contract.py index 5956f0e3..900ffb20 100644 --- a/tests/test_trial_balance_snapshot_commit_pair_contract.py +++ b/tests/test_trial_balance_snapshot_commit_pair_contract.py @@ -50,6 +50,19 @@ def test_hard_close_pair_guard_is_deferred_and_fail_closed() -> None: ) +def test_pair_upgrade_refuses_preexisting_one_sided_authority() -> None: + """Migration 0036 certifies existing pair state before installing future guards.""" + sql = CLOSE_TO_SNAPSHOT_MIGRATION.read_text(encoding="utf-8") + + assert "hard_close_snapshot_pair_control_upgrade_visibility" in sql + assert "hard_close_snapshot_pair_snapshot_upgrade_visibility" in sql + assert "hard_close_snapshot_pair_legacy_preflight" in sql + assert "trial_balance_snapshot_hard_close_pair_legacy_preflight" in sql + assert "DROP POLICY hard_close_snapshot_pair_snapshot_upgrade_visibility" in sql + assert "DROP POLICY hard_close_snapshot_pair_control_upgrade_visibility" in sql + assert "DISABLE ROW LEVEL SECURITY" not in sql + + def test_canonical_installer_cannot_stop_before_bidirectional_pair_guards() -> None: """Every supported foundation install reaches both commit-pair migrations.""" installer = INSTALLER_PATH.read_text(encoding="utf-8") From 6cc80d7082a2e936a054163c82d42149651f100e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 22:45:50 +0900 Subject: [PATCH 174/224] docs(close): trace pair upgrade preflight --- .../HARD_CLOSE_SNAPSHOT_PAIR_TRACEABILITY.md | 22 +++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/docs/doctoring/HARD_CLOSE_SNAPSHOT_PAIR_TRACEABILITY.md b/docs/doctoring/HARD_CLOSE_SNAPSHOT_PAIR_TRACEABILITY.md index f0828a86..c1450436 100644 --- a/docs/doctoring/HARD_CLOSE_SNAPSHOT_PAIR_TRACEABILITY.md +++ b/docs/doctoring/HARD_CLOSE_SNAPSHOT_PAIR_TRACEABILITY.md @@ -4,6 +4,8 @@ `accounting_book_period_control` is the authoritative tenant/book/period close-state fact. Migration 0035 already prevented the retained `trial_balance_snapshot` side of a hard close from committing unless the matching control ended `hard_closed`, but the inverse was not enforced: a writer could update an existing control to `hard_closed` and commit without a retained snapshot. The application later detected that damaged state in `_replay_close_receipt()`, but detection after commit is weaker than preventing an impossible accounting-control state. +A second review found an upgrade boundary behind the future-write trigger. A deferred trigger cannot retroactively certify rows that already existed before migration 0036. Installing the trigger over a pre-existing one-sided pair would therefore make the schema look protected while silently grandfathering uncertified close authority or retained evidence. + This is an Accounting Information Platform database/DDD invariant. IFRS does not prescribe PostgreSQL trigger timing or this physical pairing mechanism. ## Test-first lineage @@ -13,12 +15,22 @@ This is an Accounting Information Platform database/DDD invariant. IFRS does not - `a3c2b250f95bc1232edb305c9fa03904ad22332c` puts migration 0036 in the canonical foundation installer. - `b7056cd969693f9ccdd71f9e2958eedb1d9b133a` extends the static contract so every supported install requires both directions of the pair. - `80a719fe85239b864993023d4de8aa4e5765b397` records the bidirectional commit invariant and recovery semantics in ADR 0006. +- `f1935c8515a74967355cfc5ffd96ef4c134500a2` adds a real-PostgreSQL upgrade RED: after reproducing a pre-0036 `hard_closed` control with no snapshot, reapplying migration 0036 must fail rather than grandfather the one-sided fact. The test restores the schema trigger and tenant fixture in `finally` so failure does not become persistent test-environment state. +- `08c7b0f25bcdae7e5b223108eb68d06a6cf3a29a` adds the migration preflight for both one-sided legacy populations. +- `4536a84c35371926fb6e128fa92e5c3d109d7e07` ratchets the preflight markers, temporary visibility policies, policy cleanup, and prohibition on disabling RLS. -The first commit is a source RED by construction; it is not called runner-observed RED until the exact commit or a descendant containing that unchanged test executes it in PostgreSQL. Likewise, the migration is a production candidate until an unchanged exact head produces terminal GREEN evidence. +The RED commits are source REDs by construction; they are not called runner-observed RED until the exact commit or a descendant containing those unchanged tests executes them in PostgreSQL. Likewise, the migration remains a production candidate until one unchanged exact head produces terminal GREEN evidence. ## Selected control -Migration 0036 installs an `AFTER UPDATE OF period_status_code` constraint trigger on `accounting_core.accounting_book_period_control`. Only a transition from a non-`hard_closed` state to `hard_closed` is queued. At deferred execution, the trigger requires a `trial_balance_snapshot` with the same tenant, accounting book, and fiscal period. Missing evidence raises `check_violation` with the stable marker `hard_close_snapshot_pair_required`. +Migration 0036 first certifies the already-stored pair population. `accounting_book_period_control` and `trial_balance_snapshot` are FORCE-RLS relations, so the migration creates two transaction-scoped `FOR SELECT TO current_user USING (true)` policies only for its owner preflight. It rejects either of these states with SQLSTATE `23514` before installing durable behavior: + +- a `hard_closed` tenant/book/period control with no exact retained snapshot (`hard_close_snapshot_pair_legacy_preflight`); +- a retained snapshot whose exact control is missing or is not `hard_closed` (`trial_balance_snapshot_hard_close_pair_legacy_preflight`). + +The temporary policies are dropped before the future-write trigger is installed. RLS remains enabled throughout; no `BYPASSRLS`, superuser requirement, fabricated tenant binding, or `DISABLE ROW LEVEL SECURITY` path is introduced. An aborted migration rolls the temporary policies back with the transaction. + +After the preflight, migration 0036 installs an `AFTER UPDATE OF period_status_code` constraint trigger on `accounting_core.accounting_book_period_control`. Only a transition from a non-`hard_closed` state to `hard_closed` is queued. At deferred execution, the trigger requires a `trial_balance_snapshot` with the same tenant, accounting book, and fiscal period. Missing evidence raises `check_violation` with the stable marker `hard_close_snapshot_pair_required`. The trigger is `DEFERRABLE INITIALLY DEFERRED`, `FOR EACH ROW`, and uses a `SECURITY DEFINER` function with `search_path = pg_catalog, pg_temp`; PUBLIC execute is revoked. PostgreSQL 18 documents that constraint triggers are `AFTER ROW` triggers whose firing can be deferred to transaction end, and that a constraint-trigger `WHEN` expression is evaluated immediately after the row update before a matching firing is queued. That is the required timing here: the canonical hard-close command inserts its snapshot first and advances the book-period control later in the same transaction, while an unsupported hard-close-only write cannot survive commit. @@ -32,10 +44,12 @@ The existing unique `(tenant_account_id, accounting_book_id, fiscal_period_id)` ## Rejected alternatives -Application-only replay validation was rejected because it allows corrupt `hard_closed` authority to commit and pushes recovery onto the next reader. An immediate trigger was rejected because it would couple correctness to statement order and would conflict with the existing snapshot-first/status-second command transaction. Adding another snapshot writer or synthesizing retained evidence when the status changes was rejected because it would bypass close-package validation, journal-population freshness, exact currency/scope checks, and purpose-limited close authority. +Application-only replay validation was rejected because it allows corrupt `hard_closed` authority to commit and pushes recovery onto the next reader. An immediate trigger was rejected because it would couple correctness to statement order and would conflict with the existing snapshot-first/status-second command transaction. Adding another snapshot writer or synthesizing retained evidence when the status changes was rejected because it would bypass close-package validation, journal-population freshness, exact currency/scope checks, and purpose-limited close authority. Silently grandfathering pre-0036 one-sided rows was rejected because later operators could mistake trigger presence for certification of historical close evidence. ## Recovery and operability -A pairing violation aborts the whole transaction. Operators must retry the supported close command from a clean transaction with the original immutable command identity; they must not patch the status or insert retained evidence independently. A failed migration 0036 transaction rolls back its trigger/function pair. Rollback of a successfully deployed migration must first prove that no consumer depends on bidirectional pairing and must not weaken existing retained-evidence immutability. +A runtime pairing violation aborts the whole transaction. Operators must retry the supported close command from a clean transaction with the original immutable command identity; they must not patch the status or insert retained evidence independently. + +If migration 0036 reports either legacy-preflight marker, stop the upgrade and inventory the exact tenant/book/period control plus retained snapshot/line evidence. Do not synthesize the missing side to make the migration pass. Retain the prior release or use a separately reviewed audited remediation that can prove the original close command, journal population, scope/currency, maker-checker decision and retained numerical evidence. A failed migration rolls back its temporary visibility policies and any trigger/function changes made in that transaction. PostgreSQL Global Development Group. (2026). *PostgreSQL 18 documentation: CREATE TRIGGER*. https://www.postgresql.org/docs/18/sql-createtrigger.html From 9923941815db0961651ec105a77f7725bc414e56 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 22:50:02 +0900 Subject: [PATCH 175/224] docs(close): bring operability through migration 0036 --- docs/OPERABILITY.md | 37 ++++++++++++++++++++++++++++++++----- 1 file changed, 32 insertions(+), 5 deletions(-) diff --git a/docs/OPERABILITY.md b/docs/OPERABILITY.md index 27bdadeb..82262514 100644 --- a/docs/OPERABILITY.md +++ b/docs/OPERABILITY.md @@ -2,7 +2,7 @@ ## Deployment preconditions -Use PostgreSQL 18 and keep the migration owner, application runtime login and administrative / break-glass identities separate. Apply migrations in the checked-in authority order through `0025_reconciliation_lifecycle_recording_time_authority.sql` before starting the service. Do not run the application with a table-owner, superuser or `BYPASSRLS` login. +Use PostgreSQL 18 and keep the migration owner, application runtime login and administrative / break-glass identities separate. Apply the complete checked-in authority chain through `0036_hard_close_trial_balance_snapshot_pair.sql` before starting the service. Do not run the application with a table-owner, superuser or `BYPASSRLS` login. Required environment values are deployment-specific. At minimum, configure the accounting database URL and bind this AIS process to exactly one tenant reference. Secrets belong in an approved secret store; do not place database passwords, NTS credentials, bearer tokens or provider secrets in journal payloads, logs or outbox events. @@ -39,6 +39,17 @@ database/migrations/0022_reconciliation_authority_outbox_retention.sql database/migrations/0023_reconciliation_authority_outbox_orphan_guard.sql database/migrations/0024_reconciliation_control_recording_time_authority.sql database/migrations/0025_reconciliation_lifecycle_recording_time_authority.sql +database/migrations/0026_reconciliation_lifecycle_source_payload_identity.sql +database/migrations/0027_reconciliation_lifecycle_session_lock_authority.sql +database/migrations/0028_reconciliation_lifecycle_capability_privileges.sql +database/migrations/0029_trial_balance_snapshot_population_unique_index.sql +database/migrations/0030_trial_balance_snapshot_immutability.sql +database/migrations/0031_trial_balance_line_conservation_validation.sql +database/migrations/0032_period_close_journal_population_fence.sql +database/migrations/0033_open_period_journal_population_fence.sql +database/migrations/0034_book_period_control_seed.sql +database/migrations/0035_trial_balance_snapshot_hard_close_pair.sql +database/migrations/0036_hard_close_trial_balance_snapshot_pair.sql ``` Migration `0015_reconciliation_multi_match_conservation.sql` replaces the run-wide single-approved-match shortcut from `0014` with tenant/run-scoped match identity plus exact statement/journal allocation conservation. It permits multiple independently approved matches, including split and aggregate allocation populations, only when no authoritative source amount is over-consumed and grants no journal-posting authority. @@ -51,7 +62,7 @@ Migration `0018_bank_statement_balance_evidence.sql` preserves the exact numeric Migration `0019_reconciliation_run_command_evidence.sql` records the immutable command identity that opens a reconciliation run from one persisted bank statement and active bank-account assignment, then adds the evidence-derived run-finalization command and shared reconciliation command-identity namespace. The tenant-scoped idempotency key, command hash, source hash, and object-store reference are forced-RLS evidence; new runs exclude source facts recorded after `knowledge_cutoff_at`, and a deferred database guard requires one command per run with statement-to-assignment bank-account provenance. The public run API opens only `evaluating` scope and cannot post journals or close periods. -The unreleased lifecycle-parent overlay `0019_reconciliation_run_database_snapshot_authority.sql` must run after the base 0019 migration and before migrations 0020/0021/0022/0023/0024/0025. It defines `accounting_core.reconciliation_run_database_snapshot_authority`, independently reconstructs the exact bank-statement and assigned cash-book populations and bridge, and replaces caller-selected transition snapshot, statement-population, and book-population identities. The supported installer treats this overlay as part of the complete chain even though its filename shares the base numeric prefix; do not sort migrations by filename alone and accidentally omit it. +The unreleased lifecycle-parent overlay `0019_reconciliation_run_database_snapshot_authority.sql` must run after the base 0019 migration and before migrations 0020 through 0036. It defines `accounting_core.reconciliation_run_database_snapshot_authority`, independently reconstructs the exact bank-statement and assigned cash-book populations and bridge, and replaces caller-selected transition snapshot, statement-population, and book-population identities. The supported installer treats this overlay as part of the complete chain even though its filename shares the base numeric prefix; do not sort migrations by filename alone and accidentally omit it. Migration `0020_reconciliation_exception_resolution_command.sql` makes exception resolution a named maker-checker command rather than a mutable status shortcut. The database requires an active reviewable run, one open tenant/run/exception, a reviewer distinct from the exception owner, retained evidence reference/hash, temporal causality, the shared reconciliation idempotency namespace, and a database-derived command hash. A raw terminal status update is rejected unless its matching command already exists in the transaction; a deferred pair guard requires command and terminal status to commit together, then terminal exception evidence is immutable. Run finalization accepts an exception only when its terminal status and retained resolution command agree. This command does not post a journal; any correcting journal is a separate authorized General Ledger command. @@ -65,9 +76,21 @@ Migration `0024_reconciliation_control_recording_time_authority.sql` makes `reco Migration `0025_reconciliation_lifecycle_recording_time_authority.sql` has a stricter upgrade contract because a lifecycle transition already makes a run `reconciled` and can support close evidence. Migration 0019 allowed privileged callers to supply transition `recorded_at`, so a pre-0025 transition timestamp cannot be proven retroactively. Before any durable 0025 schema change, a temporary `FOR SELECT TO current_user USING (true)` policy exposes the forced-RLS transition history to the migration owner; if any transition row exists, the migration aborts with `reconciliation_lifecycle_legacy_recording_time_preflight`. Do not delete, rewrite, relabel, or invent system-time provenance to make this pass. Keep the prior release or execute a separately reviewed audited remediation backed by the original transition/status/outbox evidence. Creating a new run alone does not remove the old immutable transition and cannot satisfy the preflight. On databases that pass, new transition rows carry explicit `recording_time_authority_code = 'database_clock'`; PostgreSQL overwrites any caller-shaped `recorded_at` with `clock_timestamp()` and rejects `effective_at > recorded_at` using `reconciliation_lifecycle_future_time`. A rejected future-effective transition leaves command, run status, and outbox authority rolled back together. This migration does not post/reverse journals, close fiscal periods, or alter accounting policy. +Migrations `0026_reconciliation_lifecycle_source_payload_identity.sql` through `0028_reconciliation_lifecycle_capability_privileges.sql` finish the reconciliation lifecycle authority boundary. Source-payload identity is immutable and database-bound, lifecycle snapshot freshness is established through the canonical session-lock lease plus a fresh authority transaction, and generic PUBLIC execution of the lifecycle session-lock helpers is revoked. A consumer must use these installed controls rather than reconstruct reconciliation authority from caller payloads or mutable application state. + +Migrations `0029_trial_balance_snapshot_population_unique_index.sql` through `0031_trial_balance_line_conservation_validation.sql` establish one retained trial-balance population per tenant/book/period, freeze retained headers and lines, derive system time and aggregate scope at PostgreSQL, and validate exact `numeric(38,6)` debit/credit/net conservation. Migration 0029 uses a concurrent unique-index build and therefore has a distinct failed-install recovery state: an invalid concurrent index may remain and must be inspected/dropped or rebuilt before retrying the supported chain; do not rewrite retained evidence to make the index succeed. + +Migrations `0032_period_close_journal_population_fence.sql` and `0033_open_period_journal_population_fence.sql` protect hard-close freshness under `REPEATABLE READ`. Purpose-limited soft-close journals invalidate a stale close through the exact book-period control row. Ordinary open-period journals update one of 64 pre-existing fence rows, while a period transition locks all 64 in deterministic order. SQLSTATE `40001` is coordination failure: the whole command must roll back and retry from a fresh transaction with the same immutable command identity. Do not retry only the failed statement or retain a partial close artifact. + +Migration `0034_book_period_control_seed.sql` owns post-install book-period authority materialization. New open periods, new active books, and inactive-to-active book transitions seed only literal `open` controls with `period_closed_at = NULL` plus the complete 64-row fence. A later-created book does not inherit `soft_closed` or `hard_closed` from the shared fiscal-period compatibility projection. The migration temporarily uses owner-only `NO FORCE ROW LEVEL SECURITY` on its participating relations while RLS remains enabled, then restores FORCE before commit. A missing non-open book-period control is a fail-closed lifecycle gap, not permission to synthesize authority manually. + +Migrations `0035_trial_balance_snapshot_hard_close_pair.sql` and `0036_hard_close_trial_balance_snapshot_pair.sql` make the retained snapshot and exact book-period `hard_closed` state a bidirectional commit pair. Migration 0035 rejects retained snapshot evidence unless the exact control ends hard-closed. Migration 0036 rejects a transition to hard-closed unless the exact retained snapshot exists. Both future-write guards are deferred so the supported snapshot-first/status-second hard-close transaction remains valid. + +Migration 0036 also certifies pre-existing pair state before installing its future-write guard. It creates transaction-scoped `FOR SELECT TO current_user USING (true)` policies on the two FORCE-RLS pair relations, rejects a pre-existing hard-closed control without an exact snapshot using `hard_close_snapshot_pair_legacy_preflight`, and rejects a retained snapshot without an exact hard-closed control using `trial_balance_snapshot_hard_close_pair_legacy_preflight`. It drops both temporary policies before installing the durable trigger. If either marker appears, stop the upgrade; do not manufacture the missing status or snapshot. Retain the prior release or execute a separately reviewed audited remediation that can prove the original close command, journal population, scope/currency and retained numerical evidence. + Migration `0007_runtime_tenant_binding.sql` replaces caller-selected tenant authority with owner-controlled runtime-login binding. Migration `0008_fiscal_period_open_command.sql` adds forced-RLS, append-only command evidence so fiscal-period-open retries are bound to the original tenant key and source hash. Both must be installed before runtime database privileges are treated as production-ready. -After installation, prove with the actual runtime login that supported reads and writes work for its tenant, another tenant is inaccessible, the login is not a migration owner / superuser / `BYPASSRLS`, and direct SQL cannot bypass journal immutability, period controls, reconciliation lifecycle controls, exception-resolution authority, reconciliation authority-event admission, or reconciliation recording-time provenance. +After installation, prove with the actual runtime login that supported reads and writes work for its tenant, another tenant is inaccessible, the login is not a migration owner / superuser / `BYPASSRLS`, and direct SQL cannot bypass journal immutability, period controls, reconciliation lifecycle controls, exception-resolution authority, reconciliation authority-event admission, reconciliation recording-time provenance, or hard-close/snapshot pairing. ## Concurrency and hot-write operations @@ -157,7 +180,11 @@ Soft-close changes the period to `soft_closed`, writes no hard-close trial-balan ### Hard-close -Hard-close loads the close binder in one repeatable-read view, posts the AIS period-closing journal when required, stores the hard-close snapshot and changes the period to `hard_closed`. Hard-close is irreversible in this foundation. Open a later period for subsequent activity. +Hard-close loads the close binder in one repeatable-read view, posts the AIS period-closing journal when required, stores the hard-close snapshot and changes the exact book-period control to `hard_closed`. Hard-close is irreversible in this foundation. Open a later period for subsequent activity. + +The retained snapshot and exact `hard_closed` control are a commit-time pair. `trial_balance_snapshot_hard_close_pair_required` means a snapshot tried to commit without matching hard-close authority. `hard_close_snapshot_pair_required` means hard-close authority tried to commit without the matching retained snapshot. Either error aborts the complete transaction; retry the supported close command from a fresh transaction with the same immutable idempotency/source identity. Do not patch either side manually. + +During upgrade, `hard_close_snapshot_pair_legacy_preflight` or `trial_balance_snapshot_hard_close_pair_legacy_preflight` means pre-existing evidence cannot be certified as a complete pair. Stop before migration 0036, preserve the database, inventory the exact control/snapshot/line and original close evidence, and use only a separately reviewed audited remediation if the missing provenance can actually be proven. Do not insert a synthetic snapshot or flip a status to satisfy the preflight. If close fails, inspect the first causal missing catalog / mapping / balance / period error. Do not invent a snapshot or mark a period closed manually. @@ -213,7 +240,7 @@ Do not transfer a success from a predecessor SHA or synthetic merge ref to the c ## Backup, restore and recovery -Before release, rehearse clean install, forward migration, rollback strategy, backup restore and point-in-time recovery with production-like data volumes and the non-owner runtime identity. Restoration must preserve immutable journal / receipt / outbox lineage and tenant isolation. +Before release, rehearse clean install, forward migration, rollback strategy, backup restore and point-in-time recovery with production-like data volumes and the non-owner runtime identity. Restoration must preserve immutable journal / receipt / outbox lineage, retained trial-balance evidence, hard-close/snapshot pairing, and tenant isolation. Forward-upgrade rehearsal must include the 0036 one-sided-pair preflight and prove that an aborted preflight leaves neither temporary migration policy nor partial durable trigger state. Recovery from an accounting error is not database row editing. Restore infrastructure only for infrastructure loss; correct economic facts with reversal / reposting according to accounting policy. From eec9f4c26c2104c3f5f10cc7fa9183ee90a147b8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 22:50:32 +0900 Subject: [PATCH 176/224] test(docs): keep operability on canonical migration chain --- ...st_operability_migration_chain_contract.py | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 tests/test_operability_migration_chain_contract.py diff --git a/tests/test_operability_migration_chain_contract.py b/tests/test_operability_migration_chain_contract.py new file mode 100644 index 00000000..535c6269 --- /dev/null +++ b/tests/test_operability_migration_chain_contract.py @@ -0,0 +1,56 @@ +"""Contract tying operator migration instructions to the canonical installer.""" + +from __future__ import annotations + +import ast +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +INSTALLER = ROOT / "src/accounting_information_platform/migration_install.py" +OPERABILITY = ROOT / "docs/OPERABILITY.md" + + +def _forward_migration_names() -> tuple[str, ...]: + """Read the literal forward-migration tuple without importing database code.""" + module = ast.parse(INSTALLER.read_text(encoding="utf-8")) + for node in ast.walk(module): + if not isinstance(node, ast.Assign): + continue + if not any( + isinstance(target, ast.Name) and target.id == "forward_migration_paths" + for target in node.targets + ): + continue + if not isinstance(node.value, ast.Tuple): + continue + + names: list[str] = [] + for element in node.value.elts: + if not isinstance(element, ast.BinOp) or not isinstance(element.op, ast.Div): + raise AssertionError("forward_migration_paths must remain literal Path divisions") + if not isinstance(element.right, ast.Constant) or not isinstance( + element.right.value, str + ): + raise AssertionError("forward migration filename must remain a string literal") + names.append(element.right.value) + return tuple(names) + + raise AssertionError("canonical forward_migration_paths tuple is missing") + + +def test_operability_lists_every_canonical_forward_migration_in_order() -> None: + """Operator instructions may not stop before the installer authority chain.""" + text = OPERABILITY.read_text(encoding="utf-8") + cursor = -1 + + for migration_name in _forward_migration_names(): + next_cursor = text.find(f"database/migrations/{migration_name}", cursor + 1) + assert next_cursor > cursor, ( + f"docs/OPERABILITY.md must list {migration_name} after the previous " + "canonical forward migration" + ) + cursor = next_cursor + + assert "hard_close_snapshot_pair_legacy_preflight" in text + assert "trial_balance_snapshot_hard_close_pair_legacy_preflight" in text From 344180cf932a6d278f1b28a88d9b7b3a2714232e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 23:00:59 +0900 Subject: [PATCH 177/224] test(close): reject silent book-period authority writes --- ...eriod_control_insert_authority_contract.py | 24 ++-- ...ook_period_control_insert_authority_red.py | 112 ++++++++++++++++++ 2 files changed, 129 insertions(+), 7 deletions(-) create mode 100644 tests/test_postgres_book_period_control_insert_authority_red.py diff --git a/tests/test_book_period_control_insert_authority_contract.py b/tests/test_book_period_control_insert_authority_contract.py index 9f8ad597..8659aa72 100644 --- a/tests/test_book_period_control_insert_authority_contract.py +++ b/tests/test_book_period_control_insert_authority_contract.py @@ -8,16 +8,26 @@ def test_direct_book_period_control_insert_is_not_an_authority_writer() -> None: - """Require post-install control creation to come from nested canonical seed triggers.""" + """Require direct control inserts to fail explicitly instead of reporting silent success.""" migration = MIGRATION.read_text(encoding="utf-8") - - assert "guard_book_period_control_insert_authority" in migration - assert "pg_trigger_depth() < 2" in migration - assert "NEW.period_status_code IS DISTINCT FROM 'open'" in migration - assert "NEW.period_closed_at IS NOT NULL" in migration + guard_start = migration.index( + "CREATE OR REPLACE FUNCTION accounting_core.guard_book_period_control_insert_authority" + ) + guard_end = migration.index( + "REVOKE ALL ON FUNCTION accounting_core.guard_book_period_control_insert_authority", + guard_start, + ) + guard = migration[guard_start:guard_end] + + assert "pg_trigger_depth() < 2" in guard + assert "NEW.period_status_code IS DISTINCT FROM 'open'" in guard + assert "NEW.period_closed_at IS NOT NULL" in guard + assert "RAISE EXCEPTION" in guard + assert "book_period_control_insert_authority_required" in guard + assert "USING ERRCODE = 'check_violation';" in guard + assert "RETURN NULL;" not in guard assert "CREATE TRIGGER book_period_control_insert_authority_guard" in migration assert "BEFORE INSERT" in migration - assert "RETURN NULL;" in migration def test_authority_guard_is_installed_after_migration_repair() -> None: diff --git a/tests/test_postgres_book_period_control_insert_authority_red.py b/tests/test_postgres_book_period_control_insert_authority_red.py new file mode 100644 index 00000000..3dcb54c5 --- /dev/null +++ b/tests/test_postgres_book_period_control_insert_authority_red.py @@ -0,0 +1,112 @@ +"""Real PostgreSQL regression for explicit rejection of direct close-authority inserts.""" + +from __future__ import annotations + +import unittest +import uuid + +import psycopg + +from tests import test_postgres_posting as posting + + +class BookPeriodControlInsertAuthorityTests(unittest.TestCase): + """Keep book-period close authority on the canonical database-owned seeding path.""" + + @classmethod + def setUpClass(cls) -> None: + """Install the complete current migration chain into the PostgreSQL fixture.""" + posting.PostgresPostingTests.setUpClass() + + def setUp(self) -> None: + """Seed one isolated tenant and its normal open accounting book/period.""" + self.case = posting.PostgresPostingTests("setUp") + self.case.setUp() + self.addCleanup(self.case.doCleanups) + self.addCleanup(self.case.tearDown) + + def test_direct_open_control_insert_fails_with_explicit_authority_error(self) -> None: + """A raw INSERT must raise instead of silently dropping an unauthorized authority write.""" + new_book_id = uuid.uuid4() + with psycopg.connect(posting.DATABASE_URL) as connection: + legal_entity_id = connection.execute( + """ + SELECT legal_entity_id + FROM accounting_core.legal_entity_record + WHERE tenant_account_id = %s + ORDER BY recorded_at + LIMIT 1 + """, + (self.case.tenant_id,), + ).fetchone()[0] + period_id = connection.execute( + """ + UPDATE accounting_core.fiscal_period + SET period_status_code = 'soft_closed', + period_closed_at = clock_timestamp() + WHERE tenant_account_id = %s + AND period_code = '2026-08' + RETURNING fiscal_period_id + """, + (self.case.tenant_id,), + ).fetchone()[0] + connection.execute( + """ + INSERT INTO accounting_core.accounting_book ( + accounting_book_id, + tenant_account_id, + legal_entity_id, + book_role_code, + book_name, + reporting_currency_code, + valid_from + ) + VALUES (%s, %s, %s, %s, %s, 'USD', '2026-01-01T00:00:00Z') + """, + ( + new_book_id, + self.case.tenant_id, + legal_entity_id, + f"direct_control_{new_book_id.hex}", + f"Direct control {new_book_id.hex}", + ), + ) + connection.commit() + + with psycopg.connect(posting.DATABASE_URL) as connection: + with self.assertRaisesRegex( + psycopg.errors.CheckViolation, + "book_period_control_insert_authority_required", + ): + connection.execute( + """ + INSERT INTO accounting_core.accounting_book_period_control ( + tenant_account_id, + accounting_book_id, + fiscal_period_id, + period_status_code, + period_closed_at + ) + VALUES (%s, %s, %s, 'open', NULL) + """, + (self.case.tenant_id, new_book_id, period_id), + ) + connection.rollback() + + with psycopg.connect(posting.DATABASE_URL) as connection: + control_count = connection.execute( + """ + SELECT COUNT(*) + FROM accounting_core.accounting_book_period_control + WHERE tenant_account_id = %s + AND accounting_book_id = %s + AND fiscal_period_id = %s + """, + (self.case.tenant_id, new_book_id, period_id), + ).fetchone()[0] + + self.assertEqual(control_count, 0) + + +if __name__ == "__main__": + unittest.main() From b33b1879600ba088d2f4f7481c547ec99372456b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 23:01:47 +0900 Subject: [PATCH 178/224] fix(close): reject direct book-period authority writes --- .../migrations/0034_book_period_control_seed.sql | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/database/migrations/0034_book_period_control_seed.sql b/database/migrations/0034_book_period_control_seed.sql index edc43d3d..33dc57dd 100644 --- a/database/migrations/0034_book_period_control_seed.sql +++ b/database/migrations/0034_book_period_control_seed.sql @@ -190,11 +190,11 @@ ALTER TABLE accounting_core.accounting_book FORCE ROW LEVEL SECURITY; -- INSERT must not reconstruct authority from fiscal_period compatibility state. -- pg_trigger_depth() is structural rather than a caller-controlled custom GUC: -- the control-table trigger runs at depth 2 when invoked by a canonical seeder --- and at depth 1 for a direct control-table INSERT. Returning NULL leaves an --- unsupported direct write unapplied; the close path then reads the still- --- missing control and fails with its domain validation error. This keeps the --- database single-writer boundary intact without granting a mutable session --- flag that another writer could spoof. +-- and at depth 1 for a direct control-table INSERT. Reject unsupported writes +-- explicitly so SQL/application callers cannot mistake a silently skipped row +-- for accepted close authority. This keeps the database single-writer boundary +-- intact without granting a mutable session flag that another writer could +-- spoof. CREATE OR REPLACE FUNCTION accounting_core.guard_book_period_control_insert_authority() RETURNS trigger LANGUAGE plpgsql @@ -206,7 +206,9 @@ BEGIN OR NEW.period_status_code IS DISTINCT FROM 'open' OR NEW.period_closed_at IS NOT NULL THEN - RETURN NULL; + RAISE EXCEPTION + 'book-period control must be created by canonical master-data seeding (book_period_control_insert_authority_required)' + USING ERRCODE = 'check_violation'; END IF; RETURN NEW; From 5dd76f1dc4189ed0412ec77514430136da68cc6b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 23:02:40 +0900 Subject: [PATCH 179/224] docs(close): trace explicit authority-write rejection --- ...PERIOD_CONTROL_INSERT_AUTHORITY_TRACEABILITY.md | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/docs/doctoring/BOOK_PERIOD_CONTROL_INSERT_AUTHORITY_TRACEABILITY.md b/docs/doctoring/BOOK_PERIOD_CONTROL_INSERT_AUTHORITY_TRACEABILITY.md index 2bffa96b..d10c971f 100644 --- a/docs/doctoring/BOOK_PERIOD_CONTROL_INSERT_AUTHORITY_TRACEABILITY.md +++ b/docs/doctoring/BOOK_PERIOD_CONTROL_INSERT_AUTHORITY_TRACEABILITY.md @@ -8,6 +8,8 @@ That path could synthesize `soft_closed` or `hard_closed` authority for a later- The same authority leak remained on the read side: `_load_book_period_state()` and `_require_open_book_period_bounds()` used an outer join plus `COALESCE` to inherit the shared `fiscal_period.period_status_code` whenever the book-owned control was absent. Database containment therefore prevented a bad control write but did not make the application authority model conceptually correct. +A later review found a separate observability defect in the database containment itself. The direct-write branch of `guard_book_period_control_insert_authority()` returned `NULL`. PostgreSQL correctly skipped the row, but a raw SQL or application caller could observe a non-error command completion and mistake the attempt for accepted authority unless it separately checked affected-row count or re-read the control. An authoritative close-state write must fail explicitly at the boundary that rejects it; silent no-op semantics are not acceptable evidence. + This is a DDD/data-authority defect, not an IFRS interpretation. IFRS does not prescribe PostgreSQL trigger nesting or book-period control rows. ## Selected control @@ -19,9 +21,9 @@ The guard admits a new row only when both conditions hold: - the control INSERT is nested under one of migration 0034's canonical master-data seed triggers (`pg_trigger_depth() >= 2`); and - the new control is literal `open` with `period_closed_at IS NULL`. -A direct runtime/application INSERT therefore returns no row. The application now matches that database single-writer boundary instead of relying on the guard as a compensating control. `_lock_book_period()` performs a diagnostic fiscal-period existence lookup, then reads and locks only the requested `accounting_book_period_control` row with `FOR UPDATE OF accounting_book_period_control`; it no longer creates controls. `_load_book_period_state()` and `_require_open_book_period_bounds()` use an inner join to the same book-owned control and read its `period_status_code` directly. A missing control therefore fails closed instead of inheriting the shared calendar projection. +A direct runtime/application INSERT now raises PostgreSQL `check_violation` with stable marker `book_period_control_insert_authority_required`; it does not return a silently skipped command as if authority had been accepted. The application matches that database single-writer boundary instead of relying on the guard as a compensating control. `_lock_book_period()` performs a diagnostic fiscal-period existence lookup, then reads and locks only the requested `accounting_book_period_control` row with `FOR UPDATE OF accounting_book_period_control`; it no longer creates controls. `_load_book_period_state()` and `_require_open_book_period_bounds()` use an inner join to the same book-owned control and read its `period_status_code` directly. A missing control therefore fails closed instead of inheriting the shared calendar projection. -A caller-controlled custom GUC was rejected as an authority marker because an arbitrary session setting would itself become a spoofable mutable capability. PostgreSQL's trigger-depth signal is structural: PostgreSQL 18.6 documents `pg_trigger_depth()` as the current trigger nesting level, returning zero outside trigger execution. PostgreSQL also documents that a row-level `BEFORE` trigger can skip the current row operation, which is the fail-closed mechanism used here. +A caller-controlled custom GUC was rejected as an authority marker because an arbitrary session setting would itself become a spoofable mutable capability. PostgreSQL's trigger-depth signal is structural: PostgreSQL 18.6 documents `pg_trigger_depth()` as the current trigger nesting level, returning zero outside trigger execution. PostgreSQL also permits a row-level `BEFORE` trigger to raise an exception before the row is written; the explicit `check_violation` is used here so rejected authority writes are transaction-visible failures rather than silent skips. The guard function remains `SECURITY DEFINER`, fixes its `search_path` to `pg_catalog, pg_temp`, and revokes PUBLIC execute. This does not grant accounting authority to Billing or another foreign context and does not alter posted facts, financial amounts, period transitions, retained snapshots, or reconciliation evidence. @@ -35,7 +37,9 @@ Application-source RED `tests/test_book_period_application_authority_contract.py Production source repair `048671fe7243b6bf8c730c349b46d4f3bfc79dde` is a normal descendant of `9086422c2cd801c3be76069114ee0e6753c47f92`. Exact commit comparison reports one modified path, `src/accounting_information_platform/persistence.py`, with 9 additions and 40 deletions. The patch is limited to the three authority helpers: removing the runtime control INSERT, replacing the two shared-state fallbacks with inner book-control joins, and updating helper documentation. It does not delete or rewrite the unrelated reporting, reconciliation, HomeTax, VAT, or ledger surfaces that the preservation contract protects. -These SHAs are development lineage, not release evidence. The REDs were authored before the causal repair, but they were not observed failing on a GitHub runner in this run. Exact-head real-PostgreSQL execution, security/SAST/dependency evidence, independent review, protected-stack prerequisites, migration/recovery evidence, and immutable release evidence remain separate gates. +Explicit-rejection RED `344180cf932a6d278f1b28a88d9b7b3a2714232e` strengthens the static contract and adds `tests/test_postgres_book_period_control_insert_authority_red.py`. The real PostgreSQL case creates a legitimate missing non-open book-period pair, attempts a raw literal-open control INSERT, and requires `psycopg.errors.CheckViolation` carrying `book_period_control_insert_authority_required`, followed by zero retained control rows. Production repair `b33b1879600ba088d2f4f7481c547ec99372456b` changes only the guard's rejected branch from silent `RETURN NULL` to that stable exception; canonical nested open-only seeding still returns `NEW` unchanged. + +These SHAs are development lineage, not release evidence. The REDs were authored before their causal repairs, but queued or cancelled GitHub evidence does not become observed RED/GREEN by assertion. Exact-head real-PostgreSQL execution, security/SAST/dependency evidence, independent review, protected-stack prerequisites, migration/recovery evidence, and immutable release evidence remain separate gates. ## Scope-preservation repair @@ -47,9 +51,9 @@ The successful source repair at `048671fe7243b6bf8c730c349b46d4f3bfc79dde` was a ## Recovery and follow-up -A rejected direct control INSERT writes no authoritative row and therefore seeds no 64-row journal-population fence. The surrounding close transaction remains free to roll back normally. Operators must not repair a missing non-open pair by copying `fiscal_period` status or by manual SQL. If a later-created book must become applicable to an already non-open period, that requires an explicit book-period lifecycle/adoption/reopen command with authenticated capability, idempotency, maker-checker policy where applicable, and retained chronology. +A rejected direct control INSERT raises `check_violation`, writes no authoritative row, and therefore seeds no 64-row journal-population fence. The transaction is aborted until the caller rolls it back, preventing a caller from treating a rejected authority mutation as successful work. Operators must not repair a missing non-open pair by copying `fiscal_period` status or by manual SQL. If a later-created book must become applicable to an already non-open period, that requires an explicit book-period lifecycle/adoption/reopen command with authenticated capability, idempotency, maker-checker policy where applicable, and retained chronology. -With `048671fe7243b6bf8c730c349b46d4f3bfc79dde`, the application and database now share the same writer model: master-data seed triggers may create literal-open controls, while posting/adjusting/close helpers only consume existing book-period authority. The source cleanup is not considered execution-GREEN until an unchanged exact head runs the static authority contract and real-PostgreSQL regressions successfully. Missing-control diagnostic wording for an adjusting journal remains a possible buyer-facing refinement, but it must not reintroduce authority synthesis. +With `048671fe7243b6bf8c730c349b46d4f3bfc79dde` and `b33b1879600ba088d2f4f7481c547ec99372456b`, the application and database share the same writer model: master-data seed triggers may create literal-open controls, while posting/adjusting/close helpers only consume existing book-period authority and rejected raw control writers receive an explicit database error. The source cleanup is not considered execution-GREEN until an unchanged exact head runs the static authority contract and real-PostgreSQL regressions successfully. Missing-control diagnostic wording for an adjusting journal remains a possible buyer-facing refinement, but it must not reintroduce authority synthesis. ## References From ba6be58c3ce4f2dfbe3e6b27f2f3418cd0f71548 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 23:26:24 +0900 Subject: [PATCH 180/224] test(close): preserve posted role at hard close --- ..._period_close_posted_role_stability_red.py | 104 ++++++++++++++++++ 1 file changed, 104 insertions(+) create mode 100644 tests/test_postgres_period_close_posted_role_stability_red.py diff --git a/tests/test_postgres_period_close_posted_role_stability_red.py b/tests/test_postgres_period_close_posted_role_stability_red.py new file mode 100644 index 00000000..12cba4ec --- /dev/null +++ b/tests/test_postgres_period_close_posted_role_stability_red.py @@ -0,0 +1,104 @@ +"""Real PostgreSQL RED/GREEN for period close using immutable posted roles.""" + +from __future__ import annotations + +import unittest +from decimal import Decimal + +import psycopg + +from tests import test_postgres_posting as posting + + +class PeriodClosePostedRoleStabilityPostgresTests(unittest.TestCase): + """Keep hard-close classification bound to posted journal facts, not current catalog state.""" + + @classmethod + def setUpClass(cls) -> None: + """Install the complete current accounting migration chain.""" + posting.PostgresPostingTests.setUpClass() + + def setUp(self) -> None: + """Seed one open book with the production posting catalog.""" + self.case = posting.PostgresPostingTests("setUp") + self.case.setUp() + self.addCleanup(self.case.doCleanups) + self.addCleanup(self.case.tearDown) + + def test_hard_close_uses_posted_role_after_catalog_mapping_expires(self) -> None: + """A later catalog expiry cannot reclassify a journal that was already posted.""" + self.case.ledger.post(self.case._two_line_proposal(), self.case.policy) + + with psycopg.connect(posting.DATABASE_URL) as connection: + updated = connection.execute( + """ + UPDATE accounting_core.account_role_mapping + SET valid_to = TIMESTAMPTZ '2026-09-01 00:00:00+00' + WHERE tenant_account_id = %s + AND account_role_code = 'usage_revenue' + AND valid_to IS NULL + """, + (self.case.tenant_id,), + ).rowcount + posted_roles = connection.execute( + """ + SELECT journal_entry_line.account_role_code, + journal_entry_line.credit_amount + FROM accounting_core.journal_entry_line + JOIN accounting_core.general_journal + ON general_journal.tenant_account_id = journal_entry_line.tenant_account_id + AND general_journal.general_journal_id = journal_entry_line.general_journal_id + WHERE general_journal.tenant_account_id = %s + AND journal_entry_line.account_role_code = 'usage_revenue' + """, + (self.case.tenant_id,), + ).fetchall() + + self.assertEqual(updated, 1) + self.assertEqual(posted_roles, [("usage_revenue", Decimal("25000.000000"))]) + + receipt = self.case.ledger.close_fiscal_period( + self.case.policy.legal_entity_reference, + self.case.policy.accounting_book_reference, + "2026-08", + "KRW", + period_status_code="hard_closed", + idempotency_key=( + f"{self.case.policy.tenant_reference}:posted-role-stability:hard-close" + ), + ) + + with psycopg.connect(posting.DATABASE_URL) as connection: + snapshot_lines = { + str(row[0]): (Decimal(row[1]), Decimal(row[2]), Decimal(row[3])) + for row in connection.execute( + """ + SELECT chart_account.chart_account_code, + trial_balance_line.debit_total_amount, + trial_balance_line.credit_total_amount, + trial_balance_line.net_balance_amount + FROM accounting_reporting.trial_balance_snapshot + JOIN accounting_reporting.trial_balance_line + ON trial_balance_line.tenant_account_id = trial_balance_snapshot.tenant_account_id + AND trial_balance_line.trial_balance_snapshot_id = trial_balance_snapshot.trial_balance_snapshot_id + JOIN accounting_core.chart_account + ON chart_account.tenant_account_id = trial_balance_line.tenant_account_id + AND chart_account.chart_account_id = trial_balance_line.chart_account_id + JOIN accounting_core.fiscal_period + ON fiscal_period.tenant_account_id = trial_balance_snapshot.tenant_account_id + AND fiscal_period.fiscal_period_id = trial_balance_snapshot.fiscal_period_id + WHERE trial_balance_snapshot.tenant_account_id = %s + AND fiscal_period.period_code = '2026-08' + """, + (self.case.tenant_id,), + ).fetchall() + } + + self.assertEqual(receipt.period_status_code, "hard_closed") + self.assertEqual(self.case._count_closing_journals(), 1) + self.assertEqual(snapshot_lines["410100"], (Decimal("25000"), Decimal("25000"), Decimal("0"))) + self.assertEqual(snapshot_lines["310100"], (Decimal("0"), Decimal("25000"), Decimal("-25000"))) + + +if __name__ == "__main__": + unittest.main() From 8dabaee5b43c24810f54479d5180df54220abb0d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 23:35:19 +0900 Subject: [PATCH 181/224] test(close): ratchet posted role classification --- ...eriod_close_posted_role_source_contract.py | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 tests/test_period_close_posted_role_source_contract.py diff --git a/tests/test_period_close_posted_role_source_contract.py b/tests/test_period_close_posted_role_source_contract.py new file mode 100644 index 00000000..1edb53c6 --- /dev/null +++ b/tests/test_period_close_posted_role_source_contract.py @@ -0,0 +1,35 @@ +"""Static contract for period-close historical role classification.""" + +from __future__ import annotations + +from pathlib import Path +import unittest + + +ROOT = Path(__file__).resolve().parents[1] +PERSISTENCE = ROOT / "src/accounting_information_platform/persistence.py" + + +class PeriodClosePostedRoleSourceContractTests(unittest.TestCase): + """Keep hard-close source semantics on immutable posted journal-line facts.""" + + def test_closing_journal_classifies_income_from_posted_line_role(self) -> None: + """Do not reclassify posted P&L through the mutable current role catalog.""" + source = PERSISTENCE.read_text(encoding="utf-8") + method = source.split(" def _post_closing_journal(", 1)[1].split( + " def _require_retained_earnings_mapping(", 1 + )[0] + + self.assertIn("journal_entry_line.account_role_code", method) + self.assertIn("AND journal_entry_line.account_role_code IN (", method) + self.assertIn( + "GROUP BY chart_account.chart_account_code,\n" + " journal_entry_line.account_role_code", + method, + ) + self.assertNotIn("JOIN accounting_core.account_role_mapping", method) + self.assertNotIn("account_role_mapping.account_role_code", method) + + +if __name__ == "__main__": + unittest.main() From 3dd278e2f8cbd1987062a9b337ac7ef944772d26 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 23:35:47 +0900 Subject: [PATCH 182/224] docs(close): trace posted role authority --- .../PERIOD_CLOSE_POSTED_ROLE_TRACEABILITY.md | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 docs/doctoring/PERIOD_CLOSE_POSTED_ROLE_TRACEABILITY.md diff --git a/docs/doctoring/PERIOD_CLOSE_POSTED_ROLE_TRACEABILITY.md b/docs/doctoring/PERIOD_CLOSE_POSTED_ROLE_TRACEABILITY.md new file mode 100644 index 00000000..53abd8bc --- /dev/null +++ b/docs/doctoring/PERIOD_CLOSE_POSTED_ROLE_TRACEABILITY.md @@ -0,0 +1,44 @@ +# Period Close Posted-Role Traceability + +Status: RED / Proposed production repair + +## Problem + +`journal_entry_line.account_role_code` is persisted with every posted journal line and is part of the immutable posted accounting fact. `PostgresPostingLedger._post_closing_journal()` currently ignores that historical role when selecting revenue and expense lines for the AIS-owned closing journal. Instead, it joins `account_role_mapping` and requires the mapping to be current (`valid_to IS NULL`). + +A role mapping may legitimately expire or be superseded after a journal was posted and before the fiscal period is hard-closed. In that state the posted journal still contains the original role, but the hard-close query can omit or reclassify the historical P&L population. That can suppress the closing journal and retained-earnings transfer even though the journal and its role evidence are unchanged. + +## Authority and invariant + +The historical classification of an already-posted journal line is `accounting_core.journal_entry_line.account_role_code`. The effective-dated `account_role_mapping` is an Accounting Policy catalog used when a proposal is resolved for posting. It is not an authority for retrospectively reclassifying immutable posted facts. + +Period Close must therefore classify historical source P&L directly from the persisted journal-line role. The close-time lookup of the destination `retained_earnings` account remains a separate current-policy decision and is not changed by this repair. + +This is an AIP DDD, temporal-data, and audit-evidence control. It is not a claim that IFRS prescribes a PostgreSQL column, join shape, or implementation mechanism. + +## Test-first evidence + +Real PostgreSQL RED `ba6be58c3ce4f2dfbe3e6b27f2f3418cd0f71548` posts a `usage_revenue` line, expires the catalog mapping after posting, verifies the immutable journal line still carries `usage_revenue`, then hard-closes the period. The acceptance requires one closing journal and exact transfer from `410100` into retained earnings `310100`. + +Static RED `8dabaee5b43c24810f54479d5180df54220abb0d` pins the causal source boundary: `_post_closing_journal()` must select/filter/group by `journal_entry_line.account_role_code` and must not join `account_role_mapping` for historical P&L classification. + +Neither RED is GREEN evidence. Exact-head PostgreSQL/Accounting Foundation execution is required after the production source repair. + +## Selected repair + +Within `_post_closing_journal()` only: + +- select `journal_entry_line.account_role_code`; +- filter `journal_entry_line.account_role_code IN ('usage_revenue', 'write_off_expense')`; +- group by `chart_account.chart_account_code, journal_entry_line.account_role_code`; +- remove the `account_role_mapping` join from that historical source query. + +Do not change the posting-time effective-dated resolver, the immutable journal-line schema, reversal role preservation, or the close-time retained-earnings destination lookup. + +## Rejected alternatives + +Keeping the current catalog join is rejected because later master-data changes can rewrite hard-close semantics without changing the posted journal. Keeping expired mappings artificially current is rejected because it corrupts effective-dated policy resolution and can introduce multiple simultaneously effective mappings. Reconstructing the historical role from the present chart of accounts is rejected because the posted line already contains the authoritative fact. Weakening close or snapshot invariants is rejected because it would hide the misclassification rather than remove it. + +## Follow-up boundary + +Reporting paths that project historical journal semantics from current `account_role_mapping` are a separate Reporting-Export repair lane. They must consume immutable posted roles or immutable close snapshots rather than importing the Period Close fix as a second authority. Canonical product-gap, CHANGELOG, and standard-traceability wording remains the PR #37 single-writer responsibility. From 2851c6aed7941363c3b7a570c7d2a2b4683c61a7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 00:43:23 +0900 Subject: [PATCH 183/224] fix(close): classify historical P&L from posted roles --- src/accounting_information_platform/persistence.py | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/src/accounting_information_platform/persistence.py b/src/accounting_information_platform/persistence.py index 846a7389..c687ac10 100644 --- a/src/accounting_information_platform/persistence.py +++ b/src/accounting_information_platform/persistence.py @@ -5328,7 +5328,7 @@ def _post_closing_journal( income_rows = connection.execute( """ SELECT chart_account.chart_account_code, - account_role_mapping.account_role_code, + journal_entry_line.account_role_code, SUM(journal_entry_line.debit_amount), SUM(journal_entry_line.credit_amount) FROM accounting_core.journal_entry_line @@ -5338,18 +5338,15 @@ def _post_closing_journal( JOIN accounting_core.chart_account ON chart_account.tenant_account_id = journal_entry_line.tenant_account_id AND chart_account.chart_account_id = journal_entry_line.chart_account_id - JOIN accounting_core.account_role_mapping - ON account_role_mapping.tenant_account_id = chart_account.tenant_account_id - AND account_role_mapping.chart_account_id = chart_account.chart_account_id - AND account_role_mapping.valid_to IS NULL WHERE general_journal.tenant_account_id = %s AND general_journal.legal_entity_id = %s AND general_journal.accounting_book_id = %s AND general_journal.accounting_date <= %s - AND account_role_mapping.account_role_code IN ( + AND journal_entry_line.account_role_code IN ( 'usage_revenue', 'write_off_expense' ) - GROUP BY chart_account.chart_account_code, account_role_mapping.account_role_code + GROUP BY chart_account.chart_account_code, + journal_entry_line.account_role_code ORDER BY chart_account.chart_account_code """, (tenant_id, legal_entity_id, book_id, period_end_date), From 858d299ce64a725ded794daa8eacfd37faf1e1da Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 00:44:57 +0900 Subject: [PATCH 184/224] docs(close): trace posted-role production repair --- .../PERIOD_CLOSE_POSTED_ROLE_TRACEABILITY.md | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/docs/doctoring/PERIOD_CLOSE_POSTED_ROLE_TRACEABILITY.md b/docs/doctoring/PERIOD_CLOSE_POSTED_ROLE_TRACEABILITY.md index 53abd8bc..bd6f0fdb 100644 --- a/docs/doctoring/PERIOD_CLOSE_POSTED_ROLE_TRACEABILITY.md +++ b/docs/doctoring/PERIOD_CLOSE_POSTED_ROLE_TRACEABILITY.md @@ -1,18 +1,18 @@ # Period Close Posted-Role Traceability -Status: RED / Proposed production repair +Status: production candidate / exact-head execution pending ## Problem -`journal_entry_line.account_role_code` is persisted with every posted journal line and is part of the immutable posted accounting fact. `PostgresPostingLedger._post_closing_journal()` currently ignores that historical role when selecting revenue and expense lines for the AIS-owned closing journal. Instead, it joins `account_role_mapping` and requires the mapping to be current (`valid_to IS NULL`). +`journal_entry_line.account_role_code` is persisted with every posted journal line and is part of the immutable posted accounting fact. Before this repair, `PostgresPostingLedger._post_closing_journal()` ignored that historical role when selecting revenue and expense lines for the AIS-owned closing journal. Instead, it joined `account_role_mapping` and required the mapping to be current (`valid_to IS NULL`). -A role mapping may legitimately expire or be superseded after a journal was posted and before the fiscal period is hard-closed. In that state the posted journal still contains the original role, but the hard-close query can omit or reclassify the historical P&L population. That can suppress the closing journal and retained-earnings transfer even though the journal and its role evidence are unchanged. +A role mapping may legitimately expire or be superseded after a journal was posted and before the fiscal period is hard-closed. In that state the posted journal still contains the original role, but the hard-close query could omit or reclassify the historical P&L population. That could suppress the closing journal and retained-earnings transfer even though the journal and its role evidence were unchanged. ## Authority and invariant The historical classification of an already-posted journal line is `accounting_core.journal_entry_line.account_role_code`. The effective-dated `account_role_mapping` is an Accounting Policy catalog used when a proposal is resolved for posting. It is not an authority for retrospectively reclassifying immutable posted facts. -Period Close must therefore classify historical source P&L directly from the persisted journal-line role. The close-time lookup of the destination `retained_earnings` account remains a separate current-policy decision and is not changed by this repair. +Period Close therefore classifies historical source P&L directly from the persisted journal-line role. The close-time lookup of the destination `retained_earnings` account remains a separate current-policy decision and is not changed by this repair. This is an AIP DDD, temporal-data, and audit-evidence control. It is not a claim that IFRS prescribes a PostgreSQL column, join shape, or implementation mechanism. @@ -22,7 +22,9 @@ Real PostgreSQL RED `ba6be58c3ce4f2dfbe3e6b27f2f3418cd0f71548` posts a `usage_re Static RED `8dabaee5b43c24810f54479d5180df54220abb0d` pins the causal source boundary: `_post_closing_journal()` must select/filter/group by `journal_entry_line.account_role_code` and must not join `account_role_mapping` for historical P&L classification. -Neither RED is GREEN evidence. Exact-head PostgreSQL/Accounting Foundation execution is required after the production source repair. +Production candidate `2851c6aed7941363c3b7a570c7d2a2b4683c61a7` implements only that source-query repair. Relative to its exact parent `3dd278e2f8cbd1987062a9b337ac7ef944772d26`, the commit changes only `src/accounting_information_platform/persistence.py` with four additions and seven deletions. This scope check is necessary because the adapter contains unrelated Posting, Reporting-Export, VAT, HomeTax, reconciliation, and read-model behavior that must not be damaged by a focused close repair. + +The candidate is not GREEN evidence by itself. Exact-head PostgreSQL/Accounting Foundation execution is required, and predecessor runs do not transfer. ## Selected repair From 0281743c992702bd724faf103d9dd9b19aae1528 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 00:50:00 +0900 Subject: [PATCH 185/224] docs(close): preserve posted role history at hard close --- docs/adr/0024-hard-close-retained-earnings.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/docs/adr/0024-hard-close-retained-earnings.md b/docs/adr/0024-hard-close-retained-earnings.md index 96363ad8..d948dfe4 100644 --- a/docs/adr/0024-hard-close-retained-earnings.md +++ b/docs/adr/0024-hard-close-retained-earnings.md @@ -14,6 +14,18 @@ The closing journal zeros catalog `usage_revenue` 410100 and `write_off_expense` IAS 1 requires a statement of financial position that presents equity separately from profit or loss for the period (IFRS Foundation, 2022). The closing process transfers that period result into equity so the next period’s sheet does not carry a floating earnings plug. +## Proposed amendment on PR #53: posted-role temporal authority + +The accepted retained-earnings design does not authorize a later Accounting Policy catalog change to rewrite an already-posted journal fact. For historical P&L source classification at hard close, `journal_entry_line.account_role_code` is the authority because it was persisted with the immutable posted line. `account_role_mapping` remains the effective-dated policy authority while a new proposal is resolved, and the current `retained_earnings` mapping remains the close-time authority for the destination of the newly created AIS closing journal. + +Accordingly, `_post_closing_journal()` must select, filter, and group historical `usage_revenue` / `write_off_expense` from the persisted journal-line role and must not join the current effective mapping merely to reconstruct that historical classification. Expiring or superseding a mapping after posting must neither suppress the posted P&L population nor reclassify it at hard close. This amendment is Proposed until PR #53 reaches its protected integration gates; it does not change this ADR's previously accepted retained-earnings ownership or the posting-time policy resolver. + +The executable acceptance is `tests/test_postgres_period_close_posted_role_stability_red.py` plus `tests/test_period_close_posted_role_source_contract.py`. The detailed RED→candidate lineage, alternatives, scope-preservation evidence, and rollback boundary are maintained in `docs/doctoring/PERIOD_CLOSE_POSTED_ROLE_TRACEABILITY.md`. + +This temporal-authority split is an AIP DDD/audit-evidence control. IAS 1 does not prescribe the PostgreSQL column, join shape, or effective-dated implementation used to enforce it. + ## Consequences Controllers can hard-close once and read a balance sheet that ties without `net_income_amount` as a plug. Income-statement inquiry still shows the period’s revenue and expense activity. Soft-close remains the adjusting window from ADR 0023 and does not park earnings. + +For the Proposed PR #53 amendment, controllers can also change future account-role policy without changing the close semantics of immutable historical journal lines. Reporting projections that still infer historical semantics only from current mappings remain a separate Reporting-Export repair and must not create a second Period Close authority. From 6faea7dc50cb2421604daf7c10f7ad3aeadfd4cb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 01:07:40 +0900 Subject: [PATCH 186/224] test(close): preserve posted chart-account identity --- ...eriod_close_posted_account_identity_red.py | 113 ++++++++++++++++++ 1 file changed, 113 insertions(+) create mode 100644 tests/test_postgres_period_close_posted_account_identity_red.py diff --git a/tests/test_postgres_period_close_posted_account_identity_red.py b/tests/test_postgres_period_close_posted_account_identity_red.py new file mode 100644 index 00000000..88c0d350 --- /dev/null +++ b/tests/test_postgres_period_close_posted_account_identity_red.py @@ -0,0 +1,113 @@ +"""Real PostgreSQL RED for hard close after chart-account catalog expiry.""" + +from __future__ import annotations + +import unittest +from decimal import Decimal + +import psycopg + +from tests import test_postgres_posting as posting + + +class PeriodClosePostedAccountIdentityPostgresTests(unittest.TestCase): + """Keep hard-close offsets bound to immutable posted chart-account identity.""" + + @classmethod + def setUpClass(cls) -> None: + """Install the complete current accounting migration chain.""" + posting.PostgresPostingTests.setUpClass() + + def setUp(self) -> None: + """Seed one open book with the production posting catalog.""" + self.case = posting.PostgresPostingTests("setUp") + self.case.setUp() + self.addCleanup(self.case.doCleanups) + self.addCleanup(self.case.tearDown) + + def test_hard_close_uses_posted_account_after_chart_account_expires(self) -> None: + """A later chart-account expiry cannot strand or redirect an already-posted balance.""" + self.case.ledger.post(self.case._two_line_proposal(), self.case.policy) + + with psycopg.connect(posting.DATABASE_URL) as connection: + source_account_id = connection.execute( + """ + SELECT journal_entry_line.chart_account_id + FROM accounting_core.journal_entry_line + JOIN accounting_core.general_journal + ON general_journal.tenant_account_id = journal_entry_line.tenant_account_id + AND general_journal.general_journal_id = journal_entry_line.general_journal_id + JOIN accounting_core.chart_account + ON chart_account.tenant_account_id = journal_entry_line.tenant_account_id + AND chart_account.chart_account_id = journal_entry_line.chart_account_id + WHERE general_journal.tenant_account_id = %s + AND chart_account.chart_account_code = '410100' + AND journal_entry_line.account_role_code = 'usage_revenue' + """, + (self.case.tenant_id,), + ).fetchone()[0] + updated = connection.execute( + """ + UPDATE accounting_core.chart_account + SET valid_to = TIMESTAMPTZ '2026-09-01 00:00:00+00' + WHERE tenant_account_id = %s + AND chart_account_id = %s + AND valid_to IS NULL + """, + (self.case.tenant_id, source_account_id), + ).rowcount + connection.commit() + + self.assertEqual(updated, 1) + + receipt = self.case.ledger.close_fiscal_period( + self.case.policy.legal_entity_reference, + self.case.policy.accounting_book_reference, + "2026-08", + "KRW", + period_status_code="hard_closed", + idempotency_key=( + f"{self.case.policy.tenant_reference}:posted-account-stability:hard-close" + ), + ) + + with psycopg.connect(posting.DATABASE_URL) as connection: + closing_account_id = connection.execute( + """ + SELECT journal_entry_line.chart_account_id + FROM accounting_core.journal_entry_line + JOIN accounting_core.general_journal + ON general_journal.tenant_account_id = journal_entry_line.tenant_account_id + AND general_journal.general_journal_id = journal_entry_line.general_journal_id + WHERE general_journal.tenant_account_id = %s + AND general_journal.journal_reference LIKE + 'urn:cwl:accounting:general_journal:period_closing:%%' + AND journal_entry_line.account_role_code = 'usage_revenue' + """, + (self.case.tenant_id,), + ).fetchone()[0] + snapshot_line = connection.execute( + """ + SELECT trial_balance_line.debit_total_amount, + trial_balance_line.credit_total_amount, + trial_balance_line.net_balance_amount + FROM accounting_reporting.trial_balance_snapshot + JOIN accounting_reporting.trial_balance_line + ON trial_balance_line.tenant_account_id = trial_balance_snapshot.tenant_account_id + AND trial_balance_line.trial_balance_snapshot_id = trial_balance_snapshot.trial_balance_snapshot_id + WHERE trial_balance_snapshot.tenant_account_id = %s + AND trial_balance_line.chart_account_id = %s + """, + (self.case.tenant_id, source_account_id), + ).fetchone() + + self.assertEqual(receipt.period_status_code, "hard_closed") + self.assertEqual(closing_account_id, source_account_id) + self.assertEqual( + tuple(Decimal(value) for value in snapshot_line), + (Decimal("25000"), Decimal("25000"), Decimal("0")), + ) + + +if __name__ == "__main__": + unittest.main() From a612539a92bfd171b7037273858c7263e4eabc9e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 01:08:13 +0900 Subject: [PATCH 187/224] test(close): ratchet historical account identity boundary --- ...posted_account_identity_source_contract.py | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 tests/test_period_close_posted_account_identity_source_contract.py diff --git a/tests/test_period_close_posted_account_identity_source_contract.py b/tests/test_period_close_posted_account_identity_source_contract.py new file mode 100644 index 00000000..cadae015 --- /dev/null +++ b/tests/test_period_close_posted_account_identity_source_contract.py @@ -0,0 +1,40 @@ +"""Static contracts for historical chart-account identity during period close.""" + +from __future__ import annotations + +from pathlib import Path +import unittest + + +ROOT = Path(__file__).resolve().parents[1] +PERSISTENCE = ROOT / "src/accounting_information_platform/persistence.py" + + +class PeriodClosePostedAccountIdentitySourceContractTests(unittest.TestCase): + """Separate historical close offsets from current-catalog ordinary posting.""" + + @classmethod + def setUpClass(cls) -> None: + """Read the production adapter once for focused source-boundary assertions.""" + cls.source = PERSISTENCE.read_text(encoding="utf-8") + + def test_ordinary_journal_insert_still_requires_current_chart_account(self) -> None: + """Fixing close must not let ordinary new postings target expired accounts.""" + method = self.source.split(" def _insert_journal(", 1)[1].split( + " def _insert_receipt(", 1 + )[0] + + self.assertIn("AND valid_to IS NULL", method) + + def test_closing_source_carries_exact_posted_chart_account_identity(self) -> None: + """Closing offsets must not reconstruct a historical account from today's code catalog.""" + method = self.source.split(" def _post_closing_journal(", 1)[1].split( + " def _require_retained_earnings_mapping(", 1 + )[0] + + self.assertIn("journal_entry_line.chart_account_id", method) + self.assertIn("GROUP BY journal_entry_line.chart_account_id", method) + + +if __name__ == "__main__": + unittest.main() From 909c2c60e5c47eaecb50273467a77554c724b374 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 01:08:44 +0900 Subject: [PATCH 188/224] docs(close): trace posted account identity gap --- ...SE_POSTED_ACCOUNT_IDENTITY_TRACEABILITY.md | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 docs/doctoring/PERIOD_CLOSE_POSTED_ACCOUNT_IDENTITY_TRACEABILITY.md diff --git a/docs/doctoring/PERIOD_CLOSE_POSTED_ACCOUNT_IDENTITY_TRACEABILITY.md b/docs/doctoring/PERIOD_CLOSE_POSTED_ACCOUNT_IDENTITY_TRACEABILITY.md new file mode 100644 index 00000000..804752cd --- /dev/null +++ b/docs/doctoring/PERIOD_CLOSE_POSTED_ACCOUNT_IDENTITY_TRACEABILITY.md @@ -0,0 +1,52 @@ +# Period Close posted chart-account identity traceability + +Status: Proposed on PR #53. This note records a live repair finding and does not claim protected integration, runner GREEN, release readiness, audit assurance, or an IFRS-prescribed PostgreSQL design. + +## Problem + +A posted journal line retains both `journal_entry_line.chart_account_id` and `journal_entry_line.account_role_code` as immutable accounting facts. Effective-dated chart-account and policy catalogs may legitimately change after posting. Hard close must therefore preserve the identity of the historical P&L account that actually owns the posted balance. + +PR #53 already stopped `_post_closing_journal()` from reclassifying historical P&L through the current `account_role_mapping`, but the current implementation still selects only `chart_account_code` plus the posted role. `_insert_journal()` then resolves that code again through `chart_account.valid_to IS NULL`. If the source chart account expires after posting but before hard close, the closing command can be stranded. If a later catalog version reuses the same code, resolving only today's code can redirect the closing offset to a different `chart_account_id`; the old posted account would not be zeroed on its own identity. + +The hard-close preflight also assembles buyer-facing financial-statement projections before persistence. Those projections still use current effective chart-account / role mappings. A mutable Reporting-Export projection must not become a prerequisite authority that prevents Period Close from freezing otherwise valid immutable ledger facts. + +## Constraints + +- Posted journal headers and lines remain append-only; correction uses reversal/replacement rather than mutation. +- Ordinary new postings continue to require an active chart account. The repair must not globally relax `_insert_journal()` or allow callers to target expired accounts. +- The system-generated period-closing journal is purpose-limited. Its source-side contra lines exist to zero the exact historical temporary-account balances being closed. +- The retained-earnings destination is a new close-time accounting decision and therefore continues to use the current effective `retained_earnings` mapping. +- Billing or another foreign system gains no chart-account, posting, or close authority. + +## Alternatives + +1. Forbid chart-account expiry until every affected period hard-closes. Rejected: this couples master-data lifecycle to close timing and turns a reporting/catalog condition into ledger availability. +2. Auto-create an active alias or successor row for an expired code. Rejected: code equality does not prove account identity and may leave the historical account balance unclosed. +3. Route closing offsets to whichever active account currently has the same code. Rejected: a successor `chart_account_id` is a different Entity; posting the contra there does not zero the immutable source account. +4. Carry exact posted `chart_account_id` through the close source and use a purpose-limited historical-account insertion path only for the system closing journal. Selected direction. Ordinary posting keeps current-catalog admission, while retained earnings keeps current close-time policy resolution. + +The closeability check should depend on authoritative ledger/trial-balance facts rather than on successful construction of mutable buyer-report projections. Reporting can expose its own catalog-completeness error without acquiring veto authority over the close aggregate. + +## Executable evidence + +Test-first real PostgreSQL RED `6faea7dc50cb2421604daf7c10f7ad3aeadfd4cb` posts `usage_revenue`, records its immutable source `chart_account_id`, expires that chart account, and requires hard close to finish with the source-side closing line bound to the same account identity and the retained snapshot zeroed on that identity. + +Static RED `a612539a92bfd171b7037273858c7263e4eabc9e` simultaneously preserves the opposite boundary: ordinary `_insert_journal()` must continue requiring `valid_to IS NULL`, while `_post_closing_journal()` must carry and group the exact posted `journal_entry_line.chart_account_id` instead of reconstructing source identity from a current code lookup. + +On the current RED head these requirements are not satisfied by production source. Exact-head CI must execute before the RED is called runner-observed. A later candidate must prove both the realistic PostgreSQL scenario and the static separation contract on one unchanged head. + +## Standards boundary + +This repair implements AIP's DDD aggregate/entity identity, temporal accounting-evidence, append-only ledger, and auditability controls. IFRS Accounting Standards do not prescribe PostgreSQL identifiers, joins, triggers, or this insertion shape. Standards traceability should cite financial-reporting requirements only for the accounting outcome they support and keep implementation controls explicitly repository-owned. + +## Recovery and rollout + +No migration or historical journal rewrite is authorized by this finding. If a deployment encounters a period whose current catalog no longer exposes a historical P&L account, do not reactivate or remap the account merely to make close pass. Preserve the posted identity, apply the verified close repair through the normal release path, and rerun the same immutable close command identity from a fresh transaction. + +Rollback of a future candidate is safe only before it produces new hard-close evidence under that candidate. Once a hard-close snapshot and closing journal have committed, accounting correction follows the repository's reversal/correction and audited migration rules; source facts are not rewritten to emulate a code rollback. + +## Downstream handoff + +PR #37 owns the canonical CHANGELOG, standards table, and `docs/product-technical-gap-baseline.md`. At integration time it should reconstruct the durable invariant: effective-dated chart-account or role changes cannot retrospectively change or strand the immutable account identity used by Period Close. + +PR #52 / Reporting-Export must consume retained account identity from integrated AIP evidence. It must not infer historical account identity solely from the current chart-account or role catalog, and reporting catalog incompleteness must not manufacture a second Period Close authority. From 6c1063c113023cf0e954f982edc8cf4eac8baa8f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 01:09:19 +0900 Subject: [PATCH 189/224] docs(close): extend temporal identity amendment --- docs/adr/0024-hard-close-retained-earnings.md | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/docs/adr/0024-hard-close-retained-earnings.md b/docs/adr/0024-hard-close-retained-earnings.md index d948dfe4..8ca5c10b 100644 --- a/docs/adr/0024-hard-close-retained-earnings.md +++ b/docs/adr/0024-hard-close-retained-earnings.md @@ -8,24 +8,28 @@ The catalog seed adds chart account `310100` (`account_class_code=equity`, `normal_balance_code=credit`) and `account_role_code=retained_earnings` → `310100`, the same mapping pattern as `tax_payable` → `210100`. Ordinary Billing proposals may not use `retained_earnings`; that role is reserved for this close journal. The published Billing proposal schema forbids `account_role_code=retained_earnings` (`not: {const: "retained_earnings"}`) so a commercial payload is schema-invalid before ingest. `ingest_journal_proposal` and `POST /journal-proposals` still reject that role as HTTP 422 before a journal is written. Period-close retained-earnings journals stay AIS-authored (`POST /period-closes` is the only writer of 310100). Catalog roles stay the existing seven; this decision does not invent a withholding role. -The closing journal zeros catalog `usage_revenue` 410100 and `write_off_expense` 510100 only: debit 410100 / credit `retained_earnings` 310100 for net revenue, and debit 310100 / credit 510100 for net expense. The exact remainder lands on 310100 so post-close trial-balance equity ties. AIS does not invent another chart account. If those income-statement roles are already net zero, AIS writes zero closing journals. If the two nets offset to zero income, AIS still posts the clearing lines and omits the 310100 plug. Hard-close loads the period-close package in the same REPEATABLE READ transaction first and fails closed when that binder cannot be loaded or the trial balance does not balance. Leftover cash on 210200 may stay non-zero. Re-hard-close of the same period-close `idempotency_key` replays the existing close receipt and does not post a second closing journal. A different key on an already-locked period fails closed. +The closing journal zeros catalog `usage_revenue` 410100 and `write_off_expense` 510100 only: debit 410100 / credit `retained_earnings` 310100 for net revenue, and debit 310100 / credit 510100 for net expense. The exact remainder lands on 310100 so post-close trial-balance equity ties. AIS does not invent another chart account. If those income-statement roles are already net zero, AIS writes zero closing journals. If the two nets offset to zero income, AIS still posts the clearing lines and omits the 310100 plug. Hard-close currently loads the period-close package in the same REPEATABLE READ transaction first and fails closed when that binder cannot be loaded or the trial balance does not balance; the Proposed PR #53 amendment below narrows which part of that package may authorize close. Leftover cash on 210200 may stay non-zero. Re-hard-close of the same period-close `idempotency_key` replays the existing close receipt and does not post a second closing journal. A different key on an already-locked period fails closed. `GET /financial-statements?statement_type_code=income_statement` excludes AIS closing journals (`journal_reference` prefix `urn:cwl:accounting:general_journal:period_closing:`) so period earnings match the pre-close profit or loss. `GET /financial-statements?statement_type_code=balance_sheet` after hard-close includes `310100` and returns `net_income_amount` `0` because those earnings now sit in equity. `GET /trial-balances` after hard-close is the snapshot that includes the closing journal. IAS 1 requires a statement of financial position that presents equity separately from profit or loss for the period (IFRS Foundation, 2022). The closing process transfers that period result into equity so the next period’s sheet does not carry a floating earnings plug. -## Proposed amendment on PR #53: posted-role temporal authority +## Proposed amendment on PR #53: posted temporal authority -The accepted retained-earnings design does not authorize a later Accounting Policy catalog change to rewrite an already-posted journal fact. For historical P&L source classification at hard close, `journal_entry_line.account_role_code` is the authority because it was persisted with the immutable posted line. `account_role_mapping` remains the effective-dated policy authority while a new proposal is resolved, and the current `retained_earnings` mapping remains the close-time authority for the destination of the newly created AIS closing journal. +The accepted retained-earnings design does not authorize a later Accounting Policy or chart-of-accounts catalog change to rewrite an already-posted journal fact. For historical P&L source classification at hard close, `journal_entry_line.account_role_code` is the authority because it was persisted with the immutable posted line. For the source account being cleared, the immutable `journal_entry_line.chart_account_id` is the Entity identity; a later active row that happens to reuse the same `chart_account_code` is not the same account. `account_role_mapping` remains the effective-dated policy authority while a new proposal is resolved, and the current `retained_earnings` mapping remains the close-time authority for the destination of the newly created AIS closing journal. -Accordingly, `_post_closing_journal()` must select, filter, and group historical `usage_revenue` / `write_off_expense` from the persisted journal-line role and must not join the current effective mapping merely to reconstruct that historical classification. Expiring or superseding a mapping after posting must neither suppress the posted P&L population nor reclassify it at hard close. This amendment is Proposed until PR #53 reaches its protected integration gates; it does not change this ADR's previously accepted retained-earnings ownership or the posting-time policy resolver. +Accordingly, `_post_closing_journal()` must select and group the historical source population using the persisted journal-line role and exact posted chart-account identity. It must not join the current effective role mapping merely to reconstruct historical classification or resolve a historical source offset by today's active chart-account code. Expiring or superseding a mapping or chart account after posting must neither suppress the posted P&L population, reclassify it, strand hard close, nor redirect the clearing entry to a different account Entity. -The executable acceptance is `tests/test_postgres_period_close_posted_role_stability_red.py` plus `tests/test_period_close_posted_role_source_contract.py`. The detailed RED→candidate lineage, alternatives, scope-preservation evidence, and rollback boundary are maintained in `docs/doctoring/PERIOD_CLOSE_POSTED_ROLE_TRACEABILITY.md`. +Ordinary `_insert_journal()` admission still requires an active chart account. The historical-account exception is purpose-limited to the AIS-owned period-closing contra line needed to zero an already-posted temporary account; it is not a general permission to post new business activity to expired accounts. The retained-earnings destination continues to resolve current close-time policy because it is a new accounting decision made by the close command. -This temporal-authority split is an AIP DDD/audit-evidence control. IAS 1 does not prescribe the PostgreSQL column, join shape, or effective-dated implementation used to enforce it. +The close command must also keep its authority boundary distinct from buyer-facing Reporting-Export projections. A financial-statement projection may report that current catalog metadata is incomplete, but successful construction of that mutable projection must not become the authority that decides whether an otherwise valid immutable ledger population can hard-close. Trial-balance balance, journal population, close-control state, exact posted identities and the purpose-limited retained-earnings destination remain the relevant close facts. + +The posted-role executable acceptance is `tests/test_postgres_period_close_posted_role_stability_red.py` plus `tests/test_period_close_posted_role_source_contract.py`. Posted-account acceptance adds `tests/test_postgres_period_close_posted_account_identity_red.py` and `tests/test_period_close_posted_account_identity_source_contract.py`. Detailed RED/candidate lineage, alternatives, scope-preservation evidence and rollback boundaries are maintained in `docs/doctoring/PERIOD_CLOSE_POSTED_ROLE_TRACEABILITY.md` and `docs/doctoring/PERIOD_CLOSE_POSTED_ACCOUNT_IDENTITY_TRACEABILITY.md`. + +This temporal-authority split is an AIP DDD/audit-evidence control. IAS 1 does not prescribe PostgreSQL identifiers, joins, triggers, effective-dated implementation, or the separation between the close command and buyer-report projection. ## Consequences Controllers can hard-close once and read a balance sheet that ties without `net_income_amount` as a plug. Income-statement inquiry still shows the period’s revenue and expense activity. Soft-close remains the adjusting window from ADR 0023 and does not park earnings. -For the Proposed PR #53 amendment, controllers can also change future account-role policy without changing the close semantics of immutable historical journal lines. Reporting projections that still infer historical semantics only from current mappings remain a separate Reporting-Export repair and must not create a second Period Close authority. +For the Proposed PR #53 amendment, controllers can change future account-role or chart-account policy without changing or stranding the close semantics of immutable historical journal lines. Reporting projections that infer historical semantics only from current mappings remain a separate Reporting-Export repair and must not create a second Period Close authority. From dc500642bb3818962fa28cf5b1944901f54217fb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 01:11:53 +0900 Subject: [PATCH 190/224] docs(close): note IFRS 18 transition boundary --- docs/adr/0024-hard-close-retained-earnings.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/adr/0024-hard-close-retained-earnings.md b/docs/adr/0024-hard-close-retained-earnings.md index 8ca5c10b..e153dd81 100644 --- a/docs/adr/0024-hard-close-retained-earnings.md +++ b/docs/adr/0024-hard-close-retained-earnings.md @@ -12,7 +12,7 @@ The closing journal zeros catalog `usage_revenue` 410100 and `write_off_expense` `GET /financial-statements?statement_type_code=income_statement` excludes AIS closing journals (`journal_reference` prefix `urn:cwl:accounting:general_journal:period_closing:`) so period earnings match the pre-close profit or loss. `GET /financial-statements?statement_type_code=balance_sheet` after hard-close includes `310100` and returns `net_income_amount` `0` because those earnings now sit in equity. `GET /trial-balances` after hard-close is the snapshot that includes the closing journal. -IAS 1 requires a statement of financial position that presents equity separately from profit or loss for the period (IFRS Foundation, 2022). The closing process transfers that period result into equity so the next period’s sheet does not carry a floating earnings plug. +IAS 1 requires a statement of financial position and a statement presenting profit or loss and other comprehensive income as part of a complete set of financial statements. IFRS 18, issued in April 2024, replaces IAS 1 for annual reporting periods beginning on or after 1 January 2027, with earlier application permitted. The repository must therefore treat IAS 1 versus early/mandatory IFRS 18 presentation as a versioned reporting-standard profile rather than making this database close mechanism depend on an IAS 1-only assumption. The closing process here transfers period result into equity; neither IAS 1 nor IFRS 18 prescribes the PostgreSQL mechanism used to do so. ## Proposed amendment on PR #53: posted temporal authority @@ -26,7 +26,7 @@ The close command must also keep its authority boundary distinct from buyer-faci The posted-role executable acceptance is `tests/test_postgres_period_close_posted_role_stability_red.py` plus `tests/test_period_close_posted_role_source_contract.py`. Posted-account acceptance adds `tests/test_postgres_period_close_posted_account_identity_red.py` and `tests/test_period_close_posted_account_identity_source_contract.py`. Detailed RED/candidate lineage, alternatives, scope-preservation evidence and rollback boundaries are maintained in `docs/doctoring/PERIOD_CLOSE_POSTED_ROLE_TRACEABILITY.md` and `docs/doctoring/PERIOD_CLOSE_POSTED_ACCOUNT_IDENTITY_TRACEABILITY.md`. -This temporal-authority split is an AIP DDD/audit-evidence control. IAS 1 does not prescribe PostgreSQL identifiers, joins, triggers, effective-dated implementation, or the separation between the close command and buyer-report projection. +This temporal-authority split is an AIP DDD/audit-evidence control. IAS 1 and IFRS 18 do not prescribe PostgreSQL identifiers, joins, triggers, effective-dated implementation, or the separation between the close command and buyer-report projection. ## Consequences From 0d3caba036c370f93deacc0e8208314cd9df9731 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 02:06:44 +0900 Subject: [PATCH 191/224] test: separate hard close from reporting projection authority --- ...riod_close_posted_account_identity_source_contract.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tests/test_period_close_posted_account_identity_source_contract.py b/tests/test_period_close_posted_account_identity_source_contract.py index cadae015..64d08d25 100644 --- a/tests/test_period_close_posted_account_identity_source_contract.py +++ b/tests/test_period_close_posted_account_identity_source_contract.py @@ -35,6 +35,15 @@ def test_closing_source_carries_exact_posted_chart_account_identity(self) -> Non self.assertIn("journal_entry_line.chart_account_id", method) self.assertIn("GROUP BY journal_entry_line.chart_account_id", method) + def test_hard_close_does_not_require_buyer_reporting_projection(self) -> None: + """Reporting catalog completeness must not become a second hard-close authority.""" + method = self.source.split(" def close_fiscal_period(", 1)[1].split( + " def open_fiscal_period(", 1 + )[0] + + self.assertNotIn("_assemble_period_close_package(", method) + self.assertIn("_persist_period_close(", method) + if __name__ == "__main__": unittest.main() From 06342053f2937e94748b40ed9182b20cfbf0ef74 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 02:07:27 +0900 Subject: [PATCH 192/224] docs: trace hard close reporting-authority separation --- .../PERIOD_CLOSE_POSTED_ACCOUNT_IDENTITY_TRACEABILITY.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/doctoring/PERIOD_CLOSE_POSTED_ACCOUNT_IDENTITY_TRACEABILITY.md b/docs/doctoring/PERIOD_CLOSE_POSTED_ACCOUNT_IDENTITY_TRACEABILITY.md index 804752cd..65b6234e 100644 --- a/docs/doctoring/PERIOD_CLOSE_POSTED_ACCOUNT_IDENTITY_TRACEABILITY.md +++ b/docs/doctoring/PERIOD_CLOSE_POSTED_ACCOUNT_IDENTITY_TRACEABILITY.md @@ -33,7 +33,9 @@ Test-first real PostgreSQL RED `6faea7dc50cb2421604daf7c10f7ad3aeadfd4cb` posts Static RED `a612539a92bfd171b7037273858c7263e4eabc9e` simultaneously preserves the opposite boundary: ordinary `_insert_journal()` must continue requiring `valid_to IS NULL`, while `_post_closing_journal()` must carry and group the exact posted `journal_entry_line.chart_account_id` instead of reconstructing source identity from a current code lookup. -On the current RED head these requirements are not satisfied by production source. Exact-head CI must execute before the RED is called runner-observed. A later candidate must prove both the realistic PostgreSQL scenario and the static separation contract on one unchanged head. +Static authority-separation RED `0d3caba036c370f93deacc0e8208314cd9df9731` adds the second causal boundary exposed by the same PostgreSQL scenario: `close_fiscal_period()` must not require `_assemble_period_close_package()` before the authoritative hard-close write. The supported close still has to reach `_persist_period_close()` and preserve ledger/trial-balance invariants; buyer Reporting-Export projection completeness is not close authorization. + +On the current RED head these requirements are not satisfied by production source. Exact-head CI must execute before the RED is called runner-observed. A later candidate must prove both the realistic PostgreSQL scenario and the static separation contracts on one unchanged head. ## Standards boundary From c5266ce29c181474331e8a4b035f6d57d185ed2d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 04:19:14 +0900 Subject: [PATCH 193/224] test(close): reject account-code reuse redirection --- ...eriod_close_posted_account_identity_red.py | 139 ++++++++++++++++++ 1 file changed, 139 insertions(+) diff --git a/tests/test_postgres_period_close_posted_account_identity_red.py b/tests/test_postgres_period_close_posted_account_identity_red.py index 88c0d350..109eb20d 100644 --- a/tests/test_postgres_period_close_posted_account_identity_red.py +++ b/tests/test_postgres_period_close_posted_account_identity_red.py @@ -108,6 +108,145 @@ def test_hard_close_uses_posted_account_after_chart_account_expires(self) -> Non (Decimal("25000"), Decimal("25000"), Decimal("0")), ) + def test_hard_close_does_not_redirect_posted_account_when_code_is_reused(self) -> None: + """A successor account reusing the code cannot receive the historical closing contra.""" + self.case.ledger.post(self.case._two_line_proposal(), self.case.policy) + transition_at = "2026-09-01 00:00:00+00" + + with psycopg.connect(posting.DATABASE_URL) as connection: + source_account_id, book_id = connection.execute( + """ + SELECT journal_entry_line.chart_account_id, + general_journal.accounting_book_id + FROM accounting_core.journal_entry_line + JOIN accounting_core.general_journal + ON general_journal.tenant_account_id = journal_entry_line.tenant_account_id + AND general_journal.general_journal_id = journal_entry_line.general_journal_id + JOIN accounting_core.chart_account + ON chart_account.tenant_account_id = journal_entry_line.tenant_account_id + AND chart_account.chart_account_id = journal_entry_line.chart_account_id + WHERE general_journal.tenant_account_id = %s + AND chart_account.chart_account_code = '410100' + AND journal_entry_line.account_role_code = 'usage_revenue' + """, + (self.case.tenant_id,), + ).fetchone() + mapping_updated = connection.execute( + """ + UPDATE accounting_core.account_role_mapping + SET valid_to = %s::timestamptz + WHERE tenant_account_id = %s + AND accounting_book_id = %s + AND chart_account_id = %s + AND account_role_code = 'usage_revenue' + AND valid_to IS NULL + """, + (transition_at, self.case.tenant_id, book_id, source_account_id), + ).rowcount + account_updated = connection.execute( + """ + UPDATE accounting_core.chart_account + SET valid_to = %s::timestamptz + WHERE tenant_account_id = %s + AND chart_account_id = %s + AND valid_to IS NULL + """, + (transition_at, self.case.tenant_id, source_account_id), + ).rowcount + successor_account_id = connection.execute( + """ + INSERT INTO accounting_core.chart_account ( + tenant_account_id, + accounting_book_id, + chart_account_code, + account_name, + normal_balance_code, + account_class_code, + valid_from + ) + VALUES (%s, %s, '410100', 'Usage revenue successor', 'credit', 'revenue', + %s::timestamptz) + RETURNING chart_account_id + """, + (self.case.tenant_id, book_id, transition_at), + ).fetchone()[0] + connection.execute( + """ + INSERT INTO accounting_core.account_role_mapping ( + tenant_account_id, + accounting_book_id, + account_role_code, + chart_account_id, + accounting_policy_version, + posting_rule_version, + valid_from + ) + VALUES (%s, %s, 'usage_revenue', %s, %s, %s, %s::timestamptz) + """, + ( + self.case.tenant_id, + book_id, + successor_account_id, + self.case.policy.accounting_policy_version, + self.case.policy.posting_rule_version, + transition_at, + ), + ) + connection.commit() + + self.assertEqual(mapping_updated, 1) + self.assertEqual(account_updated, 1) + self.assertNotEqual(successor_account_id, source_account_id) + + receipt = self.case.ledger.close_fiscal_period( + self.case.policy.legal_entity_reference, + self.case.policy.accounting_book_reference, + "2026-08", + "KRW", + period_status_code="hard_closed", + idempotency_key=( + f"{self.case.policy.tenant_reference}:posted-account-code-reuse:hard-close" + ), + ) + + with psycopg.connect(posting.DATABASE_URL) as connection: + closing_account_id = connection.execute( + """ + SELECT journal_entry_line.chart_account_id + FROM accounting_core.journal_entry_line + JOIN accounting_core.general_journal + ON general_journal.tenant_account_id = journal_entry_line.tenant_account_id + AND general_journal.general_journal_id = journal_entry_line.general_journal_id + WHERE general_journal.tenant_account_id = %s + AND general_journal.journal_reference LIKE + 'urn:cwl:accounting:general_journal:period_closing:%%' + AND journal_entry_line.account_role_code = 'usage_revenue' + """, + (self.case.tenant_id,), + ).fetchone()[0] + snapshot_line = connection.execute( + """ + SELECT trial_balance_line.debit_total_amount, + trial_balance_line.credit_total_amount, + trial_balance_line.net_balance_amount + FROM accounting_reporting.trial_balance_snapshot + JOIN accounting_reporting.trial_balance_line + ON trial_balance_line.tenant_account_id = trial_balance_snapshot.tenant_account_id + AND trial_balance_line.trial_balance_snapshot_id = trial_balance_snapshot.trial_balance_snapshot_id + WHERE trial_balance_snapshot.tenant_account_id = %s + AND trial_balance_line.chart_account_id = %s + """, + (self.case.tenant_id, source_account_id), + ).fetchone() + + self.assertEqual(receipt.period_status_code, "hard_closed") + self.assertEqual(closing_account_id, source_account_id) + self.assertNotEqual(closing_account_id, successor_account_id) + self.assertEqual( + tuple(Decimal(value) for value in snapshot_line), + (Decimal("25000"), Decimal("25000"), Decimal("0")), + ) + if __name__ == "__main__": unittest.main() From f8d199b222f2ceaf1ed83e2ce6664abd8ca32861 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 04:19:44 +0900 Subject: [PATCH 194/224] docs(close): trace runner red and account-code reuse --- .../PERIOD_CLOSE_POSTED_ACCOUNT_IDENTITY_TRACEABILITY.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/docs/doctoring/PERIOD_CLOSE_POSTED_ACCOUNT_IDENTITY_TRACEABILITY.md b/docs/doctoring/PERIOD_CLOSE_POSTED_ACCOUNT_IDENTITY_TRACEABILITY.md index 65b6234e..07754471 100644 --- a/docs/doctoring/PERIOD_CLOSE_POSTED_ACCOUNT_IDENTITY_TRACEABILITY.md +++ b/docs/doctoring/PERIOD_CLOSE_POSTED_ACCOUNT_IDENTITY_TRACEABILITY.md @@ -31,11 +31,15 @@ The closeability check should depend on authoritative ledger/trial-balance facts Test-first real PostgreSQL RED `6faea7dc50cb2421604daf7c10f7ad3aeadfd4cb` posts `usage_revenue`, records its immutable source `chart_account_id`, expires that chart account, and requires hard close to finish with the source-side closing line bound to the same account identity and the retained snapshot zeroed on that identity. +The exact RED head `06342053f2937e94748b40ed9182b20cfbf0ef74` subsequently reached a real hosted runner. Accounting Foundation run `33979966465`, job `101343324928`, completed `failure` in the behavior/repository-test step while its exact-head dependency-diff, SAST, and security sibling jobs completed successfully. Raw step logs are not available through the current repository connector, so this note does not attribute that failure to one assertion beyond the checked-in RED contracts. It is nevertheless runner-observed failure evidence for the unchanged RED head, not a queued or synthetic result. + +Successor RED `c5266ce29c181474331e8a4b035f6d57d185ed2d` strengthens the temporal Entity boundary with an actual code-reuse transition. It posts revenue to one `chart_account_id`, ends that account and its role mapping, creates a successor account that legally reuses `410100` with a later `valid_from`, installs the successor mapping, and then requires hard close to put the historical contra on the original account rather than the successor. This separates code equality from Entity identity and prevents a repair that merely makes the current code lookup succeed again. + Static RED `a612539a92bfd171b7037273858c7263e4eabc9e` simultaneously preserves the opposite boundary: ordinary `_insert_journal()` must continue requiring `valid_to IS NULL`, while `_post_closing_journal()` must carry and group the exact posted `journal_entry_line.chart_account_id` instead of reconstructing source identity from a current code lookup. Static authority-separation RED `0d3caba036c370f93deacc0e8208314cd9df9731` adds the second causal boundary exposed by the same PostgreSQL scenario: `close_fiscal_period()` must not require `_assemble_period_close_package()` before the authoritative hard-close write. The supported close still has to reach `_persist_period_close()` and preserve ledger/trial-balance invariants; buyer Reporting-Export projection completeness is not close authorization. -On the current RED head these requirements are not satisfied by production source. Exact-head CI must execute before the RED is called runner-observed. A later candidate must prove both the realistic PostgreSQL scenario and the static separation contracts on one unchanged head. +The successor test head is intentionally RED until a production candidate satisfies both realistic PostgreSQL scenarios and the static separation contracts on one unchanged exact head. Predecessor runner evidence does not transfer to that successor head. ## Standards boundary @@ -49,6 +53,6 @@ Rollback of a future candidate is safe only before it produces new hard-close ev ## Downstream handoff -PR #37 owns the canonical CHANGELOG, standards table, and `docs/product-technical-gap-baseline.md`. At integration time it should reconstruct the durable invariant: effective-dated chart-account or role changes cannot retrospectively change or strand the immutable account identity used by Period Close. +PR #37 owns the canonical CHANGELOG, standards table, and `docs/product-technical-gap-baseline.md`. At integration time it should reconstruct the durable invariant: effective-dated chart-account or role changes cannot retrospectively change, strand, or redirect the immutable account identity used by Period Close. PR #52 / Reporting-Export must consume retained account identity from integrated AIP evidence. It must not infer historical account identity solely from the current chart-account or role catalog, and reporting catalog incompleteness must not manufacture a second Period Close authority. From ae2aaa1ad9c75068e5db4dc9850f40ebba991137 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 04:23:40 +0900 Subject: [PATCH 195/224] test(close): bind snapshot hash to account entity --- ...napshot_hash_chart_account_identity_red.py | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 tests/test_trial_balance_snapshot_hash_chart_account_identity_red.py diff --git a/tests/test_trial_balance_snapshot_hash_chart_account_identity_red.py b/tests/test_trial_balance_snapshot_hash_chart_account_identity_red.py new file mode 100644 index 00000000..de9a074e --- /dev/null +++ b/tests/test_trial_balance_snapshot_hash_chart_account_identity_red.py @@ -0,0 +1,46 @@ +"""RED contract for chart-account Entity identity in retained snapshot provenance.""" + +from __future__ import annotations + +import unittest +from decimal import Decimal +from uuid import UUID + +from accounting_information_platform.persistence import _canonical_snapshot_hash + + +class TrialBalanceSnapshotHashChartAccountIdentityTests(unittest.TestCase): + """Bind retained trial-balance provenance to the exact chart-account Entity.""" + + def test_snapshot_hash_changes_when_only_chart_account_id_changes(self) -> None: + """Code equality must not make two different account Entities hash-identical.""" + first_account_id = UUID("00000000-0000-7000-8000-000000000101") + successor_account_id = UUID("00000000-0000-7000-8000-000000000102") + common = { + "tenant_reference": "urn:cwl:tenant_snapshot_hash_identity", + "legal_entity_reference": "urn:cwl:legal_entity:snapshot_hash_identity", + "accounting_book_reference": "urn:cwl:accounting_book:snapshot_hash_identity", + "period_code": "2026-08", + "snapshot_currency_code": "KRW", + "source_journal_count": 1, + } + + original_hash = _canonical_snapshot_hash( + **common, + lines=((first_account_id, "410100", Decimal("0"), Decimal("25000")),), + ) + successor_hash = _canonical_snapshot_hash( + **common, + lines=((successor_account_id, "410100", Decimal("0"), Decimal("25000")),), + ) + replay_hash = _canonical_snapshot_hash( + **common, + lines=((first_account_id, "410100", Decimal("0"), Decimal("25000")),), + ) + + self.assertEqual(original_hash, replay_hash) + self.assertNotEqual(original_hash, successor_hash) + + +if __name__ == "__main__": + unittest.main() From f9f926597ca2b14b4453e34f0931b676d6b780eb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 04:24:07 +0900 Subject: [PATCH 196/224] docs(close): bind retained evidence hash to entity --- ...OD_CLOSE_POSTED_ACCOUNT_IDENTITY_TRACEABILITY.md | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/docs/doctoring/PERIOD_CLOSE_POSTED_ACCOUNT_IDENTITY_TRACEABILITY.md b/docs/doctoring/PERIOD_CLOSE_POSTED_ACCOUNT_IDENTITY_TRACEABILITY.md index 07754471..520b016f 100644 --- a/docs/doctoring/PERIOD_CLOSE_POSTED_ACCOUNT_IDENTITY_TRACEABILITY.md +++ b/docs/doctoring/PERIOD_CLOSE_POSTED_ACCOUNT_IDENTITY_TRACEABILITY.md @@ -8,6 +8,8 @@ A posted journal line retains both `journal_entry_line.chart_account_id` and `jo PR #53 already stopped `_post_closing_journal()` from reclassifying historical P&L through the current `account_role_mapping`, but the current implementation still selects only `chart_account_code` plus the posted role. `_insert_journal()` then resolves that code again through `chart_account.valid_to IS NULL`. If the source chart account expires after posting but before hard close, the closing command can be stranded. If a later catalog version reuses the same code, resolving only today's code can redirect the closing offset to a different `chart_account_id`; the old posted account would not be zeroed on its own identity. +The retained snapshot hash has the same identity blind spot in a more explicit form. `_canonical_snapshot_hash()` accepts each line as `(chart_account_id, chart_account_code, debit, credit)` but currently omits `chart_account_id` from the serialized hash payload. Two different effective-dated account Entities with the same code and amounts therefore produce the same retained-evidence digest even though `trial_balance_line.chart_account_id` distinguishes them. Provenance must bind the exact Entity that owns the balance, not merely its reusable display/business code. + The hard-close preflight also assembles buyer-facing financial-statement projections before persistence. Those projections still use current effective chart-account / role mappings. A mutable Reporting-Export projection must not become a prerequisite authority that prevents Period Close from freezing otherwise valid immutable ledger facts. ## Constraints @@ -16,6 +18,7 @@ The hard-close preflight also assembles buyer-facing financial-statement project - Ordinary new postings continue to require an active chart account. The repair must not globally relax `_insert_journal()` or allow callers to target expired accounts. - The system-generated period-closing journal is purpose-limited. Its source-side contra lines exist to zero the exact historical temporary-account balances being closed. - The retained-earnings destination is a new close-time accounting decision and therefore continues to use the current effective `retained_earnings` mapping. +- Retained snapshot provenance must remain deterministic for exact replay but change when the authoritative account Entity changes even if code and monetary values are identical. - Billing or another foreign system gains no chart-account, posting, or close authority. ## Alternatives @@ -35,24 +38,28 @@ The exact RED head `06342053f2937e94748b40ed9182b20cfbf0ef74` subsequently reach Successor RED `c5266ce29c181474331e8a4b035f6d57d185ed2d` strengthens the temporal Entity boundary with an actual code-reuse transition. It posts revenue to one `chart_account_id`, ends that account and its role mapping, creates a successor account that legally reuses `410100` with a later `valid_from`, installs the successor mapping, and then requires hard close to put the historical contra on the original account rather than the successor. This separates code equality from Entity identity and prevents a repair that merely makes the current code lookup succeed again. +Hash-provenance RED `ae2aaa1ad9c75068e5db4dc9850f40ebba991137` isolates the retained-evidence digest. It supplies identical tenant/entity/book/period/currency/journal-count/code/amount inputs with two different deterministic `chart_account_id` values. Exact replay of one Entity must hash identically, while substituting only the account Entity must change the digest. The current implementation fails that contract because it discards the UUID while serializing the line payload. + Static RED `a612539a92bfd171b7037273858c7263e4eabc9e` simultaneously preserves the opposite boundary: ordinary `_insert_journal()` must continue requiring `valid_to IS NULL`, while `_post_closing_journal()` must carry and group the exact posted `journal_entry_line.chart_account_id` instead of reconstructing source identity from a current code lookup. Static authority-separation RED `0d3caba036c370f93deacc0e8208314cd9df9731` adds the second causal boundary exposed by the same PostgreSQL scenario: `close_fiscal_period()` must not require `_assemble_period_close_package()` before the authoritative hard-close write. The supported close still has to reach `_persist_period_close()` and preserve ledger/trial-balance invariants; buyer Reporting-Export projection completeness is not close authorization. -The successor test head is intentionally RED until a production candidate satisfies both realistic PostgreSQL scenarios and the static separation contracts on one unchanged exact head. Predecessor runner evidence does not transfer to that successor head. +The current successor head is intentionally RED until a production candidate satisfies both realistic PostgreSQL scenarios, the account-identity-sensitive snapshot hash, and the static separation contracts on one unchanged exact head. Predecessor runner evidence does not transfer to that successor head. ## Standards boundary -This repair implements AIP's DDD aggregate/entity identity, temporal accounting-evidence, append-only ledger, and auditability controls. IFRS Accounting Standards do not prescribe PostgreSQL identifiers, joins, triggers, or this insertion shape. Standards traceability should cite financial-reporting requirements only for the accounting outcome they support and keep implementation controls explicitly repository-owned. +This repair implements AIP's DDD aggregate/entity identity, temporal accounting-evidence, append-only ledger, and auditability controls. IFRS Accounting Standards do not prescribe PostgreSQL identifiers, joins, triggers, hashes, or this insertion shape. Standards traceability should cite financial-reporting requirements only for the accounting outcome they support and keep implementation controls explicitly repository-owned. ## Recovery and rollout No migration or historical journal rewrite is authorized by this finding. If a deployment encounters a period whose current catalog no longer exposes a historical P&L account, do not reactivate or remap the account merely to make close pass. Preserve the posted identity, apply the verified close repair through the normal release path, and rerun the same immutable close command identity from a fresh transaction. +A future hash fix changes newly produced retained-evidence digests and therefore requires explicit compatibility/recovery evidence before release. Existing committed hard-close evidence must not be silently rehashed in place. If historical digest versioning is required, introduce it as a versioned evidence contract with migration/verification rules rather than mutating already-retained facts. + Rollback of a future candidate is safe only before it produces new hard-close evidence under that candidate. Once a hard-close snapshot and closing journal have committed, accounting correction follows the repository's reversal/correction and audited migration rules; source facts are not rewritten to emulate a code rollback. ## Downstream handoff -PR #37 owns the canonical CHANGELOG, standards table, and `docs/product-technical-gap-baseline.md`. At integration time it should reconstruct the durable invariant: effective-dated chart-account or role changes cannot retrospectively change, strand, or redirect the immutable account identity used by Period Close. +PR #37 owns the canonical CHANGELOG, standards table, and `docs/product-technical-gap-baseline.md`. At integration time it should reconstruct the durable invariant: effective-dated chart-account or role changes cannot retrospectively change or strand the immutable account identity used by Period Close, and retained evidence hashes must bind that exact Entity identity. PR #52 / Reporting-Export must consume retained account identity from integrated AIP evidence. It must not infer historical account identity solely from the current chart-account or role catalog, and reporting catalog incompleteness must not manufacture a second Period Close authority. From c5adb003590880730d5e67a528312a05f6ce15fb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 05:07:57 +0900 Subject: [PATCH 197/224] test(close): acquire snapshot authority lock in concurrency RED --- .../test_postgres_trial_balance_snapshot_concurrency_red.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/test_postgres_trial_balance_snapshot_concurrency_red.py b/tests/test_postgres_trial_balance_snapshot_concurrency_red.py index 238060d1..58e1ae1d 100644 --- a/tests/test_postgres_trial_balance_snapshot_concurrency_red.py +++ b/tests/test_postgres_trial_balance_snapshot_concurrency_red.py @@ -77,7 +77,11 @@ def _insert_snapshot( ) -> None: """Insert one purpose-limited pre-close population candidate for the exact scope.""" connection.execute( - "SELECT set_config('accounting_core.journal_write_role', 'period_closing', true)" + "SELECT pg_advisory_xact_lock(hashtext(%s), hashtext(%s))", + ( + self.case.policy.tenant_reference, + f"period:{self.accounting_book_id}:2026-08", + ), ) connection.execute( """ From a1d96d91546b82fef1593b3e8026dd5d301b169e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 05:08:21 +0900 Subject: [PATCH 198/224] test(close): use real authority lock for currency scope RED --- tests/test_postgres_trial_balance_snapshot_currency_red.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/test_postgres_trial_balance_snapshot_currency_red.py b/tests/test_postgres_trial_balance_snapshot_currency_red.py index 40e53d8a..4850c115 100644 --- a/tests/test_postgres_trial_balance_snapshot_currency_red.py +++ b/tests/test_postgres_trial_balance_snapshot_currency_red.py @@ -68,7 +68,11 @@ def test_snapshot_header_rejects_currency_different_from_accounting_book(self) - legal_entity_id, accounting_book_id, fiscal_period_id, book_currency = scope wrong_currency = "USD" if book_currency != "USD" else "JPY" connection.execute( - "SELECT set_config('accounting_core.journal_write_role', 'period_closing', true)" + "SELECT pg_advisory_xact_lock(hashtext(%s), hashtext(%s))", + ( + self.case.policy.tenant_reference, + f"period:{accounting_book_id}:2026-08", + ), ) with self.assertRaisesRegex( From e91f783f7665acf7737c65e3dedee4431e3ecf0c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 05:08:48 +0900 Subject: [PATCH 199/224] test(close): bind snapshot scope REDs to real close lock --- ...stgres_trial_balance_snapshot_scope_red.py | 20 +++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/tests/test_postgres_trial_balance_snapshot_scope_red.py b/tests/test_postgres_trial_balance_snapshot_scope_red.py index 4386e0f5..4566fbc6 100644 --- a/tests/test_postgres_trial_balance_snapshot_scope_red.py +++ b/tests/test_postgres_trial_balance_snapshot_scope_red.py @@ -64,10 +64,18 @@ def _scope(self, connection: psycopg.Connection[object]) -> tuple[object, object assert row is not None return row - @staticmethod - def _enable_period_closing_classification(connection: psycopg.Connection[object]) -> None: + def _acquire_period_close_authority( + self, + connection: psycopg.Connection[object], + accounting_book_id: object, + ) -> None: + """Acquire the exact transaction-scoped close lock required by snapshot admission.""" connection.execute( - "SELECT set_config('accounting_core.journal_write_role', 'period_closing', true)" + "SELECT pg_advisory_xact_lock(hashtext(%s), hashtext(%s))", + ( + self.case.policy.tenant_reference, + f"period:{accounting_book_id}:2026-08", + ), ) def test_snapshot_header_rejects_legal_entity_from_another_book_scope(self) -> None: @@ -92,7 +100,7 @@ def test_snapshot_header_rejects_legal_entity_from_another_book_scope(self) -> N posting.VALID_FROM, ), ).fetchone()[0] - self._enable_period_closing_classification(connection) + self._acquire_period_close_authority(connection, accounting_book_id) with self.assertRaisesRegex( psycopg.errors.CheckViolation, @@ -161,7 +169,7 @@ def test_snapshot_line_rejects_chart_account_from_another_book(self) -> None: """, (self.case.tenant_id, other_book_id, posting.VALID_FROM), ).fetchone()[0] - self._enable_period_closing_classification(connection) + self._acquire_period_close_authority(connection, accounting_book_id) snapshot_id = connection.execute( """ INSERT INTO accounting_reporting.trial_balance_snapshot ( @@ -221,7 +229,7 @@ def test_snapshot_line_rejects_nonconserving_net_balance(self) -> None: """, (self.case.tenant_id, accounting_book_id), ).fetchone()[0] - self._enable_period_closing_classification(connection) + self._acquire_period_close_authority(connection, accounting_book_id) snapshot_id = connection.execute( """ INSERT INTO accounting_reporting.trial_balance_snapshot ( From 91ae0327379822c10421768546a7e722529b5978 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 05:10:05 +0900 Subject: [PATCH 200/224] test(close): use real authority lock for pre-close snapshot RED --- ...test_postgres_trial_balance_snapshot_immutability_red.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/test_postgres_trial_balance_snapshot_immutability_red.py b/tests/test_postgres_trial_balance_snapshot_immutability_red.py index 18d0800e..c5b9bb8c 100644 --- a/tests/test_postgres_trial_balance_snapshot_immutability_red.py +++ b/tests/test_postgres_trial_balance_snapshot_immutability_red.py @@ -290,7 +290,11 @@ def test_preexisting_snapshot_from_closing_capability_cannot_become_hard_close_a assert scope is not None legal_entity_id, accounting_book_id, fiscal_period_id = scope connection.execute( - "SELECT set_config('accounting_core.journal_write_role', 'period_closing', true)" + "SELECT pg_advisory_xact_lock(hashtext(%s), hashtext(%s))", + ( + self.case.policy.tenant_reference, + f"period:{accounting_book_id}:2026-08", + ), ) connection.execute( """ From 9aa4e02dbb7e27ba5beb943270da0c1b9dc8c113 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 05:12:16 +0900 Subject: [PATCH 201/224] test(close): expose weak-isolation period transition --- ...ostgres_period_transition_isolation_red.py | 79 +++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 tests/test_postgres_period_transition_isolation_red.py diff --git a/tests/test_postgres_period_transition_isolation_red.py b/tests/test_postgres_period_transition_isolation_red.py new file mode 100644 index 00000000..26c0816a --- /dev/null +++ b/tests/test_postgres_period_transition_isolation_red.py @@ -0,0 +1,79 @@ +"""Real PostgreSQL RED/GREEN for period-close transition isolation authority.""" + +from __future__ import annotations + +import unittest + +import psycopg + +from tests import test_postgres_posting as posting + + +class PeriodTransitionIsolationPostgresTests(unittest.TestCase): + """Reject close-state mutation that bypasses the supported snapshot-isolation contract.""" + + @classmethod + def setUpClass(cls) -> None: + """Install the complete production migration chain used by posting tests.""" + posting.PostgresPostingTests.setUpClass() + + def setUp(self) -> None: + """Create one open book-period with its complete journal-population fence.""" + self.case = posting.PostgresPostingTests("setUp") + self.case.setUp() + self.addCleanup(self.case.doCleanups) + self.addCleanup(self.case.tearDown) + self.case.ledger.post(self.case._two_line_proposal(), self.case.policy) + + def test_read_committed_cannot_transition_book_period_close_state(self) -> None: + """A raw READ COMMITTED writer cannot claim close authority from a weak snapshot.""" + with psycopg.connect(posting.DATABASE_URL) as connection: + scope = connection.execute( + """ + SELECT accounting_book.accounting_book_id, + accounting_book_period_control.fiscal_period_id + FROM accounting_core.accounting_book + JOIN accounting_core.accounting_book_period_control + ON accounting_book_period_control.tenant_account_id + = accounting_book.tenant_account_id + AND accounting_book_period_control.accounting_book_id + = accounting_book.accounting_book_id + JOIN accounting_core.fiscal_period + ON fiscal_period.tenant_account_id + = accounting_book_period_control.tenant_account_id + AND fiscal_period.fiscal_period_id + = accounting_book_period_control.fiscal_period_id + WHERE accounting_book.tenant_account_id = %s + AND accounting_book.book_name = %s + AND fiscal_period.period_code = '2026-08' + """, + (self.case.tenant_id, self.case.policy.accounting_book_reference), + ).fetchone() + self.assertIsNotNone(scope) + assert scope is not None + accounting_book_id, fiscal_period_id = scope + isolation = connection.execute( + "SELECT current_setting('transaction_isolation')" + ).fetchone()[0] + self.assertEqual(isolation, "read committed") + + with self.assertRaisesRegex( + psycopg.errors.CheckViolation, + "period_close_isolation_required", + ): + connection.execute( + """ + UPDATE accounting_core.accounting_book_period_control + SET period_status_code = 'soft_closed', + period_closed_at = clock_timestamp() + WHERE tenant_account_id = %s + AND accounting_book_id = %s + AND fiscal_period_id = %s + """, + (self.case.tenant_id, accounting_book_id, fiscal_period_id), + ) + connection.rollback() + + +if __name__ == "__main__": + unittest.main() From ab5cd11798d06f5b769dd41d23ea7e12e5cef42c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 05:12:53 +0900 Subject: [PATCH 202/224] fix(close): require snapshot isolation for period transitions --- .../0033_open_period_journal_population_fence.sql | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/database/migrations/0033_open_period_journal_population_fence.sql b/database/migrations/0033_open_period_journal_population_fence.sql index a7bfcb42..9c437968 100644 --- a/database/migrations/0033_open_period_journal_population_fence.sql +++ b/database/migrations/0033_open_period_journal_population_fence.sql @@ -207,6 +207,12 @@ AS $$ DECLARE locked_fence_rows integer; BEGIN + IF current_setting('transaction_isolation') = 'read committed' THEN + RAISE EXCEPTION + 'period state transition requires repeatable read or serializable isolation (period_close_isolation_required)' + USING ERRCODE = 'check_violation'; + END IF; + PERFORM period_fence.fence_slot FROM accounting_core.period_journal_population_fence AS period_fence WHERE period_fence.tenant_account_id = NEW.tenant_account_id From 23e6441e691c1e44c64aecac56f96fdf0bd93ecc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 05:14:01 +0900 Subject: [PATCH 203/224] test(close): expose unbound runtime fence seeding --- ...st_open_period_fence_installer_contract.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/tests/test_open_period_fence_installer_contract.py b/tests/test_open_period_fence_installer_contract.py index 888d6182..2aceeae2 100644 --- a/tests/test_open_period_fence_installer_contract.py +++ b/tests/test_open_period_fence_installer_contract.py @@ -48,6 +48,25 @@ def test_cross_tenant_fence_backfill_precedes_force_rls(self) -> None: self.assertIn("CROSS JOIN generate_series(0, 63)", migration) self.assertIn("period_journal_population_fence_seed", migration) + def test_runtime_fence_seeder_requires_bound_tenant_while_force_rls_is_active(self) -> None: + """Runtime seeding must reject an unbound tenant before FORCE-RLS target DML.""" + migration = OPEN_PERIOD_FENCE_MIGRATION.read_text(encoding="utf-8") + start = migration.index( + "CREATE OR REPLACE FUNCTION accounting_core.seed_period_journal_population_fence()" + ) + end = migration.index( + "REVOKE ALL ON FUNCTION accounting_core.seed_period_journal_population_fence()", + start, + ) + seeder = migration[start:end] + self.assertIn("relforcerowsecurity", seeder) + self.assertIn("accounting_core.current_tenant_account_id()", seeder) + self.assertIn("period_journal_population_fence_tenant_binding_required", seeder) + self.assertLess( + seeder.index("period_journal_population_fence_tenant_binding_required"), + seeder.index("INSERT INTO accounting_core.period_journal_population_fence"), + ) + if __name__ == "__main__": unittest.main() From d1a294aa34b99bdcb71a4796c36dc74be6664502 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 05:14:38 +0900 Subject: [PATCH 204/224] fix(close): fail closed on unbound runtime fence seeding --- ...3_open_period_journal_population_fence.sql | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/database/migrations/0033_open_period_journal_population_fence.sql b/database/migrations/0033_open_period_journal_population_fence.sql index 9c437968..e3995f81 100644 --- a/database/migrations/0033_open_period_journal_population_fence.sql +++ b/database/migrations/0033_open_period_journal_population_fence.sql @@ -62,6 +62,29 @@ SECURITY DEFINER SET search_path = pg_catalog, pg_temp AS $$ BEGIN + -- Runtime seeding runs while the fence table is FORCE RLS protected and + -- therefore needs the same authenticated tenant identity as the control row. + -- Migration 0034 temporarily removes FORCE RLS only for its owner backfill; + -- keep that repair path distinct instead of minting a synthetic binding. + IF COALESCE( + ( + SELECT relation.relforcerowsecurity + FROM pg_catalog.pg_class AS relation + JOIN pg_catalog.pg_namespace AS namespace + ON namespace.oid = relation.relnamespace + WHERE namespace.nspname = 'accounting_core' + AND relation.relname = 'period_journal_population_fence' + ), + TRUE + ) + AND accounting_core.current_tenant_account_id() + IS DISTINCT FROM NEW.tenant_account_id + THEN + RAISE EXCEPTION + 'runtime tenant binding must match journal-population fence seed scope (period_journal_population_fence_tenant_binding_required)' + USING ERRCODE = 'check_violation'; + END IF; + INSERT INTO accounting_core.period_journal_population_fence ( tenant_account_id, accounting_book_id, From 4f146ad9ab67cf449acd3460b69e5a347d4dbcdd Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 05:15:43 +0900 Subject: [PATCH 205/224] test(close): reject read-uncommitted close transitions --- ...ostgres_period_transition_isolation_red.py | 95 +++++++++++-------- 1 file changed, 55 insertions(+), 40 deletions(-) diff --git a/tests/test_postgres_period_transition_isolation_red.py b/tests/test_postgres_period_transition_isolation_red.py index 26c0816a..6004b538 100644 --- a/tests/test_postgres_period_transition_isolation_red.py +++ b/tests/test_postgres_period_transition_isolation_red.py @@ -25,54 +25,69 @@ def setUp(self) -> None: self.addCleanup(self.case.tearDown) self.case.ledger.post(self.case._two_line_proposal(), self.case.policy) + def _scope(self, connection: psycopg.Connection[object]) -> tuple[object, object]: + row = connection.execute( + """ + SELECT accounting_book.accounting_book_id, + accounting_book_period_control.fiscal_period_id + FROM accounting_core.accounting_book + JOIN accounting_core.accounting_book_period_control + ON accounting_book_period_control.tenant_account_id + = accounting_book.tenant_account_id + AND accounting_book_period_control.accounting_book_id + = accounting_book.accounting_book_id + JOIN accounting_core.fiscal_period + ON fiscal_period.tenant_account_id + = accounting_book_period_control.tenant_account_id + AND fiscal_period.fiscal_period_id + = accounting_book_period_control.fiscal_period_id + WHERE accounting_book.tenant_account_id = %s + AND accounting_book.book_name = %s + AND fiscal_period.period_code = '2026-08' + """, + (self.case.tenant_id, self.case.policy.accounting_book_reference), + ).fetchone() + self.assertIsNotNone(row) + assert row is not None + return row + + def _assert_transition_rejected(self, connection: psycopg.Connection[object]) -> None: + accounting_book_id, fiscal_period_id = self._scope(connection) + with self.assertRaisesRegex( + psycopg.errors.CheckViolation, + "period_close_isolation_required", + ): + connection.execute( + """ + UPDATE accounting_core.accounting_book_period_control + SET period_status_code = 'soft_closed', + period_closed_at = clock_timestamp() + WHERE tenant_account_id = %s + AND accounting_book_id = %s + AND fiscal_period_id = %s + """, + (self.case.tenant_id, accounting_book_id, fiscal_period_id), + ) + connection.rollback() + def test_read_committed_cannot_transition_book_period_close_state(self) -> None: """A raw READ COMMITTED writer cannot claim close authority from a weak snapshot.""" with psycopg.connect(posting.DATABASE_URL) as connection: - scope = connection.execute( - """ - SELECT accounting_book.accounting_book_id, - accounting_book_period_control.fiscal_period_id - FROM accounting_core.accounting_book - JOIN accounting_core.accounting_book_period_control - ON accounting_book_period_control.tenant_account_id - = accounting_book.tenant_account_id - AND accounting_book_period_control.accounting_book_id - = accounting_book.accounting_book_id - JOIN accounting_core.fiscal_period - ON fiscal_period.tenant_account_id - = accounting_book_period_control.tenant_account_id - AND fiscal_period.fiscal_period_id - = accounting_book_period_control.fiscal_period_id - WHERE accounting_book.tenant_account_id = %s - AND accounting_book.book_name = %s - AND fiscal_period.period_code = '2026-08' - """, - (self.case.tenant_id, self.case.policy.accounting_book_reference), - ).fetchone() - self.assertIsNotNone(scope) - assert scope is not None - accounting_book_id, fiscal_period_id = scope isolation = connection.execute( "SELECT current_setting('transaction_isolation')" ).fetchone()[0] self.assertEqual(isolation, "read committed") + self._assert_transition_rejected(connection) - with self.assertRaisesRegex( - psycopg.errors.CheckViolation, - "period_close_isolation_required", - ): - connection.execute( - """ - UPDATE accounting_core.accounting_book_period_control - SET period_status_code = 'soft_closed', - period_closed_at = clock_timestamp() - WHERE tenant_account_id = %s - AND accounting_book_id = %s - AND fiscal_period_id = %s - """, - (self.case.tenant_id, accounting_book_id, fiscal_period_id), - ) - connection.rollback() + def test_read_uncommitted_alias_cannot_transition_book_period_close_state(self) -> None: + """PostgreSQL's weak READ UNCOMMITTED alias cannot bypass the close-isolation gate.""" + with psycopg.connect(posting.DATABASE_URL) as connection: + connection.execute("SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED") + isolation = connection.execute( + "SELECT current_setting('transaction_isolation')" + ).fetchone()[0] + self.assertEqual(isolation, "read uncommitted") + self._assert_transition_rejected(connection) if __name__ == "__main__": From 0d0077bbec4afc9d54bb8b4838e5cf84dd9f4473 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 05:17:39 +0900 Subject: [PATCH 206/224] fix(close): allow only strong transition isolation --- .../migrations/0033_open_period_journal_population_fence.sql | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/database/migrations/0033_open_period_journal_population_fence.sql b/database/migrations/0033_open_period_journal_population_fence.sql index e3995f81..b92e3dd7 100644 --- a/database/migrations/0033_open_period_journal_population_fence.sql +++ b/database/migrations/0033_open_period_journal_population_fence.sql @@ -230,7 +230,9 @@ AS $$ DECLARE locked_fence_rows integer; BEGIN - IF current_setting('transaction_isolation') = 'read committed' THEN + IF current_setting('transaction_isolation') + NOT IN ('repeatable read', 'serializable') + THEN RAISE EXCEPTION 'period state transition requires repeatable read or serializable isolation (period_close_isolation_required)' USING ERRCODE = 'check_violation'; From bd1772bb06d380a3a623596e880105234cf7fb1c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 05:20:15 +0900 Subject: [PATCH 207/224] test(close): preserve PostgreSQL RLS-bypass seeding authority --- ...st_open_period_fence_installer_contract.py | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/tests/test_open_period_fence_installer_contract.py b/tests/test_open_period_fence_installer_contract.py index 2aceeae2..e7b1db67 100644 --- a/tests/test_open_period_fence_installer_contract.py +++ b/tests/test_open_period_fence_installer_contract.py @@ -67,6 +67,26 @@ def test_runtime_fence_seeder_requires_bound_tenant_while_force_rls_is_active(se seeder.index("INSERT INTO accounting_core.period_journal_population_fence"), ) + def test_runtime_binding_guard_preserves_database_roles_that_bypass_rls(self) -> None: + """The explicit guard must not be stricter than PostgreSQL's own RLS bypass semantics.""" + migration = OPEN_PERIOD_FENCE_MIGRATION.read_text(encoding="utf-8") + start = migration.index( + "CREATE OR REPLACE FUNCTION accounting_core.seed_period_journal_population_fence()" + ) + end = migration.index( + "REVOKE ALL ON FUNCTION accounting_core.seed_period_journal_population_fence()", + start, + ) + seeder = migration[start:end] + self.assertIn("pg_catalog.pg_roles", seeder) + self.assertIn("rolsuper", seeder) + self.assertIn("rolbypassrls", seeder) + self.assertIn("session_user", seeder) + self.assertLess( + seeder.index("rolbypassrls"), + seeder.index("accounting_core.current_tenant_account_id()"), + ) + if __name__ == "__main__": unittest.main() From 66867a847ca499721f5a979251026a55359f7244 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 05:20:37 +0900 Subject: [PATCH 208/224] test(close): align RLS bypass check with effective role --- tests/test_open_period_fence_installer_contract.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/test_open_period_fence_installer_contract.py b/tests/test_open_period_fence_installer_contract.py index e7b1db67..f2b9de39 100644 --- a/tests/test_open_period_fence_installer_contract.py +++ b/tests/test_open_period_fence_installer_contract.py @@ -67,8 +67,8 @@ def test_runtime_fence_seeder_requires_bound_tenant_while_force_rls_is_active(se seeder.index("INSERT INTO accounting_core.period_journal_population_fence"), ) - def test_runtime_binding_guard_preserves_database_roles_that_bypass_rls(self) -> None: - """The explicit guard must not be stricter than PostgreSQL's own RLS bypass semantics.""" + def test_runtime_binding_guard_preserves_effective_roles_that_bypass_rls(self) -> None: + """The explicit guard must not be stricter than PostgreSQL's effective-role RLS rules.""" migration = OPEN_PERIOD_FENCE_MIGRATION.read_text(encoding="utf-8") start = migration.index( "CREATE OR REPLACE FUNCTION accounting_core.seed_period_journal_population_fence()" @@ -81,7 +81,7 @@ def test_runtime_binding_guard_preserves_database_roles_that_bypass_rls(self) -> self.assertIn("pg_catalog.pg_roles", seeder) self.assertIn("rolsuper", seeder) self.assertIn("rolbypassrls", seeder) - self.assertIn("session_user", seeder) + self.assertIn("current_user", seeder) self.assertLess( seeder.index("rolbypassrls"), seeder.index("accounting_core.current_tenant_account_id()"), From ec4d2c3583988b5bcb2458cd2d27f4050f2d1f0f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 05:21:12 +0900 Subject: [PATCH 209/224] fix(close): preserve effective RLS bypass during fence seeding --- ...3_open_period_journal_population_fence.sql | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/database/migrations/0033_open_period_journal_population_fence.sql b/database/migrations/0033_open_period_journal_population_fence.sql index b92e3dd7..dcb7c9e7 100644 --- a/database/migrations/0033_open_period_journal_population_fence.sql +++ b/database/migrations/0033_open_period_journal_population_fence.sql @@ -61,11 +61,23 @@ LANGUAGE plpgsql SECURITY DEFINER SET search_path = pg_catalog, pg_temp AS $$ +DECLARE + effective_role_bypasses_rls boolean; BEGIN + -- SECURITY DEFINER changes current_user to the function owner. RLS bypass is + -- evaluated for that effective role, while current_tenant_account_id() uses + -- the original session_user binding. Do not make this explicit guard stricter + -- than PostgreSQL itself for superuser/BYPASSRLS migration operators. + SELECT role.rolsuper OR role.rolbypassrls + INTO effective_role_bypasses_rls + FROM pg_catalog.pg_roles AS role + WHERE role.rolname = current_user; + -- Runtime seeding runs while the fence table is FORCE RLS protected and - -- therefore needs the same authenticated tenant identity as the control row. - -- Migration 0034 temporarily removes FORCE RLS only for its owner backfill; - -- keep that repair path distinct instead of minting a synthetic binding. + -- therefore needs the same authenticated tenant identity as the control row + -- whenever the effective function owner cannot bypass RLS. Migration 0034 + -- temporarily removes FORCE RLS for its owner backfill; keep that repair path + -- distinct instead of minting a synthetic runtime tenant binding. IF COALESCE( ( SELECT relation.relforcerowsecurity @@ -77,6 +89,7 @@ BEGIN ), TRUE ) + AND NOT COALESCE(effective_role_bypasses_rls, FALSE) AND accounting_core.current_tenant_account_id() IS DISTINCT FROM NEW.tenant_account_id THEN From 0602fc599e067e3c046ab84135fc0edf7c423472 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 05:21:45 +0900 Subject: [PATCH 210/224] docs(close): trace transition and fence authority repairs --- ...CLOSE_TRANSITION_AUTHORITY_TRACEABILITY.md | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 docs/doctoring/PERIOD_CLOSE_TRANSITION_AUTHORITY_TRACEABILITY.md diff --git a/docs/doctoring/PERIOD_CLOSE_TRANSITION_AUTHORITY_TRACEABILITY.md b/docs/doctoring/PERIOD_CLOSE_TRANSITION_AUTHORITY_TRACEABILITY.md new file mode 100644 index 00000000..3559cb92 --- /dev/null +++ b/docs/doctoring/PERIOD_CLOSE_TRANSITION_AUTHORITY_TRACEABILITY.md @@ -0,0 +1,56 @@ +# Period Close transition authority traceability + +## Decision scope + +This note traces two database-authority repairs on the Period Close path: transaction-isolation admission for book-period state transitions and tenant-bound runtime creation of the pre-existing journal-population fence. These are Accounting Information Platform implementation controls. They do not define IFRS accounting policy, grant Billing authority over accounting facts, or make PostgreSQL locking/RLS primitives accounting standards. + +The authoritative accounting objects remain `accounting_book_period_control`, the immutable journal population, and retained close evidence. `fiscal_period` remains shared calendar/master data rather than a substitute book-level close authority. + +## Problem and constraints + +A close transition derives evidence under a transaction snapshot and then changes `accounting_book_period_control.period_status_code`. Migration 0033 already locks all 64 pre-existing `period_journal_population_fence` rows before a state change, but that mechanism depends on a snapshot isolation level that can detect a row version committed after the close snapshot. A raw `READ COMMITTED` state update can instead wait for the newest fence row and continue without the `40001` retry boundary expected by the supported close command. + +Separately, runtime insertion of a new book-period control synchronously invokes `seed_period_journal_population_fence()`. The target fence table is `FORCE ROW LEVEL SECURITY`. Its policy is tenant-scoped through `accounting_core.current_tenant_account_id()`, which resolves the original `session_user` through `runtime_tenant_binding`. Runtime callers whose effective database role is actually subject to RLS therefore need a matching tenant binding before target DML. Migration 0034's one-time owner repair is intentionally different: it temporarily removes FORCE RLS while backfilling canonical open book-period intersections and restores FORCE RLS afterward. + +`seed_period_journal_population_fence()` is `SECURITY DEFINER`, so PostgreSQL evaluates effective-role privileges with the function owner's `current_user`, while the tenant identity function deliberately derives its tenant from `session_user`. The explicit precondition must preserve PostgreSQL's own superuser/BYPASSRLS semantics; otherwise a diagnostic guard becomes stricter than the database operation it is meant to explain. + +## Alternatives and decisions + +| Concern | Selected control | Rejected alternative | Reason | +|---|---|---|---| +| Period transition isolation | Allow only `REPEATABLE READ` or `SERIALIZABLE` before any fence locking/state transition | Reject only the literal `read committed` string | PostgreSQL accepts `READ UNCOMMITTED` syntax but provides READ COMMITTED semantics; an allow-list fails closed for every weaker/unknown isolation level | +| Freshness witness | Preserve deterministic `FOR UPDATE` over all 64 pre-existing fence rows and the exact 64-row count check | Replace the fence with one shared revision row or remove it after adding the isolation check | Isolation and a pre-existing version witness solve different parts of the stale-close race; one shared row would reintroduce a posting hotspot | +| Runtime tenant precondition | When FORCE RLS is active and the effective function owner cannot bypass RLS, require `current_tenant_account_id()` to equal `NEW.tenant_account_id` before fence INSERT | Let an unbound RLS-subject runtime caller fall through to an opaque `WITH CHECK` failure | The explicit failure identifies the missing authority without weakening tenant isolation or synthesizing identity | +| Privileged/migration execution | Preserve superuser/BYPASSRLS effective-role behavior and migration 0034's temporary NO-FORCE owner backfill | Require runtime binding even when PostgreSQL itself would bypass RLS | A diagnostic precondition must not silently redefine PostgreSQL privilege semantics or break canonical install/test operators | +| Tenant identity | Continue using database-owned `runtime_tenant_binding`; do not revive caller-set tenant GUCs as authority | Mint an `app.tenant_account_id` or similar GUC inside the seeder | Caller-controlled context must not become accounting tenant authority | + +## TDD and repair lineage + +- `c5adb003590880730d5e67a528312a05f6ce15fb`: corrected the snapshot concurrency RED to acquire the actual tenant/book/period close advisory lock rather than a non-authoritative GUC. +- `a1d96d91546b82fef1593b3e8026dd5d301b169e`, `e91f783f7665acf7737c65e3dedee4431e3ecf0c`, `91ae0327379822c10421768546a7e722529b5978`: applied the same real snapshot-authority setup to currency, scope, and pre-close immutability REDs. +- `9aa4e02dbb7e27ba5beb943270da0c1b9dc8c113`: real PostgreSQL RED for a raw weak-isolation book-period state transition. +- `ab5cd11798d06f5b769dd41d23ea7e12e5cef42c`: first causal isolation repair, rejecting `READ COMMITTED` before fence locking. +- `4f146ad9ab67cf449acd3460b69e5a347d4dbcdd`: added the PostgreSQL `READ UNCOMMITTED` alias edge case, making the literal-only guard RED again. +- `0d0077bbec4afc9d54bb8b4838e5cf84dd9f4473`: replaced the blacklist with the fail-closed `REPEATABLE READ`/`SERIALIZABLE` allow-list. +- `23e6441e691c1e44c64aecac56f96fdf0bd93ecc`: static RED for an explicit runtime tenant-binding precondition before FORCE-RLS fence DML. +- `d1a294aa34b99bdcb71a4796c36dc74be6664502`: initial explicit binding guard. +- Source review then found that the initial guard was stricter than PostgreSQL for an effective superuser/BYPASSRLS `SECURITY DEFINER` owner. `bd1772bb06d380a3a623596e880105234cf7fb1c` and `66867a847ca499721f5a979251026a55359f7244` ratcheted the required effective-role distinction without rewriting history. +- `ec4d2c3583988b5bcb2458cd2d27f4050f2d1f0f`: current causal repair. It checks `pg_roles.rolsuper/rolbypassrls` for `current_user`, preserves PostgreSQL's effective-role RLS bypass semantics, and requires the `session_user` tenant binding only when the effective role is actually subject to FORCE RLS. + +These SHAs are development lineage, not release evidence. A successor head does not inherit GREEN from a predecessor. + +## Remaining acceptance and risk + +The exact current descendant must still run the real PostgreSQL behavior suite, complete statement/branch coverage, security/SAST/dependency checks, and central required workflows. A queued or runner-less run is not GREEN. + +This repair also does not close the separate historical-account-identity RED in `_post_closing_journal()`, the Reporting-package veto coupling on hard close, or the retained snapshot hash identity gap. Those findings remain independent and must not be hidden by declaring Period Close complete. + +If test execution is later parallelized, any migration-upgrade test that temporarily removes a database trigger must move to a dedicated database or an explicitly serialized lane. The current canonical Accounting Foundation workflow executes `unittest discover` serially against a job-local PostgreSQL service, so that future condition is not currently satisfied. + +## References + +PostgreSQL Global Development Group. (2026). *PostgreSQL 18 documentation: Row security policies*. https://www.postgresql.org/docs/18/ddl-rowsecurity.html + +PostgreSQL Global Development Group. (2026). *PostgreSQL 18 documentation: Transaction isolation*. https://www.postgresql.org/docs/18/transaction-iso.html + +PostgreSQL Global Development Group. (2026). *PostgreSQL 18 documentation: Explicit locking*. https://www.postgresql.org/docs/18/explicit-locking.html From 5cb9cfb1ad762cc4878c0bb8c87cd9c8f7a80f20 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 05:25:26 +0900 Subject: [PATCH 211/224] test(close): release paused open-post thread on failure --- tests/test_postgres_open_period_journal_fence.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/test_postgres_open_period_journal_fence.py b/tests/test_postgres_open_period_journal_fence.py index 566b3333..2fe04570 100644 --- a/tests/test_postgres_open_period_journal_fence.py +++ b/tests/test_postgres_open_period_journal_fence.py @@ -130,6 +130,7 @@ def test_open_period_postings_do_not_serialize_on_application_period_lock(self) ) first_period_admission_reached = threading.Event() release_first_post = threading.Event() + self.addCleanup(release_first_post.set) second_post_finished = threading.Event() first_errors: list[BaseException] = [] second_errors: list[BaseException] = [] @@ -202,4 +203,4 @@ def post_second() -> None: if __name__ == "__main__": - unittest.main() + unittest.main() \ No newline at end of file From 8cc006683ad98b89a98d70754c39777c9397dcf8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 05:26:10 +0900 Subject: [PATCH 212/224] test(close): release paused close thread on failure --- tests/test_postgres_open_period_close_serialization_red.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_postgres_open_period_close_serialization_red.py b/tests/test_postgres_open_period_close_serialization_red.py index aacb6cb8..4e82af48 100644 --- a/tests/test_postgres_open_period_close_serialization_red.py +++ b/tests/test_postgres_open_period_close_serialization_red.py @@ -149,6 +149,7 @@ def test_open_period_journal_committed_after_close_snapshot_invalidates_stale_cl close_idempotency_key = f"{self.case.policy.tenant_reference}:open-close-race:hard" pre_period_row_lock_reached = threading.Event() concurrent_post_committed = threading.Event() + self.addCleanup(concurrent_post_committed.set) close_result: list[object] = [] close_errors: list[BaseException] = [] original_lock_book_period = close_ledger._lock_book_period From 61d82895acbcdd73207c3b78ecee46c070a00a36 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 05:26:43 +0900 Subject: [PATCH 213/224] test(close): release paused adjusting thread on failure --- tests/test_postgres_period_close_journal_serialization_red.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/test_postgres_period_close_journal_serialization_red.py b/tests/test_postgres_period_close_journal_serialization_red.py index 37f1eef3..6b1a3a9c 100644 --- a/tests/test_postgres_period_close_journal_serialization_red.py +++ b/tests/test_postgres_period_close_journal_serialization_red.py @@ -50,6 +50,7 @@ def test_hard_close_cannot_freeze_a_snapshot_before_an_admitted_adjustment_commi close_idempotency_key = f"{self.case.policy.tenant_reference}:close-race:hard" pre_lock_snapshot_reached = threading.Event() adjustment_committed = threading.Event() + self.addCleanup(adjustment_committed.set) close_result: list[object] = [] close_errors: list[BaseException] = [] original_acquire = close_ledger._acquire_command_lock @@ -229,4 +230,4 @@ def run_close() -> None: if __name__ == "__main__": - unittest.main() + unittest.main() \ No newline at end of file From efc191611edc8f450ac7a58023d10075d8542f93 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 05:34:18 +0900 Subject: [PATCH 214/224] test(close): require soft-close command evidence pair --- ...es_soft_close_command_evidence_pair_red.py | 172 ++++++++++++++++++ 1 file changed, 172 insertions(+) create mode 100644 tests/test_postgres_soft_close_command_evidence_pair_red.py diff --git a/tests/test_postgres_soft_close_command_evidence_pair_red.py b/tests/test_postgres_soft_close_command_evidence_pair_red.py new file mode 100644 index 00000000..61dbc104 --- /dev/null +++ b/tests/test_postgres_soft_close_command_evidence_pair_red.py @@ -0,0 +1,172 @@ +"""Real PostgreSQL RED/GREEN for the soft-close command-evidence commit pair.""" + +from __future__ import annotations + +from pathlib import Path +import unittest + +import psycopg + +from tests import test_postgres_posting as posting + + +ROOT = Path(__file__).resolve().parents[1] +PAIR_MIGRATION = ROOT / "database/migrations/0037_soft_close_command_evidence_pair.sql" + + +class SoftCloseCommandEvidencePairPostgresTests(unittest.TestCase): + """A soft-closed book period may commit only with its durable command evidence.""" + + @classmethod + def setUpClass(cls) -> None: + """Install the complete current accounting migration chain.""" + posting.PostgresPostingTests.setUpClass() + + def setUp(self) -> None: + """Create one governed open book period with its complete population fence.""" + self.case = posting.PostgresPostingTests("setUp") + self.case.setUp() + self.addCleanup(self.case.doCleanups) + self.addCleanup(self.case.tearDown) + self.case.ledger.post(self.case._two_line_proposal(), self.case.policy) + + def _scope(self, connection: psycopg.Connection[object]) -> tuple[object, object]: + row = connection.execute( + """ + SELECT accounting_book.accounting_book_id, + accounting_book_period_control.fiscal_period_id + FROM accounting_core.accounting_book + JOIN accounting_core.accounting_book_period_control + ON accounting_book_period_control.tenant_account_id + = accounting_book.tenant_account_id + AND accounting_book_period_control.accounting_book_id + = accounting_book.accounting_book_id + JOIN accounting_core.fiscal_period + ON fiscal_period.tenant_account_id + = accounting_book_period_control.tenant_account_id + AND fiscal_period.fiscal_period_id + = accounting_book_period_control.fiscal_period_id + WHERE accounting_book.tenant_account_id = %s + AND accounting_book.book_name = %s + AND fiscal_period.period_code = '2026-08' + """, + (self.case.tenant_id, self.case.policy.accounting_book_reference), + ).fetchone() + self.assertIsNotNone(row) + assert row is not None + return row[0], row[1] + + def test_soft_close_cannot_commit_without_durable_command_evidence(self) -> None: + """A raw database writer cannot retain soft-close authority without command evidence.""" + with psycopg.connect(posting.DATABASE_URL) as connection: + connection.execute("SET TRANSACTION ISOLATION LEVEL REPEATABLE READ") + accounting_book_id, fiscal_period_id = self._scope(connection) + connection.execute( + """ + UPDATE accounting_core.accounting_book_period_control + SET period_status_code = 'soft_closed', + period_closed_at = clock_timestamp() + WHERE tenant_account_id = %s + AND accounting_book_id = %s + AND fiscal_period_id = %s + """, + (self.case.tenant_id, accounting_book_id, fiscal_period_id), + ) + + with self.assertRaisesRegex( + psycopg.errors.CheckViolation, + "soft_close_command_evidence_pair_required", + ): + connection.commit() + connection.rollback() + + with psycopg.connect(posting.DATABASE_URL) as connection: + accounting_book_id, fiscal_period_id = self._scope(connection) + row = connection.execute( + """ + SELECT period_status_code, + soft_close_idempotency_key, + soft_close_source_payload_hash, + soft_close_source_journal_count + FROM accounting_core.accounting_book_period_control + WHERE tenant_account_id = %s + AND accounting_book_id = %s + AND fiscal_period_id = %s + """, + (self.case.tenant_id, accounting_book_id, fiscal_period_id), + ).fetchone() + + self.assertEqual(row, ("open", None, None, None)) + + def test_upgrade_refuses_preexisting_soft_close_without_command_evidence(self) -> None: + """Migration 0037 may not grandfather a one-sided soft-close authority fact.""" + migration_sql = PAIR_MIGRATION.read_text(encoding="utf-8") + + with psycopg.connect( + posting.DATABASE_URL, + autocommit=True, + cursor_factory=psycopg.ClientCursor, + ) as connection: + accounting_book_id, fiscal_period_id = self._scope(connection) + connection.execute( + """ + DROP TRIGGER soft_close_command_evidence_pair_guard + ON accounting_core.accounting_book_period_control + """ + ) + connection.execute("BEGIN ISOLATION LEVEL REPEATABLE READ") + connection.execute( + """ + UPDATE accounting_core.accounting_book_period_control + SET period_status_code = 'soft_closed', + period_closed_at = clock_timestamp() + WHERE tenant_account_id = %s + AND accounting_book_id = %s + AND fiscal_period_id = %s + """, + (self.case.tenant_id, accounting_book_id, fiscal_period_id), + ) + connection.execute("COMMIT") + + try: + with psycopg.connect( + posting.DATABASE_URL, + autocommit=True, + cursor_factory=psycopg.ClientCursor, + ) as connection: + with self.assertRaisesRegex( + psycopg.errors.CheckViolation, + "soft_close_command_evidence_pair_legacy_preflight", + ): + connection.execute(migration_sql) + connection.execute("ROLLBACK") + finally: + with psycopg.connect( + posting.DATABASE_URL, + autocommit=True, + cursor_factory=psycopg.ClientCursor, + ) as connection: + connection.execute( + """ + DROP TRIGGER IF EXISTS soft_close_command_evidence_pair_guard + ON accounting_core.accounting_book_period_control + """ + ) + connection.execute("BEGIN ISOLATION LEVEL REPEATABLE READ") + connection.execute( + """ + UPDATE accounting_core.accounting_book_period_control + SET period_status_code = 'open', + period_closed_at = NULL + WHERE tenant_account_id = %s + AND accounting_book_id = %s + AND fiscal_period_id = %s + """, + (self.case.tenant_id, accounting_book_id, fiscal_period_id), + ) + connection.execute("COMMIT") + connection.execute(migration_sql) + + +if __name__ == "__main__": + unittest.main() From 7279f05a6c1d067c25b78b8df10e5b7b99acad0e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 05:34:36 +0900 Subject: [PATCH 215/224] fix(close): bind soft-close state to command evidence --- .../0037_soft_close_command_evidence_pair.sql | 92 +++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100644 database/migrations/0037_soft_close_command_evidence_pair.sql diff --git a/database/migrations/0037_soft_close_command_evidence_pair.sql b/database/migrations/0037_soft_close_command_evidence_pair.sql new file mode 100644 index 00000000..98768ab0 --- /dev/null +++ b/database/migrations/0037_soft_close_command_evidence_pair.sql @@ -0,0 +1,92 @@ +BEGIN; + +-- Migration 0010 made the three soft-close command-evidence fields internally +-- all-or-none, but it did not bind that evidence to the authoritative +-- accounting_book_period_control status. Migration 0009 can therefore leave a +-- legacy soft_closed control that was projected from fiscal_period before the +-- per-book command-evidence boundary existed. Do not fabricate evidence from a +-- later ledger state or silently reopen an authoritative close fact. Fail the +-- upgrade so remediation can be audited against the original close command. +-- +-- accounting_book_period_control is FORCE RLS. Give only the migration role a +-- transaction-scoped permissive SELECT policy for the preflight, then remove it +-- before the durable trigger is installed. This does not grant runtime DML or +-- weaken tenant isolation after commit. +CREATE POLICY soft_close_evidence_pair_upgrade_visibility + ON accounting_core.accounting_book_period_control + FOR SELECT + TO current_user + USING (true); + +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM accounting_core.accounting_book_period_control AS period_control + WHERE period_control.period_status_code = 'soft_closed' + AND ( + period_control.soft_close_idempotency_key IS NULL + OR period_control.soft_close_source_payload_hash IS NULL + OR period_control.soft_close_source_journal_count IS NULL + ) + ) THEN + RAISE EXCEPTION + 'pre-0037 soft-closed book-period authority has no complete durable command evidence; perform audited remediation before migration 0037 (soft_close_command_evidence_pair_legacy_preflight)' + USING ERRCODE = '23514'; + END IF; +END; +$$; + +DROP POLICY soft_close_evidence_pair_upgrade_visibility + ON accounting_core.accounting_book_period_control; + +-- The supported soft-close command intentionally performs the status transition +-- and evidence write as two statements in one transaction. A deferred trigger +-- must therefore inspect the final retained row at commit rather than trust the +-- NEW image captured by the first UPDATE. This preserves that atomic command +-- while rejecting any transaction that commits soft_closed without all three +-- immutable evidence fields. +CREATE OR REPLACE FUNCTION accounting_core.require_soft_close_command_evidence_pair() +RETURNS trigger +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = pg_catalog, pg_temp +AS $$ +DECLARE + evidence_complete boolean; +BEGIN + SELECT period_control.soft_close_idempotency_key IS NOT NULL + AND period_control.soft_close_source_payload_hash IS NOT NULL + AND period_control.soft_close_source_journal_count IS NOT NULL + INTO evidence_complete + FROM accounting_core.accounting_book_period_control AS period_control + WHERE period_control.tenant_account_id = NEW.tenant_account_id + AND period_control.accounting_book_id = NEW.accounting_book_id + AND period_control.fiscal_period_id = NEW.fiscal_period_id + AND period_control.period_status_code = 'soft_closed'; + + IF NOT COALESCE(evidence_complete, FALSE) THEN + RAISE EXCEPTION + 'soft-closed book-period authority must commit with durable command evidence (soft_close_command_evidence_pair_required)' + USING ERRCODE = 'check_violation'; + END IF; + + RETURN NULL; +END; +$$; + +REVOKE ALL ON FUNCTION accounting_core.require_soft_close_command_evidence_pair() + FROM PUBLIC; + +CREATE CONSTRAINT TRIGGER soft_close_command_evidence_pair_guard + AFTER UPDATE OF period_status_code + ON accounting_core.accounting_book_period_control + DEFERRABLE INITIALLY DEFERRED + FOR EACH ROW + WHEN ( + OLD.period_status_code IS DISTINCT FROM 'soft_closed' + AND NEW.period_status_code = 'soft_closed' + ) + EXECUTE FUNCTION accounting_core.require_soft_close_command_evidence_pair(); + +COMMIT; From ce91cc19339fb8bfba5fd5b9698b321c59cf5c9e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 05:34:54 +0900 Subject: [PATCH 216/224] fix(close): install soft-close evidence pair migration --- src/accounting_information_platform/migration_install.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/accounting_information_platform/migration_install.py b/src/accounting_information_platform/migration_install.py index 2b73564b..034f18b1 100644 --- a/src/accounting_information_platform/migration_install.py +++ b/src/accounting_information_platform/migration_install.py @@ -33,6 +33,7 @@ def apply_foundation_migration(database_url: str, migration_path: Path) -> None: migration_path.parent / "0034_book_period_control_seed.sql", migration_path.parent / "0035_trial_balance_snapshot_hard_close_pair.sql", migration_path.parent / "0036_hard_close_trial_balance_snapshot_pair.sql", + migration_path.parent / "0037_soft_close_command_evidence_pair.sql", ) for forward_migration_path in forward_migration_paths: if not forward_migration_path.is_file(): From c594c78bcfaeca5c2029a21e3ef0b581d6e15fa5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 05:38:01 +0900 Subject: [PATCH 217/224] test(close): ratchet soft-close evidence authority --- ...ft_close_command_evidence_pair_contract.py | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 tests/test_soft_close_command_evidence_pair_contract.py diff --git a/tests/test_soft_close_command_evidence_pair_contract.py b/tests/test_soft_close_command_evidence_pair_contract.py new file mode 100644 index 00000000..502bafc3 --- /dev/null +++ b/tests/test_soft_close_command_evidence_pair_contract.py @@ -0,0 +1,61 @@ +"""Static contracts for the soft-close authority/evidence commit pair.""" + +from __future__ import annotations + +import ast +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +MIGRATION = ROOT / "database/migrations/0037_soft_close_command_evidence_pair.sql" +INSTALLER = ROOT / "src/accounting_information_platform/migration_install.py" + + +def test_soft_close_pair_migration_is_deferred_fail_closed_and_upgrade_safe() -> None: + """Soft-close state must commit with complete evidence without fabricating legacy facts.""" + sql = MIGRATION.read_text(encoding="utf-8") + + assert "soft_close_command_evidence_pair_legacy_preflight" in sql + assert "soft_close_command_evidence_pair_required" in sql + assert "DEFERRABLE INITIALLY DEFERRED" in sql + assert "AFTER UPDATE OF period_status_code" in sql + assert "period_control.period_status_code = 'soft_closed'" in sql + assert "period_control.soft_close_idempotency_key IS NOT NULL" in sql + assert "period_control.soft_close_source_payload_hash IS NOT NULL" in sql + assert "period_control.soft_close_source_journal_count IS NOT NULL" in sql + assert "FOR SELECT\n TO current_user\n USING (true)" in sql + assert "DROP POLICY soft_close_evidence_pair_upgrade_visibility" in sql + assert ( + "REVOKE ALL ON FUNCTION accounting_core.require_soft_close_command_evidence_pair()\n" + " FROM PUBLIC;" + ) in sql + + +def test_canonical_installer_places_soft_close_pair_after_hard_close_pair() -> None: + """Supported installs may not stop before the soft-close authority/evidence guard.""" + module = ast.parse(INSTALLER.read_text(encoding="utf-8")) + migration_names: tuple[str, ...] | None = None + + for node in ast.walk(module): + if not isinstance(node, ast.Assign): + continue + if not any( + isinstance(target, ast.Name) and target.id == "forward_migration_paths" + for target in node.targets + ): + continue + if not isinstance(node.value, ast.Tuple): + continue + names: list[str] = [] + for element in node.value.elts: + assert isinstance(element, ast.BinOp) and isinstance(element.op, ast.Div) + assert isinstance(element.right, ast.Constant) + assert isinstance(element.right.value, str) + names.append(element.right.value) + migration_names = tuple(names) + break + + assert migration_names is not None + hard_close_index = migration_names.index("0036_hard_close_trial_balance_snapshot_pair.sql") + soft_close_index = migration_names.index("0037_soft_close_command_evidence_pair.sql") + assert soft_close_index == hard_close_index + 1 From 38076ba3fe670b5ba0ba777ee3a0bdf96d19265c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 05:38:21 +0900 Subject: [PATCH 218/224] docs(close): trace soft-close evidence pair --- ...LOSE_COMMAND_EVIDENCE_PAIR_TRACEABILITY.md | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 docs/doctoring/SOFT_CLOSE_COMMAND_EVIDENCE_PAIR_TRACEABILITY.md diff --git a/docs/doctoring/SOFT_CLOSE_COMMAND_EVIDENCE_PAIR_TRACEABILITY.md b/docs/doctoring/SOFT_CLOSE_COMMAND_EVIDENCE_PAIR_TRACEABILITY.md new file mode 100644 index 00000000..11b57a9f --- /dev/null +++ b/docs/doctoring/SOFT_CLOSE_COMMAND_EVIDENCE_PAIR_TRACEABILITY.md @@ -0,0 +1,46 @@ +# Soft-close command-evidence pair traceability + +## Problem + +`accounting_book_period_control` is the authoritative per-book period state. Migration 0010 made the three soft-close evidence fields internally all-or-none, but it did not require a row whose `period_status_code` is `soft_closed` to retain that evidence. Migration 0009 can also predate that evidence model and copy a shared fiscal-period `soft_closed` projection into a book-period control row. The result is a one-sided authoritative close fact that the application later refuses to replay because the original command identity, source hash, and source journal count are absent. + +This is an Accounting Information Platform DDD/database integrity control. IFRS Accounting Standards do not prescribe this PostgreSQL trigger design. + +## Constraints + +The supported soft-close command changes the exact book-period status and writes `soft_close_idempotency_key`, `soft_close_source_payload_hash`, and `soft_close_source_journal_count` as two statements inside one transaction. A valid repair therefore cannot require all evidence on the first statement image. It must inspect the final retained row at commit. + +Historical evidence must not be invented from the current ledger population. An unverifiable pre-migration `soft_closed` row must not be silently reopened either; that would rewrite an authoritative accounting-control fact without proving the original decision. + +The repair must preserve tenant FORCE RLS, the existing 64-row journal-population freshness fence, exact hard-close/snapshot pairing, ordinary journal admission, and the application-owned idempotent soft-close command. + +## Alternatives considered + +- **Reset legacy incomplete rows to `open`.** Rejected because it rewrites an existing close state without proving that reopening was authorized. +- **Derive missing evidence from the later journal population.** Rejected because later state cannot prove the original command identity or source population observed when soft-close occurred. +- **Immediate `BEFORE`/`AFTER` validation of the transition row image.** Rejected because the canonical command intentionally writes state and evidence in separate statements of one transaction. +- **Caller-controlled GUC or application-only validation.** Rejected because it would weaken the database single-writer/fail-closed boundary and leave direct SQL capable of retaining an incomplete authority fact. + +## Decision + +Migration `0037_soft_close_command_evidence_pair.sql` adds two controls: + +1. An upgrade preflight temporarily grants only the migration role all-tenant SELECT visibility on the FORCE-RLS control relation and fails with `soft_close_command_evidence_pair_legacy_preflight` when any pre-existing `soft_closed` row lacks one of the three durable evidence values. The temporary policy is removed before durable trigger installation. +2. A `DEFERRABLE INITIALLY DEFERRED` constraint trigger fires on transitions into `soft_closed`. At commit it re-reads the exact tenant/book/period row and requires the final retained state to contain the complete command-evidence triplet. Incomplete state fails with `soft_close_command_evidence_pair_required` and rolls the transaction back. + +The trigger function is `SECURITY DEFINER` with `search_path = pg_catalog, pg_temp`; PUBLIC execute is revoked. It grants no posting, reopening, hard-close, reporting, Billing, or policy authority. + +## RED → repair → ratchet + +- RED `efc191611edc8f450ac7a58023d10075d8542f93` adds a real PostgreSQL case for raw `open -> soft_closed` without command evidence and an upgrade case for a pre-existing one-sided soft-close fact. +- Repair `7279f05a6c1d067c25b78b8df10e5b7b99acad0e` adds migration 0037 with the legacy preflight and deferred final-row pair guard. +- Installer `ce91cc19339fb8bfba5fd5b9698b321c59cf5c9e` appends 0037 after the existing 0036 hard-close/snapshot pair migration. +- Static ratchet `c594c78bcfaeca5c2029a21e3ef0b581d6e15fa5` requires the fail-closed markers, deferred semantics, temporary-policy cleanup, PUBLIC revoke, and exact installer order. + +Hosted exact-head execution evidence is separate from this source lineage. A predecessor run does not prove the current head. + +## Recovery and release effect + +If upgrade preflight finds a legacy one-sided soft-close row, stop the migration and retain the prior release/database. Inventory the exact tenant/book/period control and original command/audit/outbox evidence. Proceed only through a separately reviewed audited remediation that can prove the original soft-close command identity and source population. Do not synthesize the hash/count from current balances and do not flip the row to `open` merely to pass migration 0037. + +Release evidence must show migration 0037 installs under the production-like non-superuser/non-`BYPASSRLS` migration owner, the canonical soft-close command still commits state and evidence atomically, direct incomplete soft-close rolls back, the legacy preflight is atomic, and no temporary migration policy survives either successful or aborted installation. From 2b24fe3230d98aec64deb08fc181eee156fea377 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 06:03:37 +0900 Subject: [PATCH 219/224] docs(ops): add soft-close evidence-pair recovery --- docs/OPERABILITY.md | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/docs/OPERABILITY.md b/docs/OPERABILITY.md index 82262514..209f12a7 100644 --- a/docs/OPERABILITY.md +++ b/docs/OPERABILITY.md @@ -2,7 +2,7 @@ ## Deployment preconditions -Use PostgreSQL 18 and keep the migration owner, application runtime login and administrative / break-glass identities separate. Apply the complete checked-in authority chain through `0036_hard_close_trial_balance_snapshot_pair.sql` before starting the service. Do not run the application with a table-owner, superuser or `BYPASSRLS` login. +Use PostgreSQL 18 and keep the migration owner, application runtime login and administrative / break-glass identities separate. Apply the complete checked-in authority chain through `0037_soft_close_command_evidence_pair.sql` before starting the service. Do not run the application with a table-owner, superuser or `BYPASSRLS` login. Required environment values are deployment-specific. At minimum, configure the accounting database URL and bind this AIS process to exactly one tenant reference. Secrets belong in an approved secret store; do not place database passwords, NTS credentials, bearer tokens or provider secrets in journal payloads, logs or outbox events. @@ -50,6 +50,7 @@ database/migrations/0033_open_period_journal_population_fence.sql database/migrations/0034_book_period_control_seed.sql database/migrations/0035_trial_balance_snapshot_hard_close_pair.sql database/migrations/0036_hard_close_trial_balance_snapshot_pair.sql +database/migrations/0037_soft_close_command_evidence_pair.sql ``` Migration `0015_reconciliation_multi_match_conservation.sql` replaces the run-wide single-approved-match shortcut from `0014` with tenant/run-scoped match identity plus exact statement/journal allocation conservation. It permits multiple independently approved matches, including split and aggregate allocation populations, only when no authoritative source amount is over-consumed and grants no journal-posting authority. @@ -62,7 +63,7 @@ Migration `0018_bank_statement_balance_evidence.sql` preserves the exact numeric Migration `0019_reconciliation_run_command_evidence.sql` records the immutable command identity that opens a reconciliation run from one persisted bank statement and active bank-account assignment, then adds the evidence-derived run-finalization command and shared reconciliation command-identity namespace. The tenant-scoped idempotency key, command hash, source hash, and object-store reference are forced-RLS evidence; new runs exclude source facts recorded after `knowledge_cutoff_at`, and a deferred database guard requires one command per run with statement-to-assignment bank-account provenance. The public run API opens only `evaluating` scope and cannot post journals or close periods. -The unreleased lifecycle-parent overlay `0019_reconciliation_run_database_snapshot_authority.sql` must run after the base 0019 migration and before migrations 0020 through 0036. It defines `accounting_core.reconciliation_run_database_snapshot_authority`, independently reconstructs the exact bank-statement and assigned cash-book populations and bridge, and replaces caller-selected transition snapshot, statement-population, and book-population identities. The supported installer treats this overlay as part of the complete chain even though its filename shares the base numeric prefix; do not sort migrations by filename alone and accidentally omit it. +The unreleased lifecycle-parent overlay `0019_reconciliation_run_database_snapshot_authority.sql` must run after the base 0019 migration and before migrations 0020 through 0037. It defines `accounting_core.reconciliation_run_database_snapshot_authority`, independently reconstructs the exact bank-statement and assigned cash-book populations and bridge, and replaces caller-selected transition snapshot, statement-population, and book-population identities. The supported installer treats this overlay as part of the complete chain even though its filename shares the base numeric prefix; do not sort migrations by filename alone and accidentally omit it. Migration `0020_reconciliation_exception_resolution_command.sql` makes exception resolution a named maker-checker command rather than a mutable status shortcut. The database requires an active reviewable run, one open tenant/run/exception, a reviewer distinct from the exception owner, retained evidence reference/hash, temporal causality, the shared reconciliation idempotency namespace, and a database-derived command hash. A raw terminal status update is rejected unless its matching command already exists in the transaction; a deferred pair guard requires command and terminal status to commit together, then terminal exception evidence is immutable. Run finalization accepts an exception only when its terminal status and retained resolution command agree. This command does not post a journal; any correcting journal is a separate authorized General Ledger command. @@ -88,9 +89,11 @@ Migrations `0035_trial_balance_snapshot_hard_close_pair.sql` and `0036_hard_clos Migration 0036 also certifies pre-existing pair state before installing its future-write guard. It creates transaction-scoped `FOR SELECT TO current_user USING (true)` policies on the two FORCE-RLS pair relations, rejects a pre-existing hard-closed control without an exact snapshot using `hard_close_snapshot_pair_legacy_preflight`, and rejects a retained snapshot without an exact hard-closed control using `trial_balance_snapshot_hard_close_pair_legacy_preflight`. It drops both temporary policies before installing the durable trigger. If either marker appears, stop the upgrade; do not manufacture the missing status or snapshot. Retain the prior release or execute a separately reviewed audited remediation that can prove the original close command, journal population, scope/currency and retained numerical evidence. +Migration `0037_soft_close_command_evidence_pair.sql` binds `soft_closed` book-period authority to the complete original soft-close command-evidence triplet: idempotency key, source-payload hash and source-journal count. The durable guard is `DEFERRABLE INITIALLY DEFERRED`, so the supported status update and evidence write may occur as separate statements in one transaction but must be complete at commit. `soft_close_command_evidence_pair_required` aborts the complete transaction; roll it back and retry the supported command from a fresh transaction with the same immutable command identity. `soft_close_command_evidence_pair_legacy_preflight` means a pre-0037 soft-closed control cannot be certified from retained original evidence. Stop the upgrade and preserve the prior database state. Do not fabricate the missing hash/count from later ledger state and do not change the authoritative period state to `open` merely to satisfy the migration. Any remediation must be separately reviewed and auditable against the original close command. + Migration `0007_runtime_tenant_binding.sql` replaces caller-selected tenant authority with owner-controlled runtime-login binding. Migration `0008_fiscal_period_open_command.sql` adds forced-RLS, append-only command evidence so fiscal-period-open retries are bound to the original tenant key and source hash. Both must be installed before runtime database privileges are treated as production-ready. -After installation, prove with the actual runtime login that supported reads and writes work for its tenant, another tenant is inaccessible, the login is not a migration owner / superuser / `BYPASSRLS`, and direct SQL cannot bypass journal immutability, period controls, reconciliation lifecycle controls, exception-resolution authority, reconciliation authority-event admission, reconciliation recording-time provenance, or hard-close/snapshot pairing. +After installation, prove with the actual runtime login that supported reads and writes work for its tenant, another tenant is inaccessible, the login is not a migration owner / superuser / `BYPASSRLS`, and direct SQL cannot bypass journal immutability, period controls, soft-close command-evidence pairing, reconciliation lifecycle controls, exception-resolution authority, reconciliation authority-event admission, reconciliation recording-time provenance, or hard-close/snapshot pairing. ## Concurrency and hot-write operations @@ -176,7 +179,9 @@ Current PostgreSQL integration tests prove exact reversal replay is bound to ten ### Soft-close -Soft-close changes the period to `soft_closed`, writes no hard-close trial-balance snapshot and blocks ordinary posting. Authorized closing, adjusting and reversal paths require both the transaction classification and `accounting_closing_writer` membership. +Soft-close changes the period to `soft_closed`, writes no hard-close trial-balance snapshot and blocks ordinary posting. Authorized closing, adjusting and reversal paths require both the transaction classification and `accounting_closing_writer` membership. Migration 0037 additionally requires the committed `soft_closed` state and the complete original soft-close command-evidence triplet to exist together at transaction commit. `soft_close_command_evidence_pair_required` rolls the entire transaction back; retry the supported soft-close command from a fresh transaction with the same immutable command identity rather than patching evidence or period state manually. + +During upgrade, `soft_close_command_evidence_pair_legacy_preflight` means a pre-0037 soft-closed row has no complete durable original command evidence. Preserve the database and stop the upgrade. Do not derive a replacement payload hash or journal count from later ledger state, and do not reopen the period to make the preflight pass. Use only a separately reviewed audited remediation if the original command evidence can actually be proven. ### Hard-close @@ -240,7 +245,7 @@ Do not transfer a success from a predecessor SHA or synthetic merge ref to the c ## Backup, restore and recovery -Before release, rehearse clean install, forward migration, rollback strategy, backup restore and point-in-time recovery with production-like data volumes and the non-owner runtime identity. Restoration must preserve immutable journal / receipt / outbox lineage, retained trial-balance evidence, hard-close/snapshot pairing, and tenant isolation. Forward-upgrade rehearsal must include the 0036 one-sided-pair preflight and prove that an aborted preflight leaves neither temporary migration policy nor partial durable trigger state. +Before release, rehearse clean install, forward migration, rollback strategy, backup restore and point-in-time recovery with production-like data volumes and the non-owner runtime identity. Restoration must preserve immutable journal / receipt / outbox lineage, retained trial-balance evidence, hard-close/snapshot pairing, soft-close command evidence and tenant isolation. Forward-upgrade rehearsal must include the 0036 one-sided hard-close/snapshot pair preflight and the 0037 legacy soft-close command-evidence preflight, and prove that an aborted preflight leaves neither temporary migration policy nor partial durable trigger state. Recovery from an accounting error is not database row editing. Restore infrastructure only for infrastructure loss; correct economic facts with reversal / reposting according to accounting policy. From 82686876cbd4e926f6c1354cae59ae272fbfcbb5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 14:05:45 +0900 Subject: [PATCH 220/224] fix(close): preserve base migration errors --- src/accounting_information_platform/migration_install.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/accounting_information_platform/migration_install.py b/src/accounting_information_platform/migration_install.py index 034f18b1..ea35277a 100644 --- a/src/accounting_information_platform/migration_install.py +++ b/src/accounting_information_platform/migration_install.py @@ -14,6 +14,8 @@ def apply_foundation_migration(database_url: str, migration_path: Path) -> None: """Apply the complete checked-in foundation chain through the canonical loader.""" + _apply_foundation_migration(database_url, migration_path) + forward_migration_paths = ( migration_path.parent / "0019_reconciliation_run_database_snapshot_authority.sql", migration_path.parent / "0020_reconciliation_exception_resolution_command.sql", @@ -42,7 +44,6 @@ def apply_foundation_migration(database_url: str, migration_path: Path) -> None: f"{forward_migration_path}. Restore the checked-in migration chain, then retry." ) - _apply_foundation_migration(database_url, migration_path) psycopg = _import_psycopg() try: with psycopg.connect( From 3090bbb243237d54d14f0268050a7417832b98d3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 14:12:43 +0900 Subject: [PATCH 221/224] fix(close): preserve migration preflight atomicity --- .../migration_install.py | 39 ++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) diff --git a/src/accounting_information_platform/migration_install.py b/src/accounting_information_platform/migration_install.py index ea35277a..7fd3fd54 100644 --- a/src/accounting_information_platform/migration_install.py +++ b/src/accounting_information_platform/migration_install.py @@ -12,9 +12,45 @@ ) +_BASE_FOUNDATION_PREREQUISITES = ( + "0002_chart_account_class.sql", + "0003_home_tax_submission.sql", + "0004_close_idempotency_key.sql", + "0005_closed_period_guard.sql", + "0006_concurrency_hot_partition.sql", + "0007_runtime_tenant_binding.sql", + "0008_fiscal_period_open_command.sql", + "0009_accounting_book_period_control.sql", + "0010_soft_close_command_evidence.sql", + "0011_bank_statement_evidence.sql", + "0012_bank_assignment_command_identity.sql", + "0013_reconciliation_run_exception_evidence.sql", + "0014_reconciliation_candidate_allocation.sql", + "0015_reconciliation_multi_match_conservation.sql", + "0016_reconciliation_approval_evidence.sql", + "0017_reconciliation_approval_lock_order.sql", + "0018_bank_statement_balance_evidence.sql", + "0019_reconciliation_run_command_evidence.sql", +) + + +def _base_foundation_chain_is_complete(migration_path: Path) -> bool: + """Return whether the base loader can reach PostgreSQL after its file preflight.""" + return migration_path.is_file() and all( + (migration_path.parent / filename).is_file() + for filename in _BASE_FOUNDATION_PREREQUISITES + ) + + def apply_foundation_migration(database_url: str, migration_path: Path) -> None: """Apply the complete checked-in foundation chain through the canonical loader.""" - _apply_foundation_migration(database_url, migration_path) + # Preserve the base loader's earliest file-specific diagnostic without + # applying a partial base chain when a later required overlay is absent. + if not _base_foundation_chain_is_complete(migration_path): + _apply_foundation_migration(database_url, migration_path) + raise AccountingValidationError( + "Base foundation validation returned without a complete checked-in chain." + ) forward_migration_paths = ( migration_path.parent / "0019_reconciliation_run_database_snapshot_authority.sql", @@ -44,6 +80,7 @@ def apply_foundation_migration(database_url: str, migration_path: Path) -> None: f"{forward_migration_path}. Restore the checked-in migration chain, then retry." ) + _apply_foundation_migration(database_url, migration_path) psycopg = _import_psycopg() try: with psycopg.connect( From 3832cf72110ebc39d3978135400e0fb9378c34ac Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 08:24:58 +0900 Subject: [PATCH 222/224] fix(close): preserve posted chart-account identity --- .../persistence.py | 84 ++++++++++++++----- 1 file changed, 63 insertions(+), 21 deletions(-) diff --git a/src/accounting_information_platform/persistence.py b/src/accounting_information_platform/persistence.py index c687ac10..737dc6f6 100644 --- a/src/accounting_information_platform/persistence.py +++ b/src/accounting_information_platform/persistence.py @@ -1144,12 +1144,23 @@ def close_fiscal_period( accounting_book_reference=accounting_book_reference, idempotency_key=close_idempotency_key, ) - package = self._assemble_period_close_package( - legal_entity_reference, - accounting_book_reference, - period_code, + close_lines = self._aggregate_trial_balance( + connection, tenant_id, legal_entity_id, book_id, period_end_date + ) + debit_total = sum( + (line[2] for line in close_lines), + Decimal("0"), ) - self._require_closeable_package(package) + credit_total = sum( + (line[3] for line in close_lines), + Decimal("0"), + ) + if debit_total != credit_total: + raise AccountingValidationError( + "trial balance does not balance. " + "Correct the posted journals so debit totals equal credit totals, " + "then retry the close." + ) return self._persist_period_close( connection, tenant_id=tenant_id, @@ -5327,7 +5338,8 @@ def _post_closing_journal( ) income_rows = connection.execute( """ - SELECT chart_account.chart_account_code, + SELECT journal_entry_line.chart_account_id, + chart_account.chart_account_code, journal_entry_line.account_role_code, SUM(journal_entry_line.debit_amount), SUM(journal_entry_line.credit_amount) @@ -5345,15 +5357,18 @@ def _post_closing_journal( AND journal_entry_line.account_role_code IN ( 'usage_revenue', 'write_off_expense' ) - GROUP BY chart_account.chart_account_code, + GROUP BY journal_entry_line.chart_account_id, + chart_account.chart_account_code, journal_entry_line.account_role_code - ORDER BY chart_account.chart_account_code + ORDER BY chart_account.chart_account_code, + journal_entry_line.chart_account_id """, (tenant_id, legal_entity_id, book_id, period_end_date), ).fetchall() closing_lines: list[PostedJournalLine] = [] + historical_chart_account_ids: dict[int, UUID] = {} retained_earnings_amount = Decimal("0") - for account_code, role_code, debit_total, credit_total in income_rows: + for account_id, account_code, role_code, debit_total, credit_total in income_rows: net_amount = Decimal(credit_total) - Decimal(debit_total) if net_amount == 0: continue @@ -5378,6 +5393,7 @@ def _post_closing_journal( credit_amount=-net_amount, ) ) + historical_chart_account_ids[line_number] = account_id retained_earnings_amount += net_amount if not closing_lines: return @@ -5457,6 +5473,7 @@ def _post_closing_journal( policy=policy, proposal_record_id=proposal_record_id, lines=tuple(closing_lines), + historical_chart_account_ids=historical_chart_account_ids, ) def _require_retained_earnings_mapping( @@ -5628,6 +5645,7 @@ def _insert_journal( policy: AccountingPolicy, proposal_record_id: UUID, lines: tuple[PostedJournalLine, ...], + historical_chart_account_ids: Mapping[int, UUID] | None = None, ) -> UUID: connection.execute( "SELECT set_config('accounting_core.journal_write_role', %s, true)", @@ -5660,17 +5678,40 @@ def _insert_journal( ), ).fetchone()[0] for line in lines: - chart_account_id = connection.execute( - """ - SELECT chart_account_id - FROM accounting_core.chart_account - WHERE tenant_account_id = %s - AND accounting_book_id = %s - AND chart_account_code = %s - AND valid_to IS NULL - """, - (tenant_id, book_id, line.chart_account_code), - ).fetchone() + historical_chart_account_id = ( + historical_chart_account_ids.get(line.line_number) + if historical_chart_account_ids is not None + else None + ) + if historical_chart_account_id is None: + chart_account_id = connection.execute( + """ + SELECT chart_account_id + FROM accounting_core.chart_account + WHERE tenant_account_id = %s + AND accounting_book_id = %s + AND chart_account_code = %s + AND valid_to IS NULL + """, + (tenant_id, book_id, line.chart_account_code), + ).fetchone() + else: + chart_account_id = connection.execute( + """ + SELECT chart_account_id + FROM accounting_core.chart_account + WHERE tenant_account_id = %s + AND accounting_book_id = %s + AND chart_account_id = %s + AND chart_account_code = %s + """, + ( + tenant_id, + book_id, + historical_chart_account_id, + line.chart_account_code, + ), + ).fetchone() if chart_account_id is None: raise AccountingValidationError( f"Chart account {line.chart_account_code} is not recorded on this book. " @@ -6385,11 +6426,12 @@ def _canonical_snapshot_hash( "lines": [ { "chart_account_code": account_code, + "chart_account_id": str(account_id), "credit_total_amount": format(credit_total, "f"), "debit_total_amount": format(debit_total, "f"), "net_balance_amount": format(debit_total - credit_total, "f"), } - for _account_id, account_code, debit_total, credit_total in lines + for account_id, account_code, debit_total, credit_total in lines ], "period_code": period_code, "snapshot_currency_code": snapshot_currency_code, From 7c75e05626fa8c7477089c1272bb43719d66bb17 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 08:25:26 +0900 Subject: [PATCH 223/224] docs(close): record posted-account repair candidate --- .../PERIOD_CLOSE_POSTED_ACCOUNT_IDENTITY_TRACEABILITY.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/doctoring/PERIOD_CLOSE_POSTED_ACCOUNT_IDENTITY_TRACEABILITY.md b/docs/doctoring/PERIOD_CLOSE_POSTED_ACCOUNT_IDENTITY_TRACEABILITY.md index 520b016f..b00af64b 100644 --- a/docs/doctoring/PERIOD_CLOSE_POSTED_ACCOUNT_IDENTITY_TRACEABILITY.md +++ b/docs/doctoring/PERIOD_CLOSE_POSTED_ACCOUNT_IDENTITY_TRACEABILITY.md @@ -44,7 +44,9 @@ Static RED `a612539a92bfd171b7037273858c7263e4eabc9e` simultaneously preserves t Static authority-separation RED `0d3caba036c370f93deacc0e8208314cd9df9731` adds the second causal boundary exposed by the same PostgreSQL scenario: `close_fiscal_period()` must not require `_assemble_period_close_package()` before the authoritative hard-close write. The supported close still has to reach `_persist_period_close()` and preserve ledger/trial-balance invariants; buyer Reporting-Export projection completeness is not close authorization. -The current successor head is intentionally RED until a production candidate satisfies both realistic PostgreSQL scenarios, the account-identity-sensitive snapshot hash, and the static separation contracts on one unchanged exact head. Predecessor runner evidence does not transfer to that successor head. +Production candidate `3832cf72110ebc39d3978135400e0fb9378c34ac` applies the selected boundary in the canonical writer file. `_post_closing_journal()` now selects and groups the immutable posted `journal_entry_line.chart_account_id`, carries a line-number-to-Entity mapping, and gives that mapping only to the system closing-journal insertion. `_insert_journal()` retains its `valid_to IS NULL` lookup for every ordinary line and validates a supplied historical identity against the same tenant, book, and code without requiring the source Entity to remain active. The retained-earnings line has no historical override and therefore still resolves current close-time policy. `close_fiscal_period()` now checks balance directly from the locked ledger population instead of assembling mutable buyer-report projections, and `_canonical_snapshot_hash()` serializes the exact account UUID. + +This is a production candidate, not GREEN or protected integration. The current successor must independently pass the realistic expiry and code-reuse PostgreSQL cases, snapshot-hash identity test, static separation contracts, complete Foundation/security/SAST/CodeQL/package evidence, and current-head review on one unchanged exact head. Predecessor runner evidence does not transfer. ## Standards boundary From 9c3dba6f12aa58062faa6ba11537aabef91486d8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 9 Sep 2026 05:09:05 +0900 Subject: [PATCH 224/224] test(close): bind closing evidence to posted account identity --- ...eriod_close_posted_account_identity_red.py | 72 +++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/tests/test_postgres_period_close_posted_account_identity_red.py b/tests/test_postgres_period_close_posted_account_identity_red.py index 109eb20d..ec1d2c85 100644 --- a/tests/test_postgres_period_close_posted_account_identity_red.py +++ b/tests/test_postgres_period_close_posted_account_identity_red.py @@ -2,6 +2,8 @@ from __future__ import annotations +import hashlib +import json import unittest from decimal import Decimal @@ -238,10 +240,80 @@ def test_hard_close_does_not_redirect_posted_account_when_code_is_reused(self) - """, (self.case.tenant_id, source_account_id), ).fetchone() + closing_source_payload_hash = connection.execute( + """ + SELECT journal_proposal_record.source_payload_hash + FROM accounting_core.general_journal + JOIN accounting_integration.journal_proposal_record + ON journal_proposal_record.tenant_account_id = general_journal.tenant_account_id + AND journal_proposal_record.proposal_record_id = general_journal.source_proposal_record_id + WHERE general_journal.tenant_account_id = %s + AND general_journal.journal_reference LIKE + 'urn:cwl:accounting:general_journal:period_closing:%%' + """, + (self.case.tenant_id,), + ).fetchone()[0] + closing_lines = connection.execute( + """ + SELECT journal_entry_line.line_number, + journal_entry_line.chart_account_id, + chart_account.chart_account_code, + journal_entry_line.account_role_code, + journal_entry_line.debit_amount, + journal_entry_line.credit_amount + FROM accounting_core.journal_entry_line + JOIN accounting_core.general_journal + ON general_journal.tenant_account_id = journal_entry_line.tenant_account_id + AND general_journal.general_journal_id = journal_entry_line.general_journal_id + JOIN accounting_core.chart_account + ON chart_account.tenant_account_id = journal_entry_line.tenant_account_id + AND chart_account.chart_account_id = journal_entry_line.chart_account_id + WHERE general_journal.tenant_account_id = %s + AND general_journal.journal_reference LIKE + 'urn:cwl:accounting:general_journal:period_closing:%%' + ORDER BY journal_entry_line.line_number + """, + (self.case.tenant_id,), + ).fetchall() + + closing_payload_lines: list[dict[str, object]] = [] + for ( + line_number, + chart_account_id, + chart_account_code, + account_role_code, + debit_amount, + credit_amount, + ) in closing_lines: + payload_line: dict[str, object] = { + "account_role_code": str(account_role_code), + "chart_account_code": str(chart_account_code), + "credit_amount": format(Decimal(credit_amount), "f"), + "debit_amount": format(Decimal(debit_amount), "f"), + "line_number": int(line_number), + } + if account_role_code != "retained_earnings": + payload_line["chart_account_id"] = str(chart_account_id) + closing_payload_lines.append(payload_line) + closing_payload = json.dumps( + { + "accounting_book_reference": self.case.policy.accounting_book_reference, + "legal_entity_reference": self.case.policy.legal_entity_reference, + "lines": closing_payload_lines, + "period_code": "2026-08", + "tenant_reference": self.case.policy.tenant_reference, + }, + separators=(",", ":"), + sort_keys=True, + ) + expected_closing_source_payload_hash = "sha256:" + hashlib.sha256( + closing_payload.encode("utf-8") + ).hexdigest() self.assertEqual(receipt.period_status_code, "hard_closed") self.assertEqual(closing_account_id, source_account_id) self.assertNotEqual(closing_account_id, successor_account_id) + self.assertEqual(closing_source_payload_hash, expected_closing_source_payload_hash) self.assertEqual( tuple(Decimal(value) for value in snapshot_line), (Decimal("25000"), Decimal("25000"), Decimal("0")),