B-44: intercompany ledger events, subject review and drill-down evidence
- 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)
This commit is contained in:
@@ -569,6 +569,235 @@ MIGRATIONS: tuple[Migration, ...] = (
|
||||
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;
|
||||
""",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user