P3: party totals by what moved the money, once (defects 14, 11, 24)

Each party's part of an entry is signed as its own account would show it,
with a direction and its cash and in-kind amounts; a reversal is its
original negated. Totals on a cash or all basis: for a member, all equals
the members' equity statement and cash the K-1's cash. Money is cash,
cards and the financing accounts, and a liability credit never pays its
own creditor (creditor_of), so accruals and declared distributions count
once. tin_last4 from the TIN or W-9 facts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
This commit is contained in:
swaitsandClaude Opus 5.5 committed 2026-10-21 12:12:26 -06:00
1 parent 9ab003d0d1
commit c2ceabbae9
52 files changed
+7535 -560

No files matched your search

+491 -36
View File
@@ -5,13 +5,15 @@
//! Each example is a setup, events and expected values worked by hand from
//! its cited source (`bookspec/README.md` gives the shape). Every example
//! must load, whatever it asks or whichever appendix it is in: its shape,
//! its accounts, fixed assets, members and payees, every event (an entry,
//! a reversal, a close or a bill, posting entry #N as event N) and every
//! change that posts no entry (a rename, a policy). The queries the core
//! answers (`cash_flow`, `trial_balance`, `profit_and_loss`,
//! `vendor_1099_paid`, `entry`, `balances`, `members_equity`, `period`,
//! `day`, `calendar_year`, `account_names`, `uncategorized_accounts`) are
//! then checked value by value, and the commands it tries
//! its accounts, fixed assets, parties, members and payees, every event
//! (an entry, a reversal, a close or a bill, posting entry #N as event N,
//! with the parties and payee it is tagged with) and every change that
//! posts no entry (a rename, a policy). The queries the core answers
//! (`cash_flow`, `trial_balance`, `profit_and_loss`, `vendor_1099_paid`,
//! `entry`, `balances`, `members_equity`, `k1`, `party_part`,
//! `party_totals`, `party_tin_last4`, `period`, `day`, `calendar_year`,
//! `account_names`, `uncategorized_accounts`) are then checked value by
//! value, and the commands it tries
//! (`rename_account`, `add_account`, `set_tax_category`) accepted or
//! refused on a copy of the books, each after the event it names (`after`)
//! or the last; the formats appendix's (`iif_lines`, `iif_changes`) are
@@ -24,11 +26,13 @@ use std::path::{Path, PathBuf};
use chrono::NaiveDate;
use ledger_core::calendar::{PeriodDefaults, period_defaults};
use ledger_core::parties::{Basis, MoneyAccounts};
use ledger_core::settings::Company;
use ledger_core::{
AccountType, CashFlowParams, CashFlowSection, CashFlowStatement, CloseTo, Currency,
DocumentLine, EntryId, Form1099, Ledger, LedgerError, MemberCapital, MemberClose, Money,
NewAsset, PaymentAccounts, ReconcilingKind, Side, VendorId, cash_flow, members_equity,
NewAsset, PartyId, PartyMember, PartyShare, PaymentAccounts, ReconcilingKind, Role, Share,
Side, TinFacts, TinType, VendorId, W9Facts, cash_flow, members_equity,
profit_and_loss_for_period, trial_balance_as_of, vendor_1099_report_with_members,
};
use rust_decimal::Decimal;
@@ -71,6 +75,10 @@ struct Setup {
investing_accounts: Vec<String>,
#[serde(default)]
fixed_assets: Vec<FixedAsset>,
/// The company's directory: parties other than a payee's vendor
/// record, and the payees' when the example names their roles.
#[serde(default)]
parties: Vec<PartySpec>,
#[serde(default)]
members: Vec<MemberSpec>,
#[serde(default)]
@@ -124,6 +132,8 @@ struct FixedAsset {
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct MemberSpec {
/// The member's name: a party's in `setup.parties` (with the member
/// role) when the example asks about the member as a party.
name: String,
capital_account: String,
/// Their share of income, an exact decimal fraction; every member has
@@ -131,9 +141,14 @@ struct MemberSpec {
/// needs them.
#[serde(default)]
share: Option<String>,
/// Their distributions account (rules.md B-13), when they have one.
#[serde(default)]
distributions_account: Option<String>,
}
/// A payee marked for an information return (rules.md B-19).
/// A payee marked for an information return from the beginning (rules.md
/// B-19): a vendor record, the party's of that name in `setup.parties`
/// when there is one with the vendor role.
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct PayeeSpec {
@@ -141,6 +156,43 @@ struct PayeeSpec {
form: String,
}
/// One party in the directory (rules.md B-26, B-35).
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct PartySpec {
name: String,
/// Role keys (`member`, `vendor`, `customer`, `contractor`,
/// `financial_institution`, ...); `vendor` and `customer` give the
/// party its record (one of them at most).
roles: Vec<String>,
/// The liabilities, by code, the company owes the party itself
/// (rules.md B-35 item 6).
#[serde(default)]
creditor_of: Vec<String>,
/// Its TIN facts' last four digits (an SSN's).
#[serde(default)]
tin_last4: Option<String>,
/// Its W-9 facts' last four digits (a 1099 role's).
#[serde(default)]
w9_tin_last4: Option<String>,
/// The vault's copy of the TIN purged, the facts kept (needs
/// `tin_last4`).
#[serde(default)]
tin_purged: bool,
}
/// A party named on an entry, with its share: an `amount`, a `percent`,
/// or neither (derived from the entry: rules.md B-35).
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct ShareSpec {
party: String,
#[serde(default)]
amount: Option<String>,
#[serde(default)]
percent: Option<String>,
}
/// One event: an entry (`lines`), a reversal (`reverses`), a close
/// (`close`) or a bill (`bill`), exactly one of them.
#[derive(Deserialize)]
@@ -158,6 +210,10 @@ struct Event {
/// The payee (in `setup.payees`) the entry paid.
#[serde(default)]
payee: Option<String>,
/// The parties (in `setup.parties`) the entry is with, and their
/// shares.
#[serde(default)]
parties: Vec<ShareSpec>,
}
/// A bill from `vendor` for `amount`, dated and due on the event's date:
@@ -224,8 +280,13 @@ struct Expect {
from: Option<String>,
to: Option<String>,
as_of: Option<String>,
/// The entry an `entry` query asks about.
/// The entry an `entry` or `party_part` query asks about.
entry: Option<EntryId>,
/// The party a `party_part`, `party_totals` or `party_tin_last4`
/// query asks about (in `setup.parties`).
party: Option<String>,
/// `party_totals`' basis: `"cash"` or `"all"` (rules.md B-35 item 4).
basis: Option<String>,
/// The books after event N, not after the last.
after: Option<usize>,
/// The account a `rename_account` or `set_tax_category` command
@@ -412,15 +473,103 @@ struct Books<'a> {
currency: Currency,
/// The members, with their shares (zero when the example gives none).
members: Vec<MemberCapital>,
/// The members who are parties (`setup.parties` with the member
/// role), as the settings give them to party shares and the 1099.
party_members: Vec<PartyMember>,
/// The directory's parties, by name.
parties: BTreeMap<String, PartyId>,
/// The payees' vendor records, by name.
payees: BTreeMap<String, VendorId>,
/// The vendors bills came from, by name (a payee's record if one).
vendors: BTreeMap<String, VendorId>,
}
/// The example's directory (`setup.parties`), by name: each party with
/// its roles (a vendor or customer with its record), the liabilities it
/// is the creditor of, and its TIN and W-9 facts' last four.
fn add_parties(ex: &Example, l: &mut Ledger) -> BTreeMap<String, PartyId> {
let mut out = BTreeMap::new();
for p in &ex.setup.parties {
let what = format!("{} party {}", ex.id, p.name);
let roles: Vec<Role> = p
.roles
.iter()
.map(|r| Role::parse(r).unwrap_or_else(|| panic!("{what}: role {r:?}")))
.collect();
let record = |r: Role| roles.contains(&r);
let others: Vec<Role> = roles
.iter()
.copied()
.filter(|r| !matches!(r, Role::Vendor | Role::Customer))
.collect();
let id = match (record(Role::Vendor), record(Role::Customer)) {
(true, true) => panic!("{what}: a vendor or a customer, not both"),
(true, false) => {
let v = l
.add_vendor(&p.name)
.unwrap_or_else(|e| panic!("{what}: {e}"));
l.party_of_vendor(v).expect("a vendor's party")
}
(false, true) => {
let c = l
.add_customer(&p.name)
.unwrap_or_else(|e| panic!("{what}: {e}"));
l.party_of_customer(c).expect("a customer's party")
}
(false, false) => l
.add_party(&p.name, &others)
.unwrap_or_else(|e| panic!("{what}: {e}")),
};
if record(Role::Vendor) || record(Role::Customer) {
for r in &others {
l.set_party_role(id, *r, true)
.unwrap_or_else(|e| panic!("{what}: {e}"));
}
}
for code in &p.creditor_of {
let account = l
.chart()
.find_by_code(code)
.unwrap_or_else(|| panic!("{what}: no account {code}"))
.id;
l.set_party_creditor(id, account, true)
.unwrap_or_else(|e| panic!("{what}: creditor of {code}: {e}"));
}
assert!(
!p.tin_purged || p.tin_last4.is_some(),
"{what}: tin_purged needs tin_last4"
);
if let Some(last4) = &p.tin_last4 {
let facts = TinFacts {
tin_type: TinType::Ssn,
last4: last4.clone(),
vault_record: (!p.tin_purged).then(|| "0".repeat(64)),
purged: p.tin_purged.then(|| "purged: retention ended".into()),
};
l.set_party_tin(id, Some(facts))
.unwrap_or_else(|e| panic!("{what}: {e}"));
}
if let Some(last4) = &p.w9_tin_last4 {
let facts = W9Facts {
received: date("2026-01-01", &what),
entity_type: "individual/sole proprietor".into(),
exempt_payee_code: None,
fatca_code: None,
tin_last4: last4.clone(),
kept_at: "wanderbooks vault".into(),
vault_record: None,
};
l.set_party_w9(id, Some(facts))
.unwrap_or_else(|e| panic!("{what}: {e}"));
}
assert!(out.insert(p.name.clone(), id).is_none(), "{what}: twice");
}
out
}
impl<'a> Books<'a> {
/// The books an example sets up, before any event: its shape checked,
/// its accounts, fixed assets, members and payees.
/// its accounts, fixed assets, parties, members and payees.
fn new(ex: &'a Example) -> Self {
let s = &ex.setup;
let currency = Currency::new(&s.currency).unwrap();
@@ -508,7 +657,7 @@ impl<'a> Books<'a> {
.map(|m| MemberCapital {
name: m.name.clone(),
capital_account: id(&l, &m.capital_account),
distribution_account: None,
distribution_account: m.distributions_account.as_deref().map(|c| id(&l, c)),
share: m.share.as_deref().map_or(Decimal::ZERO, |x| {
let share = exact(x, &format!("{} {} share", ex.id, m.name));
assert!(share > Decimal::ZERO, "{}: {}'s share", ex.id, m.name);
@@ -520,14 +669,36 @@ impl<'a> Books<'a> {
let total: Decimal = members.iter().map(|m| m.share).sum();
assert_eq!(total, Decimal::ONE, "{}: the shares sum to 1", ex.id);
}
let parties = add_parties(ex, &mut l);
// A member who is a party (by name, with the member role), as the
// settings' members are (settings::Company::party_members).
let party_members: Vec<PartyMember> = members
.iter()
.filter_map(|m| {
let p = l.party_by_name(&m.name)?;
p.roles.contains(&Role::Member).then_some(PartyMember {
party: p.id,
share: m.share,
capital: m.capital_account,
distributions: m.distribution_account,
})
})
.collect();
let mut payees = BTreeMap::new();
for p in &s.payees {
let form = Form1099::parse(&p.form)
.filter(|_| p.form.starts_with("1099-"))
.unwrap_or_else(|| panic!("{}: payee {}: form {:?}", ex.id, p.name, p.form));
let v = l
.add_vendor(&p.name)
// A payee in the directory with the vendor role is that
// party's vendor record.
let v = match parties.get(&p.name).and_then(|&id| l.get_party(id)?.vendor) {
Some(v) => v,
None => l
.add_vendor(&p.name)
.unwrap_or_else(|e| panic!("{}: payee {}: {e}", ex.id, p.name)),
};
l.set_vendor_1099(v, Some(form), false)
.unwrap_or_else(|e| panic!("{}: payee {}: {e}", ex.id, p.name));
l.set_vendor_1099(v, Some(form), false).unwrap();
assert!(
payees.insert(p.name.clone(), v).is_none(),
"{}: payee {} twice",
@@ -540,6 +711,8 @@ impl<'a> Books<'a> {
l,
currency,
members,
party_members,
parties,
vendors: payees.clone(),
payees,
}
@@ -561,6 +734,10 @@ impl<'a> Books<'a> {
e.payee.is_none() || (e.reverses.is_none() && e.close.is_none() && e.bill.is_none()),
"{ex} event {n}: a reversal takes its original's payee, a bill names its vendor, and a close has none"
);
assert!(
e.parties.is_empty() || e.reverses.is_none(),
"{ex} event {n}: a reversal takes its original's parties"
);
let posted = match (&e.close, e.reverses, e.lines.is_empty()) {
(None, None, true) if e.bill.is_some() => {
let b = e.bill.as_ref().unwrap();
@@ -674,6 +851,29 @@ impl<'a> Books<'a> {
_ => panic!("{ex} event {n}: an event is one of lines, reverses, close or bill"),
};
assert_eq!(posted, n, "{ex}: event {n} must be entry #{n}");
if !e.parties.is_empty() {
let shares: Vec<PartyShare> = e
.parties
.iter()
.map(|sh| {
let party = *self.parties.get(&sh.party).unwrap_or_else(|| {
panic!("{ex} event {n}: party {:?} not in setup", sh.party)
});
let share = match (&sh.amount, &sh.percent) {
(None, None) => Share::Derived,
(Some(a), None) => Share::Amount(exact(a, ex)),
(None, Some(p)) => Share::Percent(exact(p, ex)),
_ => panic!("{ex} event {n}: a share is an amount or a percent"),
};
PartyShare { party, share }
})
.collect();
self.l
.set_entry_parties(n, &shares)
.unwrap_or_else(|err| panic!("{ex} event {n}: {err}"));
}
// The payee after the parties: a payee's party must be among
// several named (the vendor tag follows the parties).
if let Some(p) = &e.payee {
let v = *self
.payees
@@ -973,21 +1173,19 @@ fn check_profit_and_loss(ex: &Example, l: &Ledger, x: &Expect) {
}
}
/// Check one `vendor_1099_paid` expectation: each payee's payments out of
/// the cash (and financing) accounts from January 1 through `to`, a
/// payment card's left out, every payee listed (rules.md B-19, B-33).
fn check_vendor_1099_paid(
ex: &Example,
l: &Ledger,
payees: &BTreeMap<String, VendorId>,
x: &Expect,
) {
/// The 1099 report a `vendor_1099_paid` expectation asks for: the
/// calendar year of `to`, from January 1 through `to`, out of the cash
/// and financing accounts, an entry's shares resolved with the members
/// who are parties (rules.md B-19, B-33).
fn report_1099(b: &Books<'_>, x: &Expect) -> (NaiveDate, NaiveDate, ledger_core::Vendor1099Report) {
let (ex, l) = (b.ex, &b.l);
// A calendar-year count, never the fiscal default (rules.md B-30):
// both dates given.
assert!(
x.from.is_some() && x.to.is_some(),
"{}: vendor_1099_paid gives from and to",
ex.id
"{}: {} gives from and to",
ex.id,
x.query
);
let (from, to) = x.period(ex);
let year = chrono::Datelike::year(&to);
@@ -999,28 +1197,221 @@ fn check_vendor_1099_paid(
);
assert!(
!ex.setup.cash_accounts.is_empty(),
"{}: vendor_1099_paid needs cash_accounts (what payees are paid from)",
ex.id
"{}: {} needs cash_accounts (what payees are paid from)",
ex.id,
x.query
);
let sources = PaymentAccounts {
cash: codes(l, &ex.setup.cash_accounts, &ex.id),
financing: codes(l, &ex.setup.financing_accounts, &ex.id),
};
let r = vendor_1099_report_with_members(l, year, Some(to), None, &sources, &[])
let r = vendor_1099_report_with_members(l, year, Some(to), None, &sources, &b.party_members)
.unwrap_or_else(|e| panic!("{}: {e}", ex.id));
exactly(ex, x, &payees.keys().cloned().collect());
(from, to, r)
}
/// Check one `vendor_1099_paid` expectation: each payee's payments that
/// count toward its 1099 ([`report_1099`]), a payment card's left out,
/// every payee listed (rules.md B-19, B-33).
fn check_vendor_1099_paid(b: &Books<'_>, x: &Expect) {
let ex = b.ex;
let (from, to, r) = report_1099(b, x);
exactly(ex, x, &b.payees.keys().cloned().collect());
for (name, want) in x.figures(&ex.id) {
let want = exact(want, &format!("{} {name}", ex.id));
let got: Decimal = r
.vendors
.iter()
.filter(|v| v.vendor_id == payees[name])
.filter(|v| v.vendor_id == b.payees[name])
.map(|v| v.paid)
.sum();
assert_eq!(got, want, "{} 1099 paid [{from} .. {to}]: {name}", ex.id);
}
}
/// The money party shares resolve against: the cash accounts (every
/// asset account but receivables when none are named), the cards and the
/// financing accounts (rules.md B-35 item 5).
fn party_money(ex: &Example, l: &Ledger) -> MoneyAccounts {
ledger_core::parties::money_accounts(
l,
&codes(l, &ex.setup.cash_accounts, &ex.id),
&codes(l, &ex.setup.financing_accounts, &ex.id),
)
}
/// The party an expectation names.
fn expect_party(b: &Books<'_>, x: &Expect) -> PartyId {
let name = x
.party
.as_deref()
.unwrap_or_else(|| panic!("{}: {} names its party", b.ex.id, x.query));
*b.parties
.get(name)
.unwrap_or_else(|| panic!("{}: party {name:?} not in setup.parties", b.ex.id))
}
/// Check one `party_part` expectation: entry N's part for the party
/// (rules.md B-35): `resolved`, signed as a line on the party's own
/// account (debit-positive: paid or given positive, received negative),
/// the entry's `direction` (`paid`, `received` or `neither`), and what
/// of it moved as money (`cash`) and as property (`in_kind`); every one
/// given.
fn check_party_part(b: &Books<'_>, x: &Expect) {
let (ex, l) = (b.ex, &b.l);
assert!(
x.from.is_none() && x.to.is_none() && x.as_of.is_none(),
"{}: party_part asks about one entry, no dates",
ex.id
);
let n = x
.entry
.unwrap_or_else(|| panic!("{}: a party_part query names its entry", ex.id));
let party = expect_party(b, x);
let r = ledger_core::shares::resolve(
l,
n,
l.entry_parties(n),
&party_money(ex, l),
&b.party_members,
)
.unwrap_or_else(|why| panic!("{}: entry {n}'s shares: {why}", ex.id));
let part = r
.part(party)
.unwrap_or_else(|| panic!("{}: entry {n} doesn't name {:?}", ex.id, x.party));
let keys = ["resolved", "direction", "cash", "in_kind"];
exactly(ex, x, &keys.iter().map(|k| k.to_string()).collect());
let what = |k: &str| format!("{} entry {n} {:?}: {k}", ex.id, x.party);
for (key, want) in x.figures(&ex.id) {
if key == "direction" {
assert_eq!(r.target.direction.key(), want, "{}", what(key));
continue;
}
let got = match key.as_str() {
"resolved" => part.amount,
"cash" => part.cash,
_ => part.in_kind,
};
assert_eq!(got, exact(want, &what(key)), "{}", what(key));
}
}
/// Check one `party_totals` expectation: what was `paid` to and
/// `received` from the party from `from` through `to` on `basis` (`cash`:
/// money; `all`: money and property in kind), each entry's part on its
/// root original's side (rules.md B-35 item 4); both given.
fn check_party_totals(b: &Books<'_>, x: &Expect) {
let (ex, l) = (b.ex, &b.l);
assert!(
x.from.is_some() && x.to.is_some(),
"{}: party_totals gives from and to",
ex.id
);
let (from, to) = x.period(ex);
let basis = x
.basis
.as_deref()
.map(|k| Basis::parse(k).unwrap_or_else(|| panic!("{}: basis {k:?}", ex.id)))
.unwrap_or_else(|| panic!("{}: party_totals names its basis", ex.id));
let party = expect_party(b, x);
let a = ledger_core::parties::activity_with(
l,
party,
(from, to),
&party_money(ex, l),
&b.party_members,
);
assert!(
a.unresolved.is_empty(),
"{}: {:?}'s entries {:?} don't resolve",
ex.id,
x.party,
a.unresolved
);
let (paid, received) = a.totals(basis);
exactly(ex, x, &["paid".to_string(), "received".to_string()].into());
let f = x.figures(&ex.id);
let what = |k: &str| {
format!(
"{} {:?} [{from} .. {to}] {}: {k}",
ex.id,
x.party,
basis.key()
)
};
assert_eq!(paid, exact(&f["paid"], &what("paid")), "{}", what("paid"));
assert_eq!(
received,
exact(&f["received"], &what("received")),
"{}",
what("received")
);
}
/// Check one `party_tin_last4` expectation: the last four of the party's
/// TIN the books keep (its TIN facts', else its W-9's; `""` for none:
/// rules.md B-26).
fn check_party_tin_last4(b: &Books<'_>, x: &Expect) {
let ex = b.ex;
assert!(
x.from.is_none() && x.to.is_none() && x.as_of.is_none(),
"{}: party_tin_last4 takes no dates",
ex.id
);
let party = expect_party(b, x);
exactly(ex, x, &["tin_last4".to_string()].into());
let got =
b.l.get_party(party)
.and_then(|p| p.tin_last4())
.unwrap_or_default();
assert_eq!(
got,
x.figures(&ex.id)["tin_last4"],
"{}: {:?}'s TIN last four",
ex.id,
x.party
);
}
/// Check one `k1` expectation: the K-1 worksheet over the period (rules.md
/// B-13; Schedule K-1 Item L), `"<member>.<column>"` for the columns
/// `beginning`, `contributions_cash`, `contributions_property`,
/// `net_income`, `distributions`, `other` and `ending`; the figures named
/// are the example's choice. It needs `cash_accounts` (what is money
/// rather than property).
fn check_k1(ex: &Example, l: &Ledger, members: &[MemberCapital], x: &Expect) {
let (from, to) = x.period(ex);
assert!(
!members.is_empty() && members.iter().all(|m| m.share > Decimal::ZERO),
"{}: k1 needs the members and their shares",
ex.id
);
let cash = codes(l, &ex.setup.cash_accounts, &ex.id);
let rows = ledger_core::k1::k1_worksheet(l, from, to, members, &cash)
.unwrap_or_else(|e| panic!("{}: {e}", ex.id));
let mut got = BTreeMap::new();
for r in &rows {
for (column, v) in [
("beginning", r.beginning),
("contributions_cash", r.contributions_cash),
("contributions_property", r.contributions_property),
("net_income", r.net_income),
("distributions", r.distributions),
("other", r.other),
("ending", r.ending),
] {
got.insert(format!("{}.{column}", r.name), v);
}
}
for (key, want) in x.figures(&ex.id) {
let want = exact(want, &format!("{} {key}", ex.id));
let got = got
.get(key)
.unwrap_or_else(|| panic!("{}: k1 has no {key}", ex.id));
assert_eq!(*got, want, "{} K-1 [{from} .. {to}]: {key}", ex.id);
}
}
/// The formats appendix's queries, and the test (another crate's) that
/// checks them against the engine.
const FORMAT_QUERIES: [(&str, &str); 2] = [
@@ -1374,7 +1765,13 @@ fn check(b: &Books<'_>, x: &Expect) -> Option<&'static str> {
queries.join(" and ")
);
};
only(x.entry.is_some(), "entry", &["entry"]);
only(x.entry.is_some(), "entry", &["entry", "party_part"]);
only(
x.party.is_some(),
"party",
&["party_part", "party_totals", "party_tin_last4"],
);
only(x.basis.is_some(), "basis", &["party_totals"]);
only(
x.code.is_some(),
"code",
@@ -1419,10 +1816,14 @@ fn check(b: &Books<'_>, x: &Expect) -> Option<&'static str> {
"cash_flow" => check_cash_flow(ex, l, &b.members, x),
"trial_balance" => check_trial_balance(ex, l, x),
"profit_and_loss" => check_profit_and_loss(ex, l, x),
"vendor_1099_paid" => check_vendor_1099_paid(ex, l, &b.payees, x),
"vendor_1099_paid" => check_vendor_1099_paid(b, x),
"entry" => check_entry(ex, l, x),
"balances" => check_balances(ex, l, x),
"members_equity" => check_members_equity(ex, l, &b.members, x),
"k1" => check_k1(ex, l, &b.members, x),
"party_part" => check_party_part(b, x),
"party_totals" => check_party_totals(b, x),
"party_tin_last4" => check_party_tin_last4(b, x),
q => {
let &(_, by) = FORMAT_QUERIES
.iter()
@@ -1508,8 +1909,8 @@ fn every_bookspec_example_holds() {
}
// Each query the examples use ran: the cash flow's six examples at
// least, the as-of and 1099 examples', the members closes', the
// formats appendix's, and P3's default period, rename and tax
// category examples'.
// formats appendix's, and P3's default period, rename, tax category
// and parties examples'.
for (q, at_least) in [
("cash_flow", 6),
("trial_balance", 2),
@@ -1517,7 +1918,11 @@ fn every_bookspec_example_holds() {
("vendor_1099_paid", 3),
("entry", 8),
("balances", 4),
("members_equity", 5),
("members_equity", 10),
("k1", 2),
("party_part", 27),
("party_totals", 21),
("party_tin_last4", 5),
("iif_lines", 1),
("iif_changes", 1),
("period", 6),
@@ -1842,3 +2247,53 @@ fn a_command_with_values_is_refused() {
)
});
}
/// An event's parties must be in the setup's directory.
#[test]
#[should_panic(expected = "not in setup")]
fn a_party_not_in_the_setup_is_refused() {
run_edited("p3-party-signs-and-reversals.toml", |t| {
t.replacen(
"parties = [{ party = \"Tobin Harlow\" }]",
"parties = [{ party = \"Tobin Harlo\" }]",
1,
)
});
}
/// A party's part is checked for its direction as well as its figures:
/// a contribution read as paid fails.
#[test]
#[should_panic(expected = "entry 2 Some(\"Tobin Harlow\"): direction")]
fn a_party_parts_direction_is_checked() {
run_edited("p3-party-signs-and-reversals.toml", |t| {
t.replacen(
"resolved = \"-400.00\", direction = \"received\"",
"resolved = \"-400.00\", direction = \"paid\"",
1,
)
});
}
/// A reversal takes its original's parties: naming them again is
/// refused, never read as a second tag.
#[test]
#[should_panic(expected = "takes its original's parties")]
fn a_reversal_naming_parties_is_refused() {
run_edited("p3-party-signs-and-reversals.toml", |t| {
t.replacen(
"reverses = 4\n",
"reverses = 4\nparties = [{ party = \"Tobin Harlow\" }]\n",
1,
)
});
}
/// Party totals name their basis: cash and all differ for property.
#[test]
#[should_panic(expected = "party_totals names its basis")]
fn party_totals_without_a_basis_are_refused() {
run_edited("p3-party-signs-and-reversals.toml", |t| {
t.replacen("basis = \"all\"\n", "", 1)
});
}
+33
View File
@@ -315,4 +315,37 @@ proptest! {
prop_assert_eq!(fi.into_iter().map(dec).collect::<Vec<_>>(), fd);
prop_assert_eq!(ti.into_iter().map(dec).collect::<Vec<_>>(), td);
}
/// rules.md B-35: a party's target, and a reversal's, are the same on
/// i64 cents and on Decimal.
#[test]
fn party_target_agrees(
capital in prop::option::of((arb_cents(), 0i64..MAX_CENTS)),
net in arb_cents(),
own in arb_cents(),
debits in 0i64..MAX_CENTS,
) {
let as_dec = |t: kernel::PartyTarget<i64>| kernel::PartyTarget {
amount: dec(t.amount),
direction: t.direction,
cash: dec(t.cash),
in_kind: dec(t.in_kind),
};
let ti = kernel::party_target(capital, net, own, debits);
let td = kernel::party_target(
capital.map(|(e, p)| (dec(e), dec(p))),
dec(net),
dec(own),
dec(debits),
);
prop_assert_eq!(ti.map(as_dec), td);
// Never a negative zero on Decimal (party_target INVARIANT 6).
for t in td.iter().chain(td.and_then(kernel::reverse_target).iter()) {
for v in [t.amount, t.cash, t.in_kind] {
prop_assert!(!(v.is_zero() && v.is_sign_negative()), "{t:?}");
}
}
let ri = ti.and_then(kernel::reverse_target);
prop_assert_eq!(ri.map(as_dec), td.and_then(kernel::reverse_target));
}
}
+2 -3
View File
@@ -561,9 +561,8 @@ fn replayed_bases_and_shares_past_the_limit_are_accepted() {
));
b.ledger.replay_set_entry_parties(e, &shares).unwrap();
assert_eq!(b.ledger.entry_parties(e), &shares);
let money = ledger_core::parties::money_accounts(&b.ledger, &[b.cash]);
let why = ledger_core::shares::resolve(b.ledger.get_entry(e).unwrap(), &shares, &money, &[])
.unwrap_err();
let money = ledger_core::parties::money_accounts(&b.ledger, &[b.cash], &[]);
let why = ledger_core::shares::resolve(&b.ledger, e, &shares, &money, &[]).unwrap_err();
assert!(why.contains("too large to compute"), "{why}");
// Replay still checks what every version checked.
assert_eq!(
+137 -51
View File
@@ -132,6 +132,7 @@ fn bare_party(name: &str) -> Party {
tin: None,
vendor: None,
customer: None,
creditor_of: BTreeSet::new(),
}
}
@@ -1401,14 +1402,14 @@ fn entry_flow_takes_across_in_order_until_used_up() {
use ledger_core::parties::{entry_flow, money_accounts};
use ledger_core::{AccountType, Currency, Ledger, Money, Side};
let mut l = Ledger::new(Currency::USD);
let mut acct = |code: &str, t| l.add_account(code, code, t, None, false).unwrap();
let bank = acct("1000", AccountType::Asset);
let card_a = acct("2050", AccountType::Liability);
let card_b = acct("2060", AccountType::Liability);
let members = acct("2100", AccountType::Liability);
let capital = acct("3000", AccountType::Equity);
let fees = acct("6000", AccountType::Expense);
let money = money_accounts(&l, &[bank]);
let mut acct = |code: &str, name: &str, t| l.add_account(code, name, t, None, false).unwrap();
let bank = acct("1000", "Bank", AccountType::Asset);
let card_a = acct("2050", "Card A", AccountType::Liability);
let card_b = acct("2060", "Card B", AccountType::Liability);
let members = acct("2100", "Due to Members", AccountType::Liability);
let capital = acct("3000", "Capital", AccountType::Equity);
let fees = acct("6000", "Fees", AccountType::Expense);
let money = money_accounts(&l, &[bank], &[members]);
let e = l.create_entry(NaiveDate::from_ymd_opt(2026, 5, 1).unwrap(), "x");
for (a, side, amt) in [
(card_a, Side::Debit, "30.00"),
@@ -1437,11 +1438,15 @@ fn entry_flow_takes_across_in_order_until_used_up() {
);
}
/// Oracle: shares::share_target: an entry moving no money splits over its
/// total debits, whatever its credits (a draft not yet balanced).
/// Oracle: shares::target DEFINITIONS, case 3 (rules.md B-35): members
/// named on an entry moving no money split what moved equity, E (here
/// −4.00 of a draft not yet balanced), received, in kind (the equipment's
/// 10.00, at most |E|); case 5: with a party named who isn't a member,
/// the total debits, direction neither, counting nothing.
#[test]
fn a_share_target_without_money_is_the_debits() {
fn a_share_target_without_money_is_the_capital_or_the_debits() {
use ledger_core::parties::money_accounts;
use ledger_core::shares::{Direction, Target, target};
use ledger_core::{AccountType, Currency, Ledger, Money, Side};
let mut l = Ledger::new(Currency::USD);
let bank = l
@@ -1453,7 +1458,9 @@ fn a_share_target_without_money_is_the_debits() {
let capital = l
.add_account("3000", "Capital", AccountType::Equity, None, false)
.unwrap();
let money = money_accounts(&l, &[bank]);
let member = l.add_party("Tobin Harlow", &[Role::Member]).unwrap();
let vendor = l.add_party("Riley Chen", &[Role::Contractor]).unwrap();
let money = money_accounts(&l, &[bank], &[]);
let e = l.create_entry(NaiveDate::from_ymd_opt(2026, 5, 1).unwrap(), "x");
l.add_line(e, equip, Side::Debit, Money::new(d("10.00"), Currency::USD))
.unwrap();
@@ -1465,8 +1472,25 @@ fn a_share_target_without_money_is_the_debits() {
)
.unwrap();
assert_eq!(
ledger_core::shares::share_target(l.get_entry(e).unwrap(), &money),
Ok(d("10.00"))
target(&l, e, &[PartyShare::whole(member)], &money),
Ok(Target {
amount: d("-4.00"),
direction: Direction::Received,
cash: Decimal::ZERO,
in_kind: d("-4.00"),
counts_as: Direction::Received,
})
);
let both = [PartyShare::whole(member), PartyShare::whole(vendor)];
assert_eq!(
target(&l, e, &both, &money),
Ok(Target {
amount: d("10.00"),
direction: Direction::Neither,
cash: Decimal::ZERO,
in_kind: Decimal::ZERO,
counts_as: Direction::Neither,
})
);
}
@@ -1484,7 +1508,7 @@ fn derived_member_shares_net_their_capital_lines() {
let cap_b = acct("3001", AccountType::Equity);
let a = l.add_party("A", &[Role::Member]).unwrap();
let b = l.add_party("B", &[Role::Member]).unwrap();
let money = money_accounts(&l, &[bank]);
let money = money_accounts(&l, &[bank], &[]);
let member = |party, capital| ledger_core::PartyMember {
party,
share: d("0.5"),
@@ -1506,9 +1530,13 @@ fn derived_member_shares_net_their_capital_lines() {
s.share = ledger_core::Share::Derived;
s
});
let r = ledger_core::shares::resolve(&l, e, &shares, &money, &members).unwrap();
assert_eq!(
ledger_core::shares::resolve(l.get_entry(e).unwrap(), &shares, &money, &members),
Ok(vec![(a, d("-70.00")), (b, d("-30.00"))])
r.parts
.iter()
.map(|p| (p.party, p.amount))
.collect::<Vec<_>>(),
vec![(a, d("-70.00")), (b, d("-30.00"))]
);
}
@@ -1520,16 +1548,17 @@ fn entry_flow_scenarios() {
use ledger_core::{AccountType, Currency, Ledger, Money, Side};
use rust_decimal::Decimal;
let mut l = Ledger::new(Currency::USD);
let mut acct = |code: &str, t| l.add_account(code, code, t, None, false).unwrap();
let bank = acct("1000", AccountType::Asset);
let savings = acct("1010", AccountType::Asset);
let equip = acct("1500", AccountType::Asset);
let card = acct("2050", AccountType::Liability);
let members = acct("2100", AccountType::Liability);
let loan = acct("2500", AccountType::Liability);
let capital = acct("3000", AccountType::Equity);
let exp = acct("6000", AccountType::Expense);
let money = money_accounts(&l, &[bank, savings]);
let mut acct = |code: &str, name: &str, t| l.add_account(code, name, t, None, false).unwrap();
let bank = acct("1000", "Bank", AccountType::Asset);
let savings = acct("1010", "Savings", AccountType::Asset);
let equip = acct("1500", "Equipment", AccountType::Asset);
let card = acct("2050", "Business Card", AccountType::Liability);
let members = acct("2100", "Due to Members", AccountType::Liability);
let loan = acct("2500", "Loan", AccountType::Liability);
let accrued = acct("2200", "Accrued Expenses", AccountType::Liability);
let capital = acct("3000", "Capital", AccountType::Equity);
let exp = acct("6000", "Expenses", AccountType::Expense);
let money = money_accounts(&l, &[bank, savings], &[members, loan]);
let d = chrono::NaiveDate::from_ymd_opt(2026, 5, 1).unwrap();
let mut case = |lines: &[(u64, Side, &str)]| {
let e = l.create_entry(d, "x");
@@ -1619,6 +1648,13 @@ fn entry_flow_scenarios() {
(card, C, "50.00"),
]);
assert_eq!(f.net, dec("350.00"));
// 8 (defect 24). An accrual through a liability that isn't money
// moves none; its settlement is the payment, counted once.
let f = case(&[(exp, D, "40.00"), (accrued, C, "40.00")]);
assert_eq!(f.net, Decimal::ZERO);
let f = case(&[(accrued, D, "40.00"), (bank, C, "40.00")]);
assert_eq!(f.net, dec("40.00"));
assert_eq!(f.what_for, vec![(accrued, dec("40.00"))]);
}
// ------------------------------------------------------- party shares
@@ -1729,25 +1765,33 @@ mod shares {
.collect()
}
/// The money accounts: the bank, and 2100 as the members' financing
/// (`books.financing_accounts`).
fn money(b: &Books) -> ledger_core::parties::MoneyAccounts {
money_accounts(&b.l, &[b.bank], &[b.due])
}
fn resolved(b: &Books, e: EntryId) -> Result<Vec<(PartyId, Decimal)>, String> {
let money = money_accounts(&b.l, &[b.bank]);
resolve(
b.l.get_entry(e).unwrap(),
b.l.entry_parties(e),
&money,
&b.members,
)
resolve(&b.l, e, b.l.entry_parties(e), &money(b), &b.members)
.map(|r| r.parts.iter().map(|p| (p.party, p.amount)).collect())
}
fn year() -> (chrono::NaiveDate, chrono::NaiveDate) {
(date(2026, 1, 1), date(2026, 12, 31))
}
/// (paid, received) on basis cash.
fn paid_received(b: &Books, p: PartyId) -> (Decimal, Decimal) {
let a = activity_with(&b.l, p, year(), &[b.bank], &b.members);
let a = activity_with(&b.l, p, year(), &money(b), &b.members);
(a.paid, a.received)
}
/// (paid, received) on basis all.
fn paid_received_all(b: &Books, p: PartyId) -> (Decimal, Decimal) {
activity_with(&b.l, p, year(), &money(b), &b.members)
.totals(ledger_core::parties::Basis::All)
}
/// SCENARIO 1: a reimbursement to both members, derived, 50/50.
#[test]
fn reimbursing_both_members_splits_by_member_share() {
@@ -1793,8 +1837,11 @@ mod shares {
assert_eq!(paid_received(&b, b.mo), (Decimal::ZERO, d("100.00")));
}
/// SCENARIO 3: property contributed moves no money; the shares are of
/// the total, and activity counts nothing.
/// SCENARIO 3 (defect 14, rules.md B-35): property contributed moves
/// no money; each member's part is what moved their capital, signed
/// as their own account shows it: received, in kind. Basis cash
/// counts nothing; basis all counts it. (Before P3 this read +500.00
/// each, the sign of a payment.)
#[test]
fn property_contribution_resolves_against_the_total() {
let mut b = books();
@@ -1810,14 +1857,52 @@ mod shares {
.unwrap();
assert_eq!(
resolved(&b, e).unwrap(),
vec![(b.tobin, d("500.00")), (b.mo, d("500.00"))]
vec![(b.tobin, d("-500.00")), (b.mo, d("-500.00"))]
);
let r = resolve(&b.l, e, b.l.entry_parties(e), &money(&b), &b.members).unwrap();
assert_eq!(r.target.direction, ledger_core::shares::Direction::Received);
assert!(
r.parts
.iter()
.all(|p| p.cash.is_zero() && p.in_kind == d("-500.00"))
);
assert_eq!(paid_received(&b, b.tobin), (Decimal::ZERO, Decimal::ZERO));
assert_eq!(paid_received_all(&b, b.tobin), (Decimal::ZERO, d("500.00")));
assert_eq!(paid_received_all(&b, b.mo), (Decimal::ZERO, d("500.00")));
}
/// Capital lines that don't account for the whole flow are refused.
/// INVARIANT 10: capital lines that don't account for the whole
/// movement of equity (here another equity account's line) are
/// refused, and the entry is listed as unresolved.
#[test]
fn capital_lines_short_of_the_flow_are_refused() {
fn capital_lines_short_of_the_target_are_refused() {
let mut b = books();
let other =
b.l.add_account("3500", "Opening Equity", AccountType::Equity, None, false)
.unwrap();
let e = post(
&mut b.l,
&[
(b.bank, Side::Debit, "300.00"),
(b.cap_s, Side::Credit, "200.00"),
(other, Side::Credit, "100.00"),
],
);
b.l.set_entry_parties(e, &derived(&[b.tobin, b.mo]))
.unwrap();
assert!(resolved(&b, e).is_err());
let a = activity_with(&b.l, b.tobin, year(), &money(&b), &b.members);
assert!(a.unresolved.contains(&e));
assert_eq!((a.paid, a.received), (Decimal::ZERO, Decimal::ZERO));
}
/// rules.md B-35 item 3 (capital first): a members' entry counts what
/// moved their capital, not other money on it: 300.00 in, 200.00 of it
/// Tobin's capital and 100.00 an expense refunded, is Tobin's 200.00
/// received (cash) and nothing of Morgan's. (Before P3 the 300.00 net
/// was the target and the capital lines fell short of it: refused.)
#[test]
fn a_members_entry_counts_what_moved_their_capital() {
let mut b = books();
let e = post(
&mut b.l,
@@ -1829,10 +1914,12 @@ mod shares {
);
b.l.set_entry_parties(e, &derived(&[b.tobin, b.mo]))
.unwrap();
assert!(resolved(&b, e).is_err());
let a = activity_with(&b.l, b.tobin, year(), &[b.bank], &b.members);
assert!(a.unresolved.contains(&e));
assert_eq!((a.paid, a.received), (Decimal::ZERO, Decimal::ZERO));
assert_eq!(
resolved(&b, e).unwrap(),
vec![(b.tobin, d("-200.00")), (b.mo, Decimal::ZERO)]
);
assert_eq!(paid_received(&b, b.tobin), (Decimal::ZERO, d("200.00")));
assert_eq!(paid_received(&b, b.mo), (Decimal::ZERO, Decimal::ZERO));
}
/// SCENARIO 4: percent rounding, remainder to the first party.
@@ -1907,8 +1994,7 @@ mod shares {
// With no members passed (plain activity), two members can't derive.
b.l.set_entry_parties(e, &derived(&[b.tobin, b.mo]))
.unwrap();
let money = money_accounts(&b.l, &[b.bank]);
assert!(resolve(b.l.get_entry(e).unwrap(), b.l.entry_parties(e), &money, &[]).is_err());
assert!(resolve(&b.l, e, b.l.entry_parties(e), &money(&b), &[]).is_err());
// A sole party needs no members.
b.l.set_entry_parties(e, &derived(&[b.acme])).unwrap();
assert_eq!(resolved(&b, e).unwrap(), vec![(b.acme, d("10.00"))]);
@@ -2147,7 +2233,7 @@ mod shares {
],
);
b.l.set_entry_party(e, Some(b.acme)).unwrap();
let a = ledger_core::parties::activity(&b.l, b.acme, year(), &[b.bank]);
let a = ledger_core::parties::activity(&b.l, b.acme, year(), &money(&b));
assert_eq!(a.paid, d("12.34"));
assert!(a.entries.contains(&e));
}
@@ -2168,14 +2254,14 @@ mod shares {
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]);
let act = ledger_core::parties::activity(&b.l, b.acme, year(), &money(&b));
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]);
let act = ledger_core::parties::activity(&b.l, b.acme, year(), &money(&b));
assert_eq!((act.paid, act.received), (Decimal::ZERO, Decimal::ZERO));
}
@@ -2255,8 +2341,8 @@ mod shares {
b.l.set_entry_parties(e, &derived(&[b.tobin, b.mo])).unwrap();
let r = resolved(&b, e).unwrap();
prop_assert_eq!(r, vec![(b.tobin, -a), (b.mo, -c)]);
let sa = activity_with(&b.l, b.tobin, year(), &[b.bank], &b.members);
let sk = activity_with(&b.l, b.mo, year(), &[b.bank], &b.members);
let sa = activity_with(&b.l, b.tobin, year(), &money(&b), &b.members);
let sk = activity_with(&b.l, b.mo, year(), &money(&b), &b.members);
prop_assert_eq!(sa.received + sk.received, total);
}
}
@@ -0,0 +1,8 @@
# Seeds for failure cases proptest has generated in the past. It is
# automatically read and these particular cases re-run before any
# novel cases are generated.
#
# It is recommended to check this file in to source control so that
# everyone who runs the test benefits from these saved cases.
cc 816160434c103e96360af20c33f0f16533f84b4c6599ea517ce6b1569139411f # shrinks to lines = [(0, Debit, 1), (1, Credit, 1)], who = Vendor, owed = []
cc 38876dbe146abc3025abf7a96f9addc1de590a95de893435df024c709cb0672e # shrinks to steps = [(true, 0, false)]
File diff suppressed because it is too large. Load diff
@@ -199,7 +199,16 @@ fn a_draft_of_decimals_largest_is_refused_where_summed() {
let q = l
.add_party("Beta", &[ledger_core::Role::Contractor])
.unwrap();
let money = ledger_core::parties::money_accounts(&l, &[cash]);
let capital = l
.add_account("3000", "Capital", AccountType::Equity, None, false)
.unwrap();
let member = l
.add_party("Tobin Harlow", &[ledger_core::Role::Member])
.unwrap();
let money = ledger_core::parties::money_accounts(&l, &[cash], &[]);
let resolve = |l: &Ledger, e, shares: &[PartyShare]| {
ledger_core::shares::resolve(l, e, shares, &money, &[])
};
for (dr, cr) in [(rent, cash), (rent, card), (card, cash), (cash, card)] {
let e = l.create_entry(d(2026, 3, 1), "huge");
for side in [Side::Debit, Side::Debit] {
@@ -211,12 +220,31 @@ fn a_draft_of_decimals_largest_is_refused_where_summed() {
.unwrap();
let entry = l.get_entry(e).unwrap();
assert!(too_large(ledger_core::parties::entry_flow(entry, &money)));
assert!(too_large(ledger_core::shares::share_target(entry, &money)));
let why =
ledger_core::shares::resolve(entry, &[PartyShare::whole(p)], &money, &[]).unwrap_err();
assert!(too_large(ledger_core::shares::target(
&l,
e,
&[PartyShare::whole(p)],
&money
)));
let why = resolve(&l, e, &[PartyShare::whole(p)]).unwrap_err();
assert!(why.contains("too large to compute"), "{why}");
assert_eq!(l.post_entry(e).unwrap_err(), LedgerError::AmountOutOfRange);
}
// A member's capital moved past exact arithmetic (rules.md B-35, case
// 3, B-28): the target is refused, never a panic.
let e = l.create_entry(d(2026, 3, 1), "huge capital");
for _ in 0..2 {
l.replay_add_line(e, cash, Side::Debit, usd(Decimal::MAX))
.unwrap();
l.replay_add_line(e, capital, Side::Credit, usd(Decimal::MAX))
.unwrap();
}
assert!(too_large(ledger_core::shares::target(
&l,
e,
&[PartyShare::whole(member)],
&money
)));
// One line of Decimal's largest flows, and its share resolves whole.
let e = l.create_entry(d(2026, 3, 1), "one huge");
l.replay_add_line(e, rent, Side::Debit, usd(Decimal::MAX))
@@ -228,16 +256,17 @@ fn a_draft_of_decimals_largest_is_refused_where_summed() {
ledger_core::parties::entry_flow(entry, &money).unwrap().net,
Decimal::MAX
);
let r = resolve(&l, e, &[PartyShare::whole(p)]).unwrap();
assert_eq!(
ledger_core::shares::resolve(entry, &[PartyShare::whole(p)], &money, &[]).unwrap(),
[(p, Decimal::MAX)]
(r.parts[0].party, r.parts[0].amount, r.parts[0].cash),
(p, Decimal::MAX, Decimal::MAX)
);
// A percent of it: the product is past exact arithmetic.
let halves = [p, q].map(|party| PartyShare {
party,
share: ledger_core::shares::Share::Percent(Decimal::from(50)),
});
let why = ledger_core::shares::resolve(entry, &halves, &money, &[]).unwrap_err();
let why = resolve(&l, e, &halves).unwrap_err();
assert!(why.contains("too large to compute"), "{why}");
}