"""SQLite persistence: versioned migrations and connection helpers. The database, migration runner and file storage choices are recorded in ``docs/decisions/002-persistence.md``. Migrations are plain SQL applied in version order; each records itself in ``schema_migrations`` so re-running ``migrate`` on an existing database is a no-op. Every migration ships a ``down`` script so ``rollback`` can walk backwards for recovery and tests. """ from __future__ import annotations import argparse from dataclasses import dataclass from datetime import datetime, timezone from pathlib import Path import sqlite3 DEFAULT_DB_PATH = Path("data/app.db") def utc_now() -> str: return datetime.now(timezone.utc).isoformat() @dataclass(frozen=True) class Migration: version: int name: str up: str down: str MIGRATIONS: tuple[Migration, ...] = ( Migration( version=1, name="0001_core_persistence", up=""" CREATE TABLE companies ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL UNIQUE, created_at TEXT NOT NULL, updated_at TEXT NOT NULL ); CREATE TABLE bank_accounts ( id INTEGER PRIMARY KEY AUTOINCREMENT, company_id INTEGER REFERENCES companies (id), account_number TEXT NOT NULL UNIQUE, account_name TEXT, bank_name TEXT, status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'active', 'disabled')), created_at TEXT NOT NULL, updated_at TEXT NOT NULL ); CREATE TABLE source_files ( id INTEGER PRIMARY KEY AUTOINCREMENT, sha256 TEXT NOT NULL UNIQUE, original_filename TEXT NOT NULL, size_bytes INTEGER NOT NULL, storage_path TEXT NOT NULL, created_at TEXT NOT NULL ); CREATE TABLE import_batches ( id INTEGER PRIMARY KEY AUTOINCREMENT, source_file_id INTEGER NOT NULL REFERENCES source_files (id), status TEXT NOT NULL CHECK (status IN ('parsing', 'parsed', 'exception', 'failed', 'duplicate')), duplicate_of_id INTEGER REFERENCES import_batches (id), diagnostics TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL ); CREATE TABLE sheet_batches ( id INTEGER PRIMARY KEY AUTOINCREMENT, import_batch_id INTEGER NOT NULL REFERENCES import_batches (id), sheet_name TEXT NOT NULL, bank_name TEXT NOT NULL, template_id TEXT NOT NULL, template_version INTEGER NOT NULL, header_row INTEGER NOT NULL, own_account TEXT, own_name TEXT, period_start TEXT, period_end TEXT, transaction_count INTEGER NOT NULL, warnings TEXT NOT NULL DEFAULT '[]', created_at TEXT NOT NULL, UNIQUE (import_batch_id, sheet_name) ); CREATE TABLE source_rows ( id INTEGER PRIMARY KEY AUTOINCREMENT, sheet_batch_id INTEGER NOT NULL REFERENCES sheet_batches (id), source_row INTEGER NOT NULL, transaction_at TEXT NOT NULL, income TEXT NOT NULL, expense TEXT NOT NULL, balance TEXT, own_account TEXT, own_name TEXT, counterparty_account TEXT, counterparty_name TEXT, counterparty_bank TEXT, summary TEXT, purpose TEXT, reference TEXT, currency TEXT, created_at TEXT NOT NULL, UNIQUE (sheet_batch_id, source_row) ); CREATE TABLE import_exceptions ( id INTEGER PRIMARY KEY AUTOINCREMENT, import_batch_id INTEGER NOT NULL REFERENCES import_batches (id), stage TEXT NOT NULL, message TEXT NOT NULL, diagnostics TEXT, created_at TEXT NOT NULL ); CREATE TRIGGER source_files_no_update BEFORE UPDATE ON source_files BEGIN SELECT RAISE (ABORT, 'source_files rows are immutable'); END; CREATE TRIGGER source_files_no_delete BEFORE DELETE ON source_files BEGIN SELECT RAISE (ABORT, 'source_files rows are immutable'); END; CREATE TRIGGER sheet_batches_no_update BEFORE UPDATE ON sheet_batches BEGIN SELECT RAISE (ABORT, 'sheet_batches rows are immutable'); END; CREATE TRIGGER sheet_batches_no_delete BEFORE DELETE ON sheet_batches BEGIN SELECT RAISE (ABORT, 'sheet_batches rows are immutable'); END; CREATE TRIGGER source_rows_no_update BEFORE UPDATE ON source_rows BEGIN SELECT RAISE (ABORT, 'source_rows rows are immutable'); END; CREATE TRIGGER source_rows_no_delete BEFORE DELETE ON source_rows BEGIN SELECT RAISE (ABORT, 'source_rows rows are immutable'); END; """, down=""" DROP TRIGGER IF EXISTS source_rows_no_delete; DROP TRIGGER IF EXISTS source_rows_no_update; DROP TRIGGER IF EXISTS sheet_batches_no_delete; DROP TRIGGER IF EXISTS sheet_batches_no_update; DROP TRIGGER IF EXISTS source_files_no_delete; DROP TRIGGER IF EXISTS source_files_no_update; DROP TABLE IF EXISTS import_exceptions; DROP TABLE IF EXISTS source_rows; DROP TABLE IF EXISTS sheet_batches; DROP TABLE IF EXISTS import_batches; DROP TABLE IF EXISTS source_files; DROP TABLE IF EXISTS bank_accounts; DROP TABLE IF EXISTS companies; """, ), Migration( version=2, name="0002_auth_and_sessions", up=""" CREATE TABLE users ( id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL UNIQUE, password_hash TEXT NOT NULL, role TEXT NOT NULL CHECK (role IN ('admin', 'company')), company_id INTEGER REFERENCES companies (id), status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'disabled')), must_change_password INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, CHECK (role != 'company' OR company_id IS NOT NULL), CHECK (role != 'admin' OR company_id IS NULL) ); CREATE TABLE sessions ( id INTEGER PRIMARY KEY AUTOINCREMENT, token_hash TEXT NOT NULL UNIQUE, user_id INTEGER NOT NULL REFERENCES users (id), created_at TEXT NOT NULL, expires_at TEXT NOT NULL, revoked_at TEXT ); CREATE TABLE login_attempts ( id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL, ip TEXT NOT NULL, success INTEGER NOT NULL, created_at TEXT NOT NULL ); CREATE TABLE audit_log ( id INTEGER PRIMARY KEY AUTOINCREMENT, actor_user_id INTEGER REFERENCES users (id), actor_username TEXT, action TEXT NOT NULL, target TEXT, detail TEXT, ip TEXT, created_at TEXT NOT NULL ); ALTER TABLE import_batches ADD COLUMN company_id INTEGER REFERENCES companies (id); """, down=""" DROP TABLE IF EXISTS audit_log; DROP TABLE IF EXISTS login_attempts; DROP TABLE IF EXISTS sessions; DROP TABLE IF EXISTS users; ALTER TABLE import_batches DROP COLUMN company_id; """, ), Migration( version=3, name="0003_dynamic_master_data", # bank_accounts is rebuilt (SQLite cannot alter CHECK constraints): # status gains 'returned', and the account gains type, effective # interval and review fields. account_number stays UNIQUE and stores # the normalized digits-only form (see master_data.normalize). up=""" CREATE TABLE bank_accounts_new ( id INTEGER PRIMARY KEY AUTOINCREMENT, company_id INTEGER NOT NULL REFERENCES companies (id), account_number TEXT NOT NULL UNIQUE, account_name TEXT, bank_name TEXT NOT NULL DEFAULT '', account_type TEXT NOT NULL DEFAULT '一般户' CHECK (account_type IN ('基本户', '一般户', '专用户')), status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'active', 'returned', 'disabled')), effective_from TEXT, effective_to TEXT, submitted_by INTEGER REFERENCES users (id), reviewed_by INTEGER REFERENCES users (id), reviewed_at TEXT, review_reason TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL ); INSERT INTO bank_accounts_new ( id, company_id, account_number, account_name, bank_name, status, created_at, updated_at ) SELECT id, company_id, account_number, account_name, COALESCE(bank_name, ''), status, created_at, updated_at FROM bank_accounts WHERE company_id IS NOT NULL; DROP TABLE bank_accounts; ALTER TABLE bank_accounts_new RENAME TO bank_accounts; CREATE TABLE account_aliases ( id INTEGER PRIMARY KEY AUTOINCREMENT, bank_account_id INTEGER NOT NULL REFERENCES bank_accounts (id), alias_kind TEXT NOT NULL CHECK (alias_kind IN ('name', 'account')), alias_value TEXT NOT NULL, priority INTEGER NOT NULL DEFAULT 100, effective_from TEXT, effective_to TEXT, created_by INTEGER REFERENCES users (id), created_at TEXT NOT NULL, UNIQUE (bank_account_id, alias_kind, alias_value) ); CREATE TABLE master_data_changes ( id INTEGER PRIMARY KEY AUTOINCREMENT, entity_type TEXT NOT NULL CHECK (entity_type IN ('company', 'user', 'bank_account', 'account_alias')), entity_id INTEGER NOT NULL, action TEXT NOT NULL, before_json TEXT, after_json TEXT, reason TEXT, actor_user_id INTEGER REFERENCES users (id), actor_username TEXT, created_at TEXT NOT NULL ); ALTER TABLE companies ADD COLUMN credit_code TEXT; ALTER TABLE companies ADD COLUMN cashier_name TEXT; ALTER TABLE companies ADD COLUMN status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'preparing', 'disabled')); """, down=""" ALTER TABLE companies DROP COLUMN status; ALTER TABLE companies DROP COLUMN cashier_name; ALTER TABLE companies DROP COLUMN credit_code; DROP TABLE IF EXISTS master_data_changes; DROP TABLE IF EXISTS account_aliases; CREATE TABLE bank_accounts_new ( id INTEGER PRIMARY KEY AUTOINCREMENT, company_id INTEGER REFERENCES companies (id), account_number TEXT NOT NULL UNIQUE, account_name TEXT, bank_name TEXT, status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'active', 'disabled')), created_at TEXT NOT NULL, updated_at TEXT NOT NULL ); INSERT INTO bank_accounts_new ( id, company_id, account_number, account_name, bank_name, status, created_at, updated_at ) SELECT id, company_id, account_number, account_name, bank_name, CASE WHEN status = 'returned' THEN 'pending' ELSE status END, created_at, updated_at FROM bank_accounts; DROP TABLE bank_accounts; ALTER TABLE bank_accounts_new RENAME TO bank_accounts; """, ), Migration( version=4, name="0004_per_sheet_reviews", # Per-worksheet lifecycle: the parse outcome (parsed/exception/ignored) # is immutable evidence captured at import time; the human decision # (pending/confirmed/ignored) is the audit-gated gate that lets a # worksheet participate in later matching and calculation. up=""" CREATE TABLE sheet_reviews ( id INTEGER PRIMARY KEY AUTOINCREMENT, import_batch_id INTEGER NOT NULL REFERENCES import_batches (id), sheet_name TEXT NOT NULL, outcome TEXT NOT NULL CHECK (outcome IN ('parsed', 'exception', 'ignored')), message TEXT, scanned_rows INTEGER, candidate_headers TEXT, sheet_batch_id INTEGER REFERENCES sheet_batches (id), review_status TEXT NOT NULL DEFAULT 'pending' CHECK (review_status IN ('pending', 'confirmed', 'ignored')), review_reason TEXT, reviewed_by INTEGER REFERENCES users (id), reviewed_at TEXT, created_at TEXT NOT NULL, UNIQUE (import_batch_id, sheet_name) ); CREATE INDEX idx_sheet_reviews_batch ON sheet_reviews (import_batch_id); CREATE TRIGGER sheet_reviews_evidence_immutable BEFORE UPDATE ON sheet_reviews BEGIN SELECT RAISE (ABORT, 'sheet_reviews parse evidence is immutable') WHERE OLD.outcome != NEW.outcome OR OLD.message IS NOT NEW.message OR OLD.scanned_rows IS NOT NEW.scanned_rows OR OLD.candidate_headers IS NOT NEW.candidate_headers OR OLD.sheet_batch_id IS NOT NEW.sheet_batch_id OR OLD.import_batch_id IS NOT NEW.import_batch_id OR OLD.sheet_name IS NOT NEW.sheet_name; END; """, down=""" DROP TRIGGER IF EXISTS sheet_reviews_evidence_immutable; DROP INDEX IF EXISTS idx_sheet_reviews_batch; DROP TABLE IF EXISTS sheet_reviews; """, ), Migration( version=5, name="0005_canonical_transfer_matching", # Canonical transfer event layer (B-43). Immutable source rows stay # untouched; every matching decision, its participant/observation # bindings and its candidate evidence are append-only history, and the # current decision + row claims are a rebuildable projection that the # B-44 balance layer reads through the eligible view. up=""" ALTER TABLE import_batches ADD COLUMN upload_bank_account_id INTEGER REFERENCES bank_accounts (id); CREATE TABLE master_data_changes_new ( id INTEGER PRIMARY KEY AUTOINCREMENT, entity_type TEXT NOT NULL CHECK (entity_type IN ('company', 'user', 'bank_account', 'account_alias', 'personal_transit_mapping')), entity_id INTEGER NOT NULL, action TEXT NOT NULL, before_json TEXT, after_json TEXT, reason TEXT, actor_user_id INTEGER REFERENCES users (id), actor_username TEXT, created_at TEXT NOT NULL ); INSERT INTO master_data_changes_new SELECT * FROM master_data_changes; DROP TABLE master_data_changes; ALTER TABLE master_data_changes_new RENAME TO master_data_changes; CREATE TABLE personal_transit_mappings ( id INTEGER PRIMARY KEY AUTOINCREMENT, account_number TEXT NOT NULL UNIQUE, account_name TEXT, represented_company_id INTEGER NOT NULL REFERENCES companies (id), allowed_direction TEXT NOT NULL CHECK (allowed_direction IN ('outgoing', 'incoming', 'both')), status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'active', 'returned', 'disabled')), effective_from TEXT, effective_to TEXT, submitted_by INTEGER REFERENCES users (id), reviewed_by INTEGER REFERENCES users (id), reviewed_at TEXT, review_reason TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL ); CREATE TABLE canonical_transfer_events ( id INTEGER PRIMARY KEY AUTOINCREMENT, lifecycle TEXT NOT NULL DEFAULT 'active' CHECK (lifecycle IN ('active', 'superseded')), created_at TEXT NOT NULL ); CREATE TABLE transfer_match_decisions ( id INTEGER PRIMARY KEY AUTOINCREMENT, event_id INTEGER NOT NULL REFERENCES canonical_transfer_events (id), revision INTEGER NOT NULL, classification TEXT NOT NULL CHECK (classification IN ( 'unresolved', 'needs_review', 'internal_single', 'intercompany', 'same_company', 'external' )), pairing TEXT NOT NULL CHECK (pairing IN ('single', 'paired', 'not_applicable')), amount TEXT, currency TEXT, effective_at TEXT, mode TEXT NOT NULL CHECK (mode IN ('auto', 'manual', 'reversal')), rule_version TEXT, locked INTEGER NOT NULL DEFAULT 0 CHECK (locked IN (0, 1)), reason TEXT, idempotency_key TEXT, actor_user_id INTEGER REFERENCES users (id), actor_username TEXT, supersedes_decision_id INTEGER REFERENCES transfer_match_decisions (id), created_at TEXT NOT NULL, UNIQUE (event_id, revision) ); CREATE TABLE transfer_decision_observations ( id INTEGER PRIMARY KEY AUTOINCREMENT, decision_id INTEGER NOT NULL REFERENCES transfer_match_decisions (id), source_row_id INTEGER NOT NULL REFERENCES source_rows (id), role TEXT NOT NULL CHECK (role IN ('outgoing', 'incoming')), created_at TEXT NOT NULL, UNIQUE (decision_id, source_row_id) ); CREATE TABLE transfer_decision_participants ( id INTEGER PRIMARY KEY AUTOINCREMENT, decision_id INTEGER NOT NULL REFERENCES transfer_match_decisions (id), role TEXT NOT NULL CHECK (role IN ('payer', 'payee')), company_id INTEGER REFERENCES companies (id), bank_account_id INTEGER REFERENCES bank_accounts (id), resolve_method TEXT NOT NULL CHECK (resolve_method IN ( 'own_exact', 'upload_account', 'counterparty_exact', 'account_alias', 'personal_mapping', 'manual' )), alias_id INTEGER REFERENCES account_aliases (id), mapping_id INTEGER REFERENCES personal_transit_mappings (id), evidence TEXT, created_at TEXT NOT NULL, UNIQUE (decision_id, role) ); CREATE TABLE transfer_match_candidates ( id INTEGER PRIMARY KEY AUTOINCREMENT, decision_id INTEGER NOT NULL REFERENCES transfer_match_decisions (id), source_row_id INTEGER NOT NULL REFERENCES source_rows (id), rule_tier TEXT NOT NULL, date_diff_days INTEGER, account_mirror INTEGER NOT NULL DEFAULT 0, reference_match TEXT, summary_match INTEGER NOT NULL DEFAULT 0, accepted INTEGER, reason TEXT, rule_version TEXT, created_at TEXT NOT NULL ); CREATE TABLE current_transfer_decisions ( event_id INTEGER PRIMARY KEY REFERENCES canonical_transfer_events (id), decision_id INTEGER NOT NULL UNIQUE REFERENCES transfer_match_decisions (id) ); CREATE TABLE transfer_observation_claims ( source_row_id INTEGER PRIMARY KEY REFERENCES source_rows (id), event_id INTEGER NOT NULL REFERENCES canonical_transfer_events (id), decision_id INTEGER NOT NULL REFERENCES transfer_match_decisions (id) ); CREATE INDEX idx_obs_decision ON transfer_decision_observations (decision_id); CREATE INDEX idx_participants_decision ON transfer_decision_participants (decision_id); CREATE INDEX idx_candidates_decision ON transfer_match_candidates (decision_id); CREATE INDEX idx_claims_event ON transfer_observation_claims (event_id); CREATE VIEW eligible_intercompany_events AS SELECT e.id AS event_id, d.id AS decision_id, d.revision AS revision, d.effective_at AS effective_at, d.amount AS amount, d.currency AS currency, payer.company_id AS payer_company_id, payer.bank_account_id AS payer_account_id, payee.company_id AS payee_company_id, payee.bank_account_id AS payee_account_id, d.pairing AS pairing, d.rule_version AS rule_version, (SELECT COUNT(*) FROM transfer_decision_observations o WHERE o.decision_id = d.id) AS evidence_count FROM current_transfer_decisions c JOIN canonical_transfer_events e ON e.id = c.event_id JOIN transfer_match_decisions d ON d.id = c.decision_id JOIN transfer_decision_participants payer ON payer.decision_id = d.id AND payer.role = 'payer' JOIN transfer_decision_participants payee ON payee.decision_id = d.id AND payee.role = 'payee' WHERE e.lifecycle = 'active' AND d.classification = 'intercompany' AND (d.pairing = 'paired' OR d.locked = 1); CREATE TRIGGER canonical_transfer_events_no_delete BEFORE DELETE ON canonical_transfer_events BEGIN SELECT RAISE (ABORT, 'canonical_transfer_events rows are immutable'); END; CREATE TRIGGER canonical_transfer_events_no_update BEFORE UPDATE ON canonical_transfer_events BEGIN SELECT RAISE (ABORT, 'canonical_transfer_events only allow lifecycle changes') WHERE OLD.lifecycle = NEW.lifecycle OR OLD.id IS NOT NEW.id OR OLD.created_at IS NOT NEW.created_at; END; CREATE TRIGGER transfer_match_decisions_no_update BEFORE UPDATE ON transfer_match_decisions BEGIN SELECT RAISE (ABORT, 'transfer_match_decisions rows are immutable'); END; CREATE TRIGGER transfer_match_decisions_no_delete BEFORE DELETE ON transfer_match_decisions BEGIN SELECT RAISE (ABORT, 'transfer_match_decisions rows are immutable'); END; CREATE TRIGGER transfer_decision_observations_no_update BEFORE UPDATE ON transfer_decision_observations BEGIN SELECT RAISE (ABORT, 'transfer_decision_observations rows are immutable'); END; CREATE TRIGGER transfer_decision_observations_no_delete BEFORE DELETE ON transfer_decision_observations BEGIN SELECT RAISE (ABORT, 'transfer_decision_observations rows are immutable'); END; CREATE TRIGGER transfer_decision_participants_no_update BEFORE UPDATE ON transfer_decision_participants BEGIN SELECT RAISE (ABORT, 'transfer_decision_participants rows are immutable'); END; CREATE TRIGGER transfer_decision_participants_no_delete BEFORE DELETE ON transfer_decision_participants BEGIN SELECT RAISE (ABORT, 'transfer_decision_participants rows are immutable'); END; CREATE TRIGGER transfer_match_candidates_no_update BEFORE UPDATE ON transfer_match_candidates BEGIN SELECT RAISE (ABORT, 'transfer_match_candidates rows are immutable'); END; CREATE TRIGGER transfer_match_candidates_no_delete BEFORE DELETE ON transfer_match_candidates BEGIN SELECT RAISE (ABORT, 'transfer_match_candidates rows are immutable'); END; """, down=""" DROP VIEW IF EXISTS eligible_intercompany_events; DROP TABLE IF EXISTS transfer_observation_claims; DROP TABLE IF EXISTS current_transfer_decisions; DROP TABLE IF EXISTS transfer_match_candidates; DROP TABLE IF EXISTS transfer_decision_participants; DROP TABLE IF EXISTS transfer_decision_observations; DROP TABLE IF EXISTS transfer_match_decisions; DROP TABLE IF EXISTS canonical_transfer_events; DROP TABLE IF EXISTS personal_transit_mappings; CREATE TABLE master_data_changes_new ( id INTEGER PRIMARY KEY AUTOINCREMENT, entity_type TEXT NOT NULL CHECK (entity_type IN ('company', 'user', 'bank_account', 'account_alias')), entity_id INTEGER NOT NULL, action TEXT NOT NULL, before_json TEXT, after_json TEXT, reason TEXT, actor_user_id INTEGER REFERENCES users (id), actor_username TEXT, created_at TEXT NOT NULL ); INSERT INTO master_data_changes_new SELECT * FROM master_data_changes; DROP TABLE master_data_changes; ALTER TABLE master_data_changes_new RENAME TO master_data_changes; ALTER TABLE import_batches DROP COLUMN upload_bank_account_id; """, ), Migration( version=6, name="0006_intercompany_ledger_events", # B-44 canonical intercompany ledger layer. Manual records and the # ledger event revision chain are append-only facts; current pointers # (current revision per ledger event / manual decision, source claims) # are rebuildable projections. Bank events enter only through # ``eligible_intercompany_events``; nothing here rewrites bank rows. up=""" CREATE TABLE manual_records ( id INTEGER PRIMARY KEY AUTOINCREMENT, company_id INTEGER NOT NULL REFERENCES companies (id), counterparty_company_id INTEGER NOT NULL REFERENCES companies (id), occurred_at TEXT NOT NULL, direction TEXT NOT NULL CHECK (direction IN ('outgoing', 'incoming')), amount TEXT NOT NULL, amount_scale INTEGER NOT NULL, currency TEXT NOT NULL, funding_source TEXT NOT NULL CHECK (funding_source IN ( 'approved_bank_account', 'personal_transit', 'other' )), bank_account_id INTEGER REFERENCES bank_accounts (id), personal_transit_mapping_id INTEGER REFERENCES personal_transit_mappings (id), related_source_row_id INTEGER REFERENCES source_rows (id), requested_subject TEXT NOT NULL CHECK (requested_subject IN ( 'receivable', 'payable', 'other_receivable', 'other_payable' )), summary TEXT, reason TEXT, evidence_json TEXT, request_key TEXT NOT NULL, supersedes_record_id INTEGER REFERENCES manual_records (id), submitted_by INTEGER REFERENCES users (id), created_at TEXT NOT NULL, UNIQUE (company_id, request_key), CHECK (counterparty_company_id != company_id) ); CREATE TABLE manual_record_decisions ( id INTEGER PRIMARY KEY AUTOINCREMENT, record_id INTEGER NOT NULL REFERENCES manual_records (id), revision INTEGER NOT NULL, state TEXT NOT NULL CHECK (state IN ( 'pending', 'approved', 'returned', 'exception', 'reversed' )), action TEXT NOT NULL, reason TEXT, actor_user_id INTEGER REFERENCES users (id), actor_username TEXT, idempotency_key TEXT, supersedes_decision_id INTEGER REFERENCES manual_record_decisions (id), created_at TEXT NOT NULL, UNIQUE (record_id, revision) ); CREATE TABLE current_manual_record_decisions ( record_id INTEGER PRIMARY KEY REFERENCES manual_records (id), decision_id INTEGER NOT NULL UNIQUE REFERENCES manual_record_decisions (id) ); CREATE TABLE ledger_events ( id INTEGER PRIMARY KEY AUTOINCREMENT, lifecycle TEXT NOT NULL DEFAULT 'active' CHECK (lifecycle IN ('active', 'superseded')), created_at TEXT NOT NULL ); CREATE TABLE ledger_event_revisions ( id INTEGER PRIMARY KEY AUTOINCREMENT, ledger_event_id INTEGER NOT NULL REFERENCES ledger_events (id), revision INTEGER NOT NULL, state TEXT NOT NULL CHECK (state IN ('pending_subject', 'confirmed')), effective_at TEXT NOT NULL, amount TEXT NOT NULL, amount_scale INTEGER NOT NULL, currency TEXT NOT NULL, payer_company_id INTEGER NOT NULL REFERENCES companies (id), payee_company_id INTEGER NOT NULL REFERENCES companies (id), perspective_company_id INTEGER REFERENCES companies (id), subject_code TEXT CHECK (subject_code IN ( 'receivable', 'payable', 'other_receivable', 'other_payable' )), source_kind TEXT NOT NULL CHECK (source_kind IN ('bank', 'manual', 'adjustment')), source_revision_token TEXT, posting_kind TEXT NOT NULL CHECK (posting_kind IN ( 'normal', 'reversal', 'adjustment' )), reverses_ledger_event_id INTEGER REFERENCES ledger_events (id), adjusts_ledger_event_id INTEGER REFERENCES ledger_events (id), rule_version TEXT, evidence_json TEXT, idempotency_key TEXT, actor_user_id INTEGER REFERENCES users (id), actor_username TEXT, reason TEXT, supersedes_revision_id INTEGER REFERENCES ledger_event_revisions (id), created_at TEXT NOT NULL, UNIQUE (ledger_event_id, revision), CHECK (payer_company_id != payee_company_id), CHECK (state = 'confirmed' OR subject_code IS NULL), CHECK (state = 'confirmed' OR perspective_company_id IS NULL), CHECK ( state != 'confirmed' OR (perspective_company_id IS NOT NULL AND subject_code IS NOT NULL) ) ); CREATE TABLE current_ledger_event_revisions ( ledger_event_id INTEGER PRIMARY KEY REFERENCES ledger_events (id), revision_id INTEGER NOT NULL UNIQUE REFERENCES ledger_event_revisions (id) ); CREATE TABLE ledger_event_bank_sources ( bank_event_id INTEGER PRIMARY KEY REFERENCES canonical_transfer_events (id), ledger_event_id INTEGER NOT NULL REFERENCES ledger_events (id), UNIQUE (ledger_event_id, bank_event_id) ); CREATE TABLE ledger_event_manual_sources ( manual_record_id INTEGER PRIMARY KEY REFERENCES manual_records (id), ledger_event_id INTEGER NOT NULL REFERENCES ledger_events (id), UNIQUE (ledger_event_id, manual_record_id) ); CREATE TABLE ledger_subject_suggestions ( id INTEGER PRIMARY KEY AUTOINCREMENT, ledger_event_id INTEGER NOT NULL REFERENCES ledger_events (id), source_revision_id INTEGER NOT NULL REFERENCES ledger_event_revisions (id), suggested_perspective_company_id INTEGER NOT NULL REFERENCES companies (id), suggested_subject_code TEXT NOT NULL CHECK (suggested_subject_code IN ( 'receivable', 'payable', 'other_receivable', 'other_payable' )), rule_version TEXT, evidence_json TEXT, created_at TEXT NOT NULL ); CREATE INDEX idx_ledger_revisions_event ON ledger_event_revisions (ledger_event_id, revision); CREATE INDEX idx_ledger_revisions_effective ON ledger_event_revisions (effective_at); CREATE INDEX idx_ledger_revisions_pair_currency ON ledger_event_revisions (payer_company_id, payee_company_id, currency); CREATE INDEX idx_ledger_revisions_state ON ledger_event_revisions (state, effective_at); CREATE INDEX idx_manual_records_company ON manual_records (company_id, occurred_at); CREATE INDEX idx_manual_decisions_record ON manual_record_decisions (record_id, revision); CREATE INDEX idx_manual_decisions_state ON manual_record_decisions (state); CREATE INDEX idx_subject_suggestions_event ON ledger_subject_suggestions (ledger_event_id); CREATE VIEW eligible_position_events AS SELECT le.id AS ledger_event_id, cur.revision_id AS ledger_revision_id, r.effective_at AS effective_at, r.amount AS amount, r.amount_scale AS amount_scale, r.currency AS currency, r.payer_company_id AS payer_company_id, r.payee_company_id AS payee_company_id, r.perspective_company_id AS perspective_company_id, r.subject_code AS subject_code, r.source_kind AS source_kind, r.posting_kind AS posting_kind, r.reverses_ledger_event_id AS reverses_ledger_event_id, r.adjusts_ledger_event_id AS adjusts_ledger_event_id, COALESCE( (SELECT bs.bank_event_id FROM ledger_event_bank_sources bs WHERE bs.ledger_event_id = le.id LIMIT 1), (SELECT ms.manual_record_id FROM ledger_event_manual_sources ms WHERE ms.ledger_event_id = le.id LIMIT 1) ) AS source_id, ((SELECT COUNT(*) FROM ledger_event_bank_sources bs WHERE bs.ledger_event_id = le.id) + (SELECT COUNT(*) FROM ledger_event_manual_sources ms WHERE ms.ledger_event_id = le.id)) AS evidence_count FROM ledger_events le JOIN current_ledger_event_revisions cur ON cur.ledger_event_id = le.id JOIN ledger_event_revisions r ON r.id = cur.revision_id WHERE le.lifecycle = 'active' AND r.state = 'confirmed'; CREATE TRIGGER ledger_events_no_delete BEFORE DELETE ON ledger_events BEGIN SELECT RAISE (ABORT, 'ledger_events rows are immutable'); END; CREATE TRIGGER ledger_events_no_update BEFORE UPDATE ON ledger_events BEGIN SELECT RAISE (ABORT, 'ledger_events only allow lifecycle changes') WHERE OLD.lifecycle = NEW.lifecycle OR OLD.id IS NOT NEW.id OR OLD.created_at IS NOT NEW.created_at; END; CREATE TRIGGER ledger_event_revisions_no_update BEFORE UPDATE ON ledger_event_revisions BEGIN SELECT RAISE (ABORT, 'ledger_event_revisions rows are immutable'); END; CREATE TRIGGER ledger_event_revisions_no_delete BEFORE DELETE ON ledger_event_revisions BEGIN SELECT RAISE (ABORT, 'ledger_event_revisions rows are immutable'); END; CREATE TRIGGER ledger_subject_suggestions_no_update BEFORE UPDATE ON ledger_subject_suggestions BEGIN SELECT RAISE (ABORT, 'ledger_subject_suggestions rows are immutable'); END; CREATE TRIGGER ledger_subject_suggestions_no_delete BEFORE DELETE ON ledger_subject_suggestions BEGIN SELECT RAISE (ABORT, 'ledger_subject_suggestions rows are immutable'); END; CREATE TRIGGER manual_records_no_update BEFORE UPDATE ON manual_records BEGIN SELECT RAISE (ABORT, 'manual_records rows are immutable'); END; CREATE TRIGGER manual_records_no_delete BEFORE DELETE ON manual_records BEGIN SELECT RAISE (ABORT, 'manual_records rows are immutable'); END; CREATE TRIGGER manual_record_decisions_no_update BEFORE UPDATE ON manual_record_decisions BEGIN SELECT RAISE (ABORT, 'manual_record_decisions rows are immutable'); END; CREATE TRIGGER manual_record_decisions_no_delete BEFORE DELETE ON manual_record_decisions BEGIN SELECT RAISE (ABORT, 'manual_record_decisions rows are immutable'); END; """, down=""" DROP VIEW IF EXISTS eligible_position_events; DROP TRIGGER IF EXISTS manual_record_decisions_no_delete; DROP TRIGGER IF EXISTS manual_record_decisions_no_update; DROP TRIGGER IF EXISTS manual_records_no_delete; DROP TRIGGER IF EXISTS manual_records_no_update; DROP TRIGGER IF EXISTS ledger_subject_suggestions_no_delete; DROP TRIGGER IF EXISTS ledger_subject_suggestions_no_update; DROP TRIGGER IF EXISTS ledger_event_revisions_no_delete; DROP TRIGGER IF EXISTS ledger_event_revisions_no_update; DROP TRIGGER IF EXISTS ledger_events_no_update; DROP TRIGGER IF EXISTS ledger_events_no_delete; DROP TABLE IF EXISTS ledger_subject_suggestions; DROP TABLE IF EXISTS ledger_event_manual_sources; DROP TABLE IF EXISTS ledger_event_bank_sources; DROP TABLE IF EXISTS current_ledger_event_revisions; DROP TABLE IF EXISTS ledger_event_revisions; DROP TABLE IF EXISTS ledger_events; DROP TABLE IF EXISTS current_manual_record_decisions; DROP TABLE IF EXISTS manual_record_decisions; DROP TABLE IF EXISTS manual_records; """, ), ) def connect(path: str | Path) -> sqlite3.Connection: db_path = Path(path) if str(db_path) != ":memory:": db_path.parent.mkdir(parents=True, exist_ok=True) # Long busy timeout so concurrent uploads/confirmations wait for the # single SQLite writer instead of surfacing "database is locked" 500s. connection = sqlite3.connect(str(db_path), timeout=30) connection.row_factory = sqlite3.Row connection.execute("PRAGMA foreign_keys = ON") return connection def applied_versions(connection: sqlite3.Connection) -> list[int]: exists = connection.execute( "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'schema_migrations'" ).fetchone() if not exists: return [] rows = connection.execute( "SELECT version FROM schema_migrations ORDER BY version" ).fetchall() return [row["version"] for row in rows] def migrate(connection: sqlite3.Connection) -> list[int]: """Apply every pending migration; returns the versions applied now.""" connection.execute( """ CREATE TABLE IF NOT EXISTS schema_migrations ( version INTEGER PRIMARY KEY, name TEXT NOT NULL, applied_at TEXT NOT NULL ) """ ) applied = set(applied_versions(connection)) newly_applied: list[int] = [] for migration in MIGRATIONS: if migration.version in applied: continue with connection: connection.executescript(migration.up) connection.execute( "INSERT INTO schema_migrations (version, name, applied_at) VALUES (?, ?, ?)", (migration.version, migration.name, utc_now()), ) newly_applied.append(migration.version) return newly_applied def rollback(connection: sqlite3.Connection, target_version: int = 0) -> list[int]: """Reverse migrations above ``target_version``; returns reversed versions.""" applied = applied_versions(connection) reversed_versions: list[int] = [] for migration in sorted(MIGRATIONS, key=lambda item: item.version, reverse=True): if migration.version <= target_version or migration.version not in applied: continue with connection: connection.executescript(migration.down) connection.execute( "DELETE FROM schema_migrations WHERE version = ?", (migration.version,), ) reversed_versions.append(migration.version) return reversed_versions def main() -> int: parser = argparse.ArgumentParser(description="Apply or roll back database migrations.") parser.add_argument( "db_path", type=Path, nargs="?", default=DEFAULT_DB_PATH, help="SQLite database path (default: data/app.db)", ) parser.add_argument( "--rollback-to", type=int, default=None, metavar="VERSION", help="Reverse migrations above VERSION instead of migrating forward", ) args = parser.parse_args() connection = connect(args.db_path) try: if args.rollback_to is None: applied = migrate(connection) print(f"applied migrations: {applied or 'none (already up to date)'}") else: reversed_versions = rollback(connection, args.rollback_to) print(f"reversed migrations: {reversed_versions or 'none'}") finally: connection.close() return 0 if __name__ == "__main__": raise SystemExit(main())