Documentation

SQLite schema

Clovis schema v3 explained with the ERD, table families, fields, indexes, triggers, and canonical DDL.

npm

Schema v4

Facts first. Balances later.

Clovis stores accounting facts in finalized journal lines. Balances are calculated by summing those facts.

Tables19
Fields173
Indexes15
Triggers17

Core ledger path

The heart of Clovis is small: transaction headers in journals, balanced legs in journal_lines, buckets in accounts, and units in assets.

1booksworkspace

One database can hold multiple books. Every account, journal, and line is scoped back to one book.

2accountsposting buckets

The buckets for cash, cards, liabilities, income, expenses, equity, and anything else you report on.

3journalstransaction headers

The dated event: description, source, status, import context, and the lock point for posting.

4journal_linesbalanced facts

The debits and credits. Finalized journals need lines, and every posted journal must balance to zero.

5assetsunits

The currency, security, or unit attached to each line, price, lot, and conversion.

Generated ERD

Foreign keys are parsed from the current SQLite DDL, then drawn as one-to-many relationships.

Valuation and investments

Asset conversion rates and investment cost-basis memory.

Rules that protect the ledger

SQLite handles durable invariants; the TypeScript engine handles higher-level accounting policy.

Per-asset balanceFinalized journals must sum to zero for every asset.
Draft then finalizeTransactions are inserted as drafts, given lines, then finalized.
Immutable factsFinalized journal lines and reviewed statement rows cannot be casually rewritten.
Closed periodsPeriod closes block old journal finalization and reopening until explicitly reopened.

Tables and fields

Exact columns come from the canonical DDL. Descriptions come from the package schema explainer.

Identitymeta2 fields

Think of meta as a label stuck to the database file.

keyTEXT
primary key

Metadata name. Primary key.

valueTEXT
not null

Metadata value, stored as text.

Identitymigration_history3 fields

Think of migration_history as the upgrade receipt.

versionINTEGER
primary key

Schema version that was applied. Primary key.

nameTEXT
not null

Short migration name.

applied_atTEXT
not null

Timestamp when the migration ran.

Identitybooks6 fields

Think of a book as a ledger container.

idTEXT
primary key

Book ID.

nameTEXT
not nullunique

Human name. Unique.

typeTEXT
not nullcheck

Either actual or scenario.

parent_idTEXT
references books

Optional parent book.

created_atTEXT
not null

Creation timestamp.

closed_atTEXT

Scenario close/discard timestamp.

Accounting coreassets5 fields

Assets are the units used by transactions.

idTEXT
primary key

Asset ID.

symbolTEXT
not nullunique

Unique uppercase symbol, like CAD or MSFT.

typeTEXT
not nullcheck

currency, commodity, custom, or security.

scaleINTEGER
not nullcheck

Decimal places used for integer storage.

nameTEXT
not null

Human name.

Accounting coreaccounts9 fields

Accounts are the buckets.

idTEXT
primary key

Account ID.

book_idTEXT
not null

Owning book.

nameTEXT
not null

Account name, unique inside the book.

typeTEXT
not nullcheck

asset, liability, equity, income, or expense.

parent_idTEXT

Optional parent account.

default_asset_idTEXT
references assets

Optional default asset/currency for tools that need an asset.

codeTEXT
not null

Optional chart-of-accounts code.

color_hexTEXT
not nulldefault #888888

Display color.

statusTEXT
not nulldefault active

Lifecycle marker, default active.

Table constraintsUNIQUE(id, book_id)UNIQUE(book_id, name)FOREIGN KEY(book_id) REFERENCES books(id)FOREIGN KEY(parent_id, book_id) REFERENCES accounts(id, book_id)
Workflow memorysources7 fields

Sources remember where transactions came from.

idTEXT
primary key

Source ID. Import batches usually use batch_....

book_idTEXT
not nullreferences books

Owning book.

typeTEXT
not null

Source type, often import.

labelTEXT
not null

Human label.

statusTEXT
not nulldefault open

Workflow status.

created_atTEXT
not null

Creation timestamp.

metadata_jsonTEXT
not nulldefault {}

JSON metadata string.

Table constraintsUNIQUE(id, book_id)
Accounting corejournals9 fields

journals are transaction headers.

idTEXT
primary key

Transaction ID.

book_idTEXT
not null

Owning book.

source_idTEXT

Optional import/source batch.

dateTEXT
not null

Economic date, YYYY-MM-DD.

posted_atTEXT
not null

Time the row was inserted.

finalized_atTEXT

Time the journal became part of the public ledger. Null means draft.

statusTEXT
not nullcheck

posted, pending, planned, or void.

descriptionTEXT
not null

Payee/memo text.

external_idTEXT

Optional external source row ID.

Table constraintsUNIQUE(id, book_id)FOREIGN KEY(book_id) REFERENCES books(id)FOREIGN KEY(source_id, book_id) REFERENCES sources(id, book_id)
Accounting corejournal_lines8 fields

journal_lines are the actual accounting facts.

idTEXT
primary key

Line ID.

book_idTEXT
not null

Owning book.

journal_idTEXT
not null

Parent transaction.

line_noINTEGER
not null

1-based order within the transaction.

account_idTEXT
not null

Account that changed.

