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