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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
This commit is contained in:
swaitsandClaude Opus 5.5 committed 2026-10-15 20:16:03 -06:00
1 parent e1b2509c22
commit 8498e6cfab
22 files changed
+1802 -343

No files matched your search

+165 -109
View File
@@ -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<ledger_core::PartyMember>,
/// `[books] financing_accounts`, resolved (empty when not set).
pub financing_accounts: Vec<AccountId>,
/// 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<AccountId>,
/// 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<AccountId>,
/// `[books] investing_accounts`, resolved (empty when not set).
pub investing_accounts: Vec<AccountId>,
pub card_accounts: Vec<AccountId>,
@@ -292,8 +296,8 @@ pub enum PartyPeriod {
/// get back?" (owner, 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<AccountId> {
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<AccountId> = 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<AccountId> = 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<EntryId, Vec<usize>> = 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<PartyId, PartyInfo> {
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<PartyId, PartyInfo> = 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<AccountId> {
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<NaiveDate> {
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<i32>| {
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<AccountId, Decimal> = 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::<Decimal>() == 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::<Decimal>()
} 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<CapitalLine> = 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<AccountId> {
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)
}
+10
View File
@@ -282,6 +282,16 @@ pub fn describe(b: &Books, line: &JournalLine) -> Option<Event> {
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")