asset_idTEXT
not nullreferences assets

Unit/currency of the quantity.

quantityINTEGER
not null

Signed integer atomic units.

memoTEXT
not null

Optional line memo.

Table constraintsUNIQUE(journal_id, line_no)FOREIGN KEY(journal_id, book_id) REFERENCES journals(id, book_id) ON DELETE CASCADEFOREIGN KEY(account_id, book_id) REFERENCES accounts(id, book_id)
Valuation and investmentsprices7 fields

Prices tell Clovis how to convert one asset into another.

idTEXT
primary key

Price ID.

book_idTEXT
not nullreferences books

Owning book.

asset_idTEXT
not nullreferences assets

Asset being priced.

quote_asset_idTEXT
not nullreferences assets

Asset used as the quote.

rate_valueINTEGER
not nullcheck

Positive integer rate coefficient.

rate_scaleINTEGER
not nullcheck

Decimal places for rate_value.

timeTEXT
not null

Effective date/time.

Table constraintsUNIQUE(id, book_id)
Workflow memoryannotations6 fields

Annotations are sticky notes.

idTEXT
primary key

Annotation ID.

book_idTEXT
not nullreferences books

Owning book.

entity_typeTEXT
not null

What kind of thing is tagged: account, tx, book, etc.

entity_idTEXT
not null

ID of the thing being tagged.

keyTEXT
not null

Tag key.

valueTEXT
not null

Tag value.

Workflow memoryrules8 fields

Rules help categorize transactions.

idTEXT
primary key

Rule ID.

book_idTEXT
not null

Owning book.

typeTEXT
not null

Rule kind, commonly match.

account_idTEXT

Target account.

patternTEXT
not null

Text pattern to match.

priorityINTEGER
not nulldefault 100

Evaluation order.

statusTEXT
not nulldefault active

active or soft-deleted state.

created_atTEXT
not null

Creation timestamp.

Table constraintsUNIQUE(id, book_id)FOREIGN KEY(book_id) REFERENCES books(id)FOREIGN KEY(account_id, book_id) REFERENCES accounts(id, book_id)
Planning and controltargets14 fields

Targets store budgets and goals.

idTEXT
primary key

Target ID.

book_idTEXT
not null

Owning book.

typeTEXT
not nullcheck

budget or goal.

account_idTEXT
not null

Account being planned for.

asset_idTEXT
not nullreferences assets

Unit/currency.

quantityINTEGER
not nullcheck

Budget or goal amount in atomic units.

periodTEXT
check

monthly, yearly, or null. Mostly for budgets.

yearINTEGER

Optional budget year.

monthINTEGER
check

Optional budget month.

rollover_ruleTEXT
not null

Budget rollover marker.

nameTEXT
not null

Goal name.

target_dateTEXT

Goal date.

priorityINTEGER
not nulldefault 1

Goal ordering.

statusTEXT
not nulldefault active

Lifecycle marker.

Table constraintsUNIQUE(id, book_id)FOREIGN KEY(book_id) REFERENCES books(id)FOREIGN KEY(account_id, book_id) REFERENCES accounts(id, book_id)
Planning and controlrecurrences11 fields

Recurrences are scheduled transaction templates.

idTEXT
primary key

Recurrence ID.

book_idTEXT
not null

Owning book.

next_dateTEXT
not null

Next scheduled date.

quantityINTEGER
not nullcheck

Positive atomic amount.

from_account_idTEXT
not null

Source account.

to_account_idTEXT
not null

Destination account.

descriptionTEXT
not null

Transaction description.

frequencyTEXT
not nullcheck

daily, weekly, monthly, or yearly.

end_dateTEXT

Optional end date.

asset_idTEXT
not nullreferences assets

Unit/currency.

statusTEXT
not nulldefault activecheck

active, paused, or deleted.

Table constraintsUNIQUE(id, book_id)FOREIGN KEY(book_id) REFERENCES books(id)FOREIGN KEY(from_account_id, book_id) REFERENCES accounts(id, book_id)FOREIGN KEY(to_account_id, book_id) REFERENCES accounts(id, book_id)
Planning and controlperiod_closes7 fields

Period closes are locks on old accounting periods.

idTEXT
primary key

Period close ID.

book_idTEXT
not nullreferences books

Owning book.

nameTEXT
not null

Human checkpoint name.

as_ofTEXT
not null

Closed-through date.

descriptionTEXT

Optional note.

created_atTEXT
not null

Creation timestamp.

reopened_atTEXT

Reopen timestamp, null while still closed.

Table constraintsUNIQUE(id, book_id)
Valuation and investmentslots13 fields

Lots track investment cost basis.

idTEXT
primary key

Lot ID.

book_idTEXT
not null

Owning book.

account_idTEXT
not null

Holding account.

asset_idTEXT
not nullreferences assets

Held asset/security.

quantityINTEGER
not nullcheck

Positive held quantity.

cost_asset_idTEXT
not nullreferences assets

Asset used to measure cost.

cost_quantityINTEGER
not nullcheck

Positive cost amount.

opened_journal_idTEXT
not null

Transaction that opened the lot.

closed_journal_idTEXT

Transaction that closed the lot, if any.

opened_atTEXT
not null

Open date.

closed_atTEXT

