From 98320b3018fee1543c36cbbf87b582bb3c7ee80c Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Mon, 26 Oct 2026 16:25:41 -0600 Subject: [PATCH] test(store): S4.2's shapes open old books and wait for consent (P4b S4.2) The 8.0.6 checklist for this slice, which adds fields on party.updated (an email and two default terms) and the invoicing settings it guards: 1. books_written_under_older_rules_still_open now hand-writes a party's email and terms and an invoicing setting through JournalWriter, and the books still open, verify and replay them. 2. A new old-books fixture, p4_s42_settings_parties, was written by the version before this slice (1bfce915, through the old-books-writer harness) with a party, a vendor, two posted entries and three settings, and its expected.json from that version's code. This version opens it, verifies it and reports its figures identically. 3. books_p2_served_cut_over_whole gains a refused invoicing setting before the go-ahead; the same call succeeds after it. 4. documents_v2_is_its_own_feature: books that declared every earlier feature stop at its declaration as newer, wait for the upgrade, and name exactly it in the upgrade. No figure moves (item 5): settings and party facts enter no report. Merge does not need the owner's go-ahead; the live books refuse the new party and setting writes with the upgrade remedy until he gives it. --- crates/ledger-cli/tests/format_upgrade.rs | 7 + crates/ledger-store/src/format.rs | 35 +++++ .../p4_s42_settings_parties/expected.json | 131 ++++++++++++++++++ .../p4_s42_settings_parties/journal.jsonl | 20 +++ crates/ledger-store/tests/store.rs | 39 ++++++ 5 files changed, 232 insertions(+) create mode 100644 crates/ledger-store/tests/fixtures/old-books/p4_s42_settings_parties/expected.json create mode 100644 crates/ledger-store/tests/fixtures/old-books/p4_s42_settings_parties/journal.jsonl diff --git a/crates/ledger-cli/tests/format_upgrade.rs b/crates/ledger-cli/tests/format_upgrade.rs index f00056a..375e3c7 100644 --- a/crates/ledger-cli/tests/format_upgrade.rs +++ b/crates/ledger-cli/tests/format_upgrade.rs @@ -722,6 +722,13 @@ fn books_p2_served_cut_over_whole() { json!({"decided_by": "Avery Quill", "changes": [{"key": "books.schedule_convention", "value": "monthly_even"}]}), ), + // An invoicing setting (documents_v2, P4b S4.2): an older version + // refuses the key. + ( + "books.set_setting", + json!({"decided_by": "Avery Quill", + "changes": [{"key": "invoicing.default_terms", "value": "Net 30"}]}), + ), ( "books.add_account", json!({"code": "4900", "name": "Gain on sale", "account_type": "revenue", diff --git a/crates/ledger-store/src/format.rs b/crates/ledger-store/src/format.rs index 4f5dbd8..500845d 100644 --- a/crates/ledger-store/src/format.rs +++ b/crates/ledger-store/src/format.rs @@ -858,6 +858,41 @@ mod tests { assert_eq!(up.adds(), vec!["the journal feature document_standing"]); } + /// The version before P4b S4.2 (every feature through + /// `document_standing`) stops at a declaration of `documents_v2` as + /// newer, never restoring a party's email or default terms as absent + /// (or a document without its numbers, once S4.3 writes them); books + /// that declared every earlier feature still wait for it (B-27, D-39). + #[test] + fn documents_v2_is_its_own_feature() { + assert!(KNOWN_FEATURES.contains(&DOCUMENTS_V2)); + let before: Vec<&str> = KNOWN_FEATURES + .iter() + .copied() + .filter(|f| *f != DOCUMENTS_V2) + .collect(); + let l = line( + FORMAT_SET, + json!({"format": 2, "features": [DOCUMENT_STANDING, DOCUMENTS_V2]}), + ); + match declared_knowing(&l, &before) { + Err(StoreError::NewerJournal { need, .. }) => { + assert_eq!(need, "journal features documents_v2") + } + other => panic!("expected NewerJournal, got {other:?}"), + } + let older = JournalFormat { + format: 2, + features: before.iter().map(|f| f.to_string()).collect(), + }; + assert!(matches!( + older.require_feature(DOCUMENTS_V2), + Err(StoreError::FormatUpgradePending { .. }) + )); + let up = older.upgrade().expect("waits for it"); + assert_eq!(up.adds(), vec!["the journal feature documents_v2"]); + } + /// This version's format: the newest it reads, every feature it /// knows, each once, sorted (what new books and an upgrade declare). #[test] diff --git a/crates/ledger-store/tests/fixtures/old-books/p4_s42_settings_parties/expected.json b/crates/ledger-store/tests/fixtures/old-books/p4_s42_settings_parties/expected.json new file mode 100644 index 0000000..9ddcdfa --- /dev/null +++ b/crates/ledger-store/tests/fixtures/old-books/p4_s42_settings_parties/expected.json @@ -0,0 +1,131 @@ +{ + "assets": [], + "bills": [], + "entries": [ + { + "date": "2026-03-01", + "description": "Rent", + "id": 1, + "lines": [ + { + "account": 3, + "amount": { + "amount": "1000.00", + "currency": [ + 85, + 83, + 68 + ] + }, + "side": "Debit" + }, + { + "account": 1, + "amount": { + "amount": "1000.00", + "currency": [ + 85, + 83, + 68 + ] + }, + "side": "Credit" + } + ], + "reversal_of": null, + "status": "Posted" + }, + { + "date": "2026-01-01", + "description": "Opening capital", + "id": 2, + "lines": [ + { + "account": 1, + "amount": { + "amount": "500.00", + "currency": [ + 85, + 83, + 68 + ] + }, + "side": "Debit" + }, + { + "account": 2, + "amount": { + "amount": "500.00", + "currency": [ + 85, + 83, + 68 + ] + }, + "side": "Credit" + } + ], + "reversal_of": null, + "status": "Posted" + } + ], + "entry_parties": [ + [ + 1, + [ + { + "party_id": 1, + "share": "derived" + } + ] + ] + ], + "invoices": [], + "payroll_runs": [], + "periods": [], + "recurring": [], + "schedules": [], + "snapshot": { + "Ok": { + "balance_sheet": { + "assets": "-500", + "balanced": true, + "current_earnings": "-1000", + "equity": "-500", + "liabilities": "0" + }, + "profit_and_loss": { + "expenses": "1000", + "net_income": "-1000", + "revenue": "0" + }, + "trial_balance": { + "lines": [ + { + "code": "1000", + "credit": "500", + "debit": "0", + "name": "Cash" + }, + { + "code": "3000", + "credit": "500", + "debit": "0", + "name": "Capital" + }, + { + "code": "6000", + "credit": "0", + "debit": "1000", + "name": "Rent" + } + ], + "total_credits": "1000", + "total_debits": "1000" + } + } + }, + "verify": { + "Ok": null + } +} diff --git a/crates/ledger-store/tests/fixtures/old-books/p4_s42_settings_parties/journal.jsonl b/crates/ledger-store/tests/fixtures/old-books/p4_s42_settings_parties/journal.jsonl new file mode 100644 index 0000000..927bd78 --- /dev/null +++ b/crates/ledger-store/tests/fixtures/old-books/p4_s42_settings_parties/journal.jsonl @@ -0,0 +1,20 @@ +{"payload":{"base_currency":"USD"},"prev":"0000000000000000000000000000000000000000000000000000000000000000","seq":1,"ts":"2026-10-26T22:10:51Z","type":"store.created"} +{"payload":{"features":["account_renamed","asset_basis","depreciation_catch_up","disposal_before_service","document_line_projects","document_standing","members_cumulative","month_reposting","party_creditors","payment_instruments","projects","require_tax_category","schedule_convention","tax_categories_gain_exempt","vendor_1099_decisions"],"format":2},"prev":"835d71063b78e376b5b909b42db729ed67d69b8523dd478c405be61044cbcb78","seq":2,"ts":"2026-10-26T22:10:51Z","type":"journal.format_set"} +{"payload":{"account_type":"Asset","code":"1000","currency":"USD","id":1,"name":"Cash","parent":null,"placeholder":false},"prev":"a2c2087a01f0933c3a3b1d523a54e983217af0502aee9196d0f3c88147013c86","seq":3,"ts":"2026-10-26T22:10:52Z","type":"account.added"} +{"payload":{"account_type":"Equity","code":"3000","currency":"USD","id":2,"name":"Capital","parent":null,"placeholder":false},"prev":"96c579e3e187ae847f574de3aad4a5f2e5c73563deeb7542dd177a84f18fe482","seq":4,"ts":"2026-10-26T22:10:52Z","type":"account.added"} +{"payload":{"account_type":"Expense","code":"6000","currency":"USD","id":3,"name":"Rent","parent":null,"placeholder":false},"prev":"ccc5ea3d4404ac84d3c64e2859e94b7506fd98a6fc64d1e029c6f8303f67dba1","seq":5,"ts":"2026-10-26T22:10:52Z","type":"account.added"} +{"payload":{"id":1,"name":"Acme Supply","roles":["contractor"]},"prev":"24f80e9ae9bb75da5af8114a30eff149eee30005f27bca7e10db3245d6894bfb","seq":6,"ts":"2026-10-26T22:10:52Z","type":"party.added"} +{"payload":{"change":{"field":"website","value":"https://acme.example"},"id":1},"prev":"36b849235b1ec050c67df4ee00f6f89f11b5ecd630e07a47f1c17ee4df696115","seq":7,"ts":"2026-10-26T22:10:53Z","type":"party.updated"} +{"payload":{"change":{"field":"address","value":"1 Main St"},"id":1},"prev":"04972a2b0b04ea96725b9eabc31afdda68f2b052787754a8ac2f5e1b94a3a35a","seq":8,"ts":"2026-10-26T22:10:54Z","type":"party.updated"} +{"payload":{"change":{"field":"notes","value":"written before S4.2"},"id":1},"prev":"8e7c455384d4c442cb1ea6bbe89cb3aa257416fd288ef85b42218455fdb9002c","seq":9,"ts":"2026-10-26T22:10:54Z","type":"party.updated"} +{"payload":{"id":1,"name":"Acme Supply"},"prev":"d3c708bf5e9c72cda200cfc4ec89b995db3a71798aa3de24dbe1d7588ca06dd5","seq":10,"ts":"2026-10-26T22:10:54Z","type":"vendor.added"} +{"payload":{"date":"2026-03-01","description":"Rent","id":1},"prev":"ce9965f2c90588ca98f18aaa3bc687553414ab887cc3643b2c7c142309f6d6fa","seq":11,"ts":"2026-10-26T22:10:54Z","type":"entry.created"} +{"payload":{"account":3,"amount":"1000.00","currency":"USD","id":1,"side":"debit"},"prev":"6a7c114116dbc7c6bc0a8e16ce0a6a47258989d17eb18a9a8f98e5b89633de2f","seq":12,"ts":"2026-10-26T22:10:54Z","type":"entry.line_added"} +{"payload":{"account":1,"amount":"1000.00","currency":"USD","id":1,"side":"credit"},"prev":"554ebe7a6aa744a8207b9cd2281ed3ebec4dd248ff166cb639b0cb4e08d63a79","seq":13,"ts":"2026-10-26T22:10:54Z","type":"entry.line_added"} +{"payload":{"date":"2026-03-01","description":"Rent","id":1,"lines":[{"account":3,"amount":"1000.00","currency":"USD","side":"debit"},{"account":1,"amount":"1000.00","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"26b7f13ceac949b9ef63a25bc0b73c1a8d7639131c9ef6b25e0f85cbddf63a2d","seq":14,"ts":"2026-10-26T22:10:54Z","type":"entry.posted"} +{"payload":{"entry_id":1,"vendor_id":1},"prev":"ef623789bd2695347c6a0bb20dd24132d686d6f01186cebf38b919ca6e89ca96","seq":15,"ts":"2026-10-26T22:10:55Z","type":"entry.vendor_set"} +{"payload":{"date":"2026-01-01","description":"Opening capital","id":2},"prev":"c8f77ceb948f8d043d57d00c536e170fbda4bde7bb6b0c0492af2c976ecb2a08","seq":16,"ts":"2026-10-26T22:10:55Z","type":"entry.created"} +{"payload":{"account":1,"amount":"500.00","currency":"USD","id":2,"side":"debit"},"prev":"068107796481cb2c83f49e0a69c0fd430c430fcea59d16664e19e1a009f63aeb","seq":17,"ts":"2026-10-26T22:10:55Z","type":"entry.line_added"} +{"payload":{"account":2,"amount":"500.00","currency":"USD","id":2,"side":"credit"},"prev":"49febc3652a39e16a3920a1b505e5760f566954fb4273ca5db1e962c5b76999b","seq":18,"ts":"2026-10-26T22:10:55Z","type":"entry.line_added"} +{"payload":{"date":"2026-01-01","description":"Opening capital","id":2,"lines":[{"account":1,"amount":"500.00","currency":"USD","side":"debit"},{"account":2,"amount":"500.00","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"4eb247976658774447b34326ba512e2b697582b71dc46109aa59577dc706acd7","seq":19,"ts":"2026-10-26T22:10:55Z","type":"entry.posted"} +{"payload":{"changes":[{"key":"company.name","value":"Fixture Labs LLC"},{"key":"policy.parties","value":"require"},{"key":"company.fiscal_year_start","value":"01-01"}],"source":{"decided_by":"Owner","decided_on":"2026-01-01"}},"prev":"0e2c7500174265af8f10abc5fa0671478c0cc9274ca85bae383e2733e4bbfe3e","seq":20,"ts":"2026-10-26T22:10:55Z","type":"settings.set"} diff --git a/crates/ledger-store/tests/store.rs b/crates/ledger-store/tests/store.rs index 8e68376..50afafe 100644 --- a/crates/ledger-store/tests/store.rs +++ b/crates/ledger-store/tests/store.rs @@ -1833,6 +1833,28 @@ fn books_written_under_older_rules_still_open() { "foreign_currency": "EUR", "foreign_amount": "100.00", "fx_rate": "1.0834567"}), ) .unwrap(); + // P4b S4.2's new shapes, hand-written as a later version writes them: + // a party's email and default terms, and an invoicing setting. This + // reader takes them; the version before it stopped at the declaration. + w.append("party.added", json!({"id": 1, "name": "Acme", "roles": []})) + .unwrap(); + w.append( + "party.updated", + json!({"id": 1, "change": {"field": "email", "value": "billing@acme.example"}}), + ) + .unwrap(); + w.append( + "party.updated", + json!({"id": 1, "change": {"field": "default_terms_as_customer", + "value": {"kind": {"net": 30}, "text": "Net 30"}}}), + ) + .unwrap(); + w.append( + "settings.set", + json!({"changes": [{"key": "invoicing.default_terms", "value": "2/10 net 30"}], + "source": {"decided_by": "Owner", "decided_on": "2026-10-26"}}), + ) + .unwrap(); drop(w); // Opening rebuilds from the journal; it must accept all of it. let _ = std::fs::remove_file(dir.join(ledger_store::db_file())); @@ -1848,6 +1870,23 @@ fn books_written_under_older_rules_still_open() { lines[1].amount.amount, "108.34567".parse::().unwrap() ); + // The new shapes replay as written: the party's facts and the setting. + let acme = store.ledger().get_party(1).expect("the party"); + assert_eq!(acme.email.as_deref(), Some("billing@acme.example")); + assert_eq!( + acme.default_terms_as_customer + .as_ref() + .map(|t| t.describe()), + Some("Net 30".to_string()) + ); + assert_eq!( + store + .ledger() + .settings() + .get("invoicing.default_terms") + .and_then(|r| r.value.as_str()), + Some("2/10 net 30") + ); } /// The books an earlier wanderbooks wrote, one directory each under