Files
wanderbooks/crates/ledger-store/src/schema.rs
T
swaitsandClaude Opus 5.5 d7fa16e974
check / check (push) Canceled after 0s
feat(arap)!: documents read their figures from standing lines; control accounts
refuse hand entries (P4b S1, part A)

Plan P4b (D-45), slice S1, the engine half: rules.md B-38.

- ledger-core: every receivables or payables line carries its document
  (Ledger.line_docs: DocRef, Movement, DocTag), written by the document
  commands in the step that posts it, derived on replay from the existing
  document events (the role's account as it stood then; payments by the
  ordinal rule), copied onto a reversal's mirror. Ledger::stands_on;
  doc_state / invoice_state / bill_state (total, paid, outstanding,
  status Open/Partial/Paid/WrittenOff/Void, overdue days) as of any day;
  ar_aging / ap_aging read them, so reversed payments and voids are right.
  The control guard (add_line, add_line_fx, post_entry,
  register_recurring) refuses untagged lines on receivables/payables,
  exempting a reversal's mirror of a legacy line; replay never refuses.
  Bounds (kernel::running_bounds, bounds_kept) on payments and reversals;
  a document reversal never dated before its entry; deposit/payer accounts
  must be cash-like and hold no role. New API: receive_payment_with /
  pay_bill_with (PaymentDetails: instrument, reference), void_document /
  check_void_document, control_check, payments_recorded,
  standing_payments, replay_reverse_entry. Invoice.amount_paid/status
  remain only as derived caches for the MCP layer until part B.
  cashflow bill_splits reads payments from the tags.
- ledger-store: journal feature document_standing (D-39 gated) for a
  payment accepted only because an earlier one was reversed;
  receive_payment_with / pay_bill_with (instrument journaled as
  entry.instrument_set, reference in the description), void_document;
  replay uses replay_reverse_entry; schema 15 drops the projection's
  amount_paid/status. Fixture p4-arap-before + golden figures written by
  the version before S1, adjudicated in arap_standing.rs.
- ledger-gui: party card, cash "coming up" and company owed totals read
  doc_state as of today (late from the derived state).
- Lean Documents.lean (control_agrees and friends); Kani
  bounds_check_matches_definition,
bounds_kept_is_the_definition_on_books_in_bounds;
  rules B-38, B-18/B-23 amended; bookspec `older = true` for legacy hand
  lines; tests that hand-post to 1200/2000 post as replay or renumbered.

Review fixes (both reviews of part A):
- control_role_of guards only an account the role is assigned to or one
  holding its documents' lines (a chart's own 1200 Equipment or card at
  2000 is free until a document posts there); payment accounts check
  other roles by holds_role (type-checked). The MCP tests that failed on
  the guard (oracle_mcp, spec_projects) pass unchanged.
- standing_payments leaves out reversals (sorted, deduped); replayed
  tags match the movement's side; payments_recorded counts every payment
  event (tagged or not, Ledger.payment_events) and refuses overflow.
- The record caches settled (paid + discounted + written off) and owed
  (Invoice/Bill.owed, never serialized): a void never reads as paid.
- assign_role: receivables/payables refuse moving onto an account with
  untagged lines (RoleAccountHasHandLines) and, on their control account
  holding a balance, say DocumentRoleHoldsBalance (no hand entry named);
  B-23 reworded. The reversal-mirror exemption only takes a hand line
  off (even chain depth); putting it back is refused.
- DocStatus Overpaid and Unattached; aging leaves out credit balances;
  ControlCheck.attention lists out-of-bounds and unattached documents.
- stands_on counts a chain in order; indexes (doc_index, doc_accounts,
  order_pos, reversed_by) keep the derived figures linear.
- Viewer reads a post-dated document as of its own date; one state per
  row. bounds_kept's side-crossing documented as intended.
- Tests: ten new spec_documents scenarios, stronger proptests, store
  proptest arap_props (live = replay = snapshot at every date, closed
  months), fuzz ops void/payments with details, a GUI post-dated test;
  Lean Step.assign / Step.release with unattached_zero kept; model
  limits stated in docs/testing.md and B-38.