Close date.

statusTEXT
not nulldefault opencheck

open or closed.

metadata_jsonTEXT
not nulldefault {}

JSON metadata string.

Table constraintsUNIQUE(id, book_id)FOREIGN KEY(book_id) REFERENCES books(id)FOREIGN KEY(account_id, book_id) REFERENCES accounts(id, book_id)FOREIGN KEY(opened_journal_id, book_id) REFERENCES journals(id, book_id)FOREIGN KEY(closed_journal_id, book_id) REFERENCES journals(id, book_id)
Workflow memorystatement_plans16 fields

Think of a statement plan as a locked worksheet.

idTEXT
primary key

Statement plan ID.

book_idTEXT
not null

Owning book.

account_idTEXT
not null

Statement account being reconciled.

asset_idTEXT
not nullreferences assets

Currency/unit for the plan.

source_idTEXT

Import batch/source created when the plan is applied.

statusTEXT
not nulldefault plannedcheck

planned, applied, or discarded.

statement_kindTEXT
not null

File/workflow kind, such as bank, card, QFX, or CSV.

file_nameTEXT
not null

Source filename, not the full local path.

file_sha256TEXT
not null

Hash of the source file content.

expected_balanceINTEGER

Optional outside balance supplied by the statement.

planned_balanceINTEGER
not null

Ledger balance after applying the plan.

applied_balanceINTEGER

Actual balance recorded after apply.

created_atTEXT
not null

Plan creation timestamp.

applied_atTEXT

Apply timestamp, null until applied.

discarded_atTEXT

Discard timestamp, null unless discarded.

metadata_jsonTEXT
not nulldefault {}

JSON options used to build the plan.

Table constraintsUNIQUE(id, book_id)FOREIGN KEY(book_id) REFERENCES books(id)FOREIGN KEY(account_id, book_id) REFERENCES accounts(id, book_id)FOREIGN KEY(source_id, book_id) REFERENCES sources(id, book_id)
Workflow memorystatement_plan_rows15 fields

Think of statement plan rows as the individual marks on the worksheet.

idTEXT
primary key

Plan row ID.

book_idTEXT
not null

Owning book.

plan_idTEXT
not null

Parent statement plan.

row_indexINTEGER
not null

Source-row order inside the plan.

dateTEXT
not null

Statement row date.

quantityINTEGER
not null

Signed atomic quantity from the statement account's view.

descriptionTEXT
not null

Statement row description.

external_idTEXT

Stable statement ID such as QFX/OFX FITID, if present.

row_hashTEXT
not null

Hash of the normalized source row.

actionTEXT
not nullcheck

Planned action.

matched_journal_idTEXT

Existing journal used by matched, pending_to_commit, or stale-pending actions.

created_journal_idTEXT

Journal created when the row is applied.

counterpart_account_idTEXT

Other side of a new transaction, if needed.

reasonTEXT
not null

Human-readable reason for the action.

metadata_jsonTEXT
not nulldefault {}

Source amount, tags, and parser context.

Table constraintsUNIQUE(plan_id, row_index)FOREIGN KEY(book_id) REFERENCES books(id)FOREIGN KEY(plan_id, book_id) REFERENCES statement_plans(id, book_id) ON DELETE CASCADEFOREIGN KEY(matched_journal_id, book_id) REFERENCES journals(id, book_id)FOREIGN KEY(created_journal_id, book_id) REFERENCES journals(id, book_id)FOREIGN KEY(counterpart_account_id, book_id) REFERENCES accounts(id, book_id)
Otherledger_operations13 fields

idTEXT
primary key

book_idTEXT
not null

tool_nameTEXT
not null

operation_typeTEXT
not null

statusTEXT
not nulldefault appliedcheck

created_atTEXT
not null

reversed_atTEXT

reversed_by_operation_idTEXT

reverses_operation_idTEXT

input_jsonTEXT
not nulldefault {}

preview_jsonTEXT
not nulldefault {}

result_jsonTEXT
not nulldefault {}

metadata_jsonTEXT
not nulldefault {}

Table constraintsUNIQUE(id, book_id)FOREIGN KEY(book_id) REFERENCES books(id)FOREIGN KEY(reversed_by_operation_id, book_id) REFERENCES ledger_operations(id, book_id)FOREIGN KEY(reverses_operation_id, book_id) REFERENCES ledger_operations(id, book_id)
Otherledger_operation_rows14 fields

idTEXT
primary key

book_idTEXT
not null

operation_idTEXT
not null

row_indexINTEGER
not null

entity_typeTEXT
not null

entity_idTEXT
not null

actionTEXT
not nullcheck

before_hashTEXT

after_hashTEXT

before_jsonTEXT

after_jsonTEXT

correction_journal_idTEXT

reverse_journal_idTEXT

metadata_jsonTEXT
not nulldefault {}

Table constraintsUNIQUE(operation_id, row_index)FOREIGN KEY(book_id) REFERENCES books(id)FOREIGN KEY(operation_id, book_id) REFERENCES ledger_operations(id, book_id) ON DELETE CASCADEFOREIGN KEY(correction_journal_id, book_id) REFERENCES journals(id, book_id)FOREIGN KEY(reverse_journal_id, book_id) REFERENCES journals(id, book_id)

