P1: the books keep how they are kept
check / check (push) Canceled after 0s

Settings, conventions (posting templates, approval rules, policies,
close steps, calendar, mappings, notes), decisions, open items and
entry approvals are journal events (journal format 2). Books that keep
their settings never read wanderbooks.toml or config.json; the server
and the viewer take the vault key from --vault-key-file instead.
books.import_settings moves a settings file in once, exactly as the
server reads it. Approval rules, when enforced, gate every posting
path. Replay refuses what it can't read as newer, never as corrupt.

Lean: a record changes no figure. Mutation on the whole diff: every
survivor killed by a test but two equivalent (docs/testing.md). The
Company page says a late open item is overdue in words, not colour
alone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
This commit is contained in:
swaitsandClaude Opus 5.5 committed 2026-10-17 19:27:48 -06:00
1 parent ee1a372179
commit b292a505b1
71 files changed
+12249 -461

No files matched your search

@@ -0,0 +1,7 @@
# Seeds for failure cases proptest has generated in the past. It is
# automatically read and these particular cases re-run before any
# novel cases are generated.
#
# It is recommended to check this file in to source control so that
# everyone who runs the test benefits from these saved cases.
cc 71eaf212cfff55fb9489fddf65627997660881f193be6bcbb21bc2a2add6a420 # shrinks to picks = [(44, false)]
+122
View File
@@ -0,0 +1,122 @@
//! The settings file codec over settings built at random from valid and
//! refused changes (plan/served.md P1: toml_round_trip_zero_events).
//!
//! ```text
//! INVARIANTS (crate::codec):
//! 1. An export imports into the same books as no change at all.
//! 2. An export imports into empty books as exactly those settings,
//! identity values aside (exported masked: they stay unset).
//! 3. The export reads, as a settings file, as the books do.
//! ```
use chrono::NaiveDate;
use ledger_config::codec;
use ledger_core::settings::{Change, Settings, Source};
use proptest::prelude::*;
use serde_json::{Value, json};
fn src() -> Source {
Source {
decided_by: "Pat Rivera".into(),
decided_on: NaiveDate::from_ymd_opt(2026, 10, 17).unwrap(),
citation: Some("Operating agreement §4.1\nsecond line".into()),
note: None,
}
}
fn candidates() -> Vec<(&'static str, Value)> {
vec![
("company.name", json!("Acme \"Widgets\" LLC")),
("company.name", json!("Ünïcödé Holdings")),
("company.entity_type", json!("LLC, multi-member")),
("company.tax_classification", json!("S corporation")),
("company.tax_classification", json!("partnership")),
("company.tax_form", json!("1065")),
("company.fiscal_year_start", json!("07-01")),
("company.timezone", json!("America/Denver")),
("company.formation_state", json!("UT")),
("company.formed", json!("2026-10-04")),
("company.state_entity_number", json!("14745117-0160")),
("company.ein", json!("43-2215893")),
("company.irs_name_control", json!("waln")),
("company.duns", json!("149-976-972")),
("company.address", json!("1 Main St\nSuite 2, Draper, UT")),
("company.registered_agent", json!("Pat Rivera")),
("company.partnership_representative", json!("Pat Rivera")),
("company.cpa", json!("Krista Baker")),
(
"company.members",
json!([{"name": "A", "share": "50%", "capital_account": "3000",
"distribution_account": "3100", "files_jointly_with": "B"},
{"name": "B", "share": "0.5", "capital_account": "3001",
"files_jointly_with": "A"}]),
),
(
"company.members",
json!([{"name": "Solo", "share": "100%", "capital_account": "3000"}]),
),
("books.accounting_basis", json!("cash")),
("books.cash_accounts", json!(["1000", "1010"])),
("books.financing_accounts", json!(["2100"])),
("books.investing_accounts", json!(["1400"])),
("books.close_into", json!("3000")),
("books.close_sweep", json!(["3100"])),
("books.asset_tag_prefix", json!("WL")),
("policy.evidence.threshold", json!("0.00")),
("policy.evidence.threshold", json!("25.5")),
("policy.evidence.required_accounts", json!(["6200"])),
("policy.evidence.exempt_accounts", json!(["6999", "6998"])),
("policy.parties", json!("require")),
("policy.approval_rules", json!("enforce")),
]
}
fn build(picks: &[(usize, bool)]) -> Settings {
let pool = candidates();
let mut s = Settings::default();
for (seq, (i, clear)) in picks.iter().enumerate() {
let (k, v) = &pool[i % pool.len()];
let change = Change {
key: (*k).into(),
value: (!clear).then(|| v.clone()),
};
// Refused changes (settings that wouldn't hold together) are
// skipped, as the books skip them.
if let Ok(made) = s.check_changes(&[change], &src()) {
s.apply(&made, &src(), seq as u64 + 1).unwrap();
}
}
s
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(512))]
#[test]
fn exports_round_trip(picks in proptest::collection::vec((0usize..64, prop::bool::weighted(0.15)), 0..30)) {
let books = build(&picks);
let text = codec::export(&books, "USD");
// INVARIANT 3: the export reads as a settings file.
let file = ledger_config::parse(&text)
.unwrap_or_else(|e| panic!("the export reads as a settings file: {e}\n{text}"));
prop_assert_eq!(&file.company.name, &books.company("USD").name);
// INVARIANT 1.
let same = codec::import(&text, None, &books, "USD")
.unwrap_or_else(|e| panic!("{e}\n{text}"));
prop_assert!(same.changes.is_empty(), "{:?}\n{}", same.changes, text);
prop_assert!(same.unknown.is_empty(), "{:?}", same.unknown);
// INVARIANT 2.
let fresh = codec::import(&text, None, &Settings::default(), "USD")
.unwrap_or_else(|e| panic!("{e}\n{text}"));
let mut other = Settings::default();
other.apply(&fresh.changes, &src(), 1).unwrap();
for (k, r) in books.iter() {
if k.name == "company.ein" {
prop_assert!(other.get(k.name).is_none());
} else {
prop_assert_eq!(other.get(k.name).map(|o| &o.value), Some(&r.value), "{}", k.name);
}
}
prop_assert_eq!(other.iter().count() + usize::from(books.get("company.ein").is_some()), books.iter().count());
}
}
@@ -0,0 +1,41 @@
//! The receipt policy in force when the settings come from files:
//! config.json's when it sets one, else the settings file's threshold.
use ledger_config::{BooksSettings, effective_receipt_policy};
fn dir(tag: &str) -> std::path::PathBuf {
let d = std::env::temp_dir().join(format!("ledger-config-policy-{}-{tag}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
d
}
#[test]
fn config_json_wins_else_the_settings_file() {
let d = dir("files");
let json = d.join("config.json");
let settings = BooksSettings {
receipt_threshold: Some("0.00".into()),
..BooksSettings::default()
};
// No config.json: the settings file's threshold.
let p = effective_receipt_policy(&json, &settings).unwrap();
assert_eq!(p.require_receipts_above, "0.00");
// config.json's policy, whole.
std::fs::write(
&json,
r#"{"receipt_policy": {"require_receipts_above": "10.00", "receipt_exempt_accounts": ["6999"]}}"#,
)
.unwrap();
let p = effective_receipt_policy(&json, &settings).unwrap();
assert_eq!(p.require_receipts_above, "10.00");
assert_eq!(p.receipt_exempt_accounts, ["6999"]);
// A broken one is refused, by name.
std::fs::write(&json, "{ not json").unwrap();
let err = effective_receipt_policy(&json, &settings).unwrap_err();
assert!(
err.starts_with(&format!("bad config {}", json.display())),
"{err}"
);
std::fs::remove_dir_all(&d).unwrap();
}