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).
This commit is contained in:
1 parent
10ee0681c1
commit
78a2385440
5 files changed
+576
-102
No files matched your search
+212
-101
@@ -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<JournalLine>), 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<EntryId, StoreError> {
|
||||
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<EntryId, StoreError> {
|
||||
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<EntryId, StoreError> {
|
||||
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<ClassId>)> = 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<Option<usize>, 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<Decimal, StoreError> {
|
||||
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" => {
|
||||
|
||||
@@ -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 = []
|
||||
@@ -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<Value = Op> {
|
||||
@@ -79,6 +89,15 @@ fn arb_op() -> impl Strategy<Value = Op> {
|
||||
1 => (0usize..8, 0u8..90).prop_map(|(k, n)| Op::Void(k, n)),
|
||||
1 => (1u8..4).prop_map(Op::Close),
|
||||
1 => (any::<bool>(), 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<DocRef>) -> 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) {
|
||||
|
||||
@@ -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<Value> = std::fs::read_to_string(dir.0.join(JOURNAL_FILE))
|
||||
.unwrap()
|
||||
.lines()
|
||||
.map(|l| serde_json::from_str::<Value>(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::<Vec<_>>(),
|
||||
[
|
||||
(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)
|
||||
);
|
||||
}
|
||||
@@ -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::<Decimal>().unwrap(),
|
||||
"1.00".parse::<Decimal>().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
|
||||
|
||||
Reference in new issue
Block a user