Indexes and triggers

Indexes keep common ledger scans fast. Triggers protect facts even when someone writes SQL directly.

Indexes

idx_journals_book_datejournals (book_id, date, id)
idx_journals_statusjournals (status, date)
idx_lines_journaljournal_lines (journal_id)
idx_lines_account_assetjournal_lines (account_id, asset_id)
idx_prices_pair_timeprices (book_id, asset_id, quote_asset_id, time)
idx_annotations_entityannotations (entity_type, entity_id, key)
idx_rules_type_statusrules (type, status, priority)
idx_targets_budgettargets (book_id, type, account_id, asset_id, period, coalesce(year, -1), coalesce(month, -1)) where type = 'budget'
idx_targets_goaltargets (book_id, type, account_id, asset_id) where type = 'goal'
idx_period_closes_book_as_ofperiod_closes (book_id, as_of)
idx_statement_plans_account_statusstatement_plans (book_id, account_id, status, created_at)
idx_statement_plan_rows_plan_actionstatement_plan_rows (plan_id, action, row_index)
idx_statement_plan_rows_hashstatement_plan_rows (book_id, row_hash)
idx_ledger_operations_type_statusledger_operations (book_id, operation_type, status, created_at)
idx_ledger_operation_rows_operationledger_operation_rows (operation_id, row_index)

Triggers

trg_statement_plans_no_identity_updateStatement plan identity fields cannot be edited after creation.
trg_statement_plans_status_transitionStatement plans can only move from planned to applied or discarded.
trg_statement_plans_no_deleteStatement plans are audit records and cannot be deleted.
trg_statement_plan_rows_no_semantic_updateStatement plan row decisions are immutable.
trg_statement_plan_rows_created_onceA plan row can receive a created journal exactly once.
trg_statement_plan_rows_no_deleteStatement plan rows are audit records and cannot be deleted.
trg_ledger_operations_no_identity_updateledger operations no identity update
trg_ledger_operations_status_transitionledger operations status transition
trg_ledger_operations_no_deleteledger operations no delete
trg_ledger_operation_rows_no_updateledger operation rows no update
trg_ledger_operation_rows_no_deleteledger operation rows no delete
trg_journals_no_finalized_insertJournals must be inserted as drafts, then finalized.
trg_journals_finalize_requires_linesFinalization requires lines, per-asset balance, and an open period.
trg_journals_reopen_guardFinalized journals cannot be reopened when period or lot rules forbid it.
trg_lines_no_insert_finalizedFinalized journals cannot receive new lines.
trg_lines_no_update_finalizedFinalized journal lines cannot be edited.
trg_lines_no_delete_finalizedFinalized journal lines cannot be deleted.

Canonical DDL

This page is generated from src/core/schema.ts. The JSON version is available at /docs/clovis-schema.json.

Show full SQLite DDL

