Fixed assets, fully featured: tags, serials, acquisition links, label sheets, the fixed asset register, and an Assets section in the viewer; fix a flaky verify

- core: asset tags (generated PREFIX-0001 or the human's own; unique ignoring case; never reused by another asset) and serial numbers (rules.md B-24); a journaled link from an asset to the entry that acquired it (posted, unreversed, debiting the cost account); fixed_asset_register(as_of), reconciled to the GL (unposted depreciation, unregistered cost)
- store: asset.tagged, asset.serial_set, asset.acquisition_linked journaled, replayed, projected (schema v9); differs_from now also compares control roles and tag history
- config: [books] asset_tag_prefix, default the company's initials without legal-form words
- import: Avery 5160/8160 label sheets with QR codes (geometry vendored from Avery, docs/spec/sources/avery-5160.md); the register as XLSX and PDF
- mcp: register_asset tags (and links) automatically; books.label_asset, books.asset_labels; asset tools take a tag; books.show asset / assets page / register report; export fixed_asset_register (json, csv, xlsx, pdf) and in the CPA package
- viewer: Assets page (register, reconciliation), asset page (identity, schedule posted vs not, entries, documents), Fixed asset register report, global search by tag/serial/description, journal text, needs-attention items
- fix: verify compared snapshot amounts as strings; a zero total summed in hash-map order could print "0" or "0.00", so agreeing books failed verify at random. Now by value (regression test)
- tests: spec-blind suites for tags, register, prefix, store, MCP; label and register PDF/XLSX tests; viewer tests

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
This commit is contained in:
swaitsandClaude Opus 5.5 committed 2026-10-15 12:18:00 -06:00
1 parent 4e96b5f54d
commit cd085108c1
53 files changed
+7872 -104

No files matched your search

+14 -1
View File
@@ -5,7 +5,7 @@
//! is the current version.
/// Current schema version.
pub const SCHEMA_VERSION: u32 = 8;
pub const SCHEMA_VERSION: u32 = 9;
/// Migrations, in order. `MIGRATIONS[i]` migrates from version `i` to `i+1`.
pub const MIGRATIONS: &[&str] = &[
@@ -207,5 +207,18 @@ 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
);
"#,
];
+113
View File
@@ -711,6 +711,51 @@ impl Store {
Ok(live)
}
/// Give an asset a tag (see `Ledger::set_asset_tag`). Journaled as
/// `asset.tagged`, with the tag it had.
pub fn set_asset_tag(&mut self, id: AssetId, tag: &str) -> Result<(), StoreError> {
self.ledger.check_asset_tag_for(Some(id), tag)?;
let previous = self.ledger.get_asset(id).and_then(|a| a.tag.clone());
let line = self.append_journal(
"asset.tagged",
json!({"asset_id": id, "tag": tag, "previous_tag": previous}),
)?;
self.ledger.set_asset_tag(id, tag)?;
apply_line_to_sqlite(&self.conn, &line)?;
Ok(())
}
/// Link an asset to the entry that put it on the books (see
/// `Ledger::set_asset_acquisition`). Journaled as
/// `asset.acquisition_linked`.
pub fn set_asset_acquisition(&mut self, id: AssetId, entry: EntryId) -> Result<(), StoreError> {
self.ledger.check_asset_acquisition(id, entry)?;
let line = self.append_journal(
"asset.acquisition_linked",
json!({"asset_id": id, "entry_id": entry}),
)?;
self.ledger.set_asset_acquisition(id, entry)?;
apply_line_to_sqlite(&self.conn, &line)?;
Ok(())
}
/// Record or clear an asset's serial number (see
/// `Ledger::set_asset_serial`). Journaled as `asset.serial_set`.
pub fn set_asset_serial(
&mut self,
id: AssetId,
serial: Option<&str>,
) -> Result<(), StoreError> {
self.ledger.check_asset_serial(id, serial)?;
let line = self.append_journal(
"asset.serial_set",
json!({"asset_id": id, "serial": serial}),
)?;
self.ledger.set_asset_serial(id, serial)?;
apply_line_to_sqlite(&self.conn, &line)?;
Ok(())
}
/// Was depreciation already posted for this asset and period?
fn depreciation_posted_entry(
&self,
@@ -2812,6 +2857,32 @@ impl Store {
)?;
}
}
// Tags in the order they were given (a retag keeps the old tag
// reserved), then serial numbers.
let mut tstmt = self
.conn
.prepare("SELECT asset_id, tag FROM asset_tags_log ORDER BY seq")?;
let tags = tstmt.query_map([], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, String>(1)?)))?;
for t in tags {
let (id, tag) = t?;
ledger.set_asset_tag(id as u64, &tag)?;
}
let mut sstmt = self
.conn
.prepare("SELECT id, serial FROM assets WHERE serial IS NOT NULL ORDER BY id")?;
let serials = sstmt.query_map([], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, String>(1)?)))?;
for row in serials {
let (id, serial) = row?;
ledger.set_asset_serial(id as u64, Some(&serial))?;
}
let mut qstmt = self.conn.prepare(
"SELECT id, acquisition_entry FROM assets WHERE acquisition_entry IS NOT NULL ORDER BY id",
)?;
let links = qstmt.query_map([], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, i64>(1)?)))?;
for row in links {
let (id, entry) = row?;
ledger.set_asset_acquisition(id as u64, entry as u64)?;
}
// Payroll runs: id order; lines in insertion order. Amounts are
// TEXT in SQLite, so they parse here with exact Decimal math.
let parse_payroll_amount = |s: String| {
@@ -3356,6 +3427,22 @@ pub(crate) fn replay_line_into_ledger(
)));
}
}
"asset.tagged" => {
ledger.set_asset_tag(get_u64(p, "asset_id")?, get_str(p, "tag")?)?;
}
"asset.acquisition_linked" => {
ledger.set_asset_acquisition(get_u64(p, "asset_id")?, get_u64(p, "entry_id")?)?;
}
"asset.serial_set" => {
let serial =
match p.get("serial") {
None | Some(Value::Null) => None,
Some(v) => Some(v.as_str().ok_or_else(|| {
StoreError::JournalCorrupt("asset.serial_set serial".into())
})?),
};
ledger.set_asset_serial(get_u64(p, "asset_id")?, serial)?;
}
"asset.depreciation_posted" => {
// The entry itself was journaled as entry.created/entry.posted;
// this line is idempotency bookkeeping for the projection.
@@ -3809,6 +3896,32 @@ fn apply_line_rows(conn: &Connection, line: &JournalLine) -> Result<(), StoreErr
// Roles live in the ledger (rebuilt by replay); the events
// mirror below keeps the history.
}
"asset.tagged" => {
let (id, tag) = (get_u64(p, "asset_id")? as i64, get_str(p, "tag")?);
conn.execute("UPDATE assets SET tag = ? WHERE id = ?", params![tag, id])?;
conn.execute(
"INSERT INTO asset_tags_log(asset_id, tag) VALUES (?, ?)",
params![id, tag],
)?;
}
"asset.acquisition_linked" => {
conn.execute(
"UPDATE assets SET acquisition_entry = ? WHERE id = ?",
params![
get_u64(p, "entry_id")? as i64,
get_u64(p, "asset_id")? as i64
],
)?;
}
"asset.serial_set" => {
conn.execute(
"UPDATE assets SET serial = ? WHERE id = ?",
params![
p.get("serial").and_then(|v| v.as_str()),
get_u64(p, "asset_id")? as i64
],
)?;
}
"receipt.attached" | "document.filed" => {
conn.execute(
"INSERT OR IGNORE INTO blobs(sha256, size_bytes, mime_type, original_name, created_at) VALUES (?,?,?,?,?)",
+7 -2
View File
@@ -16,7 +16,7 @@ use std::path::{Path, PathBuf};
#[serde(tag = "show", rename_all = "snake_case")]
pub enum ViewerTarget {
/// A top-level page: overview, cash, profit, transactions, reports,
/// accounts, documents, company, journal.
/// accounts, assets, documents, company, journal.
Page {
/// Page name.
page: String,
@@ -39,7 +39,7 @@ pub enum ViewerTarget {
Report {
/// profit_and_loss, balance_sheet, cash_flow, members_equity,
/// trial_balance, general_ledger, tax_summary, vendor_1099,
/// ar_aging, ap_aging.
/// ar_aging, ap_aging, fixed_asset_register.
report: String,
/// First date, YYYY-MM-DD.
start: Option<String>,
@@ -57,6 +57,11 @@ pub enum ViewerTarget {
/// Attachment id.
attachment_id: u64,
},
/// One fixed asset: its identity, schedule, entries, and documents.
Asset {
/// Asset id.
asset_id: u64,
},
/// The transactions list, searched.
Search {
/// Search text: description, account, vendor, amount.
+501
View File
@@ -0,0 +1,501 @@
//! Spec-derived store tests for asset tags and serial numbers (rules.md
//! B-24): journaling (`asset.tagged` {asset_id, tag, previous_tag},
//! `asset.serial_set` {asset_id, serial | null}), replay on reopen, the v9
//! SQLite projection agreeing with the journal (`Store::verify`), books
//! written before tags existed, refusals journaling nothing, and corrupt
//! `asset.serial_set` lines refused on open.
//!
//! Expectations come from the doc comments on `Store::set_asset_tag`,
//! `Store::set_asset_serial`, `Store::open`, `Store::verify`,
//! `Ledger::set_asset_tag` and the schema v9 migration comment.
use chrono::NaiveDate;
use ledger_core::{AccountType, AssetId, Currency, LedgerError, Money, NewAsset};
use ledger_store::journal::read_and_verify_journal;
use ledger_store::{DB_FILE, JOURNAL_FILE, JournalWriter, Store, StoreError};
use serde_json::{Value, json};
use std::path::{Path, PathBuf};
fn test_dir(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("wb-asset-tags-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
fn usd(amount: &str) -> Money {
Money::new(amount.parse().unwrap(), Currency::USD)
}
fn date(y: i32, m: u32, d: u32) -> NaiveDate {
NaiveDate::from_ymd_opt(y, m, d).unwrap()
}
/// The journal's (type, payload) pairs, verified.
fn journal(dir: &Path) -> Vec<(String, Value)> {
read_and_verify_journal(&dir.join(JOURNAL_FILE))
.unwrap()
.into_iter()
.map(|l| (l.line_type, l.payload))
.collect()
}
struct Accts {
cost: u64,
accum: u64,
expense: u64,
bank: u64,
gain: u64,
}
fn accounts(store: &mut Store) -> Accts {
let mut add =
|code: &str, name: &str, ty| store.add_account(code, name, ty, None, false).unwrap();
Accts {
cost: add("1500", "Equipment", AccountType::Asset),
accum: add("1501", "Accumulated Depreciation", AccountType::Asset),
expense: add("6200", "Depreciation Expense", AccountType::Expense),
bank: add("1000", "Bank", AccountType::Asset),
gain: add("4900", "Gain on Disposal", AccountType::Revenue),
}
}
fn laptop(store: &mut Store, a: &Accts, name: &str) -> AssetId {
store
.register_asset(NewAsset {
description: name.to_string(),
purchase_date: date(2026, 1, 1),
cost: usd("1200.00"),
salvage_value: usd("0.00"),
useful_life_months: 12,
cost_account: a.cost,
accum_depr_account: a.accum,
expense_account: a.expense,
})
.unwrap()
}
/// A store with two untagged laptops.
fn fixture(name: &str) -> (PathBuf, Store, Accts, AssetId, AssetId) {
let dir = test_dir(name);
let mut s = Store::create(&dir, Currency::USD).unwrap();
let a = accounts(&mut s);
let x = laptop(&mut s, &a, "Laptop A");
let y = laptop(&mut s, &a, "Laptop B");
(dir, s, a, x, y)
}
fn tag_of(s: &Store, id: AssetId) -> Option<String> {
s.ledger().get_asset(id).unwrap().tag.clone()
}
fn serial_of(s: &Store, id: AssetId) -> Option<String> {
s.ledger().get_asset(id).unwrap().serial.clone()
}
// ------------------------------------------------- journaling
/// Store::set_asset_tag: journaled as `asset.tagged`, with the tag it had.
#[test]
fn tagging_journals_asset_tagged_with_the_previous_tag() {
let (dir, mut s, _, x, _) = fixture("journal-tag");
s.set_asset_tag(x, "FL-0001").unwrap();
s.set_asset_tag(x, "FL-0007").unwrap();
let j = journal(&dir);
let tagged: Vec<&Value> = j
.iter()
.filter(|(t, _)| t == "asset.tagged")
.map(|(_, p)| p)
.collect();
assert_eq!(tagged.len(), 2);
assert_eq!(
tagged[0],
&json!({"asset_id": x, "tag": "FL-0001", "previous_tag": null})
);
assert_eq!(
tagged[1],
&json!({"asset_id": x, "tag": "FL-0007", "previous_tag": "FL-0001"})
);
assert_eq!(tag_of(&s, x).as_deref(), Some("FL-0007"));
s.verify().unwrap();
}
/// Store::set_asset_serial: journaled as `asset.serial_set`, serial or null.
#[test]
fn serials_journal_asset_serial_set() {
let (dir, mut s, _, x, _) = fixture("journal-serial");
s.set_asset_serial(x, Some("C02XG0FDJGH5")).unwrap();
s.set_asset_serial(x, None).unwrap();
let j = journal(&dir);
let set: Vec<&Value> = j
.iter()
.filter(|(t, _)| t == "asset.serial_set")
.map(|(_, p)| p)
.collect();
assert_eq!(
set,
vec![
&json!({"asset_id": x, "serial": "C02XG0FDJGH5"}),
&json!({"asset_id": x, "serial": null}),
]
);
assert_eq!(serial_of(&s, x), None);
s.verify().unwrap();
}
// ------------------------------------------------- refusals journal nothing
/// Ledger::set_asset_tag INVARIANT 3 through the store: a refusal changes
/// nothing, so nothing is journaled.
#[test]
fn a_refused_tag_journals_nothing() {
let (dir, mut s, _, x, y) = fixture("refused-tag");
s.set_asset_tag(x, "FL-0001").unwrap();
s.set_asset_tag(x, "FL-0002").unwrap();
let lines = journal(&dir).len();
let before = s.ledger().clone();
// Taken (current and released), wrong form, unknown asset.
assert!(matches!(
s.set_asset_tag(y, "fl-0002"),
Err(StoreError::Ledger(LedgerError::AssetTagTaken { .. }))
));
assert!(matches!(
s.set_asset_tag(y, "FL-0001"),
Err(StoreError::Ledger(LedgerError::AssetTagTaken { .. }))
));
assert!(matches!(
s.set_asset_tag(y, "no spaces"),
Err(StoreError::Ledger(LedgerError::InvalidAssetTag { .. }))
));
assert!(s.set_asset_tag(y + 99, "FL-0050").is_err());
assert_eq!(journal(&dir).len(), lines, "nothing journaled");
assert_eq!(s.ledger(), &before);
s.verify().unwrap();
// And nothing reappears on reopen.
drop(s);
let s = Store::open(&dir).unwrap();
assert_eq!(s.ledger(), &before);
s.verify().unwrap();
}
#[test]
fn a_refused_serial_journals_nothing() {
let (dir, mut s, _, x, _) = fixture("refused-serial");
s.set_asset_serial(x, Some("SN-1")).unwrap();
let lines = journal(&dir).len();
let before = s.ledger().clone();
for bad in ["", " SN", "SN\u{1}"] {
assert!(matches!(
s.set_asset_serial(x, Some(bad)),
Err(StoreError::Ledger(LedgerError::InvalidSerial(_)))
));
}
assert!(s.set_asset_serial(x + 99, Some("SN-2")).is_err());
assert_eq!(journal(&dir).len(), lines);
assert_eq!(s.ledger(), &before);
s.verify().unwrap();
}
// ------------------------------------------------- replay
/// Tags (with their history) and serials survive a reopen, both with the
/// database present and rebuilt from the journal alone (Store::open
/// SCENARIO 5), and verify passes each time.
#[test]
fn tags_and_serials_replay_on_reopen() {
let (dir, mut s, a, x, y) = fixture("replay");
s.set_asset_tag(x, "FL-0001").unwrap();
s.set_asset_tag(y, "FL-0002").unwrap();
s.set_asset_tag(x, "Desk/7").unwrap(); // releases FL-0001 (still reserved)
s.set_asset_tag(x, "FL-0001").unwrap(); // takes it back
s.set_asset_tag(x, "FL-0003").unwrap();
s.set_asset_serial(x, Some("SN 123")).unwrap();
s.set_asset_serial(y, Some("first")).unwrap();
s.set_asset_serial(y, Some("second")).unwrap();
let z = laptop(&mut s, &a, "Laptop C");
s.set_asset_serial(z, Some("gone")).unwrap();
s.set_asset_serial(z, None).unwrap();
s.verify().unwrap();
let live = s.ledger().clone();
drop(s);
for rebuild in [false, true] {
if rebuild {
std::fs::remove_file(dir.join(DB_FILE)).unwrap();
}
let s = Store::open(&dir).unwrap();
s.verify().unwrap();
assert_eq!(s.ledger(), &live, "rebuild={rebuild}");
assert_eq!(tag_of(&s, x).as_deref(), Some("FL-0003"));
assert_eq!(tag_of(&s, y).as_deref(), Some("FL-0002"));
assert_eq!(tag_of(&s, z), None);
assert_eq!(serial_of(&s, x).as_deref(), Some("SN 123"));
assert_eq!(serial_of(&s, y).as_deref(), Some("second"));
assert_eq!(serial_of(&s, z), None);
assert_eq!(s.ledger().asset_by_tag("fl-0003").map(|a| a.id), Some(x));
assert!(s.ledger().asset_by_tag("Desk/7").is_none());
// Every tag x ever held stays reserved after replay.
for t in ["FL-0001", "desk/7", "FL-0003"] {
assert!(
matches!(
s.ledger().check_asset_tag_for(Some(z), t),
Err(LedgerError::AssetTagTaken { asset, .. }) if asset == x
),
"{t} rebuild={rebuild}"
);
}
assert_eq!(s.ledger().next_asset_tag("FL"), "FL-0004");
// x may still take back what it held.
s.ledger().check_asset_tag_for(Some(x), "Desk/7").unwrap();
drop(s);
}
}
/// Tags on a disposed asset stay reserved through the store and a reopen.
#[test]
fn a_disposed_assets_tag_stays_reserved_after_reopen() {
let (dir, mut s, a, x, y) = fixture("disposed");
s.set_asset_tag(x, "FL-0001").unwrap();
s.set_asset_serial(x, Some("SN-X")).unwrap();
s.dispose_asset(x, date(2026, 3, 31), usd("500.00"), a.bank, a.gain)
.unwrap();
s.verify().unwrap();
drop(s);
let mut s = Store::open(&dir).unwrap();
s.verify().unwrap();
assert_eq!(tag_of(&s, x).as_deref(), Some("FL-0001"));
assert_eq!(serial_of(&s, x).as_deref(), Some("SN-X"));
assert!(matches!(
s.set_asset_tag(y, "FL-0001"),
Err(StoreError::Ledger(LedgerError::AssetTagTaken { .. }))
));
s.set_asset_tag(y, "FL-0002").unwrap();
s.verify().unwrap();
}
/// The store's projection keeps up after mutations without a reopen, and
/// stays right across many retags (verify compares every projection table
/// to a fresh projection of the journal).
#[test]
fn verify_passes_after_many_retags_and_serial_changes() {
let (dir, mut s, _, x, y) = fixture("many");
for n in 1..=20u32 {
let id = if n % 2 == 0 { x } else { y };
s.set_asset_tag(id, &format!("FL-{n:04}")).unwrap();
s.set_asset_serial(id, Some(&format!("SN {n}"))).unwrap();
if n % 5 == 0 {
s.set_asset_serial(id, None).unwrap();
}
}
s.verify().unwrap();
assert_eq!(s.ledger().next_asset_tag("FL"), "FL-0021");
drop(s);
let s = Store::open(&dir).unwrap();
s.verify().unwrap();
assert_eq!(s.ledger().next_asset_tag("FL"), "FL-0021");
}
// ------------------------------------------------- older books
/// Books written before tags existed (no asset.tagged lines) open, verify,
/// and then take tags.
#[test]
fn books_written_before_tags_open_and_verify() {
let (dir, s, _, x, y) = fixture("older");
drop(s);
let _ = std::fs::remove_file(dir.join(DB_FILE));
let mut s = Store::open(&dir).expect("older books still open");
s.verify().unwrap();
assert_eq!(tag_of(&s, x), None);
assert_eq!(serial_of(&s, y), None);
s.set_asset_tag(x, "FL-0001").unwrap();
s.verify().unwrap();
}
/// What an older wanderbooks wrote, by hand (as tests/store.rs
/// `books_written_under_older_rules_still_open` does): an asset.registered
/// line from before tags, appended with JournalWriter, then the database
/// rebuilt from the journal. It opens, verifies, and takes a tag.
#[test]
fn an_older_asset_registered_line_still_replays() {
let (dir, s, a, x, y) = fixture("older-line");
drop(s);
let mut w = JournalWriter::open(&dir.join(JOURNAL_FILE)).unwrap();
w.append(
"asset.registered",
json!({"id": y + 1, "description": "Old printer", "purchase_date": "2026-02-01",
"cost": {"amount": "600.00", "currency": "USD"},
"salvage_value": {"amount": "0.00", "currency": "USD"},
"useful_life_months": 6, "method": "straight_line",
"cost_account": a.cost, "accum_depr_account": a.accum,
"expense_account": a.expense}),
)
.unwrap();
drop(w);
let _ = std::fs::remove_file(dir.join(DB_FILE));
let mut s = Store::open(&dir).expect("older books still open");
s.verify().unwrap();
let old = y + 1;
assert_eq!(tag_of(&s, old), None);
assert_eq!(serial_of(&s, old), None);
assert_eq!(tag_of(&s, x), None);
s.set_asset_tag(old, "FL-0001").unwrap();
s.set_asset_serial(old, Some("HP-1")).unwrap();
s.verify().unwrap();
drop(s);
let s = Store::open(&dir).unwrap();
s.verify().unwrap();
assert_eq!(tag_of(&s, old).as_deref(), Some("FL-0001"));
}
/// Downgrade the database to schema v8 (no assets.tag / assets.serial, no
/// asset_tags_log), as an older wanderbooks left it.
fn downgrade_db_to_v8(dir: &Path) {
let conn = rusqlite::Connection::open(dir.join(DB_FILE)).unwrap();
conn.execute_batch(
"ALTER TABLE assets DROP COLUMN tag;
ALTER TABLE assets DROP COLUMN serial;
ALTER TABLE assets DROP COLUMN acquisition_entry;
DROP TABLE asset_tags_log;
UPDATE meta SET value = '8' WHERE key = 'schema_version';",
)
.unwrap();
}
/// An existing v8 database migrates to v9 on open and verifies.
#[test]
fn a_v8_database_migrates_and_verifies() {
let (dir, s, _, x, y) = fixture("v8");
drop(s);
downgrade_db_to_v8(&dir);
let mut s = Store::open(&dir).expect("a v8 database opens");
s.verify().unwrap();
s.set_asset_tag(x, "FL-0001").unwrap();
s.set_asset_serial(y, Some("SN-Y")).unwrap();
s.verify().unwrap();
drop(s);
let s = Store::open(&dir).unwrap();
s.verify().unwrap();
assert_eq!(tag_of(&s, x).as_deref(), Some("FL-0001"));
assert_eq!(serial_of(&s, y).as_deref(), Some("SN-Y"));
}
/// A v8 database behind a journal that has tag lines (Store::open SCENARIO
/// 7: apply the rest) migrates, applies them, and verifies.
#[test]
fn a_v8_database_behind_a_journal_with_tags_catches_up() {
let (dir, s, _, x, _) = fixture("v8-behind");
drop(s);
downgrade_db_to_v8(&dir);
let mut w = JournalWriter::open(&dir.join(JOURNAL_FILE)).unwrap();
w.append(
"asset.tagged",
json!({"asset_id": x, "tag": "FL-0001", "previous_tag": null}),
)
.unwrap();
w.append("asset.serial_set", json!({"asset_id": x, "serial": "SN-1"}))
.unwrap();
drop(w);
let s = Store::open(&dir).expect("opens and catches up");
s.verify().unwrap();
assert_eq!(tag_of(&s, x).as_deref(), Some("FL-0001"));
assert_eq!(serial_of(&s, x).as_deref(), Some("SN-1"));
}
// ------------------------------------------------- corruption
/// A journal is the system of record; an `asset.serial_set` line whose
/// serial is neither a string nor null is corruption, refused on open. The
/// line is appended with `JournalWriter`, so the chain is valid and only
/// the payload is wrong.
#[test]
fn an_asset_serial_set_line_with_a_non_string_serial_is_corrupt() {
for bad in [json!(12), json!(true), json!(["SN"]), json!({"s": "SN"})] {
let (dir, s, _, x, _) = fixture("corrupt-serial");
drop(s);
let mut w = JournalWriter::open(&dir.join(JOURNAL_FILE)).unwrap();
w.append("asset.serial_set", json!({"asset_id": x, "serial": bad}))
.unwrap();
drop(w);
match Store::open(&dir) {
Err(StoreError::JournalCorrupt(_)) => {}
Err(other) => panic!("serial {bad}: expected JournalCorrupt, got {other:?}"),
Ok(_) => panic!("serial {bad}: a corrupt asset.serial_set line opened"),
}
// Rebuilding from the journal alone refuses it too.
let _ = std::fs::remove_file(dir.join(DB_FILE));
assert!(Store::open(&dir).is_err(), "serial {bad}: rebuild opened");
}
}
/// A journaled tag the rules refuse (taken by another asset) can't replay:
/// opening refuses rather than give one tag to two assets.
#[test]
fn a_journaled_tag_taken_by_another_asset_is_refused_on_open() {
let (dir, mut s, _, x, y) = fixture("corrupt-taken");
s.set_asset_tag(x, "FL-0001").unwrap();
drop(s);
let mut w = JournalWriter::open(&dir.join(JOURNAL_FILE)).unwrap();
w.append(
"asset.tagged",
json!({"asset_id": y, "tag": "fl-0001", "previous_tag": null}),
)
.unwrap();
drop(w);
assert!(Store::open(&dir).is_err());
}
/// The acquisition link is journaled, replays on reopen, survives a
/// rebuild of the database from the journal, and verifies; a refused link
/// journals nothing.
#[test]
fn the_acquisition_link_replays_and_verifies() {
let (dir, mut s, a, x, _) = fixture("acquired");
let e = s.create_entry(date(2026, 1, 1), "bought laptop").unwrap();
s.add_line(e, a.cost, ledger_core::Side::Debit, usd("1200.00"))
.unwrap();
s.add_line(e, a.bank, ledger_core::Side::Credit, usd("1200.00"))
.unwrap();
s.post_entry(e).unwrap();
s.verify().expect("before linking");
let before = journal(&dir).len();
assert!(s.set_asset_acquisition(x, 999).is_err());
assert_eq!(journal(&dir).len(), before, "a refusal journals nothing");
s.set_asset_acquisition(x, e).unwrap();
let (kind, p) = journal(&dir).pop().unwrap();
assert_eq!(kind, "asset.acquisition_linked");
assert_eq!(p, json!({"asset_id": x, "entry_id": e}));
s.verify().unwrap();
drop(s);
let s = Store::open(&dir).unwrap();
assert_eq!(s.ledger().get_asset(x).unwrap().acquired_by, Some(e));
s.verify().unwrap();
drop(s);
std::fs::remove_file(dir.join(DB_FILE)).unwrap();
let s = Store::open(&dir).unwrap();
assert_eq!(s.ledger().get_asset(x).unwrap().acquired_by, Some(e));
s.verify().unwrap();
}
/// Regression: verify compared the snapshot's amounts as strings, and a
/// zero total summed in hash-map order could be "0" or "0.00", so books
/// that agreed failed verify at random (found 2026-10-15). Fresh stores
/// (fresh hash seeds) with an entry that nets the assets to zero must all
/// verify.
#[test]
fn verify_compares_amounts_by_value() {
for n in 0..40 {
let dir = test_dir(&format!("by-value-{n}"));
let mut s = Store::create(&dir, Currency::USD).unwrap();
let a = accounts(&mut s);
let e = s.create_entry(date(2026, 3, 5), "bought").unwrap();
s.add_line(e, a.cost, ledger_core::Side::Debit, usd("1200.00"))
.unwrap();
s.add_line(e, a.bank, ledger_core::Side::Credit, usd("1200.00"))
.unwrap();
s.post_entry(e).unwrap();
s.verify().unwrap();
drop(s);
Store::open(&dir).unwrap().verify().unwrap();
}
}