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")
+46
View File
@@ -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()));
}
+81 -5
View File
@@ -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: Tobin's 2026
/// money_cash_accounts), so the laptop on 1500 reads as cash: Tobin'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::<Decimal>(), cents(9_900));
assert_eq!(
b.party_span(PartyPeriod::AllTime),
(d(2026, 1, 1), d(2026, 11, 20))
);
}