CREATE TABLE IF NOT EXISTS meta (
  key TEXT PRIMARY KEY,
  value TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS migration_history (
  version INTEGER PRIMARY KEY,
  name TEXT NOT NULL,
  applied_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS books (
  id TEXT PRIMARY KEY,
  name TEXT NOT NULL UNIQUE,
  type TEXT NOT NULL CHECK(type IN ('actual', 'scenario')),
  parent_id TEXT REFERENCES books(id),
  created_at TEXT NOT NULL,
  closed_at TEXT
);
CREATE TABLE IF NOT EXISTS assets (
  id TEXT PRIMARY KEY,
  symbol TEXT NOT NULL UNIQUE,
  type TEXT NOT NULL CHECK(type IN ('currency', 'commodity', 'custom', 'security')),
  scale INTEGER NOT NULL CHECK(scale >= 0 AND scale <= 18 AND scale = CAST(scale AS INTEGER)),
  name TEXT NOT NULL DEFAULT ''
);
CREATE TABLE IF NOT EXISTS accounts (
  id TEXT PRIMARY KEY,
  book_id TEXT NOT NULL,
  name TEXT NOT NULL,
  type TEXT NOT NULL CHECK(type IN ('asset', 'liability', 'equity', 'income', 'expense')),
  parent_id TEXT,
  default_asset_id TEXT REFERENCES assets(id),
  code TEXT NOT NULL DEFAULT '',
  color_hex TEXT NOT NULL DEFAULT '#888888',
  status TEXT NOT NULL DEFAULT 'active',
  UNIQUE(id, book_id),
  UNIQUE(book_id, name),
  FOREIGN KEY(book_id) REFERENCES books(id),
  FOREIGN KEY(parent_id, book_id) REFERENCES accounts(id, book_id)
);
CREATE TABLE IF NOT EXISTS sources (
  id TEXT PRIMARY KEY,
  book_id TEXT NOT NULL REFERENCES books(id),
  type TEXT NOT NULL,
  label TEXT NOT NULL DEFAULT '',
  status TEXT NOT NULL DEFAULT 'open',
  created_at TEXT NOT NULL,
  metadata_json TEXT NOT NULL DEFAULT '{}',
  UNIQUE(id, book_id)
);
CREATE TABLE IF NOT EXISTS journals (
  id TEXT PRIMARY KEY,
  book_id TEXT NOT NULL,
  source_id TEXT,
  date TEXT NOT NULL,
  posted_at TEXT NOT NULL,
  finalized_at TEXT,
  status TEXT NOT NULL CHECK(status IN ('posted', 'pending', 'planned', 'void')),
  description TEXT NOT NULL DEFAULT '',
  external_id TEXT,
  UNIQUE(id, book_id),
  FOREIGN KEY(book_id) REFERENCES books(id),
  FOREIGN KEY(source_id, book_id) REFERENCES sources(id, book_id)
);
CREATE INDEX IF NOT EXISTS idx_journals_book_date ON journals(book_id, date, id);
CREATE INDEX IF NOT EXISTS idx_journals_status ON journals(status, date);
CREATE TABLE IF NOT EXISTS journal_lines (
  id TEXT PRIMARY KEY,
  book_id TEXT NOT NULL,
  journal_id TEXT NOT NULL,
  line_no INTEGER NOT NULL,
  account_id TEXT NOT NULL,
  asset_id TEXT NOT NULL REFERENCES assets(id),
  quantity INTEGER NOT NULL,
  memo TEXT NOT NULL DEFAULT '',
  UNIQUE(journal_id, line_no),
  FOREIGN KEY(journal_id, book_id) REFERENCES journals(id, book_id) ON DELETE CASCADE,
  FOREIGN KEY(account_id, book_id) REFERENCES accounts(id, book_id)
);
CREATE INDEX IF NOT EXISTS idx_lines_journal ON journal_lines(journal_id);
CREATE INDEX IF NOT EXISTS idx_lines_account_asset ON journal_lines(account_id, asset_id);
CREATE TABLE IF NOT EXISTS prices (
  id TEXT PRIMARY KEY,
  book_id TEXT NOT NULL REFERENCES books(id),
  asset_id TEXT NOT NULL REFERENCES assets(id),
  quote_asset_id TEXT NOT NULL REFERENCES assets(id),
  rate_value INTEGER NOT NULL CHECK(rate_value > 0),
  rate_scale INTEGER NOT NULL CHECK(rate_scale >= 0 AND rate_scale <= 18 AND rate_scale = CAST(rate_scale AS INTEGER)),
  time TEXT NOT NULL,
  UNIQUE(id, book_id)
);
CREATE INDEX IF NOT EXISTS idx_prices_pair_time ON prices(book_id, asset_id, quote_asset_id, time);
CREATE TABLE IF NOT EXISTS annotations (
  id TEXT PRIMARY KEY,
  book_id TEXT NOT NULL REFERENCES books(id),
  entity_type TEXT NOT NULL,
  entity_id TEXT NOT NULL,
  key TEXT NOT NULL,
  value TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_annotations_entity ON annotations(entity_type, entity_id, key);
CREATE TABLE IF NOT EXISTS rules (
  id TEXT PRIMARY KEY,
  book_id TEXT NOT NULL,
  type TEXT NOT NULL,
  account_id TEXT,
  pattern TEXT NOT NULL,
  priority INTEGER NOT NULL DEFAULT 100,
  status TEXT NOT NULL DEFAULT 'active',
  created_at TEXT NOT NULL,
  UNIQUE(id, book_id),
  FOREIGN KEY(book_id) REFERENCES books(id),
  FOREIGN KEY(account_id, book_id) REFERENCES accounts(id, book_id)
);
CREATE INDEX IF NOT EXISTS idx_rules_type_status ON rules(type, status, priority);
CREATE TABLE IF NOT EXISTS targets (
  id TEXT PRIMARY KEY,
  book_id TEXT NOT NULL,
  type TEXT NOT NULL CHECK(type IN ('budget', 'goal')),
  account_id TEXT NOT NULL,
  asset_id TEXT NOT NULL REFERENCES assets(id),
  quantity INTEGER NOT NULL CHECK((type = 'budget' AND quantity >= 0) OR (type = 'goal' AND quantity > 0)),
  period TEXT CHECK(period IS NULL OR period IN ('monthly', 'yearly')),
  year INTEGER,
  month INTEGER CHECK(month IS NULL OR (month >= 1 AND month <= 12)),
  rollover_rule TEXT NOT NULL DEFAULT '',
  name TEXT NOT NULL DEFAULT '',
  target_date TEXT,
  priority INTEGER NOT NULL DEFAULT 1,
  status TEXT NOT NULL DEFAULT 'active',
  UNIQUE(id, book_id),
  FOREIGN KEY(book_id) REFERENCES books(id),
  FOREIGN KEY(account_id, book_id) REFERENCES accounts(id, book_id)
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_targets_budget ON targets(book_id, type, account_id, asset_id, period, coalesce(year, -1), coalesce(month, -1))
  WHERE type = 'budget';
CREATE UNIQUE INDEX IF NOT EXISTS idx_targets_goal ON targets(book_id, type, account_id, asset_id)
  WHERE type = 'goal';
CREATE TABLE IF NOT EXISTS recurrences (
  id TEXT PRIMARY KEY,
  book_id TEXT NOT NULL,
  next_date TEXT NOT NULL,
  quantity INTEGER NOT NULL CHECK(quantity > 0),
  from_account_id TEXT NOT NULL,
  to_account_id TEXT NOT NULL,
  description TEXT NOT NULL DEFAULT '',
  frequency TEXT NOT NULL CHECK(frequency IN ('daily', 'weekly', 'monthly', 'yearly')),
  end_date TEXT,
  asset_id TEXT NOT NULL REFERENCES assets(id),
  status TEXT NOT NULL DEFAULT 'active' CHECK(status IN ('active', 'paused', 'deleted')),
  UNIQUE(id, book_id),
  FOREIGN KEY(book_id) REFERENCES books(id),
  FOREIGN KEY(from_account_id, book_id) REFERENCES accounts(id, book_id),
  FOREIGN KEY(to_account_id, book_id) REFERENCES accounts(id, book_id)
);
CREATE TABLE IF NOT EXISTS period_closes (
  id TEXT PRIMARY KEY,
  book_id TEXT NOT NULL REFERENCES books(id),
  name TEXT NOT NULL,
  as_of TEXT NOT NULL,
  description TEXT,
  created_at TEXT NOT NULL,
  reopened_at TEXT,
  UNIQUE(id, book_id)
);
CREATE INDEX IF NOT EXISTS idx_period_closes_book_as_of ON period_closes(book_id, as_of);
CREATE TABLE IF NOT EXISTS lots (
  id TEXT PRIMARY KEY,
  book_id TEXT NOT NULL,
  account_id TEXT NOT NULL,
  asset_id TEXT NOT NULL REFERENCES assets(id),
  quantity INTEGER NOT NULL CHECK(quantity > 0),
  cost_asset_id TEXT NOT NULL REFERENCES assets(id),
  cost_quantity INTEGER NOT NULL CHECK(cost_quantity > 0),
  opened_journal_id TEXT NOT NULL,
  closed_journal_id TEXT,
  opened_at TEXT NOT NULL,
  closed_at TEXT,
  status TEXT NOT NULL DEFAULT 'open' CHECK(status IN ('open', 'closed')),
  metadata_json TEXT NOT NULL DEFAULT '{}',
  UNIQUE(id, book_id),
  FOREIGN KEY(book_id) REFERENCES books(id),
  FOREIGN KEY(account_id, book_id) REFERENCES accounts(id, book_id),
  FOREIGN KEY(opened_journal_id, book_id) REFERENCES journals(id, book_id),
  FOREIGN KEY(closed_journal_id, book_id) REFERENCES journals(id, book_id)
);
CREATE TABLE IF NOT EXISTS statement_plans (
  id TEXT PRIMARY KEY,
  book_id TEXT NOT NULL,
  account_id TEXT NOT NULL,
  asset_id TEXT NOT NULL REFERENCES assets(id),
  source_id TEXT,
  status TEXT NOT NULL DEFAULT 'planned' CHECK(status IN ('planned', 'applied', 'discarded')),
  statement_kind TEXT NOT NULL DEFAULT '',
  file_name TEXT NOT NULL DEFAULT '',
  file_sha256 TEXT NOT NULL DEFAULT '',
  expected_balance INTEGER,
  planned_balance INTEGER NOT NULL,
  applied_balance INTEGER,
  created_at TEXT NOT NULL,
  applied_at TEXT,
  discarded_at TEXT,
  metadata_json TEXT NOT NULL DEFAULT '{}',
  UNIQUE(id, book_id),
  FOREIGN KEY(book_id) REFERENCES books(id),
  FOREIGN KEY(account_id, book_id) REFERENCES accounts(id, book_id),
  FOREIGN KEY(source_id, book_id) REFERENCES sources(id, book_id)
);
CREATE INDEX IF NOT EXISTS idx_statement_plans_account_status ON statement_plans(book_id, account_id, status, created_at);
CREATE TABLE IF NOT EXISTS statement_plan_rows (
  id TEXT PRIMARY KEY,
  book_id TEXT NOT NULL,
  plan_id TEXT NOT NULL,
  row_index INTEGER NOT NULL,
  date TEXT NOT NULL,
  quantity INTEGER NOT NULL,
  description TEXT NOT NULL DEFAULT '',
  external_id TEXT,
  row_hash TEXT NOT NULL,
  action TEXT NOT NULL CHECK(action IN ('matched', 'pending_to_commit', 'new_posted', 'new_pending', 'stale_pending_to_void', 'ambiguous', 'ignored')),
  matched_journal_id TEXT,
  created_journal_id TEXT,
  counterpart_account_id TEXT,
  reason TEXT NOT NULL DEFAULT '',
  metadata_json TEXT NOT NULL DEFAULT '{}',
  UNIQUE(plan_id, row_index),
  FOREIGN KEY(book_id) REFERENCES books(id),
  FOREIGN KEY(plan_id, book_id) REFERENCES statement_plans(id, book_id) ON DELETE CASCADE,
  FOREIGN KEY(matched_journal_id, book_id) REFERENCES journals(id, book_id),
  FOREIGN KEY(created_journal_id, book_id) REFERENCES journals(id, book_id),
  FOREIGN KEY(counterpart_account_id, book_id) REFERENCES accounts(id, book_id)
);
CREATE INDEX IF NOT EXISTS idx_statement_plan_rows_plan_action ON statement_plan_rows(plan_id, action, row_index);
CREATE INDEX IF NOT EXISTS idx_statement_plan_rows_hash ON statement_plan_rows(book_id, row_hash);
CREATE TABLE IF NOT EXISTS ledger_operations (
  id TEXT PRIMARY KEY,
  book_id TEXT NOT NULL,
  tool_name TEXT NOT NULL,
  operation_type TEXT NOT NULL,
  status TEXT NOT NULL DEFAULT 'applied' CHECK(status IN ('applied', 'reversed')),
  created_at TEXT NOT NULL,
  reversed_at TEXT,
  reversed_by_operation_id TEXT,
  reverses_operation_id TEXT,
  input_json TEXT NOT NULL DEFAULT '{}',
  preview_json TEXT NOT NULL DEFAULT '{}',
  result_json TEXT NOT NULL DEFAULT '{}',
  metadata_json TEXT NOT NULL DEFAULT '{}',
  UNIQUE(id, book_id),
  FOREIGN KEY(book_id) REFERENCES books(id),
  FOREIGN KEY(reversed_by_operation_id, book_id) REFERENCES ledger_operations(id, book_id),
  FOREIGN KEY(reverses_operation_id, book_id) REFERENCES ledger_operations(id, book_id)
);
CREATE INDEX IF NOT EXISTS idx_ledger_operations_type_status ON ledger_operations(book_id, operation_type, status, created_at);
CREATE TABLE IF NOT EXISTS ledger_operation_rows (
  id TEXT PRIMARY KEY,
  book_id TEXT NOT NULL,
  operation_id TEXT NOT NULL,
  row_index INTEGER NOT NULL,
  entity_type TEXT NOT NULL,
  entity_id TEXT NOT NULL,
  action TEXT NOT NULL CHECK(action IN ('insert', 'update', 'delete', 'correction', 'reverse')),
  before_hash TEXT,
  after_hash TEXT,
  before_json TEXT,
  after_json TEXT,
  correction_journal_id TEXT,
  reverse_journal_id TEXT,
  metadata_json TEXT NOT NULL DEFAULT '{}',
  UNIQUE(operation_id, row_index),
  FOREIGN KEY(book_id) REFERENCES books(id),
  FOREIGN KEY(operation_id, book_id) REFERENCES ledger_operations(id, book_id) ON DELETE CASCADE,
  FOREIGN KEY(correction_journal_id, book_id) REFERENCES journals(id, book_id),
  FOREIGN KEY(reverse_journal_id, book_id) REFERENCES journals(id, book_id)
);
CREATE INDEX IF NOT EXISTS idx_ledger_operation_rows_operation ON ledger_operation_rows(operation_id, row_index);
CREATE TRIGGER IF NOT EXISTS trg_statement_plans_no_identity_update
BEFORE UPDATE OF book_id, account_id, asset_id, statement_kind, file_name, file_sha256, expected_balance, planned_balance, metadata_json, created_at ON statement_plans
BEGIN
  SELECT RAISE(ABORT, 'statement plan identity is immutable');
END;
CREATE TRIGGER IF NOT EXISTS trg_statement_plans_status_transition
BEFORE UPDATE OF status ON statement_plans
WHEN OLD.status != NEW.status
BEGIN
  SELECT CASE
    WHEN OLD.status != 'planned'
    THEN RAISE(ABORT, 'statement plan status is final')
  END;
  SELECT CASE
    WHEN NEW.status NOT IN ('applied', 'discarded')
    THEN RAISE(ABORT, 'invalid statement plan status transition')
  END;
  SELECT CASE
    WHEN NEW.status = 'applied' AND NEW.applied_at IS NULL
    THEN RAISE(ABORT, 'applied statement plan requires applied_at')
  END;
  SELECT CASE
    WHEN NEW.status = 'discarded' AND NEW.discarded_at IS NULL
    THEN RAISE(ABORT, 'discarded statement plan requires discarded_at')
  END;
END;
CREATE TRIGGER IF NOT EXISTS trg_statement_plans_no_delete
BEFORE DELETE ON statement_plans
BEGIN
  SELECT RAISE(ABORT, 'statement plans are audit records');
END;
CREATE TRIGGER IF NOT EXISTS trg_statement_plan_rows_no_semantic_update
BEFORE UPDATE OF book_id, plan_id, row_index, date, quantity, description, external_id, row_hash, action, matched_journal_id, counterpart_account_id, reason, metadata_json ON statement_plan_rows
BEGIN
  SELECT RAISE(ABORT, 'statement plan rows are immutable');
END;
CREATE TRIGGER IF NOT EXISTS trg_statement_plan_rows_created_once
BEFORE UPDATE OF created_journal_id ON statement_plan_rows
WHEN OLD.created_journal_id IS NOT NULL OR NEW.created_journal_id IS NULL
BEGIN
  SELECT RAISE(ABORT, 'created_journal_id can only be set once');
END;
CREATE TRIGGER IF NOT EXISTS trg_statement_plan_rows_no_delete
BEFORE DELETE ON statement_plan_rows
BEGIN
  SELECT RAISE(ABORT, 'statement plan rows are audit records');
END;
CREATE TRIGGER IF NOT EXISTS trg_ledger_operations_no_identity_update
BEFORE UPDATE OF book_id, tool_name, operation_type, created_at, reverses_operation_id, input_json, preview_json, result_json, metadata_json ON ledger_operations
BEGIN
  SELECT RAISE(ABORT, 'ledger operations are audit records');
END;
CREATE TRIGGER IF NOT EXISTS trg_ledger_operations_status_transition
BEFORE UPDATE OF status ON ledger_operations
WHEN OLD.status != NEW.status
BEGIN
  SELECT CASE
    WHEN OLD.status != 'applied' OR NEW.status != 'reversed'
    THEN RAISE(ABORT, 'invalid ledger operation status transition')
  END;
  SELECT CASE
    WHEN NEW.reversed_at IS NULL OR NEW.reversed_by_operation_id IS NULL
    THEN RAISE(ABORT, 'reversed ledger operation requires reversal metadata')
  END;
END;
CREATE TRIGGER IF NOT EXISTS trg_ledger_operations_no_delete
BEFORE DELETE ON ledger_operations
BEGIN
  SELECT RAISE(ABORT, 'ledger operations are audit records');
END;
CREATE TRIGGER IF NOT EXISTS trg_ledger_operation_rows_no_update
BEFORE UPDATE ON ledger_operation_rows
BEGIN
  SELECT RAISE(ABORT, 'ledger operation rows are immutable');
END;
CREATE TRIGGER IF NOT EXISTS trg_ledger_operation_rows_no_delete
BEFORE DELETE ON ledger_operation_rows
BEGIN
  SELECT RAISE(ABORT, 'ledger operation rows are audit records');
END;
CREATE TRIGGER IF NOT EXISTS trg_journals_no_finalized_insert
BEFORE INSERT ON journals
WHEN NEW.finalized_at IS NOT NULL
BEGIN
  SELECT RAISE(ABORT, 'insert journal as draft, then finalize');
END;
CREATE TRIGGER IF NOT EXISTS trg_journals_finalize_requires_lines
BEFORE UPDATE OF finalized_at ON journals
WHEN OLD.finalized_at IS NULL AND NEW.finalized_at IS NOT NULL
BEGIN
  SELECT CASE
    WHEN NOT EXISTS (
      SELECT 1 FROM journal_lines
      WHERE book_id = NEW.book_id AND journal_id = NEW.id
    )
    THEN RAISE(ABORT, 'finalized journal must have lines')
  END;
  SELECT CASE
    WHEN EXISTS (
      SELECT 1
      FROM (
        SELECT asset_id, sum(quantity) AS total
        FROM journal_lines
        WHERE book_id = NEW.book_id AND journal_id = NEW.id
        GROUP BY asset_id
        HAVING total != 0
      )
    )
    THEN RAISE(ABORT, 'finalized journal must balance per asset')
  END;
  SELECT CASE
    WHEN EXISTS (
      SELECT 1 FROM period_closes
      WHERE book_id = NEW.book_id
        AND reopened_at IS NULL
        AND as_of >= NEW.date
      LIMIT 1
    )
    THEN RAISE(ABORT, 'journal date is in a closed period')
  END;
END;
CREATE TRIGGER IF NOT EXISTS trg_journals_reopen_guard
BEFORE UPDATE OF finalized_at ON journals
WHEN OLD.finalized_at IS NOT NULL AND NEW.finalized_at IS NULL
BEGIN
  SELECT CASE
    WHEN EXISTS (
      SELECT 1 FROM period_closes
      WHERE book_id = OLD.book_id
        AND reopened_at IS NULL
        AND as_of >= OLD.date
      LIMIT 1
    )
    THEN RAISE(ABORT, 'cannot reopen journal in a closed period')
  END;
  SELECT CASE
    WHEN EXISTS (
      SELECT 1 FROM lots
      WHERE book_id = OLD.book_id
        AND (opened_journal_id = OLD.id OR closed_journal_id = OLD.id)
      LIMIT 1
    )
    THEN RAISE(ABORT, 'cannot reopen journal linked to investment lots')
  END;
END;
CREATE TRIGGER IF NOT EXISTS trg_lines_no_insert_finalized
BEFORE INSERT ON journal_lines
WHEN EXISTS (
  SELECT 1 FROM journals
  WHERE book_id = NEW.book_id
    AND id = NEW.journal_id
    AND finalized_at IS NOT NULL
)
BEGIN
  SELECT RAISE(ABORT, 'cannot insert lines on finalized journal');
END;
CREATE TRIGGER IF NOT EXISTS trg_lines_no_update_finalized
BEFORE UPDATE ON journal_lines
WHEN EXISTS (
  SELECT 1 FROM journals
  WHERE book_id = OLD.book_id
    AND id = OLD.journal_id
    AND finalized_at IS NOT NULL
)
BEGIN
  SELECT RAISE(ABORT, 'cannot update lines on finalized journal');
END;
CREATE TRIGGER IF NOT EXISTS trg_lines_no_delete_finalized
BEFORE DELETE ON journal_lines
WHEN EXISTS (
  SELECT 1 FROM journals
  WHERE book_id = OLD.book_id
    AND id = OLD.journal_id
    AND finalized_at IS NOT NULL
)
BEGIN
  SELECT RAISE(ABORT, 'cannot delete lines on finalized journal');
END;