From 78a2385440c2c649603dad13580e51e407873644 Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Tue, 27 Oct 2026 10:34:25 -0600 Subject: [PATCH] feat(store): journal settlements and replay their lines (P4b S6) Store::settle now journals a settlement once, line for line, and one payment event per application, so replay rebuilds the same tags and the projection sees the same entry. A single legacy-shaped payment is still journaled byte for byte; more than one document, a discount or a fee needs documents_v2, and a payment accepted only because an earlier one was reversed still needs document_standing. Each event names the control line it tags (line) and, when it took one, its discount and discount line; the fee is on the first application's event with its line. Replay tags exactly those lines and leaves a line it cannot match untagged, which books.control_check lists; a legacy event without line keeps the first-untagged-control-line rule. Tests: a_settlement_names_its_lines_and_replays (two invoices, a discount and a fee; event indices match the plan; replay equals live), settlement_waits_for_documents_v2 (refused with the feature remedy and no journal line, accepted after the upgrade), and legacy_payment_events_keep_the_positional_rule. arap_props gains a Settle op (two documents, discount, fee), and books_written_under_older_rules_still_open gains a hand-written payment event of the new shape through JournalWriter (8.0.6 item 1). --- crates/ledger-store/src/store.rs | 313 ++++++++++++------ .../tests/arap_props.proptest-regressions | 1 + crates/ledger-store/tests/arap_props.rs | 80 +++++ crates/ledger-store/tests/arap_standing.rs | 214 ++++++++++++ crates/ledger-store/tests/store.rs | 70 +++- 5 files changed, 576 insertions(+), 102 deletions(-) diff --git a/crates/ledger-store/src/store.rs b/crates/ledger-store/src/store.rs index 055ff0b..6846a7f 100644 --- a/crates/ledger-store/src/store.rs +++ b/crates/ledger-store/src/store.rs @@ -2859,26 +2859,6 @@ impl Store { }) } - /// Journal an auto-posted two-line entry (debit one account, credit - /// another for the same amount): [`Self::journal_auto_lines`]. - fn journal_auto_entry( - &mut self, - date: NaiveDate, - description: &str, - debit_account: AccountId, - credit_account: AccountId, - amount: Decimal, - ) -> Result<(EntryId, Vec), StoreError> { - self.journal_auto_lines( - date, - description, - &[ - (debit_account, Side::Debit, amount, None), - (credit_account, Side::Credit, amount, None), - ], - ) - } - /// Journal an auto-posted entry of `lines` (account, side, amount, /// project) through the standard entry.created / entry.line_added / /// entry.posted flow, a tagged line's `entry.line_class_set` right @@ -3548,42 +3528,26 @@ impl Store { cash_account: AccountId, details: &ledger_core::PaymentDetails, ) -> Result { - self.rehearse(|l| l.receive_payment_with(invoice_id, date, amount, cash_account, details))?; - self.approval_gate( - |l| l.receive_payment_with(invoice_id, date, amount, cash_account, details), - false, - )?; - // The rehearsal above is the whole check: hand-written mirrors of - // the engine's refusals drift from it (and were never reached). - self.ensure_payment_features(ledger_core::DocRef::Invoice(invoice_id), amount, details)?; - let ar = self.ensure_control_account(&ledger_core::RECEIVABLES)?; - let description = details.description(&format!("Payment on invoice {invoice_id}")); - let (entry_id, mut journaled) = - self.journal_auto_entry(date, &description, cash_account, ar, amount)?; - journaled.push(self.append_journal( - "invoice.payment_received", - json!({ - "invoice_id": invoice_id, - "date": date.to_string(), - "amount": amount.to_string(), - "cash_account": cash_account, - "entry_id": entry_id, - }), - )?); - if details.instrument.is_some() { - journaled.push(self.append_journal( - "entry.instrument_set", - crate::form1099::entry_instrument_set(entry_id, details.instrument), - )?); - } - let live = - self.ledger - .receive_payment_with(invoice_id, date, amount, cash_account, details)?; - debug_assert_eq!(live, entry_id, "id allocator diverged from journal"); - for line in &journaled { - apply_line_to_sqlite(&self.conn, line)?; - } - Ok(live) + let customer = self + .ledger + .get_invoice(invoice_id) + .ok_or(ledger_core::LedgerError::UnknownInvoice(invoice_id))? + .customer_id; + self.settle(ledger_core::SettleParams { + date, + description: details.description(&format!("Payment on invoice {invoice_id}")), + cash_account, + party: customer, + applications: vec![ledger_core::Application { + doc: ledger_core::DocRef::Invoice(invoice_id), + cash: amount, + discount: Decimal::ZERO, + discount_reason: None, + }], + fee: None, + instrument: details.instrument, + reference: details.reference.clone(), + }) } /// Pay a vendor bill ([`Self::pay_bill_with`], with no payment method @@ -3617,35 +3581,140 @@ impl Store { cash_account: AccountId, details: &ledger_core::PaymentDetails, ) -> Result { - self.rehearse(|l| l.pay_bill_with(bill_id, date, amount, cash_account, details))?; - self.approval_gate( - |l| l.pay_bill_with(bill_id, date, amount, cash_account, details), - false, - )?; - self.ensure_payment_features(ledger_core::DocRef::Bill(bill_id), amount, details)?; - let ap = self.ensure_control_account(&ledger_core::PAYABLES)?; - let description = details.description(&format!("Payment on bill {bill_id}")); + let vendor = self + .ledger + .get_bill(bill_id) + .ok_or(ledger_core::LedgerError::UnknownBill(bill_id))? + .vendor_id; + self.settle(ledger_core::SettleParams { + date, + description: details.description(&format!("Payment on bill {bill_id}")), + cash_account, + party: vendor, + applications: vec![ledger_core::Application { + doc: ledger_core::DocRef::Bill(bill_id), + cash: amount, + discount: Decimal::ZERO, + discount_reason: None, + }], + fee: None, + instrument: details.instrument, + reference: details.reference.clone(), + }) + } + + /// Settle one or more invoices or bills in one entry (plan P4b S6; + /// rules.md B-42): the core posts it ([`ledger_core::Ledger::settle`]); + /// this journals its lines once and one `invoice.payment_received` or + /// `bill.paid` per application, each naming the control line it tags, + /// then the payment method. A single legacy-shaped payment is + /// journaled exactly as before; more than one application, a discount + /// or a fee needs `documents_v2`, and a payment accepted only because + /// an earlier one was reversed needs `document_standing`. + pub fn settle(&mut self, params: ledger_core::SettleParams) -> Result { + self.rehearse(|l| l.settle(params.clone()))?; + self.approval_gate(|l| l.settle(params.clone()), false)?; + self.ensure_payment_features(¶ms)?; + let kind = match params.applications.first().map(|a| a.doc) { + Some(ledger_core::DocRef::Bill(_)) => ledger_core::DocumentKind::Bill, + _ => ledger_core::DocumentKind::Invoice, + }; + let control_role = match kind { + ledger_core::DocumentKind::Invoice => ledger_core::RECEIVABLES, + ledger_core::DocumentKind::Bill => ledger_core::PAYABLES, + }; + self.ensure_control_account(&control_role)?; + if params + .applications + .iter() + .any(|a| a.discount > Decimal::ZERO) + { + let role = match kind { + ledger_core::DocumentKind::Invoice => ledger_core::SALES_ALLOWANCES, + ledger_core::DocumentKind::Bill => ledger_core::PURCHASE_DISCOUNTS, + }; + self.ensure_control_account(&role)?; + } + if params.fee.as_ref().is_some_and(|f| f.account.is_none()) { + self.ensure_control_account(&ledger_core::PAYMENT_PROCESSING_FEES)?; + } + let rows = self.ledger.settlement_lines(¶ms)?; + let simple: Vec<(AccountId, Side, Decimal, Option)> = rows + .iter() + .map(|&(account, side, amount, _)| (account, side, amount, None)) + .collect(); + let description = if params.description.trim().is_empty() { + format!( + "Settlement of {} document{}", + params.applications.len(), + if params.applications.len() == 1 { + "" + } else { + "s" + } + ) + } else { + params.description.clone() + }; let (entry_id, mut journaled) = - self.journal_auto_entry(date, &description, ap, cash_account, amount)?; - journaled.push(self.append_journal( - "bill.paid", - json!({ - "bill_id": bill_id, - "date": date.to_string(), - "amount": amount.to_string(), - "cash_account": cash_account, + self.journal_auto_lines(params.date, &description, &simple)?; + let documents_v2 = params.applications.len() > 1 + || params + .applications + .iter() + .any(|a| a.discount > Decimal::ZERO) + || params.fee.is_some(); + let discounts = params + .applications + .iter() + .filter(|a| a.discount > Decimal::ZERO) + .count(); + let fee_line = params.applications.len() + discounts; + let (event, id_key) = match kind { + ledger_core::DocumentKind::Invoice => ("invoice.payment_received", "invoice_id"), + ledger_core::DocumentKind::Bill => ("bill.paid", "bill_id"), + }; + for (i, app) in params.applications.iter().enumerate() { + let doc_id = match app.doc { + ledger_core::DocRef::Invoice(id) | ledger_core::DocRef::Bill(id) => id, + }; + let mut payload = json!({ + "date": params.date.to_string(), + "amount": app.cash.to_string(), + "cash_account": params.cash_account, "entry_id": entry_id, - }), - )?); - if details.instrument.is_some() { + }); + payload[id_key] = json!(doc_id); + if documents_v2 { + payload["line"] = json!(i); + if app.discount > Decimal::ZERO { + let line = params.applications.len() + + params.applications[..i] + .iter() + .filter(|a| a.discount > Decimal::ZERO) + .count(); + payload["discount"] = json!(app.discount.to_string()); + payload["discount_line"] = json!(line); + if let Some(reason) = &app.discount_reason { + payload["discount_reason"] = json!(reason); + } + } + if let Some(fee) = ¶ms.fee + && i == 0 + { + payload["fee"] = json!(fee.amount.to_string()); + payload["fee_line"] = json!(fee_line); + } + } + journaled.push(self.append_journal(event, payload)?); + } + if params.instrument.is_some() { journaled.push(self.append_journal( "entry.instrument_set", - crate::form1099::entry_instrument_set(entry_id, details.instrument), + crate::form1099::entry_instrument_set(entry_id, params.instrument), )?); } - let live = self - .ledger - .pay_bill_with(bill_id, date, amount, cash_account, details)?; + let live = self.ledger.settle(params)?; debug_assert_eq!(live, entry_id, "id allocator diverged from journal"); for line in &journaled { apply_line_to_sqlite(&self.conn, line)?; @@ -3653,29 +3722,39 @@ impl Store { Ok(live) } - /// The journal features a payment of `amount` on `doc` needs - /// ([`Self::receive_payment_with`] SCENARIOS 2 and 3): document_standing - /// when more than an older version thinks is still owed (the - /// document's total less every payment event it recorded, reversed - /// or not, its line tagged or not: `Ledger::payments_recorded`), - /// payment_instruments when it names a payment method. + /// The journal features a settlement needs (rules.md B-27, B-42): + /// `documents_v2` for more than one application, a discount or a fee + /// (an older version would drop them, and the balance would be wrong), + /// `document_standing` when an application pays more than an older + /// version thinks is still owed (the document's total less every + /// payment event it recorded, reversed or not, its line tagged or + /// not), and `payment_instruments` when it names a payment method. fn ensure_payment_features( &self, - doc: ledger_core::DocRef, - amount: Decimal, - details: &ledger_core::PaymentDetails, + params: &ledger_core::SettleParams, ) -> Result<(), StoreError> { - let total = match doc { - ledger_core::DocRef::Invoice(id) => self.ledger.get_invoice(id).map(|i| i.total()), - ledger_core::DocRef::Bill(id) => self.ledger.get_bill(id).map(|b| b.total()), - }; - if let Some(total) = total { - let older_owed = total?.checked_sub(self.ledger.payments_recorded(doc)?); - if older_owed.is_none_or(|owed| amount > owed) { - self.ensure_feature(crate::format::DOCUMENT_STANDING)?; + if params.applications.len() > 1 + || params + .applications + .iter() + .any(|a| a.discount > Decimal::ZERO) + || params.fee.is_some() + { + self.ensure_feature(crate::format::DOCUMENTS_V2)?; + } + for app in ¶ms.applications { + let total = match app.doc { + ledger_core::DocRef::Invoice(id) => self.ledger.get_invoice(id).map(|i| i.total()), + ledger_core::DocRef::Bill(id) => self.ledger.get_bill(id).map(|b| b.total()), + }; + if let Some(total) = total { + let older_owed = total?.checked_sub(self.ledger.payments_recorded(app.doc)?); + if older_owed.is_none_or(|owed| app.cash > owed) { + self.ensure_feature(crate::format::DOCUMENT_STANDING)?; + } } } - if details.instrument.is_some() { + if params.instrument.is_some() { self.ensure_feature(crate::format::PAYMENT_INSTRUMENTS)?; } Ok(()) @@ -6401,6 +6480,31 @@ fn get_str<'a>(p: &'a Value, key: &str) -> Result<&'a str, StoreError> { .ok_or_else(|| StoreError::JournalCorrupt(format!("missing/invalid '{key}'"))) } +/// An optional integer a newer payment event carries (its `line` or +/// `discount_line`): absent or null is none; anything else must be an +/// integer. +fn get_opt_usize(p: &Value, key: &str) -> Result, StoreError> { + match p.get(key) { + None | Some(Value::Null) => Ok(None), + Some(v) => v + .as_u64() + .map(|n| Some(n as usize)) + .ok_or_else(|| StoreError::JournalCorrupt(format!("invalid '{key}'"))), + } +} + +/// An optional decimal string a newer payment event carries (its +/// `discount`): absent or null is zero. +fn get_opt_decimal(p: &Value, key: &str) -> Result { + match p.get(key) { + None | Some(Value::Null) => Ok(Decimal::ZERO), + Some(v) => v + .as_str() + .and_then(|s| s.parse().ok()) + .ok_or_else(|| StoreError::JournalCorrupt(format!("invalid '{key}'"))), + } +} + /// Replay one journal line into a ledger, asserting recorded ids. Used by /// hydration, `verify`, and `rebuild`: the journal is the script, the engine /// is the actor. @@ -6980,23 +7084,30 @@ fn replay_line( )?; } "invoice.payment_received" => { - // The payment entry was already rebuilt; this only moves the - // invoice's paid amount and status. - ledger.replay_invoice_payment( + // The payment entry was already rebuilt; this only tags its + // receivables lines (the line the event names, or the first + // untagged one for a legacy event) and moves the invoice. + ledger.replay_invoice_payment_at( get_u64(p, "invoice_id")?, get_str(p, "amount")? .parse() .map_err(|_| StoreError::JournalCorrupt("bad amount".into()))?, get_u64(p, "entry_id")?, + get_opt_usize(p, "line")?, + get_opt_decimal(p, "discount")?, + get_opt_usize(p, "discount_line")?, )?; } "bill.paid" => { - ledger.replay_bill_payment( + ledger.replay_bill_payment_at( get_u64(p, "bill_id")?, get_str(p, "amount")? .parse() .map_err(|_| StoreError::JournalCorrupt("bad amount".into()))?, get_u64(p, "entry_id")?, + get_opt_usize(p, "line")?, + get_opt_decimal(p, "discount")?, + get_opt_usize(p, "discount_line")?, )?; } "fx.rate_set" => { diff --git a/crates/ledger-store/tests/arap_props.proptest-regressions b/crates/ledger-store/tests/arap_props.proptest-regressions index 788a3a7..cdae27a 100644 --- a/crates/ledger-store/tests/arap_props.proptest-regressions +++ b/crates/ledger-store/tests/arap_props.proptest-regressions @@ -5,3 +5,4 @@ # It is recommended to check this file in to source control so that # everyone who runs the test benefits from these saved cases. cc 704ab49c9bea84359475c3afa56c3354275a353670fc71156fba151f846f4f73 # shrinks to first = [Create(false, 0, 1), Create(false, 0, 1)], then = [Close(1), Hand(false, 1)] +cc f678814ec34db3abef40116e17e562fe5abe652c09f12600e19841beaf9d6fe6 # shrinks to first = [Create(false, 0, 13902), Settle { a: 0, b: 0, n: 0, cash: 6676, discount: 551, fee: 0 }], then = [] diff --git a/crates/ledger-store/tests/arap_props.rs b/crates/ledger-store/tests/arap_props.rs index a014f20..cb4ebe7 100644 --- a/crates/ledger-store/tests/arap_props.rs +++ b/crates/ledger-store/tests/arap_props.rs @@ -68,6 +68,16 @@ enum Op { Close(u8), /// A hand line on receivables (true) or payables: always refused. Hand(bool, i64), + /// One payment settling two documents of one party, the first with a + /// discount and (on invoices) a processor's fee. + Settle { + a: usize, + b: usize, + n: u8, + cash: i64, + discount: i64, + fee: i64, + }, } fn arb_op() -> impl Strategy { @@ -79,6 +89,15 @@ fn arb_op() -> impl Strategy { 1 => (0usize..8, 0u8..90).prop_map(|(k, n)| Op::Void(k, n)), 1 => (1u8..4).prop_map(Op::Close), 1 => (any::(), 1i64..10_000).prop_map(|(r, c)| Op::Hand(r, c)), + 2 => (0usize..8, 0usize..8, 0u8..90, 1i64..20_000, 0i64..2_000, 0i64..500) + .prop_map(|(a, b, n, cash, discount, fee)| Op::Settle { + a, + b, + n, + cash, + discount, + fee, + }), ] } @@ -187,6 +206,67 @@ fn run(s: &mut Store, k: &Setup, ops: &[Op], docs: &mut Vec) -> Result<( }) .map(drop) } + Op::Settle { .. } if docs.is_empty() => Ok(()), + Op::Settle { + a, + b, + n, + cash, + discount, + fee, + } => { + let first = docs[a % docs.len()]; + let mut second = docs[b % docs.len()]; + if second == first && docs.len() > 1 { + second = docs[(b + 1) % docs.len()]; + } + let same_kind = matches!( + (first, second), + (DocRef::Invoice(_), DocRef::Invoice(_)) | (DocRef::Bill(_), DocRef::Bill(_)) + ); + if !same_kind { + // One party and one kind per settlement: not this pair. + Ok(()) + } else { + let party = match first { + DocRef::Invoice(id) => s.ledger().get_invoice(id).map(|i| i.customer_id), + DocRef::Bill(id) => s.ledger().get_bill(id).map(|v| v.vendor_id), + } + .unwrap(); + let invoices = matches!(first, DocRef::Invoice(_)); + let money = cents(cash); + let apps = vec![ + ledger_core::Application { + doc: first, + cash: money, + discount: cents(discount), + discount_reason: (discount > 0) + .then(|| format!("a late discount, n {n}")), + }, + ledger_core::Application { + doc: second, + cash: money, + discount: Decimal::ZERO, + discount_reason: None, + }, + ]; + s.settle(ledger_core::SettleParams { + date: day(n), + description: "two documents, one payment".into(), + cash_account: k.bank, + party, + applications: apps, + fee: (invoices && fee > 0).then_some(ledger_core::Fee { + amount: cents(fee), + account: None, + vendor: None, + }), + instrument: None, + reference: None, + }) + .map(drop) + } + } Op::Hand(receivables, c) => { let code = if receivables { "1200" } else { "2000" }; match s.ledger().chart().find_by_code(code).map(|a| a.id) { diff --git a/crates/ledger-store/tests/arap_standing.rs b/crates/ledger-store/tests/arap_standing.rs index eec3711..c01f884 100644 --- a/crates/ledger-store/tests/arap_standing.rs +++ b/crates/ledger-store/tests/arap_standing.rs @@ -1054,3 +1054,217 @@ fn preview_invoice_and_bill_journal_nothing() { ); s.verify().unwrap(); } + +/// A settlement of two invoices, a discount and a processor's fee: one +/// entry whose events name the lines they tag, every figure derived, and +/// the books replaying to the same lines (plan P4b S6; rules.md B-42 +/// INVARIANTS 1 and 3). +#[test] +fn a_settlement_names_its_lines_and_replays() { + use ledger_core::{Application, DocRef, Fee, SettleParams}; + let dir = test_dir("settlement-lines"); + let mut s = Store::create(&dir.0, Currency::USD).unwrap(); + let bank = s + .add_account("1000", "Bank", AccountType::Asset, None, false) + .unwrap(); + let c = s.add_customer("Fernhill Labs").unwrap(); + let first = s + .create_invoice(c, d(2026, 4, 1), d(2026, 5, 1), vec![line("Design", 7_000)]) + .unwrap(); + let second = s + .create_invoice( + c, + d(2026, 4, 2), + d(2026, 5, 2), + vec![line("Support", 2_000)], + ) + .unwrap(); + let from = journal_len(&dir.0); + let entry = s + .settle(SettleParams { + date: d(2026, 4, 10), + description: "One deposit, two invoices".into(), + cash_account: bank, + party: c, + applications: vec![ + Application { + doc: DocRef::Invoice(first), + cash: cents(6_000), + discount: cents(1_000), + discount_reason: Some("vendor honoured the early discount late".into()), + }, + Application { + doc: DocRef::Invoice(second), + cash: cents(2_000), + discount: Decimal::ZERO, + discount_reason: None, + }, + ], + fee: Some(Fee { + amount: cents(500), + account: None, + vendor: None, + }), + instrument: None, + reference: Some("ch_42".into()), + }) + .unwrap(); + let got = since(&dir.0, from); + // The two role accounts created on demand, then the entry, then one + // event per application. + assert_eq!( + got.iter().filter(|(t, _)| t == "account.added").count(), + 2, + "{got:?}" + ); + assert_eq!( + got.iter() + .filter(|(t, _)| t == "invoice.payment_received") + .count(), + 2 + ); + assert_eq!( + got.iter().filter(|(t, _)| t == "entry.line_added").count(), + 6 + ); + // One credit per invoice's cash, one for the discount, the fee, the + // discount expense and the net deposit: the rows the event lines name. + let events: Vec<&Value> = got + .iter() + .filter(|(t, _)| t == "invoice.payment_received") + .map(|(_, p)| p) + .collect(); + assert_eq!(events.len(), 2); + assert_eq!(events[0]["line"], json!(0)); + assert_eq!(events[0]["discount"], json!("10.00")); + assert_eq!(events[0]["discount_line"], json!(2)); + assert_eq!(events[0]["fee"], json!("5.00")); + assert_eq!(events[0]["fee_line"], json!(3)); + assert_eq!(events[1]["line"], json!(1)); + assert!(events[1].get("discount").is_none()); + assert!(events[1].get("fee").is_none()); + let live = s.ledger().clone(); + let ar = live.control_check(d(2026, 4, 10)).unwrap(); + let receivables = ar.iter().find(|c| c.role == "receivables").unwrap(); + assert_eq!(receivables.balance, cents(0)); + assert_eq!(receivables.documents_total, cents(0)); + assert_eq!(receivables.difference, Decimal::ZERO); + for (id, paid, discounted) in [(first, 6_000, 1_000), (second, 2_000, 0)] { + let st = live.invoice_state(id, d(2026, 4, 10)).unwrap().unwrap(); + assert_eq!( + (st.paid, st.discounted, st.outstanding), + (cents(paid), cents(discounted), Decimal::ZERO) + ); + } + assert_eq!(s.ledger().get_entry(entry).unwrap().lines().len(), 6); + drop(s); + let s = Store::open(&dir.0).unwrap(); + assert!( + live.differs_from(s.ledger()).is_empty(), + "{:?}", + live.differs_from(s.ledger()) + ); + s.verify().unwrap(); +} + +/// A settlement of more than one document, a discount or a fee needs +/// `documents_v2`: refused, journaling nothing, in books without it, and +/// accepted after the owner's upgrade, replaying to the same books (plan +/// P4b S6; rules.md B-27, B-42). +#[test] +fn settlement_waits_for_documents_v2() { + use ledger_core::{Application, DocRef, SettleParams}; + let dir = fixture("settlement-v2"); + let mut s = Store::open(&dir.0).unwrap(); + assert!( + !s.journal_format() + .has_feature(ledger_store::format::DOCUMENTS_V2) + ); + let bank = s.ledger().chart().find_by_code("1000").unwrap().id; + let head = s.head(); + let params = |discount: Decimal| SettleParams { + date: d(2026, 2, 15), + description: "Two bills, one payment".into(), + cash_account: bank, + party: 1, + applications: vec![ + Application { + doc: DocRef::Bill(1), + cash: cents(7_000), + discount, + discount_reason: (!discount.is_zero()).then_some("late discount".into()), + }, + Application { + doc: DocRef::Bill(2), + cash: cents(4_000), + discount: Decimal::ZERO, + discount_reason: None, + }, + ], + fee: None, + instrument: None, + reference: None, + }; + match s.settle(params(Decimal::ZERO)) { + Err(StoreError::FormatUpgradePending { need }) => { + assert_eq!(need, "the journal feature documents_v2") + } + other => panic!("expected FormatUpgradePending, got {other:?}"), + } + assert_eq!(s.head(), head, "nothing journaled"); + s.upgrade_format().unwrap(); + // The discount is outside the invoice's terms (it has none), so it + // needs its reason; the payment is also accepted only because the + // earlier one was reversed, which needs document_standing too. + s.settle(params(cents(1_000))).unwrap(); + s.verify().unwrap(); + let live = s.ledger().clone(); + drop(s); + let s = Store::open(&dir.0).unwrap(); + assert!( + live.differs_from(s.ledger()).is_empty(), + "{:?}", + live.differs_from(s.ledger()) + ); + let st = s.ledger().bill_state(1, d(2026, 12, 31)).unwrap().unwrap(); + assert_eq!(st.status, ledger_core::DocStatus::Paid); +} + +/// Legacy payment events carry no `line`, so replay keeps the positional +/// rule: the first untagged control line for the payment's amount, in the +/// order the events come (plan P4b S6; rules.md B-42 INVARIANT 3). +#[test] +fn legacy_payment_events_keep_the_positional_rule() { + use ledger_core::{DocRef, Movement}; + let dir = fixture("legacy-positional"); + let s = Store::open(&dir.0).unwrap(); + let payments: Vec = std::fs::read_to_string(dir.0.join(JOURNAL_FILE)) + .unwrap() + .lines() + .map(|l| serde_json::from_str::(l).unwrap()) + .filter(|v| v["type"] == json!("invoice.payment_received")) + .map(|v| v["payload"].clone()) + .collect(); + assert!(!payments.is_empty()); + for p in &payments { + assert!(p.get("line").is_none(), "{p}"); + } + let lines = s.ledger().doc_lines(DocRef::Invoice(3)); + assert_eq!( + lines + .iter() + .map(|l| (l.movement, l.amount)) + .collect::>(), + [ + (Movement::Origin, cents(30_000)), + (Movement::Payment, cents(-12_000)) + ] + ); + assert_eq!( + s.ledger() + .line_doc(lines[1].entry, lines[1].line) + .unwrap() + .doc, + DocRef::Invoice(3) + ); +} diff --git a/crates/ledger-store/tests/store.rs b/crates/ledger-store/tests/store.rs index 412fecc..34d7961 100644 --- a/crates/ledger-store/tests/store.rs +++ b/crates/ledger-store/tests/store.rs @@ -1864,6 +1864,8 @@ fn books_written_under_older_rules_still_open() { (4, "4000", "Revenue", "Revenue"), (5, "2000", "Accounts Payable", "Liability"), (6, "6500", "Supplies", "Expense"), + (7, "4100", "Refunds and Allowances", "Revenue"), + (8, "1020", "Bank", "Asset"), ] { w.append( "account.added", @@ -1946,13 +1948,55 @@ fn books_written_under_older_rules_still_open() { "memo": "A4 paper", "duplicate_reason": "the vendor sent it twice"}), ) .unwrap(); + // P4b S6's new shape, hand-written as this version writes it: a + // payment event naming its line and the discount line it also tags. + // This reader tags both; the version before it would drop the discount + // and read the invoice paid above its total. + w.append( + "entry.created", + json!({"id": 4, "date": "2026-04-05", "description": "Payment on invoice 1"}), + ) + .unwrap(); + for (account, side, amount) in [ + (3, "credit", "98.99"), + (3, "credit", "1.00"), + (7, "debit", "1.00"), + (8, "debit", "98.99"), + ] { + w.append( + "entry.line_added", + json!({"id": 4, "account": account, "side": side, "amount": amount, + "currency": "USD"}), + ) + .unwrap(); + } + w.append( + "entry.posted", + json!({"id": 4, "date": "2026-04-05", "description": "Payment on invoice 1", + "reversal_of": null, + "lines": [ + {"account": 3, "side": "credit", "amount": "98.99", "currency": "USD"}, + {"account": 3, "side": "credit", "amount": "1.00", "currency": "USD"}, + {"account": 7, "side": "debit", "amount": "1.00", "currency": "USD"}, + {"account": 8, "side": "debit", "amount": "98.99", "currency": "USD"} + ]}), + ) + .unwrap(); + w.append( + "invoice.payment_received", + json!({"invoice_id": 1, "date": "2026-04-05", "amount": "98.99", + "cash_account": 8, "entry_id": 4, "line": 0, + "discount": "1.00", "discount_line": 1, + "discount_reason": "the customer paid the rest late"}), + ) + .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())); let store = Store::open(&dir).expect("older books still open"); store.verify().unwrap(); assert_eq!(store.journal_format().format, 1); - assert_eq!(store.ledger().chart().len(), 6); + assert_eq!(store.ledger().chart().len(), 8); assert_eq!(store.ledger().list_periods().len(), 2); let lines = store.ledger().get_entry(1).unwrap().lines(); assert_eq!(lines.len(), 2); @@ -2007,6 +2051,30 @@ fn books_written_under_older_rules_still_open() { ); assert_eq!(bill.memo.as_deref(), Some("A4 paper")); assert_eq!(bill.lines[0].account, Some(6)); + // The S6 shape: the invoice reads paid and discounted, and replay + // tagged the lines the event names. + let st = store + .ledger() + .invoice_state(1, NaiveDate::from_ymd_opt(2026, 12, 31).unwrap()) + .unwrap() + .unwrap(); + assert_eq!( + (st.status, st.paid, st.discounted, st.outstanding), + ( + ledger_core::DocStatus::Paid, + "98.99".parse::().unwrap(), + "1.00".parse::().unwrap(), + Decimal::ZERO + ) + ); + assert_eq!( + store.ledger().line_doc(4, 0).unwrap().movement, + ledger_core::Movement::Payment + ); + assert_eq!( + store.ledger().line_doc(4, 1).unwrap().movement, + ledger_core::Movement::Discount + ); } /// The books an earlier wanderbooks wrote, one directory each under