- migration 6: manual_records, ledger_event_revisions chain, current projections, source claims, subject suggestions, eligible_position_events - ledger_events.py: bank-event reconciliation, reversal/adjustment/reopen, append-only revision chain and rebuildable current projection - subjects.py: fixed subject mirror, draft suggestion dictionary, explicit administrator subject confirmation with expected_revision + idempotency - manual_records.py: submit, approve new/link, return/exception/reverse, candidate hints, idempotent replay and concurrency-safe claims - positions.py: Decimal aggregation, both-perspective conservation asserts, cutoff window, unresolved gross buckets, keyset pagination, evidence visibility (visible/masked/missing) - server.py: admin + company intercompany APIs with tenant isolation (404 on cross-tenant reads, 403 on company writes) and auto reconcile wiring - admin/company portals: balance directory, pair drill-down drawer, evidence drawer, subject/manual audit queue, company balance summary - tests: ledger events, subjects, manual records, positions, HTTP API and migration persistence (233 total, all green)
904 lines
40 KiB
Python
904 lines
40 KiB
Python
"""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())
|