Files
wanderbooks/docs/receipt-store.md
T

4.4 KiB

Receipt & document store + persistence spec

Two coupled pieces: where the bytes live (content-addressed blob store) and where the facts live (SQLite via ledger-store). Both are immutable-first, matching the ledger's philosophy: the books never forget, and neither does the filing cabinet.

Blob store

  • Content-addressed. The key is SHA-256 of the exact bytes. Path: $DATA/blobs/<first2>/<next2>/<full-hex>. Same bytes, same path, forever.
  • Immutable. Blobs are never modified or deleted by the application. A retention/deletion policy is an explicit future human decision, not an accident of implementation.
  • Deduplicated. Attaching the same receipt twice stores one blob and two attachment rows. attach_receipt / file_document return deduplicated: true when the blob already existed.
  • Limits. 25 MB per file by default, configurable. MIME type is accepted from the client and stored; server-side sniffing is future work.
  • Metadata lives in SQLite (blobs table), not in the filesystem: sha256 PK, size_bytes, mime_type, original_name, created_at.

Attachments & documents

  • attachments: id PK, blob_sha FK, entry_id FK NULL, category, description, created_at.
    • entry_id is nullable: a receipt can be filed before its journal entry exists, then linked with books.link_receipt.
    • category mirrors today's Drive layout: formation, legal, tax, accounting (today's 01 Formation / 02 Legal / 03 Tax / 04 Accounting), plus receipts.
  • Documents filed via books.file_document and receipts via books.attach_receipt share the blob store; they differ only in metadata.

ledger-store (SQLite)

  • Location. $WANDERBOOKS_DATA/wanderbooks.db, falling back to ~/.local/share/wanderbooks/wanderbooks.db. Blobs live next to it under $WANDERBOOKS_DATA/blobs/. WAL mode on.
  • Schema.
    • meta(key TEXT PK, value TEXT) — schema_version, base_currency.
    • accounts(id INTEGER PK, code TEXT UNIQUE, name TEXT, type TEXT, parent INTEGER NULL, placeholder INTEGER, currency TEXT).
    • entries(id INTEGER PK, date TEXT, description TEXT, status TEXT, reversal_of INTEGER NULL).
    • lines(id INTEGER PK, entry_id INTEGER, account_id INTEGER, side TEXT, amount TEXT, currency TEXT). Amounts are TEXT (decimal strings). Never REAL. This is load-bearing: a REAL column would silently reintroduce binary floats into the money path.
    • blobs, attachments as above.
    • events(seq INTEGER PK AUTOINCREMENT, type TEXT, payload TEXT, created_at TEXT) — appended on every mutation: account added, entry posted, entry reversed, receipt attached, document filed. Payload is JSON.
  • Append-only enforcement. Application code never UPDATEs or DELETEs entries/lines/blobs. Belt and suspenders: SQLite triggers abort any such statement:
    CREATE TRIGGER no_update_entries BEFORE UPDATE ON entries BEGIN
      SELECT RAISE(ABORT, 'entries are immutable; post a reversing entry');
    END;
    
    Same shape for lines (UPDATE and DELETE) and blobs.
  • The event log is the live-update mechanism. The future read-only GUI does not poll the database and does not watch the filesystem: it tails events (poll WHERE seq > ?, later SSE over the HTTP transport). This is the correct answer to "watch its files to update live" — watch the event stream, not the files.
  • Migrations. Numbered SQL files, applied in order, bumping meta.schema_version. The store opens read-write only after migrations are current; a newer schema version than the binary understands is a hard error, not a guess.

Rebuildability

The database is a cache, not the source of truth. The source of truth is: the append-only entry log + the blob store. ledger-store must be able to rebuild every report from those two. (A verify subcommand that replays the log and compares against live reports is a good acceptance test for the crate.)

Migration from Google Drive

Backfill walks the Drive Wanderland Labs LLC folders and files each document via the equivalent of books.file_document with its category. The Drive folders stay exactly as they are until the capability-parity cutover rule (see docs/capability-parity.md) is satisfied. Migration is an implementation task, not part of this spec — but the spec is written so the migration is a straightforward script, not a redesign.