From 2275948a64feb7e2366481fd0c92ac6425b86447 Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Thu, 15 Oct 2026 20:16:03 -0600 Subject: [PATCH] Audit fixes: party money, reconciliations, require policy, K-1 property An independent recompute of the real books found no numeric disagreements; review found 15 defects, all fixed with spec-first regression tests: - k1: a prepaid credited straight to capital is property; a member paying a vendor through 2100 is cash. Docs and B-13 match. - parties: a reversal of a reversal counts on its root's side; liability-to-liability payments (member pays the card) count as paid. - viewer: party money uses the server's cash accounts and full calendar year; per-party "What for" scales multi-party entries. - reconciliations: refuse a withdrawn statement before journaling; new books.void_statement; reconciled lines can't be un-cleared; one shared reconcilable-accounts rule for digest and viewer. - CPA package includes receipts linked to entries; a file can't be both filed on and linked to one entry. - parties = "require" covers payroll, tax remittance (agency) and disposal (buyer); no-party marks follow reversal chains and clear when a vendor is tagged. - derived member shares are frozen to exact amounts when posted, so a settings change never re-splits history. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX --- crates/ledger-config/src/lib.rs | 24 +- crates/ledger-core/src/k1.rs | 163 +++++++---- crates/ledger-core/src/ledger.rs | 95 +++++- crates/ledger-core/src/parties.rs | 88 ++++-- crates/ledger-core/tests/spec_parties.rs | 117 ++++++++ crates/ledger-gui/src/books.rs | 274 +++++++++++------- crates/ledger-gui/src/pages/journal.rs | 10 + crates/ledger-gui/tests/attachments.rs | 46 +++ crates/ledger-gui/tests/parties.rs | 86 +++++- crates/ledger-mcp/src/server.rs | 301 +++++++++++++++----- crates/ledger-mcp/src/types.rs | 38 ++- crates/ledger-mcp/tests/mcp_stdio.rs | 2 +- crates/ledger-mcp/tests/spec_attachments.rs | 177 +++++++++++- crates/ledger-mcp/tests/spec_mcp.rs | 5 + crates/ledger-mcp/tests/spec_parties.rs | 155 +++++++++- crates/ledger-store/src/lib.rs | 1 + crates/ledger-store/src/reconcilable.rs | 130 +++++++++ crates/ledger-store/src/store.rs | 127 ++++++++- crates/ledger-store/tests/attachments.rs | 148 ++++++++++ docs/mcp-interface.md | 101 +++++-- docs/parties-and-attachments.md | 49 +++- docs/spec/rules.md | 8 +- 22 files changed, 1802 insertions(+), 343 deletions(-) create mode 100644 crates/ledger-store/src/reconcilable.rs diff --git a/crates/ledger-config/src/lib.rs b/crates/ledger-config/src/lib.rs index 31a36eb..fa19790 100644 --- a/crates/ledger-config/src/lib.rs +++ b/crates/ledger-config/src/lib.rs @@ -81,18 +81,26 @@ impl Default for Company { impl Company { /// The members as party shares see them ([`ledger_core::PartyMember`]): /// each member whose name (ignoring case and surrounding space) is a - /// party with the member role not archived, and whose capital account - /// is in the chart. Members missing either are left out, so their - /// shares can't be derived (and the viewer shows no capital - /// statement for them). + /// party with the member role, and whose capital account is in the + /// chart. The party not archived of that name when there is one, else + /// the most recent archived one: archiving a member ("no longer dealt + /// with; history stays") never takes their history away. Members + /// missing either are left out, so their shares can't be derived (and + /// the viewer shows no capital statement for them). pub fn party_members(&self, ledger: &ledger_core::Ledger) -> Vec { + let is_member = |p: &&ledger_core::Party| p.roles.contains(&ledger_core::Role::Member); self.members .iter() .filter_map(|m| { - let party = ledger.party_by_name(&m.name)?; - if !party.roles.contains(&ledger_core::Role::Member) { - return None; - } + let key = ledger_core::parties::name_key(&m.name); + let party = match ledger.party_by_name(&m.name) { + Some(p) => Some(p).filter(is_member)?, + None => ledger + .parties() + .filter(|p| p.archived && ledger_core::parties::name_key(&p.name) == key) + .filter(is_member) + .max_by_key(|p| p.id)?, + }; Some(ledger_core::PartyMember { party: party.id, share: m.share, diff --git a/crates/ledger-core/src/k1.rs b/crates/ledger-core/src/k1.rs index 5721375..edf91fc 100644 --- a/crates/ledger-core/src/k1.rs +++ b/crates/ledger-core/src/k1.rs @@ -17,18 +17,19 @@ //! 2. contributions_cash + contributions_property = contributions, //! exactly, per member. //! 3. Property is what an entry crediting a member's capital account -//! debits to asset accounts that are neither cash (`cash`), -//! receivables, nor prepaid: equipment, a vehicle, software a member -//! hands the company. The rest of a contribution is cash: deposits, -//! and what a member paid for the company (an expense, a debt they -//! took on, e.g. Dr 2100 / Cr capital, or a service paid in advance). -//! A prepaid account is the balance account of a prepaid-expense -//! schedule, or an asset account whose name says "prepaid" (1300 -//! Prepaid Expenses before its schedule exists): a member paying the -//! company's service in advance paid cash on its behalf, and it reads -//! the same booked to capital directly or through 2100. Item L -//! property is property the member owned, with its own basis -//! (§723), never cash they spent for the company. +//! debits to asset accounts that are neither cash (`cash`) nor +//! receivables: equipment, a vehicle, software, or the unexpired part +//! of a service contract (AppleCare+, a prepaid subscription) that a +//! member owned and hands the company, each with its own basis +//! (§723). The rest of a contribution is cash: deposits, and what a +//! member paid for the company (an expense booked straight to +//! capital, or a debt they took on, e.g. Dr 2100 / Cr capital). A +//! member who pays a vendor for the company's prepaid service is +//! booked through Due to Shareholders (Dr prepaid / Cr 2100), then +//! converted (Dr 2100 / Cr capital): cash. A prepaid credited +//! straight to capital is a contract they handed over: property. +//! (DECISION, Stephen, 2026-10-15, after the AppleCare+ contributed +//! with the MacBook read as cash.) //! 4. Within one entry, the property (at most the members' credits in //! it) is shared among the members credited in proportion to their //! credits, to the cent, leftover cents by largest remainder @@ -52,12 +53,16 @@ //! 1. A joint 1,000.00 contribution (Dr 1000 / Cr 3000 500, Cr 3001 //! 500) reversed the next day: each member 0.00 contributed, 0.00 //! distributed. -//! 2. Members prepay an annual service from joint funds straight to -//! capital (Dr 1300 Prepaid 3,000 / Cr 3000 1,500, Cr 3001 1,500): -//! 1,500.00 cash each, no property. +//! 2. Members pay a vendor ahead for the company's annual service from +//! joint funds, through Due to Shareholders (Dr 1300 Prepaid 3,000 / +//! Cr 2100 3,000), later converted to capital (Dr 2100 3,000 / Cr +//! 3000 1,500, Cr 3001 1,500): 1,500.00 cash each, no property. //! 3. A joint mixed contribution (Dr 1000 600, Dr 1500 400 / Cr 3000 //! 500, Cr 3001 500): each member 300.00 cash and 200.00 property, //! whichever member is listed first. +//! 4. Members hand the company a service contract they already owned, +//! straight to capital (Dr 1300 Prepaid 3,000 / Cr 3000 1,500, Cr +//! 3001 1,500): 1,500.00 property each, no cash. //! ``` use crate::reports::{MemberCapital, members_equity}; @@ -165,18 +170,13 @@ pub fn k1_detail( let receivables = ledger.role_account(&crate::RECEIVABLES); let closings = ledger.closing_entry_ids(); let cancelled = crate::reports::cancelled_in_span(ledger, start, end); - let prepaid: Vec = ledger - .schedules() - .filter(|s| s.kind == crate::schedules::ScheduleKind::PrepaidExpense) - .map(|s| s.balance_account) - .collect(); let property_account = |id: AccountId| { !cash.contains(&id) && Some(id) != receivables - && !prepaid.contains(&id) - && ledger.chart().get(id).is_some_and(|a| { - a.account_type == AccountType::Asset && !a.name.to_lowercase().contains("prepaid") - }) + && ledger + .chart() + .get(id) + .is_some_and(|a| a.account_type == AccountType::Asset) }; let sum = |e: &crate::JournalEntry, side: Side, account: AccountId| -> Decimal { e.lines() @@ -701,13 +701,13 @@ mod tests { assert_eq!(split(&bk, &ms), vec![back, back]); } - /// INVARIANT 3 and SCENARIO 2, by hand: members prepay a 3,000 annual - /// service straight to capital (Dr 1300 Prepaid / Cr 3000 1,500, Cr - /// 3001 1,500): 1,500 cash each, no property, as when routed through - /// 2100. A prepaid-expense schedule's account is prepaid whatever its - /// name. + /// INVARIANT 3, by hand: a contract the members hand over (Dr 1300 + /// Prepaid 25 / Cr 3000 12.50, Cr 3001 12.50, the AppleCare+ with the + /// MacBook) is property, 12.50 each. Members paying a 3,000 annual + /// service for the company go through 2100: Dr 1300 / Cr 2100 3,000, + /// then Dr 2100 / Cr 3000 1,500, Cr 3001 1,500: 1,500 cash each. #[test] - fn a_prepaid_service_paid_by_members_is_cash() { + fn a_contract_handed_over_is_property_a_service_paid_for_is_cash() { let mut bk = books(); let (prepaid, a, b) = (bk.prepaid, bk.a, bk.b); post( @@ -715,45 +715,90 @@ mod tests { 1, 5, &[ - (prepaid, Side::Debit, "3000"), + (prepaid, Side::Debit, "25"), + (a, Side::Credit, "12.50"), + (b, Side::Credit, "12.50"), + ], + ); + let ms = members(&bk); + let handed = (d("0"), d("12.50"), d("0")); + assert_eq!(split(&bk, &ms), vec![handed, handed]); + let due = + bk.l.add_account( + "2100", + "Due to Members", + AccountType::Liability, + None, + false, + ) + .unwrap(); + post( + &mut bk.l, + 2, + 1, + &[(prepaid, Side::Debit, "3000"), (due, Side::Credit, "3000")], + ); + post( + &mut bk.l, + 2, + 2, + &[ + (due, Side::Debit, "3000"), + (a, Side::Credit, "1500"), + (b, Side::Credit, "1500"), + ], + ); + let each = (d("1500"), d("12.50"), d("0")); + assert_eq!(split(&bk, &ms), vec![each, each]); + } + + /// SCENARIOS 2 and 4 exactly as the module doc states them: 3,000 of + /// prepaid service through 2100 is 1,500.00 cash each; the same 3,000 + /// straight to capital is 1,500.00 property each. + #[test] + fn scenarios_2_and_4_prepaid_through_2100_is_cash_straight_to_capital_is_property() { + let mut bk = books(); + let (prepaid, a, b) = (bk.prepaid, bk.a, bk.b); + let due = + bk.l.add_account( + "2100", + "Due to Shareholders", + AccountType::Liability, + None, + false, + ) + .unwrap(); + post( + &mut bk.l, + 1, + 5, + &[(prepaid, Side::Debit, "3000"), (due, Side::Credit, "3000")], + ); + post( + &mut bk.l, + 1, + 6, + &[ + (due, Side::Debit, "3000"), (a, Side::Credit, "1500"), (b, Side::Credit, "1500"), ], ); let ms = members(&bk); - let each = (d("1500"), d("0"), d("0")); - assert_eq!(split(&bk, &ms), vec![each, each]); - // An account named otherwise, backing a prepaid schedule. - let ins = - bk.l.add_account( - "1310", - "Insurance paid ahead", - AccountType::Asset, - None, - false, - ) - .unwrap(); - let exp = - bk.l.add_account("6300", "Insurance", AccountType::Expense, None, false) - .unwrap(); + let cash = (d("1500"), d("0"), d("0")); + assert_eq!(split(&bk, &ms), vec![cash, cash]); post( &mut bk.l, 2, 1, - &[(ins, Side::Debit, "1200"), (a, Side::Credit, "1200")], + &[ + (prepaid, Side::Debit, "3000"), + (a, Side::Credit, "1500"), + (b, Side::Credit, "1500"), + ], ); - assert_eq!(split(&bk, &ms)[0].1, d("1200"), "no schedule yet: property"); - bk.l.register_schedule(crate::schedules::NewSchedule { - description: "2026 insurance".into(), - kind: crate::schedules::ScheduleKind::PrepaidExpense, - total: Money::new(d("1200"), Currency::USD), - start: NaiveDate::from_ymd_opt(2026, 2, 1).unwrap(), - months: 12, - balance_account: ins, - pl_account: exp, - }) - .unwrap(); - assert_eq!(split(&bk, &ms)[0], (d("2700"), d("0"), d("0"))); + let both = (d("1500"), d("1500"), d("0")); + assert_eq!(split(&bk, &ms), vec![both, both]); } /// INVARIANT 4 and SCENARIO 3, by hand: Dr 1000 600, Dr 1500 400 / diff --git a/crates/ledger-core/src/ledger.rs b/crates/ledger-core/src/ledger.rs index 9b2e1a3..695024c 100644 --- a/crates/ledger-core/src/ledger.rs +++ b/crates/ledger-core/src/ledger.rs @@ -1386,6 +1386,11 @@ impl Ledger { if let Some(p) = self.entry_parties.get(&entry).cloned() { self.entry_parties.insert(id, p); } + // An entry with no counterparty is reversed with none either + // (set_entry_no_party INVARIANT 1). + if let Some(why) = self.entry_no_party.get(&entry).cloned() { + self.entry_no_party.insert(id, why); + } Ok(id) } @@ -2461,8 +2466,10 @@ impl Ledger { /// 2. Tagging MUST change no amount (it's metadata, allowed on posted /// entries). /// 3. On an entry naming no party or one: Some(v) makes v's party the - /// entry's one party (all of it); None clears the party too when - /// it was the vendor's. + /// entry's one party (all of it) and clears a no-party mark + /// (set_entry_parties INVARIANT 3: an entry never names a party + /// and carries the mark at once); None clears the party too when + /// it was the vendor's, and leaves no mark. /// 4. On an entry naming several parties, Some(v) with v's party /// among them sets the tag and keeps every party and share as /// they are. IF v's party is not among them (or v has none): @@ -2542,6 +2549,9 @@ impl Ledger { self.entry_parties .insert(entry, vec![crate::PartyShare::whole(p)]); } + if self.entry_parties.contains_key(&entry) { + self.entry_no_party.remove(&entry); + } } None => { // The party tag goes too only when it was the vendor's. @@ -3175,14 +3185,25 @@ impl Ledger { /// ```text /// SOURCE: docs/parties-and-attachments.md "Counterparties"; DECISION /// (Stephen, 2026-10-15). + /// INVARIANTS: + /// 1. An entry and its reversal (and theirs: Self::reversal_chain) + /// carry the same mark, as they name the same parties + /// (set_entry_parties INVARIANT 5): marking any one marks the + /// chain, and reverse_entry copies the mark to the reversal. /// SCENARIOS: /// 1. IF the entry is unknown, already names a party, or the reason /// is blank or fails the party text rules: SHOULD refuse. - /// 2. Tagging the entry with a party later clears the mark. + /// 2. Tagging the entry with a party later clears the mark + /// (set_entry_parties, set_entry_party, set_entry_vendor), on the + /// whole chain. + /// 3. A transfer between the company's own accounts marked, then + /// reversed: the reversal is marked with the same reason. /// ``` pub fn set_entry_no_party(&mut self, entry: EntryId, reason: &str) -> Result<(), LedgerError> { self.check_set_entry_no_party(entry, reason)?; - self.entry_no_party.insert(entry, reason.trim().to_string()); + for e in self.reversal_chain(entry) { + self.entry_no_party.insert(e, reason.trim().to_string()); + } Ok(()) } @@ -3195,10 +3216,16 @@ impl Ledger { if !self.entries.contains_key(&entry) { return Err(LedgerError::UnknownEntry(entry)); } - if self.entry_parties.contains_key(&entry) { - return Err(LedgerError::InvalidParty(format!( - "entry {entry} names a party; clear it first" - ))); + if let Some(e) = self + .reversal_chain(entry) + .into_iter() + .find(|e| self.entry_parties.contains_key(e)) + { + return Err(LedgerError::InvalidParty(if e == entry { + format!("entry {entry} names a party; clear it first") + } else { + format!("entry {entry}'s reversal chain entry {e} names a party; clear it first") + })); } if reason.trim().is_empty() { return Err(LedgerError::InvalidParty( @@ -4102,9 +4129,18 @@ impl Ledger { /// FICA, Cr 2110-2150 the withholdings and both shares of FICA, Cr cash /// net pay. /// 2. Net pay MUST equal gross minus the employee's withholdings. + /// 3. When every line with net pay names its employee party, the entry + /// names those parties (Self::entry_parties), each with its net pay + /// (summed over its lines): the whole of it for one employee, else + /// an exact amount each, so each employee's books.party counts what + /// they were paid. Lines named by employee_name only (or shares + /// Self::set_entry_parties would refuse) leave the entry naming no + /// party (the agent tags it). /// SCENARIOS: /// 1. IF any amount is negative, gross is zero, or withholdings exceed /// gross: SHOULD refuse. + /// 2. Pat (net 1,500.00) and Sam (net 900.00), both parties: the entry + /// names Pat 1,500.00 and Sam 900.00. /// ``` pub fn run_payroll( &mut self, @@ -4131,6 +4167,7 @@ impl Ledger { self.add_line(entry, account, side, amount)?; } self.post_entry(entry)?; + self.name_payroll_parties(entry, &lines)?; let id = self.next_payroll_run_id; self.next_payroll_run_id += 1; self.payroll_runs.insert( @@ -4145,6 +4182,45 @@ impl Ledger { Ok(id) } + /// [`Self::run_payroll`] INVARIANT 3: name the employees a payroll + /// entry paid as its parties, each with their net pay. Live and on + /// replay ([`Self::replay_payroll_run`]) alike. + fn name_payroll_parties( + &mut self, + entry: EntryId, + lines: &[PayrollLine], + ) -> Result<(), LedgerError> { + let mut paid: Vec<(crate::PartyId, Decimal)> = Vec::new(); + let mut unnamed = false; + for l in lines.iter().filter(|l| !l.net_pay().is_zero()) { + match l.employee { + Some(p) => match paid.iter_mut().find(|(x, _)| *x == p) { + Some((_, v)) => *v += l.net_pay(), + None => paid.push((p, l.net_pay())), + }, + None => unnamed = true, + } + } + if !unnamed && !paid.is_empty() { + let shares: Vec = match paid.as_slice() { + [(p, _)] => vec![crate::PartyShare::whole(*p)], + _ => paid + .iter() + .map(|&(party, v)| crate::PartyShare { + party, + share: crate::Share::Amount(v), + }) + .collect(), + }; + // Shares set_entry_parties would refuse (two employees with + // vendor records) leave the entry for the agent to tag. + if self.check_set_entry_parties(entry, &shares).is_ok() { + self.set_entry_parties(entry, &shares)?; + } + } + Ok(()) + } + /// Current balances of the payroll tax payable accounts (`2110`–`2150`), /// in code order. Accounts that don't exist yet count as zero and are /// skipped, as are zero balances. Balances are normal-side: positive @@ -4245,6 +4321,9 @@ impl Ledger { return Err(LedgerError::ReplayIdMismatch(id, self.next_payroll_run_id)); } self.next_payroll_run_id += 1; + if self.entries.contains_key(&entry_id) { + self.name_payroll_parties(entry_id, &lines)?; + } self.payroll_runs.insert( id, PayrollRun { diff --git a/crates/ledger-core/src/parties.rs b/crates/ledger-core/src/parties.rs index 0e5065b..132d6e8 100644 --- a/crates/ledger-core/src/parties.rs +++ b/crates/ledger-core/src/parties.rs @@ -514,22 +514,38 @@ pub struct EntryFlow { /// down = min(liab_dr, cash_cr): cash paying down a liability (its /// creditor is paid; the liability is what for); /// up = min(liab_cr, cash_dr): a liability raising cash (borrowing: the -/// lender pays the company). +/// lender pays the company); +/// left(a), for each money liability a: its debits less what of them +/// `down` takes, less its credits less what of them `up` takes (`down` +/// and `up` taken from its lines in line order), so one account's +/// debits and credits offset first; +/// across = min(Σ left(a) > 0, Σ −left(a) < 0): one liability paying +/// down another (a member paying the card from their own funds: Dr +/// 2050 / Cr 2100; a loan paying off a card): the debited liability's +/// creditor is paid, by the credited one's. /// INVARIANTS: -/// 1. net = (cash_cr − cash_dr) + (liab_cr − liab_dr) + down − up. +/// 1. net = (cash_cr − cash_dr) + (liab_cr − liab_dr) + down − up +/// + across. /// 2. what_for: every line not on a money account (debits − credits), /// plus `down` on the debited liabilities and −`up` on the credited -/// ones (in line order), zero sums left out, accounts merged; its sum -/// is net exactly, because the entry balances. +/// ones (in line order), plus `across` on the liabilities with +/// left(a) > 0 (in order of first appearance), zero sums left out, +/// accounts merged; its sum is net exactly, because the entry +/// balances. /// SCENARIOS: /// 1. Expense from the bank, on a card, or paid by a member for the /// company (Dr expense / Cr 1000, 2050, or 2100): net is the amount. /// 2. Reimbursing members (Dr 2100 / Cr 1000) or paying a card (Dr 2050 / /// Cr 1000): net is the amount, what for is the liability. /// 3. A loan (Dr 1000 / Cr loan): net is minus the amount (received). -/// 4. A refund (Dr 1000 or Dr card / Cr expense): negative net. +/// 4. A refund (Dr 1000 or Dr card / Cr expense): negative net. A charge +/// and a refund on the same card in one entry net on that card: no +/// card paid down. /// 5. A transfer between the company's own cash accounts: zero. /// 6. A member contributing property (Dr equipment / Cr capital): zero. +/// 7. A member paying the card bill from their own funds (Dr 2050 / Cr +/// 2100), or a loan paying it off (Dr 2050 / Cr loan): net is the +/// amount, what for is the card, as if paid from the bank. /// ``` pub fn entry_flow(entry: &crate::JournalEntry, money: &MoneyAccounts) -> EntryFlow { use crate::Side; @@ -552,40 +568,72 @@ pub fn entry_flow(entry: &crate::JournalEntry, money: &MoneyAccounts) -> EntryFl } let down = liab_dr.min(cash_cr); let up = liab_cr.min(cash_dr); - let net = (cash_cr - cash_dr) + (liab_cr - liab_dr) + down - up; let mut by: std::collections::BTreeMap = Default::default(); let (mut down_left, mut up_left) = (down, up); let mut order: Vec = Vec::new(); + // left(a) per money liability, in order of first appearance. + let mut left: Vec<(crate::AccountId, Decimal)> = Vec::new(); + let add = |by: &mut std::collections::BTreeMap, + order: &mut Vec, + account: crate::AccountId, + part: Decimal| { + if !part.is_zero() { + if !by.contains_key(&account) { + order.push(account); + } + *by.entry(account).or_default() += part; + } + }; for l in entry.lines() { let a = l.amount.amount; let part = if money.cash.contains(&l.account) { Decimal::ZERO } else if money.liabilities.contains(&l.account) { - match l.side { + let (part, rest) = match l.side { Side::Debit => { let take = a.min(down_left); down_left -= take; - take + (take, a - take) } Side::Credit => { let take = a.min(up_left); up_left -= take; - -take + (-take, -(a - take)) } + }; + match left.iter_mut().find(|(x, _)| *x == l.account) { + Some((_, v)) => *v += rest, + None => left.push((l.account, rest)), } + part } else { match l.side { Side::Debit => a, Side::Credit => -a, } }; - if !part.is_zero() { - if !by.contains_key(&l.account) { - order.push(l.account); - } - *by.entry(l.account).or_default() += part; + add(&mut by, &mut order, l.account, part); + } + let owed_down: Decimal = left + .iter() + .map(|(_, v)| *v) + .filter(|v| *v > Decimal::ZERO) + .sum(); + let owed_up: Decimal = -left + .iter() + .map(|(_, v)| *v) + .filter(|v| *v < Decimal::ZERO) + .sum::(); + let across = owed_down.min(owed_up); + let mut across_left = across; + for (account, v) in left { + if v > Decimal::ZERO && !across_left.is_zero() { + let take = v.min(across_left); + across_left -= take; + add(&mut by, &mut order, account, take); } } + let net = (cash_cr - cash_dr) + (liab_cr - liab_dr) + down - up + across; EntryFlow { net, what_for: order @@ -625,7 +673,9 @@ pub fn activity( /// zero counts the party's resolved share of it /// (crate::shares::resolve with `members`; the whole net for a sole /// party): positive is a payment, negative a receipt. A reversal -/// counts on its original's side, so it subtracts. +/// counts on its root original's side (the first entry of +/// Ledger::reversal_chain), so a reversal subtracts and a reversal +/// of that reversal adds back, on the same side. /// 3. An entry moving no money (net zero, like property contributed) /// counts nothing. An entry whose shares don't resolve counts /// nothing and is listed in `unresolved`. @@ -672,8 +722,12 @@ pub fn activity_with( continue; } }; - let side = match e.reversal_of().and_then(|o| ledger.get_entry(o)) { - Some(original) => net_out(original), + let side = match e.reversal_of() { + Some(_) => ledger + .reversal_chain(id) + .first() + .and_then(|&root| ledger.get_entry(root)) + .map_or(net, net_out), None => net, }; if side > Decimal::ZERO { diff --git a/crates/ledger-core/tests/spec_parties.rs b/crates/ledger-core/tests/spec_parties.rs index 5cec67f..8c48062 100644 --- a/crates/ledger-core/tests/spec_parties.rs +++ b/crates/ledger-core/tests/spec_parties.rs @@ -1252,6 +1252,40 @@ fn entry_flow_scenarios() { // Split payment: part bank, part card. let f = case(&[(exp, D, "100.00"), (bank, C, "60.00"), (card, C, "40.00")]); assert_eq!(f.net, dec("100.00")); + // 7. A member pays the card bill from their own funds, or a loan pays + // it off: the issuer is paid, for the card, as from the bank. + for funder in [members, loan] { + let f = case(&[(card, D, "300.00"), (funder, C, "300.00")]); + assert_eq!(f.net, dec("300.00")); + assert_eq!(f.what_for, vec![(card, dec("300.00"))]); + } + // Part bank, part a member. + let f = case(&[ + (card, D, "300.00"), + (bank, C, "200.00"), + (members, C, "100.00"), + ]); + assert_eq!(f.net, dec("300.00")); + assert_eq!(f.what_for, vec![(card, dec("300.00"))]); + // 4. A charge and its refund on one card offset there: no card paid + // down, 80.00 paid by card. + let f = case(&[ + (exp, D, "100.00"), + (card, C, "100.00"), + (card, D, "20.00"), + (exp, C, "20.00"), + ]); + assert_eq!(f.net, dec("80.00")); + assert_eq!(f.what_for, vec![(exp, dec("80.00"))]); + // Paying the card from the bank while the issuer charges a fee on it: + // 300.00 cash plus the 50.00 fee by card. + let f = case(&[ + (card, D, "300.00"), + (bank, C, "300.00"), + (exp, D, "50.00"), + (card, C, "50.00"), + ]); + assert_eq!(f.net, dec("350.00")); } // ------------------------------------------------------- party shares @@ -1698,6 +1732,62 @@ mod shares { }); } + /// set_entry_no_party INVARIANT 1 and SCENARIO 3: a marked transfer's + /// reversal carries the mark; marking a reversed entry marks the + /// whole chain; naming a party on any of them clears it on all. + #[test] + fn a_no_party_mark_follows_the_reversal_chain() { + let mut b = books(); + let own = |b: &mut Books| { + let (due, bank) = (b.due, b.bank); + post( + &mut b.l, + &[(due, Side::Debit, "10.00"), (bank, Side::Credit, "10.00")], + ) + }; + let e = own(&mut b); + b.l.set_entry_no_party(e, "transfer between our own accounts") + .unwrap(); + let r = b.l.reverse_entry(e, date(2026, 5, 2), "undo").unwrap(); + assert_eq!( + b.l.entry_no_party(r), + Some("transfer between our own accounts") + ); + // Marked after it was reversed: the whole chain. + let e2 = own(&mut b); + let r2 = b.l.reverse_entry(e2, date(2026, 5, 2), "undo").unwrap(); + b.l.set_entry_no_party(r2, "accrual").unwrap(); + assert_eq!(b.l.entry_no_party(e2), Some("accrual")); + assert_eq!(b.l.entry_no_party(r2), Some("accrual")); + // A party named on the original clears both. + b.l.set_entry_party(e2, Some(b.acme)).unwrap(); + assert_eq!(b.l.entry_no_party(e2), None); + assert_eq!(b.l.entry_no_party(r2), None); + refused(&mut b.l, "chain names a party", |l| { + l.set_entry_no_party(r2, "none") + }); + } + + /// set_entry_vendor SCENARIO 3: tagging a vendor names its party and + /// clears a no-party mark, so the entry never carries both; clearing + /// the vendor afterwards leaves no stale mark to satisfy "require". + #[test] + fn tagging_a_vendor_clears_a_no_party_mark() { + let mut b = books(); + let e = b.l.try_create_entry(date(2026, 5, 1), "hosting").unwrap(); + b.l.add_line(e, b.exp, Side::Debit, usd("12.00")).unwrap(); + b.l.add_line(e, b.bank, Side::Credit, usd("12.00")).unwrap(); + b.l.set_entry_no_party(e, "none really").unwrap(); + let v = b.l.add_vendor("Namecheap").unwrap(); + let pv = b.l.party_of_vendor(v).unwrap(); + b.l.set_entry_vendor(e, Some(v)).unwrap(); + assert_eq!(b.l.entry_parties(e), &[PartyShare::whole(pv)]); + assert_eq!(b.l.entry_no_party(e), None); + b.l.set_entry_vendor(e, None).unwrap(); + assert!(b.l.entry_parties(e).is_empty()); + assert_eq!(b.l.entry_no_party(e), None); + } + /// Reversals carry every share; the pair nets to zero for each party. #[test] fn reversal_carries_all_shares() { @@ -1739,6 +1829,33 @@ mod shares { assert!(a.entries.contains(&e)); } + /// activity_with INVARIANT 2: a reversal of a reversal reinstates the + /// payment on its root original's side: paid, never a negative + /// received. + #[test] + fn reversing_a_reversal_reinstates_the_payment_as_paid() { + let mut b = books(); + let a = post( + &mut b.l, + &[ + (b.exp, Side::Debit, "100.00"), + (b.bank, Side::Credit, "100.00"), + ], + ); + b.l.set_entry_party(a, Some(b.acme)).unwrap(); + let r1 = b.l.reverse_entry(a, date(2026, 5, 2), "undo").unwrap(); + let r2 = b.l.reverse_entry(r1, date(2026, 5, 3), "redo").unwrap(); + let act = ledger_core::parties::activity(&b.l, b.acme, year(), &[b.bank]); + assert_eq!(act.entries, std::collections::BTreeSet::from([a, r1, r2])); + assert_eq!((act.paid, act.received), (d("100.00"), Decimal::ZERO)); + // A third level takes it back out again, still on the paid side. + let _r3 = + b.l.reverse_entry(r2, date(2026, 5, 4), "undo again") + .unwrap(); + let act = ledger_core::parties::activity(&b.l, b.acme, year(), &[b.bank]); + assert_eq!((act.paid, act.received), (Decimal::ZERO, Decimal::ZERO)); + } + fn cents() -> impl Strategy { (1i64..10_000_000).prop_map(|c| Decimal::new(c, 2)) } diff --git a/crates/ledger-gui/src/books.rs b/crates/ledger-gui/src/books.rs index df77fff..466aa14 100644 --- a/crates/ledger-gui/src/books.rs +++ b/crates/ledger-gui/src/books.rs @@ -63,8 +63,9 @@ pub struct EntryInfo { /// One party an entry names, with its share. /// /// ```text -/// SOURCE: ledger_core::shares (resolve), with Books::cash_accounts and -/// Books::members, as the server's books.get_entry resolves them. +/// SOURCE: ledger_core::shares (resolve), with Books::money_cash_accounts +/// and Books::members, as the server's books.get_entry resolves +/// them. /// INVARIANTS: /// 1. resolved: the party's part from ledger_core::shares::resolve, None /// when the entry's shares don't resolve; over an entry's parties the @@ -124,11 +125,12 @@ impl AssetTrail { /// The party's entries: Ledger::entry_parties names it, alone or among /// others (core tags hand tags, vendor tags, bills, invoices, their /// payments, reversals). -/// The year: the calendar year of `today`, Jan 1 through today (the -/// year 1099s are counted in). +/// The year: the calendar year of `today`, Jan 1 through Dec 31 (the +/// year 1099s are counted in, and books.party's year: a posted entry, +/// invoice, or bill dated later this year counts). /// Money: Books::activity (ledger_core::parties::activity_with with -/// Books::cash_accounts and Books::members: a party's share of an -/// entry naming several; the same numbers books.party reports). +/// Books::money_cash_accounts and Books::members: a party's share of +/// an entry naming several; the same numbers books.party reports). /// INVARIANTS: /// 1. entries: every entry that is the party's, drafts included, in /// Books::entries order (newest first). @@ -233,17 +235,19 @@ pub struct Books { pub members: Vec, /// `[books] financing_accounts`, resolved (empty when not set). pub financing_accounts: Vec, - /// The cash accounts the member capital statement hands - /// ledger_core::k1 to tell cash from property: `[books] - /// cash_accounts`, resolved; when that is empty, core's fallback - /// (ledger_core::parties::money_accounts: every asset account but - /// receivables), so the viewer reads money as core does. Without the - /// setting every contribution therefore reads as cash: property can't - /// be told from cash without knowing which accounts hold cash. - /// Kept apart from `cash_accounts`, whose name-based guess the Cash - /// page, dashboard and charts keep (changing those is out of scope: - /// "every asset" would put equipment on the Cash page). - pub k1_cash_accounts: Vec, + /// The cash accounts every number the server also reports reads + /// money with: `[books] cash_accounts`, resolved; when that is empty, + /// core's fallback (ledger_core::parties::money_accounts: every asset + /// account but receivables), exactly as the server passes them. Party + /// money (Books::activity, shares, what for), the party 1099 line, and + /// the member capital statement (ledger_core::k1, to tell cash from + /// property) all use it, so the viewer shows books.party's, + /// books.parties' and the K-1 export's numbers. Without the setting + /// every contribution therefore reads as cash: property can't be told + /// from cash without knowing which accounts hold cash. Kept apart from + /// `cash_accounts`, whose name-based guess the Cash page, dashboard and + /// charts keep ("every asset" would put equipment on the Cash page). + pub money_cash_accounts: Vec, /// `[books] investing_accounts`, resolved (empty when not set). pub investing_accounts: Vec, pub card_accounts: Vec, @@ -292,8 +296,8 @@ pub enum PartyPeriod { /// get back?" (Stephen, 2026-10-15) with the books' own numbers. /// SOURCE: ledger_core::parties::activity_with (paid, received) and /// ledger_core::parties::entry_flow (what for), with -/// Books::cash_accounts and Books::members, as PartyInfo uses -/// them. +/// Books::money_cash_accounts and Books::members, as PartyInfo +/// uses them. /// INVARIANTS: /// 1. paid, received: Books::activity(party, span)'s, exactly. /// 2. net() = paid − received. @@ -364,11 +368,11 @@ pub struct CapitalLine { /// SOURCE: ledger_core::k1::k1_detail (members_equity's roll-forward, /// split into cash and property); ledger_config::Company::members /// ([[company.members]], all of them, in order, as the CPA -/// package builds its MemberCapital list); Books::k1_cash_accounts; +/// package builds its MemberCapital list); Books::money_cash_accounts; /// Books::financing_accounts; DECISION (the due-to accounts, and /// the cash fallback, below). /// DEFINITIONS: -/// row: k1_detail(span, members, k1_cash_accounts).rows[i], i this +/// row: k1_detail(span, members, money_cash_accounts).rows[i], i this /// member's position in [[company.members]] (matched by capital /// account). /// INVARIANTS: @@ -538,14 +542,13 @@ impl Books { let receipt_threshold = policy.threshold(); let cash_accounts = cash_accounts(&ledger, &settings); let members = settings.company.party_members(&ledger); - let share_money = ledger_core::parties::money_accounts(&ledger, &cash_accounts); let codes = |cs: &[String]| -> Vec { cs.iter() .filter_map(|c| ledger.chart().find_by_code(c).map(|a| a.id)) .collect() }; let financing_accounts = codes(&settings.financing_accounts); - let k1_cash_accounts = { + let money_cash_accounts = { let configured = codes(&settings.cash_accounts); if configured.is_empty() { let mut all: Vec = ledger_core::parties::money_accounts(&ledger, &[]) @@ -558,15 +561,9 @@ impl Books { configured } }; + let share_money = ledger_core::parties::money_accounts(&ledger, &money_cash_accounts); let investing_accounts = codes(&settings.investing_accounts); - let card_accounts: Vec = ledger - .chart() - .all() - .filter(|a| { - a.account_type == AccountType::Liability && a.name.to_lowercase().contains("card") - }) - .map(|a| a.id) - .collect(); + let card_accounts = ledger_store::reconcilable::card_accounts(&ledger); // Journal events per entry. let mut events: HashMap> = HashMap::new(); @@ -755,7 +752,7 @@ impl Books { cash_accounts, members, financing_accounts, - k1_cash_accounts, + money_cash_accounts, investing_accounts, card_accounts, fresh, @@ -962,8 +959,7 @@ impl Books { fn party_infos(&self) -> BTreeMap { let l = &self.ledger; let year = self.today.year(); - let year_start = NaiveDate::from_ymd_opt(year, 1, 1).expect("valid"); - let in_year = |d: NaiveDate| d >= year_start && d <= self.today; + let in_year = |d: NaiveDate| d.year() == year; let mut out: BTreeMap = l .parties() .map(|p| { @@ -977,7 +973,7 @@ impl Books { }) .collect(); for (id, info) in out.iter_mut() { - let a = self.activity(*id, (year_start, self.today)); + let a = self.activity(*id, self.party_span(PartyPeriod::ThisYear)); info.entries = self .entries .iter() @@ -1281,15 +1277,15 @@ impl Books { } /// The accounts the books expect to reconcile monthly: the cash - /// accounts, then the card accounts, each once. + /// accounts, then the card accounts, then any other liability with a + /// recorded statement, each once (ledger_store::reconcilable, the + /// server's digest rule). pub fn reconcilable_accounts(&self) -> Vec { - let mut out = self.cash_accounts.clone(); - for a in &self.card_accounts { - if !out.contains(a) { - out.push(*a); - } - } - out + ledger_store::reconcilable::reconcilable_accounts( + &self.ledger, + &self.cash_accounts, + &self.statements, + ) } /// When an account is due a reconciliation: the date it should be @@ -1301,7 +1297,9 @@ impl Books { /// attachments.md, Reconciliations: "an account with activity /// not reconciled through last month's end"). /// SOURCE: that rule; CONVENTION (a bank or card statement closes - /// monthly; AGENTS.md's monthly close reconciles the bank). + /// monthly; AGENTS.md's monthly close reconciles the bank); + /// ledger_store::reconcilable (the server's digest reads the + /// same function). /// INVARIANTS: /// 1. Some(last_month_end()) iff the account has a posted line /// dated on or before last_month_end() (activity to reconcile) @@ -1310,28 +1308,48 @@ impl Books { /// 2. None otherwise (including activity only this month). /// ``` pub fn reconciliation_due(&self, account: AccountId) -> Option { - let through = self.last_month_end(); - let active = self - .ledger - .posted_entries() - .any(|e| e.date() <= through && e.lines().iter().any(|l| l.account == account)); - (active && self.reconciled_through(account).is_none_or(|d| d < through)).then_some(through) + ledger_store::reconcilable::reconciliation_due( + &self.ledger, + &self.reconciliations, + account, + self.today, + ) } // ------------------------------------------------------------------ // A party's money over a span // ------------------------------------------------------------------ - /// The dates a [`PartyPeriod`] covers: a year is January 1 to - /// December 31, cut at today; all time starts at the earliest entry - /// in the books (today when there is none). + /// The dates a [`PartyPeriod`] covers. + /// + /// ```text + /// GOAL: the viewer's totals are books.party's for the same year + /// (PartyTotalsOut: "the viewer shows the same numbers"). + /// INVARIANTS: + /// 1. A year is January 1 to December 31, cut at the later of today + /// and its last posted entry: no posted entry of the year falls + /// outside it (so its sums are the calendar year's exactly), and + /// months after today with nothing posted aren't shown. + /// 2. All time starts at the earliest entry in the books (today when + /// there is none) and ends at the later of today and the last + /// posted entry. + /// ``` pub fn party_span(&self, period: PartyPeriod) -> (NaiveDate, NaiveDate) { + let last_posted = |within: Option| { + self.entries + .iter() + .filter(|e| e.status == EntryStatus::Posted) + .filter(|e| within.is_none_or(|y| e.date.year() == y)) + .map(|e| e.date) + .max() + .map_or(self.today, |d| d.max(self.today)) + }; let year = |y: i32| { ( NaiveDate::from_ymd_opt(y, 1, 1).expect("valid"), NaiveDate::from_ymd_opt(y, 12, 31) .expect("valid") - .min(self.today), + .min(last_posted(Some(y))), ) }; match period { @@ -1346,7 +1364,7 @@ impl Books { .min() .unwrap_or(self.today) .min(self.today); - (first, self.today) + (first, last_posted(None)) } } } @@ -1419,29 +1437,7 @@ impl Books { let mut by: BTreeMap = BTreeMap::new(); for e in &posted { let flow = ledger_core::parties::entry_flow(e, &money); - let share = if l.entry_parties(e.id()).len() == 1 { - flow.net - } else { - self.party_share(e, party) - }; - // A member's own capital lines are what their share was - // for, when they account for it exactly. - let own: Vec<(AccountId, Decimal)> = self - .member(party) - .map(|m| { - flow.what_for - .iter() - .copied() - .filter(|(a, _)| *a == m.capital || Some(*a) == m.distributions) - .collect() - }) - .unwrap_or_default(); - let parts = - if !own.is_empty() && own.iter().map(|(_, v)| *v).sum::() == share { - own - } else { - share_of_what_for(&flow, share) - }; + let parts = self.party_what_for(e, party, &flow); for (account, amount) in parts { *by.entry(account).or_default() += amount; } @@ -1457,8 +1453,9 @@ impl Books { } } - /// ledger_core::parties::activity_with over `span`, with the books' - /// cash accounts and members: the one source of a party's money. + /// ledger_core::parties::activity_with over `span`, with + /// Books::money_cash_accounts and the members: the one source of a + /// party's money, the server's own (books.party, books.parties). pub fn activity( &self, party: PartyId, @@ -1468,11 +1465,84 @@ impl Books { &self.ledger, party, span, - &self.cash_accounts, + &self.money_cash_accounts, &self.members, ) } + /// One entry's what-for, for one party (see [`PartyFlow`] INVARIANT + /// 3): its fraction of the entry's entry_flow what_for, summing to its + /// money share exactly (zero when the entry moves no money, like a + /// bill or a contribution of property). + /// + /// ```text + /// The fraction: 1 for an entry with one party; else its resolved + /// share over what the shares split (the net, or the entry's total + /// debits when the net is zero). + /// A member's own capital and distribution lines are theirs in full; + /// another member's are never theirs. Every other line is scaled by + /// the fraction (to the cent, toward zero), and the first scaled line + /// takes the leftover so the parts sum to the money share. + /// ``` + pub fn party_what_for( + &self, + e: &ledger_core::JournalEntry, + party: PartyId, + flow: &ledger_core::parties::EntryFlow, + ) -> Vec<(AccountId, Decimal)> { + use rust_decimal::RoundingStrategy::ToZero; + let parties = self.ledger.entry_parties(e.id()).len(); + if parties <= 1 { + return flow.what_for.clone(); + } + let resolved = self.party_share(e, party); + let target = if flow.net.is_zero() { + e.lines() + .iter() + .filter(|l| l.side == Side::Debit) + .map(|l| l.amount.amount) + .sum::() + } else { + flow.net + }; + if target.is_zero() { + return Vec::new(); + } + let fraction = resolved / target; + let money_share = if flow.net.is_zero() { + Decimal::ZERO + } else { + resolved + }; + let me = self.member(party).copied(); + let member_line = |a: AccountId| { + self.members + .iter() + .any(|m| m.capital == a || m.distributions == Some(a)) + }; + let mut own: Vec<(AccountId, Decimal)> = Vec::new(); + let mut scaled: Vec<(AccountId, Decimal)> = Vec::new(); + for &(a, v) in &flow.what_for { + if me.is_some_and(|m| m.capital == a || m.distributions == Some(a)) { + own.push((a, v)); + } else if !member_line(a) { + scaled.push((a, (v * fraction).round_dp_with_strategy(2, ToZero))); + } + } + let have: Decimal = own.iter().chain(&scaled).map(|(_, v)| *v).sum(); + let left = money_share - have; + if !left.is_zero() { + match scaled.first_mut() { + Some(first) => first.1 += left, + None => own.push((e.lines()[0].account, left)), + } + } + own.into_iter() + .chain(scaled) + .filter(|(_, v)| !v.is_zero()) + .collect() + } + /// A party's resolved share of an entry's flow (its entry_flow net /// for a sole party; zero when the shares don't resolve or it isn't /// named). @@ -1499,7 +1569,8 @@ impl Books { .iter() .position(|x| x.capital_account == m.capital)?; let detail = - ledger_core::k1::k1_detail(l, span.0, span.1, &members, &self.k1_cash_accounts).ok()?; + ledger_core::k1::k1_detail(l, span.0, span.1, &members, &self.money_cash_accounts) + .ok()?; let row = detail.rows.get(i)?; let lines: Vec = detail .lines @@ -1605,18 +1676,21 @@ impl Books { } /// A party's 1099 worksheet line for a calendar year and that year's - /// threshold: ledger-core's vendor_1099_report_with (cash and - /// financing accounts) line for its vendor record; None without a - /// vendor record in the report, or when the year's threshold isn't - /// known. + /// threshold: ledger-core's vendor_1099_report_with_members line for + /// its vendor record, with Books::money_cash_accounts, the financing + /// accounts and the members (the server's books.vendor_1099 and + /// books.party amount_1099); None without a vendor record in the + /// report, or when the year's threshold isn't known. pub fn party_1099(&self, party: PartyId, year: i32) -> Option<(Vendor1099Line, Decimal)> { let l = &self.ledger; let vendor = l.get_party(party)?.vendor?; let sources = ledger_core::PaymentAccounts { - cash: self.cash_accounts.clone(), + cash: self.money_cash_accounts.clone(), financing: self.financing_accounts.clone(), }; - let r = ledger_core::vendor_1099_report_with(l, year, None, &sources).ok()?; + let r = + ledger_core::vendor_1099_report_with_members(l, year, None, &sources, &self.members) + .ok()?; let line = r.vendors.iter().find(|v| v.vendor_id == vendor)?.clone(); Some((line, r.threshold)) } @@ -1816,26 +1890,8 @@ fn origin(events: &[usize], journal: &[JournalLine], closing: bool) -> &'static } /// Cash accounts: from `[books] cash_accounts`, else asset accounts whose -/// names say they hold cash. +/// names say they hold cash (ledger_store::reconcilable::cash_accounts, +/// as the server's digest reads them). fn cash_accounts(ledger: &Ledger, settings: &BooksSettings) -> Vec { - if !settings.cash_accounts.is_empty() { - return settings - .cash_accounts - .iter() - .filter_map(|c| ledger.chart().find_by_code(c).map(|a| a.id)) - .collect(); - } - ledger - .chart() - .all() - .filter(|a| { - let n = a.name.to_lowercase(); - a.account_type == AccountType::Asset - && !a.placeholder - && ["cash", "bank", "checking", "savings", "money market"] - .iter() - .any(|w| n.contains(w)) - }) - .map(|a| a.id) - .collect() + ledger_store::reconcilable::cash_accounts(ledger, &settings.cash_accounts) } diff --git a/crates/ledger-gui/src/pages/journal.rs b/crates/ledger-gui/src/pages/journal.rs index 4993387..fb272a4 100644 --- a/crates/ledger-gui/src/pages/journal.rs +++ b/crates/ledger-gui/src/pages/journal.rs @@ -282,6 +282,16 @@ pub fn describe(b: &Books, line: &JournalLine) -> Option { also: account.map(Route::Account), }) } + "statement.voided" => Some(Event { + icon: icon::PROHIBIT, + text: format!( + "Voided the statement recorded on {}: {}", + attachment_name(b, att), + s(p, "reason") + ), + route: Some(Route::Document(att)), + also: None, + }), "reconciliation.completed" => { let account = p.get("account_id").and_then(|v| v.as_u64()); let balance = s(p, "statement_balance") diff --git a/crates/ledger-gui/tests/attachments.rs b/crates/ledger-gui/tests/attachments.rs index 0f71917..5f7b142 100644 --- a/crates/ledger-gui/tests/attachments.rs +++ b/crates/ledger-gui/tests/attachments.rs @@ -603,3 +603,49 @@ fn books_show_opens_a_reconciliation() { h.state_mut().show(ViewerTarget::Reconciliation { id: 999 }); assert_eq!(h.state_mut().route(), &Route::Reconciliation(r)); } + +/// ledger_store::reconcilable INVARIANT 1, the digest's rule: a liability +/// not named for a card (an Amex) joins the accounts to reconcile once it +/// has a recorded statement, so Needs attention asks for it as the digest +/// does (it used to be listed by the digest only). +#[test] +fn a_liability_with_a_statement_needs_reconciling_whatever_its_name() { + let mut f = fixture("amex"); + let amex = f + .store + .add_account("2060", "Amex", AccountType::Liability, None, false) + .unwrap(); + let software = f.store.ledger().chart().find_by_code("6300").unwrap().id; + let e = f.store.create_entry(d(2026, 9, 3), "Amex charge").unwrap(); + f.store + .add_line(e, software, Side::Debit, usd(1_000)) + .unwrap(); + f.store.add_line(e, amex, Side::Credit, usd(1_000)).unwrap(); + f.store.post_entry(e).unwrap(); + let b = load(&f.dir.0); + assert!(!b.reconcilable_accounts().contains(&amex)); + let (att, _, _) = f + .store + .file_document( + "statement", + "amex-aug.pdf", + "application/pdf", + b"amex aug", + None, + ) + .unwrap(); + f.store + .record_statement(Statement { + attachment_id: att, + account_id: amex, + period_start: d(2026, 8, 1), + period_end: d(2026, 8, 31), + opening_balance: Decimal::ZERO, + closing_balance: Decimal::ZERO, + }) + .unwrap(); + let b = load(&f.dir.0); + assert!(b.reconcilable_accounts().contains(&amex)); + assert_eq!(b.reconciliation_due(amex), Some(d(2026, 9, 30))); + assert!(reconcile_items(&b).contains(&"Reconcile Amex through Sep 30, 2026".to_string())); +} diff --git a/crates/ledger-gui/tests/parties.rs b/crates/ledger-gui/tests/parties.rs index 269ffef..451797e 100644 --- a/crates/ledger-gui/tests/parties.rs +++ b/crates/ledger-gui/tests/parties.rs @@ -362,11 +362,14 @@ fn each_partys_totals_are_the_books_numbers() { assert!(out > Decimal::ZERO); assert_eq!(info(f.irs).paid_ytd, out); - // Pat: both runs (one by party, one by name), no tagged entries. + // Pat: both runs (one by party, one by name). The run by party names + // Pat on its entry (Ledger::run_payroll INVARIANT 3): its net pay, + // 1,000.00 − 100.00 − 30.00 − 62.00 − 14.50 = 793.50, paid to Pat; + // the run by name only names no one. let pat = info(f.pat); assert_eq!(pat.payroll_runs.len(), 2); - assert!(pat.entries.is_empty()); - assert_eq!(pat.paid_ytd, Decimal::ZERO); + assert_eq!(pat.entries.len(), 1); + assert_eq!(pat.paid_ytd, cents(79_350)); // Every entry's party is the one whose list holds it. for e in &b.entries { @@ -1437,7 +1440,7 @@ fn owed_to_members_reads_the_financing_accounts_first() { /// Without [books] cash_accounts the capital statement reads money as /// core does (every asset account but receivables: Books:: -/// k1_cash_accounts), so the laptop on 1500 reads as cash: Stephen's 2026 +/// money_cash_accounts), so the laptop on 1500 reads as cash: Stephen's 2026 /// contributions are $600.00 cash, none property. The Cash page keeps its /// name-based guess ("Bluevine" names no cash word: no cash accounts). #[test] @@ -1446,7 +1449,7 @@ fn without_cash_accounts_the_capital_statement_reads_money_as_core_does() { write_member_toml(&m.dir.0, ""); let b = load_with_toml(&m.dir.0); let id = |c: &str| b.ledger.chart().find_by_code(c).unwrap().id; - assert_eq!(b.k1_cash_accounts, vec![id("1000"), id("1500")]); + assert_eq!(b.money_cash_accounts, vec![id("1000"), id("1500")]); assert!( b.cash_accounts.is_empty(), "the Cash page's guess is unchanged" @@ -1471,3 +1474,76 @@ fn without_cash_accounts_the_capital_statement_reads_money_as_core_does() { (cents(160_000), Decimal::ZERO) ); } + +/// PartyTotalsOut's promise ("the viewer shows the same numbers"): with no +/// `[books] cash_accounts`, party money reads cash as core does (every +/// asset account but receivables, as the server passes it), and the year +/// is the calendar year, so a posted entry dated after today counts. +/// Equipment bought from Checking (an asset for an asset: no money moved, +/// under core's every-asset fallback) and software dated next month, both +/// with Apple: 99.00 paid, in the viewer and the server alike (the viewer +/// used to say 2,000.00: its name-based cash guess, cut at today). +#[test] +fn party_money_is_the_servers_without_cash_accounts_and_after_today() { + use ledger_gui::books::PartyPeriod; + let dir = TempDir::new("server-numbers"); + let mut s = Store::create(&dir.0.join("books-data"), Currency::USD).unwrap(); + let checking = s + .add_account("1000", "Checking", AccountType::Asset, None, false) + .unwrap(); + let equipment = s + .add_account("1500", "Equipment", AccountType::Asset, None, false) + .unwrap(); + let capital = s + .add_account("3000", "Capital", AccountType::Equity, None, false) + .unwrap(); + let software = s + .add_account("6200", "Software", AccountType::Expense, None, false) + .unwrap(); + let e = s.create_entry(d(2026, 1, 1), "Opening").unwrap(); + s.add_line(e, checking, Side::Debit, usd(1_000_000)) + .unwrap(); + s.add_line(e, capital, Side::Credit, usd(1_000_000)) + .unwrap(); + s.post_entry(e).unwrap(); + let apple_v = s.add_vendor("Apple").unwrap(); + let apple = s.ledger().party_of_vendor(apple_v).unwrap(); + for (date, account, c) in [ + (d(2026, 5, 1), equipment, 200_000), + (d(2026, 11, 20), software, 9_900), + ] { + let e = s.create_entry(date, "Apple").unwrap(); + s.add_line(e, account, Side::Debit, usd(c)).unwrap(); + s.add_line(e, checking, Side::Credit, usd(c)).unwrap(); + s.post_entry(e).unwrap(); + s.set_entry_party(e, Some(apple)).unwrap(); + } + let oracle = s.ledger().clone(); + drop(s); + let b = load(&dir.0); + // What books.party computes: activity_with over the calendar year + // with the (empty) cash_accounts setting. + let server = ledger_core::parties::activity_with( + &oracle, + apple, + (d(2026, 1, 1), d(2026, 12, 31)), + &[], + &[], + ); + assert_eq!( + (server.paid, server.received), + (cents(9_900), Decimal::ZERO) + ); + let span = b.party_span(PartyPeriod::ThisYear); + assert_eq!(span, (d(2026, 1, 1), d(2026, 11, 20))); + let flow = b.party_flow(apple, span, true); + assert_eq!((flow.paid, flow.received), (server.paid, server.received)); + assert_eq!(b.party(apple).unwrap().paid_ytd, cents(9_900)); + let months = b.party_months(apple, span); + assert_eq!(months.len(), 11); + assert_eq!(months.iter().map(|m| m.paid).sum::(), cents(9_900)); + assert_eq!( + b.party_span(PartyPeriod::AllTime), + (d(2026, 1, 1), d(2026, 11, 20)) + ); +} diff --git a/crates/ledger-mcp/src/server.rs b/crates/ledger-mcp/src/server.rs index 630d48c..daf3b65 100644 --- a/crates/ledger-mcp/src/server.rs +++ b/crates/ledger-mcp/src/server.rs @@ -54,7 +54,7 @@ use rust_decimal::Decimal; use std::sync::{Arc, Mutex}; /// The agent operating contract, delivered via initialize. -const INSTRUCTIONS: &str = "You are the bookkeeping agent for these books. The engine enforces the accounting; you enforce the workflow.\n\nRULES\n- Money is exact decimal. Always pass amounts as decimal strings (\"123.45\"), never floats.\n- Never post directly. The flow is draft (books.draft_entry) -> add lines (books.add_line) -> preview (books.post_entry without confirmed) -> review the preview -> post (books.post_entry with confirmed=true).\n- Every entry must balance: debits equal credits, exactly. Every line needs a real account from books.chart. The engine rejects anything else.\n- Receipts: any entry moving more than $25.00 in debits needs at least one receipt attached (books.attach_receipt) before posting. The server rejects the post otherwise. This is enforced, not a suggestion.\n- Never invent accounts. If the account you need does not exist, add it with books.add_account first.\n- Corrections go through books.reverse_entry. Posted entries are immutable; there is no edit.\n- Receipt corrections: receipts can change, but nothing is erased. To replace one, attach the new receipt, then books.unlink_receipt the old (with a reason); retire unwanted receipts or documents with books.withdraw_attachment (with a reason). The journal keeps every step.\n- Attachments: file a file once and link it wherever it is evidence (books.link_attachment, or link_to on books.attach_receipt and books.file_document): an entry (a file linked to an entry counts as its receipt), an account, an asset, a party, an invoice, a bill, a period, a payroll run, a schedule, a reconciliation, or the company. books.attachments lists a subject's files; books.unlink_attachment takes a link off with a reason. File documents under their kind (statement, contract, photo, warranty, import_source, formation, legal, tax, accounting, other). File a bank export before importing it and pass source_attachment_id to books.import_bank_csv.\n- Reconciliations: every month, for each bank, cash, and card account, file the statement (books.file_document category statement), record what it says (books.record_statement), mark the lines it shows (books.mark_cleared), preview books.reconcile with statement_attachment, and confirm with confirmed=true once the difference is exactly zero: that records the reconciliation with its statement. Never confirm by forcing the numbers; find the difference. books.void_reconciliation (with a reason) undoes a mistake. books.digest lists unreconciled_accounts: accounts not reconciled through the end of last month.\n- After posting, check books.digest to review what changed, so a human can review too.\n- Amounts in every response are decimal strings. Treat them as exact.\n- Fixed assets: register with books.register_asset, inspect with books.asset_schedule and books.list_assets, post monthly depreciation with books.run_depreciation (exempt from the receipt policy; the schedule is the evidence), and dispose with books.dispose_asset (preview first; the bill of sale is the receipt). Depreciation is straight-line, book basis only - tax depreciation stays with the CPA. Every asset gets a tag for the label on the thing itself (books.register_asset generates the next one, like WL-0001, unless the human has their own); record the manufacturer's serial number too, change either with books.label_asset, and print label sheets with books.asset_labels (save the PDF; print at 100% on Avery 5160/8160). Asset tools take the tag in place of the id. Record every asset's federal tax basis beside its book value (books.set_asset_tax_basis, or tax_basis_rule on books.register_asset): cost for a purchase; for a member's contribution, the member's adjusted basis (what they paid, less depreciation they took) with rule personal_use_conversion (their personal property: tax basis is the lesser of value and their basis) or carryover (their business property). Book value stays fair market value. books.export fixed_asset_register is the register for the CPA; books.show asset opens one in the viewer.\n- Prepaid expenses and deferred revenue: record the original payment (with its receipt), then books.create_schedule; books.run_schedules posts the monthly amortization (preview first; exempt from the receipt policy like depreciation). Recurring entries (books.create_recurring, books.run_recurring) only ever create drafts: attach each actual receipt and post through the normal preview.\n- Platform payouts (app stores, payment processors, marketplaces): never book the net deposit as revenue. books.record_payout grosses it up (gross revenue, fees, adjustments) into a draft and refuses unless it reconciles to the cent; attach the payout statement, then preview and post.\n- The viewer: the human usually has the read-only viewer open beside you (`just gui`). When they ask to see something, and after you post, call books.show so it's on their screen.\n- Parties: keep the directory current. Every counterparty (vendor, contractor, landlord, customer, payout platform, tax agency, bank or card issuer, member, employee) gets a party: books.add_party with its roles (books.add_vendor and books.add_customer link their record to the party of the same name, or make one). Tag every entry with its party (books.draft_entry party, or books.set_entry_party; a party with a vendor record tags the vendor for 1099s too). An entry with several counterparties names them all with parties ([{party, amount?|percent?}] on books.draft_entry or books.set_entry_party): there is no \"joint\" party. A joint member transaction (paid from or reimbursed to joint funds on Due to Shareholders, a contribution from joint funds, a distribution to both) names both members with no shares, and their shares derive (from their capital or distribution lines on the entry, else their [[company.members]] shares); when the split is uneven, or a party isn't a member, give each party an exact amount (or a percent). The post_entry preview lists each party's resolved share (parties) and refuses to post (party_shares, parties_error) until the shares add up to what the entry moved; show the split to the human before confirming. Every post_entry preview says where the entry stands (party_policy, party_missing, party_suggestions): under [books] parties = \"suggest\" (the default), when party_missing, name the likely party to the human and tag it (or add it) before confirming; under \"require\", posting is refused (party_required) until the entry names a party or is marked as having none (books.set_entry_party with party null and no_party_reason, or draft_entry no_party_reason: only for an entry with truly no counterparty, like a transfer between the company's own accounts); under \"off\", don't raise it. Record W-9 facts (books.set_w9) for contractors and landlords before paying them, and W-4 facts (books.set_w4) for employees before their first payroll; name the employee party on every books.run_payroll line. Never put a full TIN or SSN anywhere in the books, only the last four digits: the engine refuses any nine-digit number in a party field. The full TIN goes only to the encrypted TIN vault, and every member's and every 1099 payee's belongs there: books.set_tin (party, tin, tin_type: ssn, ein, itin; any role, so each member's SSN for their K-1) and books.set_w9 with tin and tin_type for a payee's W-9, and the signed form with w9_file_path (never books.file_document or books.attach_receipt: the form shows the TIN). Never repeat a TIN back to the human, in chat, a description, note, file name, commit message, or anywhere else; tools show the last four only. books.get_w9 saves the form to a path outside the books for the human; books.purge_tin (preview, then confirmed, with a reason) deletes a TIN no longer needed. books.parties lists the directory, books.party shows one party with the year's totals, and books.show party opens it in the viewer. Archive (books.update_party archived) parties no longer dealt with; never delete history.\n- 1099s: mark contractors and landlords with books.set_vendor_1099 (and whether their W-9 is on file), and tag every payment to them with its vendor or party (books.draft_entry vendor or party, books.set_entry_vendor, or books.set_entry_party; books.pay_bill tags automatically). books.vendor_1099 is the year-end worksheet for the CPA; with include_tins: true (only when the human asks for the CPA's copy) it carries the full TINs from the vault, the one output that does: hand it over (save_csv_to, a path outside the books) and never repeat those numbers anywhere else.\n- CPA package: books.export_cpa_package carries every member's and 1099 payee's full TIN from the vault (the K-1 worksheet, k1-members.csv, and the 1099 worksheet), so it needs save_to, a new .zip or a folder outside the books dir (never the books or its exports/: git keeps them forever). Tell the human the path it was saved to; never open, read, attach, or copy the file, and never echo its TINs. tin_gaps lists each member or payee whose TIN it lacks and why: collect them (books.set_tin, books.set_w9) and export again.\n- Rules that change with the law: wanderbooks knows some legal amounts (like the 1099 threshold) only through a given year and never guesses past it. When a tool refuses with \"wanderbooks knows the ... only through\", or books.digest lists stale_rules: check today's date, find the current figure from the official source (the refusal names the vendored source file) or ask the human (owner, CPA), confirm it with them, and pass it explicitly. Then update the rule for everyone: open a pull request at https://github.com/swaits/wanderbooks with the new value and its official text (best), or at least an issue; if you can't reach GitHub, give the human the issue text to file.\n- Control roles: invoices, bills, payroll, and closes post to the account filling each role (receivables, revenue, payables, general expense, retained earnings, payroll expense and withholdings); books.roles lists them. A role uses its default code (1200, 4000, 2000, 6999, 3999, 6100, 6110, 2110-2150) until it is assigned one of the chart's own accounts with books.assign_role (journaled). If the chart uses a default code for a different kind of account, the engine refuses and says which. It can't tell an account of the right type but another purpose (a hosting expense at payroll's 6100): check books.roles before running payroll, bills, or invoices. Never assign roles or renumber the human's accounts on your own: explain the conflict, and when they decide, assign the role to their account (best: their numbering stays) or books.renumber_account the account (its history stays).\n- Settings: wanderbooks.toml beside the books holds the company's facts and settings (name, fiscal year, tax form, members and their capital accounts, basis, receipt threshold, cash flow roles, close target); books.settings shows what is in force and lists problems, settings that disagree with the chart. Edit the file when the human tells you a company fact, when a tool refuses naming a wanderbooks.toml setting, when books.settings or books.digest lists a settings problem, and after books.renumber_account lists config_mentions. The server reloads the file whenever it changes, with no restart: after each edit, call books.settings and check reload_error is null (a broken edit leaves the last good settings in force) and problems is empty. Never put a member's SSN or any TIN in it."; +const INSTRUCTIONS: &str = "You are the bookkeeping agent for these books. The engine enforces the accounting; you enforce the workflow.\n\nRULES\n- Money is exact decimal. Always pass amounts as decimal strings (\"123.45\"), never floats.\n- Never post directly. The flow is draft (books.draft_entry) -> add lines (books.add_line) -> preview (books.post_entry without confirmed) -> review the preview -> post (books.post_entry with confirmed=true).\n- Every entry must balance: debits equal credits, exactly. Every line needs a real account from books.chart. The engine rejects anything else.\n- Receipts: any entry moving more than $25.00 in debits needs at least one receipt attached (books.attach_receipt) before posting. The server rejects the post otherwise. This is enforced, not a suggestion.\n- Never invent accounts. If the account you need does not exist, add it with books.add_account first.\n- Corrections go through books.reverse_entry. Posted entries are immutable; there is no edit.\n- Receipt corrections: receipts can change, but nothing is erased. To replace one, attach the new receipt, then books.unlink_receipt the old (with a reason); retire unwanted receipts or documents with books.withdraw_attachment (with a reason). The journal keeps every step.\n- Attachments: file a file once and link it wherever it is evidence (books.link_attachment, or link_to on books.attach_receipt and books.file_document): an entry (a file linked to an entry counts as its receipt), an account, an asset, a party, an invoice, a bill, a period, a payroll run, a schedule, a reconciliation, or the company. books.attachments lists a subject's files; books.unlink_attachment takes a link off with a reason. File documents under their kind (statement, contract, photo, warranty, import_source, formation, legal, tax, accounting, other). File a bank export before importing it and pass source_attachment_id to books.import_bank_csv.\n- Reconciliations: every month, for each bank, cash, and card account, file the statement (books.file_document category statement), record what it says (books.record_statement), mark the lines it shows (books.mark_cleared), preview books.reconcile with statement_attachment, and confirm with confirmed=true once the difference is exactly zero: that records the reconciliation with its statement. Never confirm by forcing the numbers; find the difference. books.void_reconciliation (with a reason) undoes a mistake; books.void_statement (with a reason) a mis-recorded statement, to record it again. books.digest lists unreconciled_accounts: accounts not reconciled through the end of last month.\n- After posting, check books.digest to review what changed, so a human can review too.\n- Amounts in every response are decimal strings. Treat them as exact.\n- Fixed assets: register with books.register_asset, inspect with books.asset_schedule and books.list_assets, post monthly depreciation with books.run_depreciation (exempt from the receipt policy; the schedule is the evidence), and dispose with books.dispose_asset (preview first; the bill of sale is the receipt). Depreciation is straight-line, book basis only - tax depreciation stays with the CPA. Every asset gets a tag for the label on the thing itself (books.register_asset generates the next one, like WL-0001, unless the human has their own); record the manufacturer's serial number too, change either with books.label_asset, and print label sheets with books.asset_labels (save the PDF; print at 100% on Avery 5160/8160). Asset tools take the tag in place of the id. Record every asset's federal tax basis beside its book value (books.set_asset_tax_basis, or tax_basis_rule on books.register_asset): cost for a purchase; for a member's contribution, the member's adjusted basis (what they paid, less depreciation they took) with rule personal_use_conversion (their personal property: tax basis is the lesser of value and their basis) or carryover (their business property). Book value stays fair market value. books.export fixed_asset_register is the register for the CPA; books.show asset opens one in the viewer.\n- Prepaid expenses and deferred revenue: record the original payment (with its receipt), then books.create_schedule; books.run_schedules posts the monthly amortization (preview first; exempt from the receipt policy like depreciation). Recurring entries (books.create_recurring, books.run_recurring) only ever create drafts: attach each actual receipt and post through the normal preview.\n- Platform payouts (app stores, payment processors, marketplaces): never book the net deposit as revenue. books.record_payout grosses it up (gross revenue, fees, adjustments) into a draft and refuses unless it reconciles to the cent; attach the payout statement, then preview and post.\n- The viewer: the human usually has the read-only viewer open beside you (`just gui`). When they ask to see something, and after you post, call books.show so it's on their screen.\n- Parties: keep the directory current. Every counterparty (vendor, contractor, landlord, customer, payout platform, tax agency, bank or card issuer, member, employee) gets a party: books.add_party with its roles (books.add_vendor and books.add_customer link their record to the party of the same name, or make one). Tag every entry with its party (books.draft_entry party, or books.set_entry_party; a party with a vendor record tags the vendor for 1099s too). An entry with several counterparties names them all with parties ([{party, amount?|percent?}] on books.draft_entry or books.set_entry_party): there is no \"joint\" party. A joint member transaction (paid from or reimbursed to joint funds on Due to Shareholders, a contribution from joint funds, a distribution to both) names both members with no shares, and their shares derive (from their capital or distribution lines on the entry, else their [[company.members]] shares); when the split is uneven, or a party isn't a member, give each party an exact amount (or a percent). The post_entry preview lists each party's resolved share (parties) and refuses to post (party_shares, parties_error) until the shares add up to what the entry moved; show the split to the human before confirming. Every post_entry preview says where the entry stands (party_policy, party_missing, party_suggestions): under [books] parties = \"suggest\" (the default), when party_missing, name the likely party to the human and tag it (or add it) before confirming; under \"require\", posting is refused (party_required) until the entry names a party or is marked as having none (books.set_entry_party with party null and no_party_reason, or draft_entry no_party_reason: only for an entry with truly no counterparty, like a transfer between the company's own accounts); under \"off\", don't raise it. Record W-9 facts (books.set_w9) for contractors and landlords before paying them, and W-4 facts (books.set_w4) for employees before their first payroll; name the employee party on every books.run_payroll line. Never put a full TIN or SSN anywhere in the books, only the last four digits: the engine refuses any nine-digit number in a party field. The full TIN goes only to the encrypted TIN vault, and every member's and every 1099 payee's belongs there: books.set_tin (party, tin, tin_type: ssn, ein, itin; any role, so each member's SSN for their K-1) and books.set_w9 with tin and tin_type for a payee's W-9, and the signed form with w9_file_path (never books.file_document or books.attach_receipt: the form shows the TIN). Never repeat a TIN back to the human, in chat, a description, note, file name, commit message, or anywhere else; tools show the last four only. books.get_w9 saves the form to a path outside the books for the human; books.purge_tin (preview, then confirmed, with a reason) deletes a TIN no longer needed. books.parties lists the directory, books.party shows one party with the year's totals, and books.show party opens it in the viewer. Archive (books.update_party archived) parties no longer dealt with; never delete history.\n- 1099s: mark contractors and landlords with books.set_vendor_1099 (and whether their W-9 is on file), and tag every payment to them with its vendor or party (books.draft_entry vendor or party, books.set_entry_vendor, or books.set_entry_party; books.pay_bill tags automatically). books.vendor_1099 is the year-end worksheet for the CPA; with include_tins: true (only when the human asks for the CPA's copy) it carries the full TINs from the vault, the one output that does: hand it over (save_csv_to, a path outside the books) and never repeat those numbers anywhere else.\n- CPA package: books.export_cpa_package carries every member's and 1099 payee's full TIN from the vault (the K-1 worksheet, k1-members.csv, and the 1099 worksheet), so it needs save_to, a new .zip or a folder outside the books dir (never the books or its exports/: git keeps them forever). Tell the human the path it was saved to; never open, read, attach, or copy the file, and never echo its TINs. tin_gaps lists each member or payee whose TIN it lacks and why: collect them (books.set_tin, books.set_w9) and export again.\n- Rules that change with the law: wanderbooks knows some legal amounts (like the 1099 threshold) only through a given year and never guesses past it. When a tool refuses with \"wanderbooks knows the ... only through\", or books.digest lists stale_rules: check today's date, find the current figure from the official source (the refusal names the vendored source file) or ask the human (owner, CPA), confirm it with them, and pass it explicitly. Then update the rule for everyone: open a pull request at https://github.com/swaits/wanderbooks with the new value and its official text (best), or at least an issue; if you can't reach GitHub, give the human the issue text to file.\n- Control roles: invoices, bills, payroll, and closes post to the account filling each role (receivables, revenue, payables, general expense, retained earnings, payroll expense and withholdings); books.roles lists them. A role uses its default code (1200, 4000, 2000, 6999, 3999, 6100, 6110, 2110-2150) until it is assigned one of the chart's own accounts with books.assign_role (journaled). If the chart uses a default code for a different kind of account, the engine refuses and says which. It can't tell an account of the right type but another purpose (a hosting expense at payroll's 6100): check books.roles before running payroll, bills, or invoices. Never assign roles or renumber the human's accounts on your own: explain the conflict, and when they decide, assign the role to their account (best: their numbering stays) or books.renumber_account the account (its history stays).\n- Settings: wanderbooks.toml beside the books holds the company's facts and settings (name, fiscal year, tax form, members and their capital accounts, basis, receipt threshold, cash flow roles, close target); books.settings shows what is in force and lists problems, settings that disagree with the chart. Edit the file when the human tells you a company fact, when a tool refuses naming a wanderbooks.toml setting, when books.settings or books.digest lists a settings problem, and after books.renumber_account lists config_mentions. The server reloads the file whenever it changes, with no restart: after each edit, call books.settings and check reload_error is null (a broken edit leaves the last good settings in force) and problems is empty. Never put a member's SSN or any TIN in it."; /// Max receipt/document upload: 25 MB. const MAX_UPLOAD_BYTES: usize = 25 * 1024 * 1024; @@ -461,13 +461,17 @@ impl BooksServer { }) } - /// Cash and card accounts due for reconciliation: the `[books] - /// cash_accounts` and every liability with a recorded statement, with - /// posted activity through the end of last month, not reconciled - /// through it. + /// Cash and card accounts due for reconciliation + /// (ledger_store::reconcilable, the viewer's Needs-attention rule): + /// the cash accounts (`[books] cash_accounts`, else assets named for + /// cash), the card accounts (liabilities named "card", before their + /// first statement), and every other liability with a recorded + /// statement, with posted activity through the end of last month, not + /// reconciled through it. /// /// ```text - /// GOAL: the monthly close's reconciliation step can't be forgotten. + /// GOAL: the monthly close's reconciliation step can't be forgotten, + /// and the digest and the viewer agree on what is due. /// SOURCE: docs/parties-and-attachments.md "Reconciliations" (Needs /// attention: an account with activity not reconciled through /// last month's end). @@ -477,40 +481,17 @@ impl BooksServer { store: &Store, today: NaiveDate, ) -> Result, String> { + use ledger_store::reconcilable; let ledger = store.ledger(); - let due = chrono::Datelike::with_day(&today, 1) - .and_then(|d| d.pred_opt()) - .expect("a month before today"); - let mut accounts: Vec = self - .settings() - .roles - .cash - .iter() - .filter_map(|c| ledger.chart().find_by_code(c).map(|a| a.id)) - .collect(); - for st in store.statements(None).map_err(|e| e.to_string())? { - if ledger - .chart() - .get(st.account_id) - .is_some_and(|a| a.account_type == AccountType::Liability) - { - accounts.push(st.account_id); - } - } - accounts.sort_unstable(); - accounts.dedup(); + let cash = reconcilable::cash_accounts(ledger, &self.settings().roles.cash); + let statements = store.statements(None).map_err(|e| e.to_string())?; + let reconciliations = store.reconciliations(None).map_err(|e| e.to_string())?; let mut out = Vec::new(); - for id in accounts { - let active = ledger - .posted_entries() - .any(|e| e.date() <= due && e.lines().iter().any(|l| l.account == id)); - if !active { + for id in reconcilable::reconcilable_accounts(ledger, &cash, &statements) { + let Some(due) = reconcilable::reconciliation_due(ledger, &reconciliations, id, today) + else { continue; - } - let through = reconciled_through(store, id)?; - if through.is_some_and(|t| t >= due) { - continue; - } + }; let (code, name) = code_name(store, id); out.push(UnreconciledAccountOut { next_step: format!( @@ -518,7 +499,8 @@ impl BooksServer { ), code, name, - reconciled_through: through.map(|d| d.to_string()), + reconciled_through: reconcilable::reconciled_through(&reconciliations, id) + .map(|d| d.to_string()), due_through: due.to_string(), }); } @@ -1565,6 +1547,19 @@ fn party_activity( }) } +/// The `party_required` refusal for an engine-posted entry (payroll, a +/// remittance, a disposal) that names no counterparty under `[books] +/// parties = "require"`: `what` says what to pass. +fn party_required(what: &str) -> String { + serde_json::json!({ + "code": "party_required", + "message": format!( + "these books require a counterparty on every entry ([books] parties = \"require\"): {what} (add the party with books.add_party first if it's new)" + ), + }) + .to_string() +} + /// The chart's ids for these account codes (codes it lacks are skipped). fn cash_ids(ledger: &Ledger, codes: &[String]) -> Vec { codes @@ -1650,6 +1645,58 @@ fn check_shares_resolve( ledger_core::shares::resolve(entry, shares, &money, &members).map(|_| ()) } +/// Fix a posted entry's derived split among several parties: journal the +/// exact amounts they resolve to now, so a later change to +/// `[[company.members]]` (a new income split, a member archived) never +/// re-splits posted history. +/// +/// ```text +/// GOAL: posted is forever, the split between its parties included. +/// SOURCE: AGENTS.md ("Posted is forever"); docs/parties-and-attachments.md +/// "Shares"; ledger_core::shares INVARIANT 10 (derived several: +/// the members' capital lines, else their income shares, both read +/// from the settings). +/// INVARIANTS: +/// 1. Only a posted entry naming two or more parties with derived shares +/// changes: its shares become Amount(|part|) for each party, in +/// order, journaled as entry.parties_set (the reversal chain too), so +/// each party's resolved part is exactly what it was. +/// 2. Nothing changes when the shares don't resolve, or a part is zero +/// (an amount must be positive): the entry keeps its derived shares. +/// ``` +fn fix_derived_shares(store: &mut Store, settings: &Settings, entry_id: u64) -> Result<(), String> { + let ledger = store.ledger(); + let Some(entry) = ledger.get_entry(entry_id) else { + return Ok(()); + }; + let shares = ledger.entry_parties(entry_id); + if entry.status() != ledger_core::EntryStatus::Posted + || shares.len() < 2 + || !shares + .iter() + .any(|s| matches!(s.share, ledger_core::Share::Derived)) + { + return Ok(()); + } + let (money, members) = share_context(ledger, settings); + let Ok(parts) = ledger_core::shares::resolve(entry, shares, &money, &members) else { + return Ok(()); + }; + if parts.iter().any(|(_, v)| v.is_zero()) { + return Ok(()); + } + let fixed: Vec = parts + .into_iter() + .map(|(party, v)| ledger_core::PartyShare { + party, + share: ledger_core::Share::Amount(v.abs()), + }) + .collect(); + store + .set_entry_parties(entry_id, &fixed) + .map_err(|e| e.to_string()) +} + /// An entry's parties for output, each share resolved, and why they don't /// resolve when they don't. fn entry_parties_out( @@ -2482,6 +2529,7 @@ impl BooksServer { .to_tool_error()); } store.post_entry(p.entry_id).map_err(|e| e.to_string())?; + fix_derived_shares(&mut store, &self.settings(), p.entry_id)?; let mut posted = out; posted.status = "posted".to_string(); Ok(Json(posted)) @@ -2957,6 +3005,10 @@ impl BooksServer { .map(|p| p.label().to_string()), parties, parties_error, + no_party_reason: store + .ledger() + .entry_no_party(p.entry_id) + .map(str::to_string), })) } @@ -4971,7 +5023,7 @@ impl BooksServer { #[tool( name = "books.dispose_asset", - description = "Dispose of a fixed asset (sell or retire). Without confirmed=true returns a preview — book value, gain/loss, the auto-posted final depreciation, and the disposal entry's lines — and posts nothing. With confirmed=true posts the disposal entry (plus any missing final-period depreciation) after enforcing the receipt policy: file the bill of sale with books.file_document first and pass its attachment ids as receipt_attachment_ids." + description = "Dispose of a fixed asset (sell or retire). Without confirmed=true returns a preview — book value, gain/loss, the auto-posted final depreciation, and the disposal entry's lines — and posts nothing. With confirmed=true posts the disposal entry (plus any missing final-period depreciation) after enforcing the receipt policy: file the bill of sale with books.file_document first and pass its attachment ids as receipt_attachment_ids. Name the buyer (buyer: a party id or name): it is the entry's party, and [books] parties = \"require\" refuses a sale without one; a retirement with no proceeds and no buyer is marked as having no counterparty." )] async fn dispose_asset( &self, @@ -4985,6 +5037,11 @@ impl BooksServer { let proceeds_account = resolve_account(&store, &p.proceeds_account)?; let gain_loss_account = resolve_account(&store, &p.gain_loss_account)?; let asset_id = resolve_asset(&store, p.asset_id, p.tag.as_deref())?; + let buyer = p + .buyer + .as_deref() + .map(|r| resolve_party(&store, r)) + .transpose()?; let asset = store .ledger() .get_asset(asset_id) @@ -5076,6 +5133,14 @@ impl BooksServer { for aid in &attachment_ids { store.check_linkable(*aid).map_err(|e| e.to_string())?; } + if buyer.is_none() + && !proceeds.amount.is_zero() + && self.settings().parties == ledger_config::PartyPolicy::Require + { + return Err(party_required( + "pass buyer: the party the asset was sold to", + )); + } let entry_id = store .dispose_asset( asset_id, @@ -5090,6 +5155,14 @@ impl BooksServer { .link_receipt(*aid, entry_id) .map_err(|e| e.to_string())?; } + match buyer { + Some(b) => store.set_entry_party(entry_id, Some(b)), + None if proceeds.amount.is_zero() => { + store.set_entry_no_party(entry_id, "asset retired with no proceeds") + } + None => Ok(()), + } + .map_err(|e| e.to_string())?; let mut posted = out; posted.status = "posted".to_string(); posted.entry_id = entry_id; @@ -5235,7 +5308,8 @@ impl BooksServer { /// SCENARIOS: /// 1. IF statement_attachment is given: the account, date, and /// balance are the statement's; any given that disagree: SHOULD - /// refuse. + /// refuse. IF its attachment is withdrawn (the statement retired) + /// or its statement voided: SHOULD refuse, preview or not. /// 2. IF it isn't: account, statement_date, and statement_balance are /// required, and confirmed=true SHOULD refuse. /// 3. IF confirmed=true and problems is not empty: SHOULD refuse, @@ -5265,7 +5339,11 @@ impl BooksServer { .transpose()?; let (account, date, balance) = match p.statement_attachment { Some(att) => { - find_attachment(&store, att)?; + if find_attachment(&store, att)?.withdrawn_at.is_some() { + return Err(format!( + "attachment {att} is withdrawn, so its statement is retired: file the statement again and record it (books.record_statement)" + )); + } let st = store .statement(att) .map_err(|e| e.to_string())? @@ -5481,6 +5559,38 @@ impl BooksServer { Ok(Json(ReconciliationsOut { reconciliations })) } + /// ```text + /// GOAL: correct a mis-recorded statement on the record. + /// SOURCE: Store::void_statement. + /// SCENARIOS: + /// 1. IF the attachment records no statement, a reconciliation not + /// voided rests on it, or the reason is blank: SHOULD refuse, + /// journaling nothing. + /// 2. Otherwise: journaled as `statement.voided`; books.statements no + /// longer lists it, and the same attachment can record the + /// statement again (books.record_statement). + /// ``` + #[tool( + name = "books.void_statement", + description = "Void a recorded statement that was recorded wrong (a mistyped balance or period), with the reason recorded in the journal, so the same filed statement can be recorded again with books.record_statement. Refused while a reconciliation not voided rests on it (books.void_reconciliation first). Nothing is erased: the journal keeps what it said. Journaled as statement.voided. Returns the statement as it was recorded." + )] + async fn void_statement( + &self, + params: Parameters, + ) -> Result, String> { + let p = params.0; + let mut store = self.lock()?; + let st = store + .statement(p.attachment_id) + .map_err(|e| e.to_string())? + .ok_or_else(|| format!("attachment {} records no statement", p.attachment_id))?; + let out = statement_out(&store, &st)?; + store + .void_statement(p.attachment_id, &p.reason) + .map_err(|e| e.to_string())?; + Ok(Json(out)) + } + /// ```text /// GOAL: undo a mistaken reconciliation on the record, to redo it. /// SOURCE: Store::void_reconciliation. @@ -6356,7 +6466,7 @@ impl BooksServer { #[tool( name = "books.set_entry_party", - description = "Tag an entry (draft or posted) with the party it was with (id or name), or pass party null (or omit it) to clear the tag. A party with a vendor record also tags the entry's vendor, so a payment to a contractor or landlord counts in books.vendor_1099; a party without one, or clearing, clears the vendor tag. For an entry with several counterparties pass parties instead: [{party, amount?|percent?}], each share an exact amount (cents), a percent (summing to 100; leftover cents to the first party), or neither (derived: a sole party gets all; members named together get what their capital or distributions lines say, else their [[company.members]] share). Name both members on a joint member transaction (a reimbursement on 2100, a joint contribution) and let the shares derive; give amounts when a split is uneven. Refused: a party twice, mixed kinds of share, two parties with vendor records, and, once the entry has balanced lines, shares that don't add up to what it moved. Metadata only: amounts never change. An entry and its reversal carry the same parties: tagging either tags both, so each party's part nets to zero. Journaled as entry.party_set (one party) or entry.parties_set (parties). Returns the entry (as books.get_entry), each party's share resolved." + description = "Tag an entry (draft or posted) with the party it was with (id or name), or pass party null (or omit it) to clear the tag. A party with a vendor record also tags the entry's vendor, so a payment to a contractor or landlord counts in books.vendor_1099; a party without one, or clearing, clears the vendor tag. For an entry with several counterparties pass parties instead: [{party, amount?|percent?}], each share an exact amount (cents), a percent (summing to 100; leftover cents to the first party), or neither (derived: a sole party gets all; members named together get what their capital or distributions lines say, else their [[company.members]] share; once the entry is posted, a derived split among several parties is recorded as the exact amounts it resolves to, so a later change to [[company.members]] never re-splits posted history). Name both members on a joint member transaction (a reimbursement on 2100, a joint contribution) and let the shares derive; give amounts when a split is uneven. Refused: a party twice, mixed kinds of share, two parties with vendor records, and, once the entry has balanced lines, shares that don't add up to what it moved. Metadata only: amounts never change. An entry and its reversal carry the same parties: tagging either tags both, so each party's part nets to zero. Journaled as entry.party_set (one party) or entry.parties_set (parties). Returns the entry (as books.get_entry), each party's share resolved." )] async fn set_entry_party( &self, @@ -6418,6 +6528,7 @@ impl BooksServer { store .set_entry_parties(p.entry_id, &sh) .map_err(|e| e.to_string())?; + fix_derived_shares(&mut store, &settings, p.entry_id)?; } (party, None, None) => { store @@ -6705,6 +6816,15 @@ impl BooksServer { }) }) .collect::>()?; + if self.settings().parties == ledger_config::PartyPolicy::Require + && lines + .iter() + .any(|l| l.employee.is_none() && !l.net_pay().is_zero()) + { + return Err(party_required( + "name each payroll line's employee party (employee), so the entry names who was paid", + )); + } let run_id = store .run_payroll(parse_date(&p.date)?, lines, cash) .map_err(|e| e.to_string())?; @@ -6724,7 +6844,7 @@ impl BooksServer { #[tool( name = "books.remit_payroll_taxes", - description = "Remit all outstanding payroll tax payables (federal/state withholding, SS, Medicare, other deductions) to cash in one balanced entry." + description = "Remit all outstanding payroll tax payables (federal/state withholding, SS, Medicare, other deductions) to cash in one balanced entry. Name the tax agency paid (agency: a party id or name, e.g. the IRS): it is the entry's party, and [books] parties = \"require\" refuses without it." )] async fn remit_payroll_taxes( &self, @@ -6733,6 +6853,14 @@ impl BooksServer { let p = params.0; let mut store = self.lock()?; let cash = resolve_account(&store, &p.cash_account)?; + let agency = p + .agency + .as_deref() + .map(|r| resolve_party(&store, r)) + .transpose()?; + if agency.is_none() && self.settings().parties == ledger_config::PartyPolicy::Require { + return Err(party_required("pass agency: the tax agency paid")); + } // Total before remitting, for the response. let total: Decimal = store .ledger() @@ -6743,6 +6871,11 @@ impl BooksServer { let entry_id = store .remit_payroll_taxes(parse_date(&p.date)?, cash) .map_err(|e| e.to_string())?; + if let Some(a) = agency { + store + .set_entry_party(entry_id, Some(a)) + .map_err(|e| e.to_string())?; + } Ok(Json(RemitPayrollTaxesOut { entry_id, total: money(total), @@ -6937,7 +7070,7 @@ impl BooksServer { /// ``` #[tool( name = "books.export_cpa_package", - description = "Build the CPA handoff package as a zip: the profit and loss, balance sheet, cash flow, statement of changes in members' equity (multi-member LLCs), tax summary, and trial balance, each as PDF and XLSX; the fixed asset register as PDF and XLSX when there are fixed assets; the general ledger (by account) and general journal (entry by entry) as XLSX; the journal as IIF (imports into QuickBooks Desktop); the 1099 worksheet as CSV; with members, the K-1 worksheet (k1-members.csv: per member their party, share, capital account, TIN, and beginning capital, contributions in cash and in property, share of income, distributions, and ending capital for the fiscal year to date); every receipt behind the year's entries (receipts/, with an index.csv tying each file to its entry) and the company's documents (documents/), unless include_receipts is false; and a README with the journal's verified hash. By default (include_tins) the K-1 and 1099 worksheets carry every member's and payee's full TIN from the TIN vault, so the package is saved, never returned: save_to (a new .zip path or an existing folder, outside the books dir) is required, and the output gives only its path; tell the human where it is and never open, attach, or copy it. A locked vault or a missing TIN doesn't stop the package: tin_gaps lists each one (collect them with books.set_tin or books.set_w9 and export again). include_tins: false builds it with last fours only, returned as base64 unless save_to is given. cash_accounts (e.g. [\"1000\"]) is required: the cash flow statement reconciles to them. as_of defaults to today; period reports cover the fiscal year to date.", + description = "Build the CPA handoff package as a zip: the profit and loss, balance sheet, cash flow, statement of changes in members' equity (multi-member LLCs), tax summary, and trial balance, each as PDF and XLSX; the fixed asset register as PDF and XLSX when there are fixed assets; the general ledger (by account) and general journal (entry by entry) as XLSX; the journal as IIF (imports into QuickBooks Desktop); the 1099 worksheet as CSV; with members, the K-1 worksheet (k1-members.csv: per member their party, share, capital account, TIN, and beginning capital, contributions in cash and in property, share of income, distributions, and ending capital for the fiscal year to date); every receipt behind the year's entries, filed on them or linked to them (receipts/, with an index.csv tying each file to its entry) and the company's documents (documents/), unless include_receipts is false; and a README with the journal's verified hash. By default (include_tins) the K-1 and 1099 worksheets carry every member's and payee's full TIN from the TIN vault, so the package is saved, never returned: save_to (a new .zip path or an existing folder, outside the books dir) is required, and the output gives only its path; tell the human where it is and never open, attach, or copy it. A locked vault or a missing TIN doesn't stop the package: tin_gaps lists each one (collect them with books.set_tin or books.set_w9 and export again). include_tins: false builds it with last fours only, returned as base64 unless save_to is given. cash_accounts (e.g. [\"1000\"]) is required: the cash flow statement reconciles to them. as_of defaults to today; period reports cover the fiscal year to date.", annotations(read_only_hint = true) )] async fn export_cpa_package( @@ -7252,8 +7385,11 @@ impl BooksServer { format!("{e}\n").into_bytes(), )), } - // The evidence: every current receipt on the year's posted entries, - // and the company's documents, with an index tying them together. + // The evidence: every current file behind the year's posted + // entries, filed on them or linked to them (a file linked to an + // entry counts as its receipt), one copy per entry with a row in + // the index; and the company's documents, the files behind no + // entry. let mut receipt_count = 0; if p.include_receipts { let clean = |s: &str| -> String { @@ -7272,44 +7408,55 @@ impl BooksServer { if a.withdrawn_at.is_some() { continue; } - let bytes = store.attachment_bytes(a.id).map_err(|e| e.to_string())?; - match a.entry_id.and_then(|e| ledger.get_entry(e)) { - Some(e) - if e.status() == ledger_core::EntryStatus::Posted - && e.date() >= fy - && e.date() <= as_of => + let mut behind: Vec = a.entry_id.into_iter().collect(); + for sub in store.attachment_links(a.id).map_err(|e| e.to_string())? { + if let ledger_store::Subject::Entry(e) = sub + && !behind.contains(&e) { - let name = format!( - "receipts/{}-entry-{}-{}", - e.date(), - e.id(), - clean(&a.file_name) - ); - let amount: Decimal = e - .lines() - .iter() - .filter(|l| l.side == Side::Debit) - .map(|l| l.amount.amount) - .sum(); - index.push_str(&format!( - "{},{},\"{}\",{},{},{}\n", - e.id(), - e.date(), - e.description().replace('"', "\"\""), - money(amount), - name, - a.blob_sha - )); - files.push((name, bytes)); - receipt_count += 1; + behind.push(e); } - None if a.entry_id.is_none() && a.category != "receipts" => { + } + behind.sort_unstable(); + let bytes = store.attachment_bytes(a.id).map_err(|e| e.to_string())?; + if behind.is_empty() { + if a.category != "receipts" { files.push(( format!("documents/{}/{}", clean(&a.category), clean(&a.file_name)), bytes, )); } - _ => {} + continue; + } + for e in behind.iter().filter_map(|&e| ledger.get_entry(e)) { + if e.status() != ledger_core::EntryStatus::Posted + || e.date() < fy + || e.date() > as_of + { + continue; + } + let name = format!( + "receipts/{}-entry-{}-{}", + e.date(), + e.id(), + clean(&a.file_name) + ); + let amount: Decimal = e + .lines() + .iter() + .filter(|l| l.side == Side::Debit) + .map(|l| l.amount.amount) + .sum(); + index.push_str(&format!( + "{},{},\"{}\",{},{},{}\n", + e.id(), + e.date(), + e.description().replace('"', "\"\""), + money(amount), + name, + a.blob_sha + )); + files.push((name, bytes.clone())); + receipt_count += 1; } } files.push(("receipts/index.csv".into(), index.into_bytes())); diff --git a/crates/ledger-mcp/src/types.rs b/crates/ledger-mcp/src/types.rs index 8c9a440..3a4397e 100644 --- a/crates/ledger-mcp/src/types.rs +++ b/crates/ledger-mcp/src/types.rs @@ -1463,6 +1463,9 @@ pub struct GetEntryOut { pub parties: Vec, /// Why the shares don't resolve, when they don't. pub parties_error: Option, + /// Why it has no counterparty, when it is marked so (a reversal + /// carries its original's mark). + pub no_party_reason: Option, } // --------------------------------------------------------------------------- @@ -1989,6 +1992,12 @@ pub struct DisposeAssetParams { /// sale) to link to the disposal entry on posting. Required when the /// receipt policy applies. pub receipt_attachment_ids: Option>, + /// The buyer (party id or name), named on the disposal entry. Under + /// `[books] parties = "require"` a sale with proceeds needs one; a + /// retirement with no proceeds and no buyer is marked as having no + /// counterparty. + #[serde(default)] + pub buyer: Option, /// Confirm the disposal. Without confirmed=true returns a preview and /// posts nothing. pub confirmed: Option, @@ -2416,6 +2425,10 @@ pub struct RemitPayrollTaxesParams { pub date: String, /// Cash account id or code the remittance comes from. pub cash_account: String, + /// The tax agency paid (party id or name, e.g. the IRS), named on the + /// remittance entry. Required under `[books] parties = "require"`. + #[serde(default)] + pub agency: Option, } /// Output of `books.remit_payroll_taxes`. @@ -2530,8 +2543,10 @@ pub struct ExportCpaPackageParams { /// `[books] investing_accounts`. #[serde(default)] pub investing_accounts: Vec, - /// Include every receipt for the year's entries and the company's - /// documents (default true). + /// Include every receipt for the year's entries (every current file + /// filed on or linked to each posted entry, one copy per entry, each + /// with a row in receipts/index.csv) and the company's documents (the + /// files behind no entry) (default true). #[serde(default = "default_true")] pub include_receipts: bool, /// Carry every member's and 1099 payee's full TIN from the TIN vault @@ -2986,10 +3001,12 @@ pub struct ListPartiesOut { /// liability (a card, a member paying for it, a loan; not payables or /// payroll withholdings), with cash that pays a liability down counting /// once, as paid to that liability's creditor (a reimbursement to the -/// members, a card payment to the issuer), and cash raised by a liability -/// counting as received from the lender. Positive is paid to the party, -/// negative received from it; a reversal counts against its original's -/// side. A transfer between the company's own cash accounts nets to zero. +/// members, a card payment to the issuer), a liability paid down by +/// another counting the same way (a member paying the card from their own +/// funds, a loan paying it off), and cash raised by a liability counting +/// as received from the lender. Positive is paid to the party, negative +/// received from it; a reversal counts against its root original's side +/// (reversing the reversal counts on that side again). A transfer between the company's own cash accounts nets to zero. /// A bill is not paid until its payment is. /// An entry naming several parties counts each one's share of that money /// (its amount, its percent, or its derived share: see @@ -3271,6 +3288,15 @@ pub struct ReconciliationOut { pub voided_reason: Option, } +/// Parameters for `books.void_statement`. +#[derive(Debug, Clone, Deserialize, JsonSchema)] +pub struct VoidStatementParams { + /// The attachment whose recorded statement to void. + pub attachment_id: u64, + /// Why (what was recorded wrong), recorded in the journal. + pub reason: String, +} + /// Parameters for `books.void_reconciliation`. #[derive(Debug, Clone, Deserialize, JsonSchema)] pub struct VoidReconciliationParams { diff --git a/crates/ledger-mcp/tests/mcp_stdio.rs b/crates/ledger-mcp/tests/mcp_stdio.rs index b2bd9af..d389896 100644 --- a/crates/ledger-mcp/tests/mcp_stdio.rs +++ b/crates/ledger-mcp/tests/mcp_stdio.rs @@ -147,7 +147,7 @@ fn mcp_end_to_end_over_stdio() { // Tool listing: the narrow tools; chart is read-only. let list = mcp.request("tools/list", json!({})); let tools = list["result"]["tools"].as_array().unwrap(); - assert_eq!(tools.len(), 92, "expected 92 tools, got {}", tools.len()); + assert_eq!(tools.len(), 93, "expected 93 tools, got {}", tools.len()); let chart = tools.iter().find(|t| t["name"] == "books.chart").unwrap(); assert_eq!(chart["annotations"]["readOnlyHint"], true); assert!( diff --git a/crates/ledger-mcp/tests/spec_attachments.rs b/crates/ledger-mcp/tests/spec_attachments.rs index 26de284..53a738f 100644 --- a/crates/ledger-mcp/tests/spec_attachments.rs +++ b/crates/ledger-mcp/tests/spec_attachments.rs @@ -1148,6 +1148,155 @@ fn void_reconciliation_lets_it_be_redone() { assert_ne!(redo["reconciliation"]["reconciliation_id"], json!(rid)); } +/// books.reconcile SCENARIO 1: a statement whose file was withdrawn is +/// retired; the preview and the confirmation are refused, and nothing is +/// journaled (it used to return an error after journaling +/// reconciliation.completed). books.statements no longer lists it. +#[test] +fn a_withdrawn_statement_is_retired_and_reconciles_nothing() { + let mut m = books("withdrawn-statement"); + let (dep, pay) = january(&mut m); + m.clear(dep, 0); + m.clear(pay, 1); + let st = m.statement("1000", "2026-01-01", "2026-01-31", "800.00"); + m.ok( + "books.unlink_attachment", + json!({"attachment_id": st, "subject": {"kind": "account", "id": 1}, "reason": "wrong file"}), + ); + m.ok( + "books.withdraw_attachment", + json!({"attachment_id": st, "reason": "wrong file"}), + ); + let why = m.refuse("books.reconcile", json!({"statement_attachment": st})); + assert!(why.contains("withdrawn"), "{why}"); + m.refuse( + "books.reconcile", + json!({"statement_attachment": st, "confirmed": true}), + ); + assert_eq!(m.count("reconciliation.completed"), 0); + assert!( + m.read_only("books.reconciliations", json!({}))["reconciliations"] + .as_array() + .unwrap() + .is_empty() + ); + assert!( + m.read_only("books.statements", json!({}))["statements"] + .as_array() + .unwrap() + .is_empty() + ); + assert_eq!(m.ok("books.verify", json!({}))["status"], "ok"); +} + +/// books.void_statement SCENARIOS 1–2: a statement recorded with the wrong +/// closing balance is voided and recorded again on the same file, then +/// reconciles; voiding it while that reconciliation stands is refused. +#[test] +fn a_mis_recorded_statement_is_voided_and_recorded_again() { + let mut m = books("void-statement"); + let (dep, pay) = january(&mut m); + m.clear(dep, 0); + m.clear(pay, 1); + let st = m.statement("1000", "2026-01-01", "2026-01-31", "880.00"); + m.refuse( + "books.void_statement", + json!({"attachment_id": st, "reason": " "}), + ); + let v = m.ok( + "books.void_statement", + json!({"attachment_id": st, "reason": "closing balance mistyped"}), + ); + assert_eq!(v["closing_balance"], "880.00"); + assert_eq!(m.count("statement.voided"), 1); + m.refuse( + "books.void_statement", + json!({"attachment_id": st, "reason": "again"}), + ); + assert!( + m.read_only("books.statements", json!({}))["statements"] + .as_array() + .unwrap() + .is_empty() + ); + m.ok( + "books.record_statement", + json!({"attachment_id": st, "account": "1000", "period_start": "2026-01-01", + "period_end": "2026-01-31", "opening_balance": "0.00", "closing_balance": "800.00"}), + ); + m.ok( + "books.reconcile", + json!({"statement_attachment": st, "confirmed": true}), + ); + m.refuse( + "books.void_statement", + json!({"attachment_id": st, "reason": "why"}), + ); + // A line the reconciliation cleared stays cleared while it stands. + m.refuse( + "books.mark_cleared", + json!({"entry_id": pay, "line_index": 1, "cleared": false}), + ); + assert_eq!(m.ok("books.verify", json!({}))["status"], "ok"); +} + +/// books.export_cpa_package `include_receipts`: receipts/ holds every +/// current file behind each posted entry, filed on it or linked to it (a +/// linked file counts as the entry's receipt, so the receipt policy let +/// the entry post), one copy per entry with an index row; documents/ holds +/// the files behind no entry. A linked-only receipt used to be left out +/// of the package entirely. +#[test] +fn the_cpa_package_carries_receipts_linked_to_entries() { + use base64::Engine as _; + let mut m = books_at("cpa-linked", "0.00", ""); + let receipt = m.receipt(None, b"%PDF linked receipt"); + let e = m.draft( + "2026-03-01", + json!({}), + &[("6999", "debit", "40.00"), ("1000", "credit", "40.00")], + ); + m.link(receipt, json!({"kind": "entry", "id": e})); + // A contract linked to the same entry is evidence for it too. + m.doc( + "contract", + b"%PDF contract", + json!([{"kind": "entry", "id": e}]), + ); + m.ok( + "books.post_entry", + json!({"entry_id": e, "confirmed": true}), + ); + let pkg = m.ok( + "books.export_cpa_package", + json!({"as_of": "2026-12-31", "cash_accounts": ["1000"], "include_tins": false}), + ); + let bytes = base64::engine::general_purpose::STANDARD + .decode(pkg["content_base64"].as_str().unwrap()) + .unwrap(); + let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap(); + let names: Vec = (0..zip.len()) + .map(|i| zip.by_index(i).unwrap().name().unwrap().to_string()) + .collect(); + let mut index = String::new(); + std::io::Read::read_to_string(&mut zip.by_name("receipts/index.csv").unwrap(), &mut index) + .unwrap(); + let rows: Vec<&str> = index.lines().skip(1).collect(); + let for_e: Vec<&&str> = rows + .iter() + .filter(|r| r.starts_with(&format!("{e},"))) + .collect(); + assert_eq!(for_e.len(), 2, "the linked receipt and contract: {index}"); + for r in for_e { + let file = r.split(',').nth(4).unwrap(); + assert!(names.iter().any(|n| n == file), "{file} in {names:?}"); + } + assert!( + !names.iter().any(|n| n.starts_with("documents/contract")), + "the contract is behind an entry, filed with it: {names:?}" + ); +} + // ------------------------------------------------- import sources #[test] @@ -1243,9 +1392,12 @@ fn unreconciled(m: &mut Mcp) -> Vec { .collect() } -/// Cash accounts (`[books] cash_accounts`) and card accounts with -/// statements are listed until reconciled through the end of last month; -/// accounts without activity by then aren't. +/// Cash accounts (`[books] cash_accounts`), card accounts (liabilities +/// named "card", before their first statement, so the first +/// reconciliation is asked for), and any other liability once it has a +/// statement are listed until reconciled through the end of last month; +/// accounts without activity by then aren't (ledger_store::reconcilable, +/// the viewer's rule too). #[test] fn the_digest_lists_accounts_not_reconciled_through_last_month() { let mut m = books("digest"); @@ -1263,10 +1415,15 @@ fn the_digest_lists_accounts_not_reconciled_through_last_month() { json!({}), &[("6999", "debit", "40.00"), ("2050", "credit", "40.00")], ); + let owed = m.post( + &day, + json!({}), + &[("6999", "debit", "15.00"), ("2100", "credit", "15.00")], + ); assert_eq!( unreconciled(&mut m), - ["1000"], - "the card has no statement yet" + ["1000", "2050"], + "the card is due before its first statement; 2100 isn't a card" ); let d = m.read_only("books.digest", json!({"since": "2020-01-01"})); let row = &d["unreconciled_accounts"][0]; @@ -1278,15 +1435,21 @@ fn the_digest_lists_accounts_not_reconciled_through_last_month() { .unwrap() .contains("books.record_statement") ); - // The card's statement makes it a card account to reconcile. let card_st = m.statement("2050", &start, &day, "40.00"); - assert_eq!(unreconciled(&mut m), ["1000", "2050"]); m.clear(card, 1); m.ok( "books.reconcile", json!({"statement_attachment": card_st, "confirmed": true}), ); assert_eq!(unreconciled(&mut m), ["1000"]); + // A liability not named for a card joins with its first statement. + let owed_st = m.statement("2100", &start, &day, "15.00"); + assert_eq!(unreconciled(&mut m), ["1000", "2100"]); + m.clear(owed, 1); + m.ok( + "books.reconcile", + json!({"statement_attachment": owed_st, "confirmed": true}), + ); m.clear(dep, 0); let st = m.statement("1000", &start, &day, "100.00"); m.ok( diff --git a/crates/ledger-mcp/tests/spec_mcp.rs b/crates/ledger-mcp/tests/spec_mcp.rs index 4c660ae..60d8b5a 100644 --- a/crates/ledger-mcp/tests/spec_mcp.rs +++ b/crates/ledger-mcp/tests/spec_mcp.rs @@ -997,6 +997,11 @@ fn happy_path(name: &str) -> Mcp { "books.void_reconciliation", json!({"reconciliation_id": rec, "reason": "redo after the March import"}), ); + // Voided, its statement can be voided too, to record it again. + m.ok( + "books.void_statement", + json!({"attachment_id": stmt, "reason": "closing balance mistyped"}), + ); // Periods. let p = m.ok( diff --git a/crates/ledger-mcp/tests/spec_parties.rs b/crates/ledger-mcp/tests/spec_parties.rs index 604eda7..b84f997 100644 --- a/crates/ledger-mcp/tests/spec_parties.rs +++ b/crates/ledger-mcp/tests/spec_parties.rs @@ -1383,7 +1383,8 @@ fn a_joint_reimbursement_names_both_members_and_splits_by_share() { "books.post_entry", json!({"entry_id": id, "confirmed": true}), ); - assert_eq!(m.count("entry.parties_set"), 1); + // Named derived on the draft, then fixed at the amounts it posted at. + assert_eq!(m.count("entry.parties_set"), 2); for who in ["Stephen Waits", "Katherine Pendergast"] { let t = m.party(who, 2026)["totals"].clone(); assert_eq!(t["paid"], "50.00", "{who}'s half"); @@ -1563,3 +1564,155 @@ fn under_require_several_parties_satisfy_the_policy() { ); m.read_only("books.verify", json!({})); } + +/// docs/parties-and-attachments.md "Counterparties" (require): the +/// engine's own posting paths name their counterparty or are refused +/// before anything posts. books.run_payroll names the employee party on +/// the entry (so their books.party counts the net pay); a line by name +/// only is refused. books.remit_payroll_taxes needs `agency`; +/// books.dispose_asset needs `buyer` for a sale, and marks a retirement +/// with no proceeds as having none. +#[test] +fn under_require_engine_postings_name_their_counterparty() { + let mut m = books_with("require-engine", "parties = \"require\"\n"); + m.accounts(&[ + ("1500", "Equipment", "asset"), + ("1510", "Accumulated Depreciation", "asset"), + ("6500", "Depreciation", "expense"), + ("7100", "Gain or Loss on Disposal", "revenue"), + ]); + let pat = m.add_party("Pat Doe", &["employee"]); + let irs = m.add_party("IRS", &["tax_agency"]); + let buyer = m.add_party("Buyer Co", &[]); + let mut by_name = pat_line("Pat Doe"); + by_name.as_object_mut().unwrap().remove("employee"); + by_name["employee_name"] = json!("Pat Doe"); + let e = m.refuse( + "books.run_payroll", + json!({"date": "2026-02-15", "cash_account": "1000", "lines": [by_name]}), + ); + assert!(e.contains("party_required"), "{e}"); + let run = m.ok( + "books.run_payroll", + json!({"date": "2026-02-15", "cash_account": "1000", "lines": [pat_line("Pat Doe")]}), + ); + let entry = m.read_only("books.get_entry", json!({"entry_id": run["entry_id"]})); + assert_eq!(entry["parties"][0]["party_id"], json!(pat), "{entry}"); + assert_eq!(m.party("Pat Doe", 2026)["totals"]["paid"], "773.50"); + let e = m.refuse( + "books.remit_payroll_taxes", + json!({"date": "2026-03-01", "cash_account": "1000"}), + ); + assert!(e.contains("party_required"), "{e}"); + let r = m.ok( + "books.remit_payroll_taxes", + json!({"date": "2026-03-01", "cash_account": "1000", "agency": "IRS"}), + ); + let entry = m.read_only("books.get_entry", json!({"entry_id": r["entry_id"]})); + assert_eq!(entry["parties"][0]["party_id"], json!(irs), "{entry}"); + // Assets: one sold, one retired. + let reg = |m: &mut Mcp, tag: &str| { + m.ok( + "books.register_asset", + json!({"description": "Laptop", "purchase_date": "2026-01-10", "cost": "1200.00", + "salvage_value": "0.00", "useful_life_months": 36, "cost_account": "1500", + "accum_depr_account": "1510", "expense_account": "6500", "tag": tag, + "tax_basis_rule": "cost"}), + )["asset_id"] + .as_u64() + .unwrap() + }; + let (sold, retired) = (reg(&mut m, "LAP-1"), reg(&mut m, "LAP-2")); + let sale = json!({"asset_id": sold, "date": "2026-06-30", "proceeds": "500.00", + "proceeds_account": "1000", "gain_loss_account": "7100", + "confirmed": true}); + let before = m.journal(); + let e = m.refuse("books.dispose_asset", sale.clone()); + assert!(e.contains("party_required"), "{e}"); + assert!(m.journal() == before, "refused, nothing journaled"); + let mut with_buyer = sale; + with_buyer["buyer"] = json!("Buyer Co"); + let d = m.ok("books.dispose_asset", with_buyer); + let entry = m.read_only("books.get_entry", json!({"entry_id": d["entry_id"]})); + assert_eq!(entry["parties"][0]["party_id"], json!(buyer), "{entry}"); + let d = m.ok( + "books.dispose_asset", + json!({"asset_id": retired, "date": "2026-06-30", "proceeds": "0.00", + "proceeds_account": "1000", "gain_loss_account": "7100", "confirmed": true}), + ); + let entry = m.read_only("books.get_entry", json!({"entry_id": d["entry_id"]})); + assert!(entry["no_party_reason"].is_string(), "{entry}"); + assert_eq!(m.ok("books.verify", json!({}))["status"], "ok"); +} + +/// fix_derived_shares INVARIANT 1 (docs/parties-and-attachments.md +/// "Shares": posted is forever, the split included): a joint +/// reimbursement posted with derived shares at 50/50 keeps 50.00 each when +/// the members' shares later become 60/40, and when a member is archived; +/// a draft still follows the settings until it posts. +#[test] +fn a_posted_derived_split_survives_settings_changes_and_archiving() { + let mut m = member_books("shares-fixed", ""); + let posted = m.post( + "2026-03-01", + both(), + &[("2100", "debit", "100.00"), ("1000", "credit", "100.00")], + ); + let e = m.read_only("books.get_entry", json!({"entry_id": posted})); + assert_eq!( + resolved(&e), + [ + ("Stephen Waits".to_string(), "50.00".to_string()), + ("Katherine Pendergast".to_string(), "50.00".to_string()) + ] + ); + assert_eq!(e["parties"][0]["share"], "amount", "fixed when posted: {e}"); + assert_eq!(e["parties"][0]["amount"], "50.00", "{e}"); + let draft = m.draft( + "2026-03-02", + both(), + &[("2100", "debit", "100.00"), ("1000", "credit", "100.00")], + ); + // The members' split changes to 60/40. + let toml = m.data.parent().unwrap().join("wanderbooks.toml"); + let text = std::fs::read_to_string(&toml).unwrap(); + let changed = text + .replacen("share = \"50%\"", "share = \"60%\"", 1) + .replacen("share = \"50%\"", "share = \"40%\"", 1); + std::fs::write(&toml, changed).unwrap(); + // The server reloads it on change: wait until the draft reads 60/40. + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30); + let d = loop { + let d = m.ok("books.post_entry", json!({"entry_id": draft})); + if resolved(&d)[0].1 == "60.00" || std::time::Instant::now() > deadline { + break d; + } + std::thread::sleep(std::time::Duration::from_millis(100)); + }; + assert_eq!( + resolved(&d), + [ + ("Stephen Waits".to_string(), "60.00".to_string()), + ("Katherine Pendergast".to_string(), "40.00".to_string()) + ], + "a draft follows the settings" + ); + let e = m.read_only("books.get_entry", json!({"entry_id": posted})); + assert_eq!( + resolved(&e), + [ + ("Stephen Waits".to_string(), "50.00".to_string()), + ("Katherine Pendergast".to_string(), "50.00".to_string()) + ], + "posted history keeps its split" + ); + // Katherine archived: her history stays, and Stephen's. + let kat = e["parties"][1]["party_id"].as_u64().unwrap(); + m.ok( + "books.update_party", + json!({"party": kat.to_string(), "archived": true}), + ); + assert_eq!(m.party("Stephen Waits", 2026)["totals"]["paid"], "50.00"); + assert_eq!(m.party(&kat.to_string(), 2026)["totals"]["paid"], "50.00"); + assert_eq!(m.ok("books.verify", json!({}))["status"], "ok"); +} diff --git a/crates/ledger-store/src/lib.rs b/crates/ledger-store/src/lib.rs index f9ef5f0..1bd8781 100644 --- a/crates/ledger-store/src/lib.rs +++ b/crates/ledger-store/src/lib.rs @@ -21,6 +21,7 @@ pub mod blob; pub mod error; pub mod journal; +pub mod reconcilable; pub mod schema; pub mod snapshot; pub mod store; diff --git a/crates/ledger-store/src/reconcilable.rs b/crates/ledger-store/src/reconcilable.rs new file mode 100644 index 0000000..ce7e3a0 --- /dev/null +++ b/crates/ledger-store/src/reconcilable.rs @@ -0,0 +1,130 @@ +//! Which accounts the monthly close reconciles, and when one is due: one +//! rule, read by the server's `books.digest` (`unreconciled_accounts`) and +//! the viewer's Needs attention and account pages alike. +//! +//! ```text +//! GOAL: the monthly close's reconciliation step can't be forgotten, and +//! the digest and the viewer never disagree on what is due. +//! SOURCE: docs/parties-and-attachments.md "Reconciliations" (Needs +//! attention: an account with activity not reconciled through last +//! month's end); CONVENTION (bank and card statements close +//! monthly); AGENTS.md's monthly close. +//! DEFINITIONS: +//! cash accounts: `[books] cash_accounts`, resolved; when none are set, +//! the asset accounts (not placeholders) whose names say they hold +//! cash ("cash", "bank", "checking", "savings", "money market"). +//! card accounts: liability accounts whose names contain "card". +//! INVARIANTS: +//! 1. reconcilable_accounts: the cash accounts, then the card accounts, +//! then every other liability with a recorded statement, each once. +//! A card is expected before its first statement, so its first +//! reconciliation is asked for; a liability named otherwise (an Amex) +//! joins once its first statement is recorded. +//! 2. reconciliation_due(account) = Some(through), through the last day +//! of the month before today's, iff the account has a posted line +//! dated on or before it and no reconciliation not voided reaches it; +//! None otherwise (including activity only this month). +//! ``` + +use crate::{Reconciliation, Statement}; +use chrono::{Datelike, NaiveDate}; +use ledger_core::{AccountId, AccountType, Ledger}; + +/// The words an asset account's name uses to say it holds cash. +pub const CASH_WORDS: [&str; 5] = ["cash", "bank", "checking", "savings", "money market"]; + +/// The cash accounts: `configured` (account codes) resolved, else the +/// asset accounts whose names say they hold cash. +pub fn cash_accounts(ledger: &Ledger, configured: &[String]) -> Vec { + if !configured.is_empty() { + return configured + .iter() + .filter_map(|c| ledger.chart().find_by_code(c).map(|a| a.id)) + .collect(); + } + ledger + .chart() + .all() + .filter(|a| { + let n = a.name.to_lowercase(); + a.account_type == AccountType::Asset + && !a.placeholder + && CASH_WORDS.iter().any(|w| n.contains(w)) + }) + .map(|a| a.id) + .collect() +} + +/// The card accounts: liabilities whose names contain "card". +pub fn card_accounts(ledger: &Ledger) -> Vec { + ledger + .chart() + .all() + .filter(|a| { + a.account_type == AccountType::Liability && a.name.to_lowercase().contains("card") + }) + .map(|a| a.id) + .collect() +} + +/// The accounts the books reconcile monthly (INVARIANT 1). +pub fn reconcilable_accounts( + ledger: &Ledger, + cash: &[AccountId], + statements: &[Statement], +) -> Vec { + let mut out: Vec = Vec::new(); + let with_statement = statements.iter().map(|s| s.account_id).filter(|&a| { + ledger + .chart() + .get(a) + .is_some_and(|x| x.account_type == AccountType::Liability) + }); + for a in cash + .iter() + .copied() + .chain(card_accounts(ledger)) + .chain(with_statement) + { + if !out.contains(&a) { + out.push(a); + } + } + out +} + +/// The last day of the month before `today`'s. +pub fn last_month_end(today: NaiveDate) -> NaiveDate { + today + .with_day(1) + .and_then(|d| d.pred_opt()) + .expect("a month before today") +} + +/// The latest statement date `account` is reconciled through: its newest +/// reconciliation not voided. +pub fn reconciled_through( + reconciliations: &[Reconciliation], + account: AccountId, +) -> Option { + reconciliations + .iter() + .filter(|r| r.account_id == account && r.voided.is_none()) + .map(|r| r.statement_date) + .max() +} + +/// When `account` is due a reconciliation (INVARIANT 2). +pub fn reconciliation_due( + ledger: &Ledger, + reconciliations: &[Reconciliation], + account: AccountId, + today: NaiveDate, +) -> Option { + let through = last_month_end(today); + let active = ledger + .posted_entries() + .any(|e| e.date() <= through && e.lines().iter().any(|l| l.account == account)); + (active && reconciled_through(reconciliations, account).is_none_or(|d| d < through)) + .then_some(through) +} diff --git a/crates/ledger-store/src/store.rs b/crates/ledger-store/src/store.rs index 7e32978..0892829 100644 --- a/crates/ledger-store/src/store.rs +++ b/crates/ledger-store/src/store.rs @@ -1473,12 +1473,39 @@ impl Store { /// Mark a posted entry's line cleared (or uncleared) for bank /// reconciliation. Journaled as `line.cleared`. + /// + /// ```text + /// GOAL: a recorded reconciliation stays proof: the lines it cleared + /// stay cleared while it stands. + /// SOURCE: docs/parties-and-attachments.md "Reconciliations" (never + /// edited; void to redo). + /// SCENARIOS: + /// 1. IF the line is one a reconciliation not voided recorded as + /// cleared and `cleared` is false: SHOULD refuse + /// (ReconciliationRefused, naming it: void it first), journaling + /// nothing. + /// 2. Clearing a line dated on or before a reconciliation (an + /// outstanding check that clears next month) is allowed: the + /// later statement shows it. + /// 3. Otherwise as Ledger::mark_cleared. + /// ``` pub fn mark_cleared( &mut self, entry_id: EntryId, line_index: usize, cleared: bool, ) -> Result<(), StoreError> { + if !cleared + && let Some(r) = self + .reconciliations(None)? + .into_iter() + .find(|r| r.voided.is_none() && r.cleared.contains(&(entry_id, line_index))) + { + return Err(StoreError::ReconciliationRefused(format!( + "entry {entry_id} line {line_index} is cleared by reconciliation {} (account {} through {}): void that reconciliation (with a reason) to un-clear it", + r.id, r.account_id, r.statement_date + ))); + } let mut next = self.ledger.clone(); next.mark_cleared(entry_id, line_index, cleared)?; let line = self.append_journal( @@ -2477,7 +2504,12 @@ impl Store { /// 2. IF it is withdrawn: SHOULD refuse (AttachmentWithdrawn). /// 3. IF it is linked (to any entry): SHOULD refuse (AttachmentLinked). /// 4. IF the entry is unknown: SHOULD refuse. - /// 5. Otherwise: SHOULD journal `receipt.linked`. + /// 5. IF it is already linked to that entry (link_attachment): SHOULD + /// refuse (AlreadyLinked) BECAUSE filed on and linked to the same + /// entry is one receipt counted once, which neither unlink_receipt + /// nor unlink_attachment could then take off (link_attachment + /// SCENARIO 2, the other way round). + /// 6. Otherwise: SHOULD journal `receipt.linked`. /// ``` pub fn link_receipt( &mut self, @@ -2488,6 +2520,10 @@ impl Store { .get_entry(entry_id) .ok_or(LedgerError::UnknownEntry(entry_id))?; self.check_linkable(attachment_id)?; + let subject = Subject::Entry(entry_id); + if self.attachment_links(attachment_id)?.contains(&subject) { + return Err(StoreError::AlreadyLinked(attachment_id, subject.describe())); + } let line = self.append_journal( "receipt.linked", json!({ "attachment_id": attachment_id, "entry_id": entry_id }), @@ -2763,10 +2799,12 @@ impl Store { /// then `attachment.linked` to the account. /// SCENARIOS: /// 1. IF the attachment is unknown or withdrawn, already records a - /// statement, the account is unknown or not an asset or liability - /// (a bank, cash, or card account), the period runs backwards, or - /// a balance has more than two decimal places: SHOULD refuse, - /// journaling nothing. + /// statement (not voided), the account is unknown or not an asset + /// or liability (a bank, cash, or card account), the period runs + /// backwards, or a balance has more than two decimal places: + /// SHOULD refuse, journaling nothing. + /// 2. IF its statement was voided (void_statement): records it + /// afresh, the correction of a mis-recorded statement. /// ``` pub fn record_statement(&mut self, st: Statement) -> Result<(), StoreError> { let (_, withdrawn) = self.attachment_state(st.attachment_id)?; @@ -2818,7 +2856,57 @@ impl Store { Ok(()) } - /// The statement an attachment records, if any. + /// Void a recorded statement, with the reason: what it says was + /// recorded wrong (a mistyped balance or period), or it shouldn't have + /// been recorded. Journaled as `statement.voided {attachment_id, + /// reason}`. + /// + /// ```text + /// GOAL: a mis-recorded statement can be corrected on the record, + /// nothing erased (the journal keeps what it said and why it was + /// voided). + /// SOURCE: docs/parties-and-attachments.md "Reconciliations"; R-13a. + /// INVARIANTS: + /// 1. After it, the statement is no longer listed (statements, + /// statement), backs no reconciliation, and the same attachment + /// may record a statement again (record_statement). + /// SCENARIOS: + /// 1. IF the reason is blank: SHOULD refuse (ReasonRequired). + /// 2. IF the attachment records no statement: SHOULD refuse. + /// 3. IF a reconciliation not voided names it: SHOULD refuse + /// (ReconciliationRefused: void that reconciliation first). + /// 4. Every refusal journals nothing. + /// ``` + pub fn void_statement(&mut self, attachment_id: u64, reason: &str) -> Result<(), StoreError> { + let reason = reason.trim(); + if reason.is_empty() { + return Err(StoreError::ReasonRequired); + } + if self.statement(attachment_id)?.is_none() { + return Err(StoreError::InvalidStatement(format!( + "attachment {attachment_id} records no statement" + ))); + } + if let Some(r) = self + .reconciliations(None)? + .into_iter() + .find(|r| r.voided.is_none() && r.attachment_id == attachment_id) + { + return Err(StoreError::ReconciliationRefused(format!( + "statement {attachment_id} backs reconciliation {}: void that reconciliation first", + r.id + ))); + } + let line = self.append_journal( + "statement.voided", + json!({"attachment_id": attachment_id, "reason": reason}), + )?; + apply_line_to_sqlite(&self.conn, &line)?; + Ok(()) + } + + /// The statement an attachment records, if any (not voided, its file + /// not withdrawn). pub fn statement(&self, attachment_id: u64) -> Result, StoreError> { Ok(self .statements(None)? @@ -2826,7 +2914,9 @@ impl Store { .find(|s| s.attachment_id == attachment_id)) } - /// Recorded statements, optionally one account's, by period end. + /// Recorded statements, optionally one account's, by period end: those + /// not voided whose file isn't withdrawn (a withdrawn file retires its + /// statement). pub fn statements(&self, account: Option) -> Result, StoreError> { query_statements(&self.conn, account) } @@ -2851,16 +2941,23 @@ impl Store { /// 4. Journaled as `reconciliation.completed`; never edited (void and /// redo). /// SCENARIOS: - /// 1. IF the attachment records no statement: SHOULD refuse. + /// 1. IF the attachment is withdrawn: SHOULD refuse + /// (AttachmentWithdrawn). IF it records no statement (or only a + /// voided one): SHOULD refuse. /// 2. IF the books differ from the statement: SHOULD refuse with /// NotReconciled naming the difference. /// 3. IF a reconciliation not voided is dated on or after it: SHOULD /// refuse. + /// 4. Every refusal comes before anything is journaled. /// ``` pub fn complete_reconciliation( &mut self, statement_attachment: u64, ) -> Result { + let (_, withdrawn) = self.attachment_state(statement_attachment)?; + if withdrawn { + return Err(StoreError::AttachmentWithdrawn(statement_attachment)); + } let st = self.statement(statement_attachment)?.ok_or_else(|| { StoreError::InvalidStatement(format!( "attachment {statement_attachment} records no statement (record_statement first)" @@ -4029,6 +4126,7 @@ pub(crate) fn replay_line_into_ledger( "attachment.linked" | "attachment.unlinked" | "statement.recorded" + | "statement.voided" | "reconciliation.completed" | "reconciliation.voided" => { // Evidence, not accounting state: the projection records them. @@ -4668,6 +4766,14 @@ fn apply_line_rows(conn: &Connection, line: &JournalLine) -> Result<(), StoreErr ], )?; } + "statement.voided" => { + // The journal keeps what it said; the projection lists only + // statements that stand. + conn.execute( + "DELETE FROM statements WHERE attachment_id = ?", + params![get_u64(p, "attachment_id")? as i64], + )?; + } "reconciliation.completed" => { let id = get_u64(p, "id")? as i64; conn.execute( @@ -5326,13 +5432,14 @@ pub struct Reconciliation { /// Recorded statements in the projection, optionally one account's, by /// period end (shared by [`Store::statements`] and the read-only -/// snapshot). +/// snapshot): those not voided, leaving out any whose attachment is +/// withdrawn. pub(crate) fn query_statements( conn: &Connection, account: Option, ) -> Result, StoreError> { let mut stmt = conn.prepare( - "SELECT attachment_id, account_id, period_start, period_end, opening_balance, closing_balance FROM statements ORDER BY period_end, attachment_id", + "SELECT attachment_id, account_id, period_start, period_end, opening_balance, closing_balance FROM statements WHERE attachment_id NOT IN (SELECT id FROM attachments WHERE withdrawn_at IS NOT NULL) ORDER BY period_end, attachment_id", )?; let rows = stmt.query_map([], |r| { Ok(( diff --git a/crates/ledger-store/tests/attachments.rs b/crates/ledger-store/tests/attachments.rs index e825723..5dc7ec3 100644 --- a/crates/ledger-store/tests/attachments.rs +++ b/crates/ledger-store/tests/attachments.rs @@ -1273,3 +1273,151 @@ fn everything_survives_replay_and_verifies() { assert_eq!(view(&s, &subjects), before, "full replay"); s.verify().unwrap(); } + +// ------------------------------------------- withdrawn and voided statements + +/// Oracle: complete_reconciliation SCENARIOS 1 and 4 (a withdrawn +/// statement file: AttachmentWithdrawn, before anything is journaled); +/// statements ("a withdrawn file retires its statement"). The statement's +/// account link is taken off and the file withdrawn; confirming then must +/// record no reconciliation, and the statement is no longer listed. +#[test] +fn a_withdrawn_statement_records_no_reconciliation() { + let dir = test_dir("withdrawn-statement"); + let (mut s, fx) = fixture(&dir); + let a = jan_cash(&mut s, &fx, "jan.pdf"); + clear_jan(&mut s, &fx); + s.unlink_attachment(a, Subject::Account(fx.cash), "filed wrong") + .unwrap(); + s.withdraw_attachment(a, "filed wrong").unwrap(); + let e = refused_nothing_journaled(&mut s, &dir, "withdrawn", |s| s.complete_reconciliation(a)); + assert!( + matches!(e, StoreError::AttachmentWithdrawn(x) if x == a), + "{e:?}" + ); + assert!(s.reconciliations(None).unwrap().is_empty()); + assert_eq!(s.statement(a).unwrap(), None); + assert!(s.statements(Some(fx.cash)).unwrap().is_empty()); + s.verify().unwrap(); +} + +/// Oracle: void_statement SCENARIOS 1–4 and INVARIANT 1; record_statement +/// SCENARIO 2 (a voided statement is recorded afresh on the same file); +/// the docs' `statement.voided {attachment_id, reason}`. A January +/// statement mistyped at 830.00 is voided and recorded again at 380.00, +/// then reconciled; voiding it while that reconciliation stands is +/// refused. Reopening and a full replay agree. +#[test] +fn a_mis_recorded_statement_is_voided_and_recorded_again() { + let dir = test_dir("void-statement"); + let (mut s, fx) = fixture(&dir); + let a = doc(&mut s, "jan.pdf"); + let typo = stmt( + a, + fx.cash, + date(2026, 1, 1), + date(2026, 1, 31), + "0.00", + "830.00", + ); + s.record_statement(typo).unwrap(); + for blank in ["", " "] { + let e = refused_nothing_journaled(&mut s, &dir, "blank", |s| s.void_statement(a, blank)); + assert!(matches!(e, StoreError::ReasonRequired), "{e:?}"); + } + let plain = doc(&mut s, "plain.pdf"); + let e = refused_nothing_journaled(&mut s, &dir, "no statement", |s| { + s.void_statement(plain, "why") + }); + assert!(matches!(e, StoreError::InvalidStatement(_)), "{e:?}"); + s.void_statement(a, "closing balance mistyped").unwrap(); + let (kind, payload) = journal(&dir).last().cloned().unwrap(); + assert_eq!(kind, "statement.voided"); + assert_eq!( + payload, + json!({"attachment_id": a, "reason": "closing balance mistyped"}) + ); + assert_eq!(s.statement(a).unwrap(), None); + assert!(s.statements(None).unwrap().is_empty()); + let e = refused_nothing_journaled(&mut s, &dir, "voided twice", |s| { + s.void_statement(a, "again") + }); + assert!(matches!(e, StoreError::InvalidStatement(_)), "{e:?}"); + let right = stmt( + a, + fx.cash, + date(2026, 1, 1), + date(2026, 1, 31), + "0.00", + "380.00", + ); + s.record_statement(right.clone()).unwrap(); + assert_eq!(s.statements(None).unwrap(), vec![right.clone()]); + clear_jan(&mut s, &fx); + let rec = s.complete_reconciliation(a).unwrap(); + let e = refused_nothing_journaled(&mut s, &dir, "backs a reconciliation", |s| { + s.void_statement(a, "why") + }); + assert!(matches!(e, StoreError::ReconciliationRefused(_)), "{e:?}"); + s.verify().unwrap(); + drop(s); + delete_db(&dir); + let s = Store::open(&dir).unwrap(); + assert_eq!(s.statements(None).unwrap(), vec![right]); + assert_eq!(s.reconciliations(None).unwrap(), vec![rec]); + s.verify().unwrap(); +} + +/// Oracle: mark_cleared SCENARIOS 1–3. A line January's reconciliation +/// recorded as cleared can't be un-cleared while it stands; once it is +/// voided it can. A line dated in January but cleared later (an +/// outstanding item) may still be cleared. +#[test] +fn a_reconciled_line_stays_cleared_until_the_reconciliation_is_voided() { + let dir = test_dir("reconciled-line"); + let (mut s, fx) = fixture(&dir); + let a = jan_cash(&mut s, &fx, "jan.pdf"); + clear_jan(&mut s, &fx); + let rec = s.complete_reconciliation(a).unwrap(); + let e = refused_nothing_journaled(&mut s, &dir, "un-clear", |s| { + s.mark_cleared(fx.e2, 1, false) + }); + assert!(matches!(e, StoreError::ReconciliationRefused(_)), "{e:?}"); + assert!(s.ledger().is_cleared(fx.e2, 1)); + // An outstanding January item cleared afterwards: allowed. + let late = post( + &mut s, + date(2026, 1, 30), + "Check 101", + fx.expense, + fx.cash, + "10.00", + ); + s.mark_cleared(late, 1, true).unwrap(); + s.mark_cleared(late, 1, false).unwrap(); + s.void_reconciliation(rec.id, "redo").unwrap(); + s.mark_cleared(fx.e2, 1, false).unwrap(); + assert!(!s.ledger().is_cleared(fx.e2, 1)); + s.verify().unwrap(); +} + +/// Oracle: link_receipt SCENARIO 5 (already linked to that entry: refuse, +/// AlreadyLinked, journaling nothing), the mirror of link_attachment +/// SCENARIO 2. A file linked to an entry can't then be filed on it too. +#[test] +fn a_file_linked_to_an_entry_cannot_also_be_filed_on_it() { + let dir = test_dir("linked-then-filed"); + let (mut s, fx) = fixture(&dir); + let (a, _, _) = s + .attach_receipt(None, "r.pdf", "application/pdf", b"%PDF r", None) + .unwrap(); + s.link_attachment(a, Subject::Entry(fx.e2)).unwrap(); + let e = refused_nothing_journaled(&mut s, &dir, "filed on too", |s| s.link_receipt(a, fx.e2)); + assert!( + matches!(e, StoreError::AlreadyLinked(x, _) if x == a), + "{e:?}" + ); + // Another entry is fine: filed on one, linked to another. + s.link_receipt(a, fx.e3).unwrap(); + s.verify().unwrap(); +} diff --git a/docs/mcp-interface.md b/docs/mcp-interface.md index 963fdb6..08d7957 100644 --- a/docs/mcp-interface.md +++ b/docs/mcp-interface.md @@ -138,8 +138,9 @@ below. preview/confirm pattern as post. - `books.get_entry { entry_id }` → full entry with lines, receipts, and its `vendor`, `party_id`, and `party` (the party's label, when it names - exactly one) tags, and `parties` / `parties_error` as in the - `post_entry` preview. + exactly one) tags, `parties` / `parties_error` as in the `post_entry` + preview, and `no_party_reason` when it is marked as having no + counterparty. - `books.search_entries { account?, date_from?, date_to?, text?, limit?, offset? }` → entries with lines, newest first. `limit` caps at 100. - `books.trial_balance { as_of? }` → `as_of`, lines, totals, `balanced` @@ -165,10 +166,13 @@ below. ask the human, then update it upstream (pull request or issue at github.com/swaits/wanderbooks). `unreconciled_accounts` lists the cash and card accounts due for reconciliation (`{ code, name, - reconciled_through?, due_through, next_step }`): every `[books] - cash_accounts` account and every liability with a recorded statement - that has posted activity through the end of last month but no - reconciliation (not voided) through it. + reconciled_through?, due_through, next_step }`): the cash accounts + (`[books] cash_accounts`, else asset accounts named for cash: cash, + bank, checking, savings, money market), the card accounts (liabilities + named "card", before their first statement), and every other liability + with a recorded statement, that have posted activity through the end of + last month but no reconciliation (not voided) through it. The viewer's + Needs attention uses the same rule (`ledger_store::reconcilable`). ## Tools — fixed assets @@ -197,11 +201,16 @@ so the lifetime total is exactly (cost − salvage). → per-asset `{ entry_id, amount, already_posted }`, plus `skipped` for assets outside their useful life. Idempotent per asset and period. - `books.dispose_asset { asset_id | tag, date, proceeds, proceeds_account, - gain_loss_account, confirmed?, receipt_attachment_ids? }` → without + gain_loss_account, buyer?, confirmed?, receipt_attachment_ids? }` → + without confirmation: `{ status: "preview", ... }` and posts nothing; with `confirmed: true`: auto-posts missing final-period depreciation, then posts the balanced disposal entry. Subject to receipt policy (bill of - sale); monthly depreciation is exempt as an allocation. + sale); monthly depreciation is exempt as an allocation. `buyer` (a + party) is the disposal entry's party; under `[books] parties = + "require"` a sale with proceeds without one is refused before anything + posts (`party_required`); a retirement with no proceeds and no buyer is + marked as having no counterparty. - `books.export { report: "fixed_asset_register", format: json | csv, as_of? }`: every asset registered by the date (tag, serial, cost, depreciation by schedule, book value, disposal), totals, and the @@ -447,12 +456,19 @@ include vendor and customer when it has those records. payables and payroll withholding roles (`books.roles`). A liability counts as how the company paid or was paid: a card, a member paying for it, a loan. -- `paid` / `received`: each entry's money lines, credits minus debits; a - positive net was paid to the party, a negative one received from it; a - reversal counts against its original's side. A bill isn't paid until its - payment is; an entry only moving money between money accounts nets to - zero. An entry naming several parties counts this party's resolved - share only (an entry whose shares don't resolve counts for none). +- `paid` / `received`: each entry's money lines, credits minus debits + (`ledger_core::parties::entry_flow`), with a liability paid down + counting once, as paid to its creditor: by cash (a reimbursement to the + members, a card payment) or by another liability (a member paying the + card from their own funds, a loan paying it off); cash raised by a + liability is received from the lender. A positive net was paid to the + party, a negative one received from it; a reversal counts against its + root original's side (reversing the reversal counts on that side + again). A bill isn't paid until its payment is; a transfer between the + company's own cash accounts nets to zero. An entry naming several + parties counts this party's resolved share only (an entry whose shares + don't resolve counts for none). The year is the whole calendar year; + the viewer's party page and directory show these same numbers. - `invoiced` / `billed`: its invoices' and bills' totals dated in the year. `owed` / `receivable`: its open bills' and invoices' balances now (every year). @@ -565,7 +581,8 @@ the receipt policy counts it. exists. `link_to` as in "Tools — attachments". - `books.link_receipt { receipt_id, entry_id }` → link a previously filed (unlinked, not withdrawn) receipt to its entry. A receipt linked elsewhere - must be unlinked first. + must be unlinked first; one already linked to that entry + (`books.link_attachment`) is refused, as filing it there twice. - `books.unlink_receipt { attachment_id, reason }` → `{ ok, entry_id, remaining_receipts }`. Detaches a receipt from its entry; it stays filed, unlinked. Journaled as `receipt.unlinked` with the reason. Subject to the @@ -782,10 +799,17 @@ R-FUTURE-3 and rides the engine roadmap, not the MCP layer. Records what a filed statement says (balances as printed, in the account's normal direction, cents at most) and links it to the account. Journaled as `statement.recorded` then `attachment.linked`. Refused for - an unknown or withdrawn attachment, one already recording a statement, - an account that isn't an asset or liability, or a period that runs - backwards. -- `books.statements { account? }` → `{ statements }`, by statement date. + an unknown or withdrawn attachment, one already recording a statement + (not voided), an account that isn't an asset or liability, or a period + that runs backwards. +- `books.statements { account? }` → `{ statements }`, by statement date: + those not voided whose file isn't withdrawn (withdrawing a statement's + file retires it). +- `books.void_statement { attachment_id, reason }` → the statement as it + was recorded. For a statement recorded wrong: journaled as + `statement.voided`; it is no longer listed, backs no reconciliation, and + the same attachment can record it again. Refused while a reconciliation + not voided rests on it. - `books.reconcile { statement_attachment? | account, statement_date, statement_balance, confirmed? }` → `{ status: "preview" | "reconciled", account_code, statement_date, statement_attachment?, book_balance, @@ -796,8 +820,9 @@ R-FUTURE-3 and rides the engine roadmap, not the MCP layer. account, date, and balance are the recorded statement's (any given must agree). Without `confirmed: true` it is a preview that records nothing; `problems` lists why a confirmation would be refused. Confirmed, it - needs a recorded statement, a difference of exactly zero, and a date - after the account's last reconciliation not voided; it then journals + needs a recorded statement (its file not withdrawn: refused before + anything is journaled), a difference of exactly zero, and a date after + the account's last reconciliation not voided; it then journals `reconciliation.completed` (the cleared lines, the statement) and links the statement to it, returning `reconciliation`. - `books.reconciliations { account? }` → `{ reconciliations: [{ @@ -807,6 +832,10 @@ R-FUTURE-3 and rides the engine roadmap, not the MCP layer. - `books.void_reconciliation { reconciliation_id, reason }` → the reconciliation, voided. Journaled as `reconciliation.voided`; the account can be reconciled through that date again. +- `books.mark_cleared` refuses to un-clear a line a reconciliation not + voided recorded as cleared: void the reconciliation first. Clearing a + line dated on or before a reconciliation (an outstanding check that + clears on a later statement) is allowed. ## Tools — A/R and A/P @@ -851,9 +880,15 @@ entries. `employee` names the employee's party (id or name; it must have the employee role, else nothing is written); new runs should name it, and `employee_name` then defaults to the party's name. A line needs one or - the other. -- `books.remit_payroll_taxes { date, cash_account }` → `{ entry_id, total }`. - Clears every payroll payable to cash in one entry. + the other. When every line with net pay names its party, the entry + names those parties, each with its net pay, so each employee's + `books.party` counts it; under `[books] parties = "require"` a line + with net pay and no party is refused before anything posts + (`party_required`). +- `books.remit_payroll_taxes { date, cash_account, agency? }` → `{ + entry_id, total }`. Clears every payroll payable to cash in one entry, + naming `agency` (the tax agency's party) on it; required under + `[books] parties = "require"` (`party_required`, nothing posted). - `books.list_payroll_runs` → `{ runs: [{ id, date, employee_count, total_gross, entry_id }] }`. @@ -874,6 +909,12 @@ entries. - `books.export_cpa_package { as_of?, cash_accounts?, financing_accounts?, investing_accounts?, include_receipts?, include_tins?, save_to? }` → `{ file_name, content_base64?, saved_to?, tins_included, tin_gaps, note }`. + **Receipts** (`include_receipts`, default true): `receipts/` holds every + current file behind each posted entry of the fiscal year to date, filed + on it or linked to it (`books.link_attachment`; a linked file counts as + the entry's receipt), one copy per entry, each with a row in + `receipts/index.csv`; `documents//` holds the files behind no + entry, receipts excepted. **TINs** (`include_tins`, default true; DECISION, Stephen, 2026-10-15): the K-1 worksheet and the 1099 worksheet carry every member's and payee's full TIN from the vault, so the zip is written to `save_to` (a @@ -892,10 +933,14 @@ entries. contributions_cash, contributions_property, net_income, distributions, other, ending_capital` for the fiscal year to date: the statement of members' equity's figures (`ledger_core::k1`), contributions split into - property (what a contribution debits to asset accounts other than cash, - receivables, and prepaid accounts: a prepaid schedule's account, or one - named "prepaid") and cash (the rest, including a service the members - paid ahead). Property in an entry crediting several members is shared + property (what an entry crediting a member's capital debits to asset + accounts other than cash and receivables: equipment, software, or a + prepaid service contract the members owned and handed over straight to + capital, e.g. AppleCare+ contributed with a laptop) and cash (the rest: + deposits, and what the members paid for the company, including a + service they paid a vendor ahead for, booked through Due to + Shareholders and then converted to capital; DECISION, Stephen, + 2026-10-15). Property in an entry crediting several members is shared in proportion to their credits. An entry and its reversal both in the year cancel: neither is a contribution or a distribution. A member's party is the party with the member's name (not a display name) and the diff --git a/docs/parties-and-attachments.md b/docs/parties-and-attachments.md index f0fa3b9..97f3bcb 100644 --- a/docs/parties-and-attachments.md +++ b/docs/parties-and-attachments.md @@ -109,6 +109,16 @@ derived. Spec: `ledger_core::shares`. member gets what their own lines say (and those must account for all it moved); otherwise each gets their income share, rescaled to the members named, rounded as percents are. +- **Posted is forever, the split included:** when an entry naming + several parties with derived shares posts (or a posted entry is tagged + so), the server records the exact amounts they resolve to + (`entry.parties_set` with amounts), so a later change to + `[[company.members]]` (a new income split, a member archived) never + re-splits posted history. A draft's derived shares follow the settings + until it posts. An archived member stays a member for their history + (`Company::party_members` falls back to the archived party of that + name). Entries posted with derived shares before this rule keep them + until re-tagged with `books.set_entry_party`. - **Refused** (journaling nothing): a party named twice, an unknown party, mixed kinds of share, percents not summing to 100, an amount finer than a cent, and two parties with vendor records. A 1099 counts @@ -126,6 +136,14 @@ derived. Spec: `ledger_core::shares`. every share, so a reversal subtracts each party's part; re-tagging either one (parties, shares, or vendor) re-tags the other, so the pair always nets to zero. + A reversal counts on its root original's side, so reversing the + reversal reinstates the payment as paid (never a negative received). +- **One number everywhere:** `books.party`, `books.parties`, and the + viewer's party page and directory read cash as the server does + (`[books] cash_accounts`, else every asset account but receivables) + and count the whole calendar year, so a posted entry dated later this + year counts in both. The viewer shows the year up to the later of today + and its last posted entry. - **For the agent:** a joint member transaction (a reimbursement on Due to Shareholders, a contribution or distribution from joint funds) names both members with no shares and lets them derive; an uneven @@ -315,17 +333,26 @@ statement_balance, cleared: [(entry_id, line)], attachment_id}`. It refuses unless the difference is exactly zero and a statement for that account and date is attached (the receipt rule's twin). A reconciliation is never edited; a later one supersedes it; `reconciliation.voided -{id, reason}` exists for mistakes. Viewer: per-account reconciliation +{id, reason}` exists for mistakes. While it stands, the lines it +recorded as cleared can't be un-cleared (void it first), and nothing is +journaled when it is refused (a withdrawn statement file included). A +statement recorded wrong is voided (`statement.voided {attachment_id, +reason}`, refused while a reconciliation rests on it) and recorded again +on the same file; withdrawing a statement's file retires its statement. Viewer: per-account reconciliation history with its statement; Needs-attention: an account with activity not reconciled through last month's end. Tools (built; `docs/mcp-interface.md` "Tools — bank reconciliation"): `books.record_statement`, `books.statements`, `books.reconcile` (preview by default; `statement_attachment` + `confirmed: true` records it), -`books.reconciliations`, `books.void_reconciliation`; `books.show -reconciliation`; `books.digest` `unreconciled_accounts` (the `[books] -cash_accounts` and liabilities with statements, not reconciled through -last month's end). The statement of a reconciliation not voided can't be +`books.reconciliations`, `books.void_reconciliation`, +`books.void_statement`; `books.show +reconciliation`; `books.digest` `unreconciled_accounts` and the +viewer's Needs attention, one rule (`ledger_store::reconcilable`): the +cash accounts (`[books] cash_accounts`, else assets named for cash), the +card accounts (liabilities named "card", before their first statement), +and other liabilities with statements, not reconciled through last +month's end). The statement of a reconciliation not voided can't be unlinked from it. ### Import sources @@ -378,6 +405,18 @@ without forcing one style. `[books] parties = "off" | "suggest" | - `require`: posting is refused until the entry names a party or is marked as having none (transfers, depreciation, amortization, closes are exempt by kind). + The engine's own posting paths name their counterparty or are refused + first, so nothing posts around the rule: `books.run_payroll` names each + line's employee party on the entry (each with their net pay) and, under + `require`, refuses a line without one; `books.remit_payroll_taxes` + takes `agency` (required under `require`); `books.dispose_asset` takes + `buyer` (required under `require` for a sale with proceeds; a + retirement with no proceeds and no buyer is marked as having none). +- **Marks follow the chain:** a reversal carries its original's parties, + shares, vendor tag, and no-party mark; marking or tagging any entry of a + reversal chain marks or tags them all. Tagging a vendor (or any party) + clears a no-party mark, so an entry never names a party and carries the + mark at once. Also: memorized matches (journaled rules like description contains "OPENROUTER" → OpenRouter, applied by bank-CSV import and new drafts, diff --git a/docs/spec/rules.md b/docs/spec/rules.md index df3ccae..445d084 100644 --- a/docs/spec/rules.md +++ b/docs/spec/rules.md @@ -238,8 +238,12 @@ closed period). both in a statement's span are neither contributions nor distributions (`reports::cancelled_in_span`; the K-1 worksheet reads the same). The K-1 worksheet's contributed property (Item L) is what - the member owned and handed over, with its own basis (§723): never a - prepaid service they paid for the company (cash), and shared among + the member owned and handed over, with its own basis (§723): a prepaid + service contract credited straight to capital is property (a contract + handed over), while a prepaid service they paid a vendor for on the + company's behalf, booked through Due to Shareholders (Dr prepaid / Cr + 2100, then Dr 2100 / Cr capital), is cash (DECISION, Stephen, + 2026-10-15); and it is shared among members credited in one entry in proportion to their credits (`k1` INVARIANTS 3, 4, 6).