Review fixes (CPA and engineering lenses): only an assigned role's
account or one holding document lines is a control account (a chart's
own 1200 asset stays free); a role can't move onto untagged lines or off
document lines (Lean Step.assign); a void's cache never reads as paid;
no Decimal sum panics; per-document indexes keep figures linear; a
replayed tag matches its side; the feature gate counts payment events;
a payment's reversal is never listed as standing; an overpaid document
has its own status; the viewer keeps post-dated documents.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
2026-10-24 09:17:42 -06:00

401 lines
13 KiB
Rust

//! SQLite schema and migrations.
//!
//! The projection schema. Amounts are TEXT (decimal strings) — never REAL.
//! Migrations are numbered SQL scripts applied in order; `SCHEMA_VERSION`
//! is the current version.
/// Current schema version.
pub const SCHEMA_VERSION: u32 = 15;
/// Migrations, in order. `MIGRATIONS[i]` migrates from version `i` to `i+1`.
pub const MIGRATIONS: &[&str] = &[
r#"
CREATE TABLE IF NOT EXISTS meta(
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS accounts(
id INTEGER PRIMARY KEY,
code TEXT UNIQUE NOT NULL,
name TEXT NOT NULL,
type TEXT NOT NULL,
parent INTEGER NULL,
placeholder INTEGER NOT NULL,
currency TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS entries(
id INTEGER PRIMARY KEY,
date TEXT NOT NULL,
description TEXT NOT NULL,
status TEXT NOT NULL,
reversal_of INTEGER NULL
);
CREATE TABLE IF NOT EXISTS lines(
id INTEGER PRIMARY KEY AUTOINCREMENT,
entry_id INTEGER NOT NULL,
account_id INTEGER NOT NULL,
side TEXT NOT NULL,
amount TEXT NOT NULL,
currency TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS blobs(
sha256 TEXT PRIMARY KEY,
size_bytes INTEGER NOT NULL,
mime_type TEXT NOT NULL,
original_name TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS attachments(
id INTEGER PRIMARY KEY,
blob_sha TEXT NOT NULL,
entry_id INTEGER NULL,
category TEXT NOT NULL,
description TEXT NULL,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS events(
seq INTEGER PRIMARY KEY AUTOINCREMENT,
type TEXT NOT NULL,
payload TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_lines_entry ON lines(entry_id);
CREATE INDEX IF NOT EXISTS idx_entries_date ON entries(date);
CREATE INDEX IF NOT EXISTS idx_attachments_entry ON attachments(entry_id);
"#,
r#"
-- v2: fixed-asset register and depreciation runs.
CREATE TABLE IF NOT EXISTS assets(
id INTEGER PRIMARY KEY,
description TEXT NOT NULL,
purchase_date TEXT NOT NULL,
cost_amount TEXT NOT NULL,
cost_currency TEXT NOT NULL,
salvage_amount TEXT NOT NULL,
salvage_currency TEXT NOT NULL,
useful_life_months INTEGER NOT NULL,
method TEXT NOT NULL,
cost_account INTEGER NOT NULL,
accum_depr_account INTEGER NOT NULL,
expense_account INTEGER NOT NULL,
status TEXT NOT NULL,
disposed_date TEXT NULL,
proceeds_amount TEXT NULL
);
CREATE TABLE IF NOT EXISTS asset_depreciation(
asset_id INTEGER NOT NULL,
period TEXT NOT NULL,
entry_id INTEGER NOT NULL,
amount TEXT NOT NULL,
PRIMARY KEY (asset_id, period)
);
"#,
r#"
-- v3: accounting periods, A/R + A/P, cleared-line flags, FX rates, and
-- foreign-currency columns on lines. Amounts are TEXT, never REAL.
CREATE TABLE IF NOT EXISTS periods(
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
start TEXT NOT NULL,
end TEXT NOT NULL,
status TEXT NOT NULL,
closing_entry INTEGER NULL
);
CREATE TABLE IF NOT EXISTS customers(
id INTEGER PRIMARY KEY,
name TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS vendors(
id INTEGER PRIMARY KEY,
name TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS invoices(
id INTEGER PRIMARY KEY,
customer_id INTEGER NOT NULL,
date TEXT NOT NULL,
due_date TEXT NOT NULL,
lines_json TEXT NOT NULL,
amount_paid TEXT NOT NULL,
status TEXT NOT NULL,
entry_id INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS bills(
id INTEGER PRIMARY KEY,
vendor_id INTEGER NOT NULL,
date TEXT NOT NULL,
due_date TEXT NOT NULL,
lines_json TEXT NOT NULL,
amount_paid TEXT NOT NULL,
status TEXT NOT NULL,
entry_id INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS cleared_lines(
entry_id INTEGER NOT NULL,
line_index INTEGER NOT NULL,
PRIMARY KEY (entry_id, line_index)
);
CREATE TABLE IF NOT EXISTS fx_rates(
currency TEXT NOT NULL,
date TEXT NOT NULL,
rate TEXT NOT NULL,
PRIMARY KEY (currency, date)
);
CREATE INDEX IF NOT EXISTS idx_invoices_customer ON invoices(customer_id);
CREATE INDEX IF NOT EXISTS idx_bills_vendor ON bills(vendor_id);
ALTER TABLE lines ADD COLUMN foreign_currency TEXT;
ALTER TABLE lines ADD COLUMN foreign_amount TEXT;
"#,
r#"
-- v4: classes, payroll runs, and class_id on lines. Amounts are TEXT,
-- never REAL.
CREATE TABLE IF NOT EXISTS classes(
id INTEGER PRIMARY KEY,
name TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS payroll_runs(
id INTEGER PRIMARY KEY,
date TEXT NOT NULL,
entry_id INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS payroll_lines(
id INTEGER PRIMARY KEY AUTOINCREMENT,
run_id INTEGER NOT NULL,
employee_name TEXT NOT NULL,
gross_pay TEXT NOT NULL,
federal_withholding TEXT NOT NULL,
state_withholding TEXT NOT NULL,
ss_employee TEXT NOT NULL,
medicare_employee TEXT NOT NULL,
ss_employer TEXT NOT NULL,
medicare_employer TEXT NOT NULL,
other_deductions TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_payroll_lines_run ON payroll_lines(run_id);
ALTER TABLE lines ADD COLUMN class_id INTEGER;
"#,
r#"
-- v5: withdrawn attachments. Withdrawal retires a receipt or document from
-- listings and linking; the bytes stay in the blob store and the journal
-- keeps the filing and the withdrawal.
ALTER TABLE attachments ADD COLUMN withdrawn_at TEXT;
ALTER TABLE attachments ADD COLUMN withdrawn_reason TEXT;
"#,
r#"
-- v6: tax categories on accounts (where each account lands on the return,
-- and how much of it is deductible). Percent is TEXT, never REAL.
ALTER TABLE accounts ADD COLUMN tax_category TEXT;
ALTER TABLE accounts ADD COLUMN deductible_percent TEXT;
"#,
r#"
-- v7: 1099 tracking: which return each vendor gets, whether its W-9 is on
-- file (never the TIN itself), and the vendor each entry paid.
ALTER TABLE vendors ADD COLUMN form_1099 TEXT;
ALTER TABLE vendors ADD COLUMN tin_on_file INTEGER NOT NULL DEFAULT 0;
CREATE TABLE IF NOT EXISTS entry_vendors(
entry_id INTEGER PRIMARY KEY,
vendor_id INTEGER NOT NULL
);
"#,
r#"
-- v8: each filing keeps its own file name and type. Before, they came from
-- the blob, so filing the same bytes again under another name listed the
-- first name (found by the spec-blind store suite, 2026-10-13). Existing
-- rows take the blob's; a database whose filings differed then fails
-- verify's table check and is rebuilt from the journal.
ALTER TABLE attachments ADD COLUMN file_name TEXT;
ALTER TABLE attachments ADD COLUMN mime_type TEXT;
UPDATE attachments SET
file_name = (SELECT original_name FROM blobs WHERE sha256 = attachments.blob_sha),
mime_type = (SELECT mime_type FROM blobs WHERE sha256 = attachments.blob_sha);
"#,
r#"
-- v9: asset tags, serial numbers, and each asset's acquisition entry. assets.tag is the current tag;
-- asset_tags_log keeps every tagging in journal order, because a tag is
-- never reused by another asset even after a retag.
ALTER TABLE assets ADD COLUMN tag TEXT;
ALTER TABLE assets ADD COLUMN serial TEXT;
ALTER TABLE assets ADD COLUMN acquisition_entry INTEGER;
CREATE TABLE IF NOT EXISTS asset_tags_log(
seq INTEGER PRIMARY KEY AUTOINCREMENT,
asset_id INTEGER NOT NULL,
tag TEXT NOT NULL
);
"#,
r#"
-- v10: each asset's federal tax basis: the rule (cost, carryover,
-- personal_use_conversion) and the contributing member's adjusted basis
-- (TEXT, exact decimal, never REAL).
ALTER TABLE assets ADD COLUMN tax_rule TEXT;
ALTER TABLE assets ADD COLUMN contributor_basis TEXT;
"#,
r#"
-- v11: attachments link to any subject (docs/parties-and-attachments.md):
-- entries beyond the first, accounts, assets, parties, invoices, bills,
-- periods, payroll runs, schedules, reconciliations, the company. Bank and
-- card statements carry their account, period, and balances; a
-- reconciliation records the statement, its balance, and the lines it
-- cleared. Amounts are TEXT, exact decimal.
CREATE TABLE IF NOT EXISTS attachment_links(
attachment_id INTEGER NOT NULL,
subject_kind TEXT NOT NULL,
subject_id INTEGER NOT NULL,
PRIMARY KEY(attachment_id, subject_kind, subject_id)
);
CREATE INDEX IF NOT EXISTS idx_attachment_links_subject ON attachment_links(subject_kind, subject_id);
-- Payroll lines name their employee party (Ledger PayrollLine.employee).
ALTER TABLE payroll_lines ADD COLUMN employee_id INTEGER;
CREATE TABLE IF NOT EXISTS statements(
attachment_id INTEGER PRIMARY KEY,
account_id INTEGER NOT NULL,
period_start TEXT NOT NULL,
period_end TEXT NOT NULL,
opening_balance TEXT NOT NULL,
closing_balance TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS reconciliations(
id INTEGER PRIMARY KEY,
account_id INTEGER NOT NULL,
statement_date TEXT NOT NULL,
statement_balance TEXT NOT NULL,
attachment_id INTEGER NOT NULL,
voided_reason TEXT NULL
);
CREATE TABLE IF NOT EXISTS reconciliation_lines(
reconciliation_id INTEGER NOT NULL,
entry_id INTEGER NOT NULL,
line_index INTEGER NOT NULL,
PRIMARY KEY(reconciliation_id, entry_id, line_index)
);
"#,
r#"
-- v12: the books' own record of how they are kept (journal format 2,
-- plan/served.md 5.2): settings in force (each value as canonical JSON,
-- with the seq and source that set it), conventions (the current
-- revision of each, and its retirement), decisions, open items, and
-- approvals of draft entries.
CREATE TABLE IF NOT EXISTS settings(
key TEXT PRIMARY KEY,
value_json TEXT NOT NULL,
seq INTEGER NOT NULL,
source_json TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS conventions(
id TEXT PRIMARY KEY,
kind TEXT NOT NULL,
title TEXT NOT NULL,
body_md TEXT NOT NULL,
structured_json TEXT NULL,
source_json TEXT NOT NULL,
seq INTEGER NOT NULL,
revision INTEGER NOT NULL,
retired_reason TEXT NULL,
retired_source_json TEXT NULL,
retired_seq INTEGER NULL
);
CREATE TABLE IF NOT EXISTS decisions(
id INTEGER PRIMARY KEY,
subject TEXT NOT NULL,
decision_md TEXT NOT NULL,
decided_by_json TEXT NOT NULL,
decided_on TEXT NOT NULL,
evidence_attachment INTEGER NULL,
supersedes INTEGER NULL,
citation TEXT NULL,
seq INTEGER NOT NULL,
superseded_by INTEGER NULL
);
CREATE TABLE IF NOT EXISTS open_items(
id INTEGER PRIMARY KEY,
title TEXT NOT NULL,
detail_md TEXT NULL,
due TEXT NULL,
source_json TEXT NOT NULL,
seq INTEGER NOT NULL,
resolution TEXT NULL,
resolved_source_json TEXT NULL,
resolved_seq INTEGER NULL
);
CREATE TABLE IF NOT EXISTS entry_approvals(
seq INTEGER PRIMARY KEY,
entry_id INTEGER NOT NULL,
approver TEXT NOT NULL,
fingerprint TEXT NOT NULL,
note TEXT NULL,
source_json TEXT NOT NULL
);
"#,
r#"
-- v13: 1099 decisions and payment methods (plan P3, defects 12, 18 and
-- 23; D-40). Each 1099 decision recorded with who decided (journal
-- feature vendor_1099_decisions), line by line: the date it takes effect
-- (NULL: from the beginning), nec, misc, not_reportable or NULL
-- (undetermined), and for not_reportable its kind and reason. A decision
-- from the beginning also sets vendors.form_1099, as every decision
-- written before these did. Payment methods (feature payment_instruments)
-- and the method each entry was paid with. Rows only come from lines
-- written under the features, so books written before them project the
-- same as before.
CREATE TABLE IF NOT EXISTS vendor_1099_decisions(
seq INTEGER PRIMARY KEY,
vendor_id INTEGER NOT NULL,
effective TEXT NULL,
form_1099 TEXT NULL,
reason_kind TEXT NULL,
reason TEXT NULL,
source_json TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS instruments(
id INTEGER PRIMARY KEY,
kind TEXT NOT NULL,
label TEXT NOT NULL,
last4 TEXT NULL,
owner_party INTEGER NULL,
account_id INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS entry_instruments(
entry_id INTEGER PRIMARY KEY,
instrument_id INTEGER NOT NULL
);
"#,
r#"
-- v14: assets' basis facts and their depreciation runs keyed by entry
-- (plan P3, defect 13; D-41). Its own migration: v13 shipped on main
-- before these columns, so a v13 file already built never gains them by
-- an edit to v13 (each schema's file is rebuilt from the journal, and a
-- migration's text never changes once shipped).
--
-- An asset's basis facts beyond the rule and contributor basis
-- (asset.basis_set): the fair market value at contribution (TEXT, exact
-- decimal), the §704(c) method, and who gave the facts (JSON). tax_rule
-- keeps the rule as journaled, by its pre-P3 name or its wire name.
-- Depreciation runs keyed by their entry too: the journal can hold a
-- second run for a month whose first was reversed, and the projection
-- keeps every run, as replay does.
ALTER TABLE assets ADD COLUMN fmv_at_contribution TEXT;
ALTER TABLE assets ADD COLUMN sec704c_method TEXT;
ALTER TABLE assets ADD COLUMN basis_source_json TEXT;
CREATE TABLE asset_depreciation_v13(
asset_id INTEGER NOT NULL,
period TEXT NOT NULL,
entry_id INTEGER NOT NULL,
amount TEXT NOT NULL,
PRIMARY KEY (asset_id, period, entry_id)
);
INSERT INTO asset_depreciation_v13(asset_id, period, entry_id, amount)
SELECT asset_id, period, entry_id, amount FROM asset_depreciation;
DROP TABLE asset_depreciation;
ALTER TABLE asset_depreciation_v13 RENAME TO asset_depreciation;
"#,
r#"
-- v15: a document keeps no figure of its own (plan P4b S1, D-45; rules.md
-- B-38): what an invoice or bill has been paid, and its status, are read
-- from the ledger lines tagged with it, as of any date, and a reversed
-- payment no longer counts. The columns that summed its payment events
-- (and so went on counting a reversed one) go.
ALTER TABLE invoices DROP COLUMN amount_paid;
ALTER TABLE invoices DROP COLUMN status;
ALTER TABLE bills DROP COLUMN amount_paid;
ALTER TABLE bills DROP COLUMN status;
"#,
];