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:
52 files changed
+7535
-560
No files matched your search
@@ -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)
|
||||
});
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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!(
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user