Files
wanderbooks/crates/ledger-core/tests/spec_party_totals.rs
T
swaitsandClaude Opus 5.5 528c070605 feat: invoices and bills take a project, line by line (B-37 item 8)
A client project's invoiced revenue and a contractor's billed cost could
never count toward the project: books.create_invoice and books.create_bill
posted at once with untagged lines, and only a draft's line takes a class.
Job costing (B-37 item 3) collects a job's billings and the supplier
invoices for its direct costs on its cost sheet, so each document line
now takes an optional project.

The owner decided it on 2026-10-22 (plan D-43, rules.md B-37 item 8):
each line takes one project or none; a document spanning N projects is N
lines, each with its own amount; no percentage split, so every figure is
exact; a cost truly shared among projects names none on the document
and reaches projects only as entries of their own under the company's
monthly allocation convention, when books.conventions has one (otherwise
it stays untagged and the agent asks the human). The reasoning, stated
in B-37: job costing charges a job the costs traceable to it, whole, and
reaches shared costs only by allocation on a stated basis. ASC
340-40-25-7 counts as directly related both the costs traceable to a
contract (25-7(a), (b), (d), (e)) and allocations of costs that relate
directly to it (25-7(c)); 25-8 expenses general and administrative cost.
The books carry the first whole on the document's line and the second as
the allocation convention's own entries; neither is a percentage on the
document (sources/fasb-asc.md, 25-7 and 25-8 added). The handbook's
projects rule and the line's class parameter say the same, and no
handbook sentence lets a shared cost be split across a document's lines.

The accounting: the document's entry posts one revenue (an invoice's) or
expense (a bill's) line per project its lines name, in the order each is
first named, each the sum of that project's lines, and the untagged
lines' sum as one more line (arap::document_parts, conservation checked
by a proptest). A document naming no project therefore posts exactly the
one line every document posted before. The receivable or payable line is
never tagged: it is a balance-sheet line, which item 3 counts nothing;
the payments (receive_payment, pay_bill) move cash against it and take no
project either. Nothing but the projects moves: the P&L, the 1099
worksheet (payments made, B-19) and party totals (what moved the money,
B-35) read the same with or without the tags (twin-books test); the
projects with the untagged lines still partition the P&L.

- ledger-core: DocumentLine.class_id (serde-omitted when none, so old
  documents serialize as before); create_invoice / create_bill split the
  revenue or expense side by project and tag it; an unknown project is
  refused whole (atomically).
- ledger-store: a document line's project is journaled as class_id on
  invoice.created / bill.created, and the entry's line as
  entry.line_class_set right after its entry.line_added (the entry.posted
  lines carry class_id, as a hand-built entry's do). Behind a NEW journal
  feature, document_line_projects (KNOWN_FEATURES, declared by new books,
  asked for before the first journaled line, not even the control
  accounts written on refusal); not `projects`, which the live books
  already declared: the owner consents again at the next upgrade. A
  malformed class_id is corrupt; null or absent is none. Untagged
  documents journal byte for byte as before; old journals replay
  unchanged.
- ledger-mcp: each line takes class (id or name, resolved like
  add_line's, every one before anything is written); the result gains
  notes for a finished or abandoned project, and on an invoice for an
  on-spec project (is there now an agreement? on spec a hoped-for
  payment is no receivable, B-37 item 4), tagged anyway; books lacking
  the feature refuse with format_upgrade_pending. books.get_entry shows a
  line's class_id and project. The handbook's "an invoice or bill takes no
  project yet" caveat now depends on the books: gone where the feature is
  declared, an "until their format upgrade" note where it isn't.
- ledger-gui: an invoice's or bill's page (its entry's) lists the
  document's lines, each project a link that opens it; the demo's client
  invoices are tagged to "Client work", whose snapshot is now its revenue
  less its contractors. Looking at the page in a narrow window found a
  tagged line's tag pushing the amounts past the pane (any tagged entry):
  the name is now cut to leave the tag room, the tag itself at most half
  the cell.
- bookspec: an `invoice` event, and `lines` with `project` on a bill or
  an invoice; example projects-invoice-and-bill-lines-count-by-project,
  worked by hand, both projects client work (Zeta 3,500.00 - 1,200.00 =
  2,300.00; Harbor 200.00;
  untagged -1,750.00; together the P&L's 750.00; Dana's 1099 payments
  1,250.00).
- fuzz: fuzz_store_replay can declare journal features.
- Tests: core, store, MCP, viewer, the P2-served cutover fixture and a
  viewer/server parity test (its month lists the same length, not
  empty); a tagged document's reversal nets its projects to zero, live
  and replayed; books.get_entry's class_id; the handbook served as a
  resource both ways; each fix broken to confirm its test fails
  (docs/testing.md). Docs: rules.md B-27 and B-37 items 3 and 8,
  sources/fasb-asc.md, plan/served.md D-43,
  mcp-interface.md, testing.md, parity.md, plan/STATUS.md.

No proof changed (no kernel arithmetic: document_parts sums with the
existing document_total).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
2026-10-22 19:28:40 -06:00

1836 lines
68 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! P3 defects 14, 24 and 11: a party's part of an entry, a party's
//! totals, and the directory's TIN last four.
//!
//! Oracles: rules.md B-35 (a party's part, signed as a line on its own
//! account, with its direction and its money and property; party totals
//! on basis cash or all; a member's entry counts what moved their capital;
//! money is cash, cards, the members' financing and loans; a liability
//! credit never counts as paying that liability's own creditor), B-9 (a
//! correction is the opposite entry), B-13 and the K-1 worksheet (k1
//! INVARIANT 3: what is property), B-26 (a TIN's last four), and the spec
//! blocks of `ledger_core::shares`, `ledger_core::parties` (`flow`,
//! `activity_with`, `Party::tin_last4`) and `ledger_core::kernel`
//! (`party_target`, `reverse_target`). Every expected value is worked by
//! hand from those texts and the fixture's lines; nothing is read back
//! from the code under test.
//!
//! The fixture is the shape of the first company's books at the 2026-10-16
//! audit (findings F36, F37), anonymized, its names and every amount made
//! up: two members, Tobin Harlow and Morgan Ellis, 50/50, a bank account, a
//! card, the members' financing account, equipment, prepaid expenses.
use std::str::FromStr;
use chrono::NaiveDate;
use ledger_core::k1::k1_detail;
use ledger_core::kernel::{self, Direction, PartyTarget};
use ledger_core::parties::{Basis, MoneyAccounts, activity_with, entry_flow, flow, money_accounts};
use ledger_core::reports::{MemberCapital, members_equity};
use ledger_core::shares::{Part, PartyShare, Share, Target, resolve, target};
use ledger_core::{
AccountId, AccountType, Currency, DocumentLine, EntryId, Ledger, LedgerError, Money,
PartyField, PartyId, PartyMember, Role, Side, TinFacts, TinType, W9Facts,
};
use proptest::prelude::*;
use rust_decimal::Decimal;
fn d(s: &str) -> Decimal {
Decimal::from_str(s).expect("decimal literal")
}
fn date(m: u32, day: u32) -> NaiveDate {
NaiveDate::from_ymd_opt(2026, m, day).expect("a 2026 date")
}
fn year() -> (NaiveDate, NaiveDate) {
(date(1, 1), date(12, 31))
}
/// Fernhill Labs LLC's chart and directory.
struct Books {
l: Ledger,
cash: AccountId,
prepaid: AccountId,
equip: AccountId,
card: AccountId,
due: AccountId,
accrued: AccountId,
deferred: AccountId,
note: AccountId,
/// Distributions declared and not yet paid: a liability that isn't
/// money (not a card, not a financing account).
dist_payable: AccountId,
/// Gift cards the company sold: deferred revenue, though its name says
/// "card".
gift: AccountId,
accum: AccountId,
cap_t: AccountId,
cap_m: AccountId,
dist_t: AccountId,
dist_m: AccountId,
revenue: AccountId,
org: AccountId,
hosting: AccountId,
fees: AccountId,
tobin: PartyId,
morgan: PartyId,
/// A vendor (it has a vendor record): Northwind Filings.
vendor: PartyId,
vendor_id: u64,
/// The card's issuer: Harbor Bank.
issuer: PartyId,
/// A customer (it has a customer record): Riley Chen.
customer: PartyId,
customer_id: u64,
}
fn books() -> Books {
let mut l = Ledger::new(Currency::USD);
let mut add = |code: &str, name: &str, t| l.add_account(code, name, t, None, false).unwrap();
let cash = add("1000", "Checking", AccountType::Asset);
let prepaid = add("1300", "Prepaid Expenses", AccountType::Asset);
let equip = add("1500", "Equipment", AccountType::Asset);
let card = add("2050", "Business Card", AccountType::Liability);
let due = add("2100", "Due to Members", AccountType::Liability);
let accrued = add("2200", "Accrued Expenses", AccountType::Liability);
let deferred = add("2400", "Deferred Revenue", AccountType::Liability);
let note = add("2500", "Note Payable", AccountType::Liability);
let dist_payable = add("2150", "Distributions Payable", AccountType::Liability);
let gift = add("2410", "Gift Card Liability", AccountType::Liability);
let accum = add(
"1510",
"Accumulated Depreciation — Equipment",
AccountType::Asset,
);
let cap_t = add("3000", "Capital — Tobin Harlow", AccountType::Equity);
let cap_m = add("3001", "Capital — Morgan Ellis", AccountType::Equity);
let dist_t = add("3100", "Distributions — Tobin Harlow", AccountType::Equity);
let dist_m = add("3101", "Distributions — Morgan Ellis", AccountType::Equity);
let revenue = add("4000", "Revenue", AccountType::Revenue);
let org = add("6000", "Organizational Costs", AccountType::Expense);
let hosting = add("6050", "Domain & Hosting", AccountType::Expense);
let fees = add("6400", "Bank Fees", AccountType::Expense);
let tobin = l.add_party("Tobin Harlow", &[Role::Member]).unwrap();
let morgan = l.add_party("Morgan Ellis", &[Role::Member]).unwrap();
let vendor_id = l.add_vendor("Northwind Filings").unwrap();
let vendor = l.party_of_vendor(vendor_id).unwrap();
let issuer = l
.add_party("Harbor Bank", &[Role::FinancialInstitution])
.unwrap();
let customer_id = l.add_customer("Riley Chen").unwrap();
let customer = l.party_of_customer(customer_id).unwrap();
Books {
l,
cash,
prepaid,
equip,
card,
due,
accrued,
deferred,
note,
dist_payable,
gift,
accum,
cap_t,
cap_m,
dist_t,
dist_m,
revenue,
org,
hosting,
fees,
tobin,
morgan,
vendor,
vendor_id,
issuer,
customer,
customer_id,
}
}
impl Books {
/// The money accounts: the bank, the card (by its name), and the
/// members' financing and the note (`books.financing_accounts`).
fn money(&self) -> MoneyAccounts {
money_accounts(&self.l, &[self.cash], &[self.due, self.note])
}
/// The company's members, as the settings give them.
fn members(&self) -> Vec<PartyMember> {
[
(self.tobin, self.cap_t, self.dist_t),
(self.morgan, self.cap_m, self.dist_m),
]
.map(|(party, capital, distributions)| PartyMember {
party,
share: d("0.5"),
capital,
distributions: Some(distributions),
})
.to_vec()
}
/// The members as the statement of members' equity and the K-1
/// worksheet take them.
fn capitals(&self) -> Vec<MemberCapital> {
[
("Tobin Harlow", self.cap_t, self.dist_t),
("Morgan Ellis", self.cap_m, self.dist_m),
]
.map(|(name, capital_account, distributions)| MemberCapital {
name: name.into(),
capital_account,
distribution_account: Some(distributions),
share: d("0.5"),
})
.to_vec()
}
/// Post `lines` on `day`, tagged with `shares`.
fn post(
&mut self,
day: NaiveDate,
lines: &[(AccountId, Side, &str)],
shares: &[PartyShare],
) -> EntryId {
let e = self.l.try_create_entry(day, "x").unwrap();
for (a, side, v) in lines {
self.l
.add_line(e, *a, *side, Money::new(d(v), Currency::USD))
.unwrap();
}
self.l.post_entry(e).unwrap();
if !shares.is_empty() {
self.l.set_entry_parties(e, shares).unwrap();
}
e
}
fn reverse(&mut self, e: EntryId, day: NaiveDate) -> EntryId {
self.l.reverse_entry(e, day, "reversed").unwrap()
}
/// Entry `e`'s target and parts, as books.get_entry resolves them.
fn resolved(&self, e: EntryId) -> (Target, Vec<Part>) {
let r = resolve(
&self.l,
e,
self.l.entry_parties(e),
&self.money(),
&self.members(),
)
.unwrap_or_else(|why| panic!("entry {e}: {why}"));
(r.target, r.parts)
}
/// `party`'s (paid, received) over 2026 on `basis`.
fn totals(&self, party: PartyId, basis: Basis) -> (Decimal, Decimal) {
activity_with(&self.l, party, year(), &self.money(), &self.members()).totals(basis)
}
}
fn whole(p: PartyId) -> PartyShare {
PartyShare::whole(p)
}
fn amounts(ps: &[(PartyId, &str)]) -> Vec<PartyShare> {
ps.iter()
.map(|&(party, v)| PartyShare {
party,
share: Share::Amount(d(v)),
})
.collect()
}
/// A part: (party, amount, cash, in kind).
fn part(party: PartyId, amount: &str, cash: &str, in_kind: &str) -> Part {
Part {
party,
amount: d(amount),
cash: d(cash),
in_kind: d(in_kind),
}
}
use Side::{Credit as Cr, Debit as Dr};
/// The audit's reproduction (F36, F37), anonymized, with what each row
/// should say (rules.md B-35): `resolved` signed as a line on the party's
/// own account, a direction, and what of it was money or property; a
/// reversal the exact opposite of what it reverses; totals on both bases,
/// labelled.
struct Audit {
b: Books,
/// (entry, the parts it should resolve to, its direction).
rows: Vec<(EntryId, Vec<Part>, Direction)>,
}
fn audit() -> Audit {
let mut b = books();
let (t, m, v) = (b.tobin, b.morgan, b.vendor);
let mut rows = Vec::new();
// 1. A member pays the filing fee for the company (through 2100),
// tagged with the vendor: paid to the vendor, in money.
let e = b.post(
date(10, 4),
&[(b.org, Dr, "64.00"), (b.due, Cr, "64.00")],
&[whole(v)],
);
rows.push((e, vec![part(v, "64.00", "64.00", "0")], Direction::Paid));
// 4. Each member's cash contribution: received, money.
let e = b.post(
date(10, 5),
&[(b.cash, Dr, "400.00"), (b.cap_t, Cr, "400.00")],
&[whole(t)],
);
rows.push((
e,
vec![part(t, "-400.00", "-400.00", "0")],
Direction::Received,
));
let e = b.post(
date(10, 5),
&[(b.cash, Dr, "400.00"), (b.cap_m, Cr, "400.00")],
&[whole(m)],
);
rows.push((
e,
vec![part(m, "-400.00", "-400.00", "0")],
Direction::Received,
));
// 8. Reimbursing both members: paid, money.
let e = b.post(
date(10, 8),
&[(b.due, Dr, "186.50"), (b.cash, Cr, "186.50")],
&amounts(&[(t, "93.25"), (m, "93.25")]),
);
rows.push((
e,
vec![
part(t, "93.25", "93.25", "0"),
part(m, "93.25", "93.25", "0"),
],
Direction::Paid,
));
// 9. Hosting a member paid for: paid to the vendor.
let e = b.post(
date(10, 9),
&[(b.hosting, Dr, "142.30"), (b.due, Cr, "142.30")],
&[whole(v)],
);
rows.push((e, vec![part(v, "142.30", "142.30", "0")], Direction::Paid));
// 11. Reimbursing it.
let e = b.post(
date(10, 11),
&[(b.due, Dr, "142.30"), (b.cash, Cr, "142.30")],
&amounts(&[(t, "71.15"), (m, "71.15")]),
);
rows.push((
e,
vec![
part(t, "71.15", "71.15", "0"),
part(m, "71.15", "71.15", "0"),
],
Direction::Paid,
));
// 12. A laptop Tobin contributes: received, in kind (it read +1480.00,
// the sign of a payment, before P3).
let e12 = b.post(
date(10, 12),
&[(b.equip, Dr, "1480.00"), (b.cap_t, Cr, "1480.00")],
&[whole(t)],
);
rows.push((
e12,
vec![part(t, "-1480.00", "0", "-1480.00")],
Direction::Received,
));
// 13. Its reversal: the exact opposite, paid, in kind (it read
// +1480.00, the same as entry 12, before P3).
let e13 = b.reverse(e12, date(10, 13));
rows.push((
e13,
vec![part(t, "1480.00", "0", "1480.00")],
Direction::Paid,
));
// 14. The laptop again, half each by amount: −740.00 each.
let e = b.post(
date(10, 14),
&[
(b.equip, Dr, "1480.00"),
(b.cap_t, Cr, "740.00"),
(b.cap_m, Cr, "740.00"),
],
&amounts(&[(t, "740.00"), (m, "740.00")]),
);
rows.push((
e,
vec![
part(t, "-740.00", "0", "-740.00"),
part(m, "-740.00", "0", "-740.00"),
],
Direction::Received,
));
// 15 and 18. A 960.00 device and its reversal.
let e15 = b.post(
date(10, 15),
&[
(b.equip, Dr, "960.00"),
(b.cap_t, Cr, "480.00"),
(b.cap_m, Cr, "480.00"),
],
&amounts(&[(t, "480.00"), (m, "480.00")]),
);
rows.push((
e15,
vec![
part(t, "-480.00", "0", "-480.00"),
part(m, "-480.00", "0", "-480.00"),
],
Direction::Received,
));
// 16 and 17. Two more devices, derived from the capital lines.
let e = b.post(
date(10, 16),
&[
(b.equip, Dr, "1530.00"),
(b.cap_t, Cr, "765.00"),
(b.cap_m, Cr, "765.00"),
],
&[whole(t), whole(m)],
);
rows.push((
e,
vec![
part(t, "-765.00", "0", "-765.00"),
part(m, "-765.00", "0", "-765.00"),
],
Direction::Received,
));
let e = b.post(
date(10, 17),
&[
(b.equip, Dr, "690.00"),
(b.cap_t, Cr, "345.00"),
(b.cap_m, Cr, "345.00"),
],
&[whole(t), whole(m)],
);
rows.push((
e,
vec![
part(t, "-345.00", "0", "-345.00"),
part(m, "-345.00", "0", "-345.00"),
],
Direction::Received,
));
let e18 = b.reverse(e15, date(10, 18));
rows.push((
e18,
vec![
part(t, "480.00", "0", "480.00"),
part(m, "480.00", "0", "480.00"),
],
Direction::Paid,
));
// 19. A device with its service contract (prepaid: property, k1
// INVARIANT 3), derived.
let e = b.post(
date(10, 19),
&[
(b.equip, Dr, "1010.00"),
(b.prepaid, Dr, "30.00"),
(b.cap_t, Cr, "520.00"),
(b.cap_m, Cr, "520.00"),
],
&[whole(t), whole(m)],
);
rows.push((
e,
vec![
part(t, "-520.00", "0", "-520.00"),
part(m, "-520.00", "0", "-520.00"),
],
Direction::Received,
));
// A bill (R1): Cr payables, the vendor's own account: −80.00, neither;
// its reversal +80.00 (both read +80.00 before P3).
let bill =
b.l.create_bill(
b.vendor_id,
date(10, 20),
date(11, 19),
vec![DocumentLine {
description: "filing".into(),
amount: d("80.00"),
class_id: None,
}],
)
.unwrap();
let e = b.l.get_bill(bill).unwrap().entry_id;
rows.push((e, vec![part(v, "-80.00", "0", "0")], Direction::Neither));
let r = b.reverse(e, date(10, 21));
rows.push((r, vec![part(v, "80.00", "0", "0")], Direction::Neither));
// An invoice: Dr receivables, the customer's own account: +120.00,
// neither.
let inv =
b.l.create_invoice(
b.customer_id,
date(10, 20),
date(11, 19),
vec![DocumentLine {
description: "consulting".into(),
amount: d("120.00"),
class_id: None,
}],
)
.unwrap();
let e = b.l.get_invoice(inv).unwrap().entry_id;
rows.push((
e,
vec![part(b.customer, "120.00", "0", "0")],
Direction::Neither,
));
Audit { b, rows }
}
/// Defect 14, the audit's table row by row: each part signed as a line on
/// the party's own account, with its direction and its money and
/// property; every reversal the exact opposite of what it reverses.
#[test]
fn defect14_shares_take_the_partys_side_and_reversals_flip() {
let Audit { b, rows } = audit();
for (e, want, direction) in &rows {
let (t, parts) = b.resolved(*e);
assert_eq!(&parts, want, "entry {e}");
assert_eq!(t.direction, *direction, "entry {e}");
let sum: Decimal = parts.iter().map(|p| p.amount).sum();
assert_eq!(t.amount, sum, "entry {e}: the parts sum to the target");
// A reversal counts on its original's side.
let original = b.l.get_entry(*e).unwrap().reversal_of();
let expect_side = match original {
Some(_) => direction.flip(),
None => *direction,
};
assert_eq!(t.counts_as, expect_side, "entry {e}");
// Never a negative zero (INVARIANT 6).
for p in &parts {
for v in [p.amount, p.cash, p.in_kind] {
assert!(!(v.is_zero() && v.is_sign_negative()), "entry {e}: -0");
}
}
}
// Totals: the members' money is unchanged by P3 (paid 93.25 + 71.15,
// received 400.00 each); basis all adds their property: 740.00 +
// 765.00 + 345.00 + 520.00 = 2,370.00, the 1,480.00 and 480.00
// contributions reversed in the year net to nothing.
for who in [b.tobin, b.morgan] {
assert_eq!(b.totals(who, Basis::Cash), (d("164.40"), d("400.00")));
assert_eq!(b.totals(who, Basis::All), (d("164.40"), d("2770.00")));
}
// The vendor: the two payments members made for the company; the bill
// and its reversal count nothing on either basis.
assert_eq!(
b.totals(b.vendor, Basis::Cash),
(d("206.30"), Decimal::ZERO)
);
assert_eq!(b.totals(b.vendor, Basis::All), (d("206.30"), Decimal::ZERO));
// The customer: invoiced, nothing received yet.
assert_eq!(
b.totals(b.customer, Basis::All),
(Decimal::ZERO, Decimal::ZERO)
);
}
/// B-35 item 4: on the member-only entries, basis all is the statement of
/// members' equity (contributions and distributions, the same dates), and
/// basis cash is the K-1 worksheet's cash contributions.
#[test]
fn basis_all_is_the_members_equity_statement_and_cash_the_k1s() {
let Audit { b, .. } = audit();
let (start, end) = year();
let me = members_equity(&b.l, start, end, &b.capitals()).unwrap();
let k1 = k1_detail(&b.l, start, end, &b.capitals(), &[b.cash]).unwrap();
for (i, who) in [b.tobin, b.morgan].into_iter().enumerate() {
// The reimbursements (164.40 paid) are money the member was paid
// back, not equity: they are the only entries naming the member
// that don't move their capital.
let (paid_all, received_all) = b.totals(who, Basis::All);
assert_eq!(received_all, me.members[i].contributions);
assert_eq!(paid_all - d("164.40"), me.members[i].distributions);
let (_, received_cash) = b.totals(who, Basis::Cash);
assert_eq!(received_cash, k1.rows[i].contributions_cash);
assert_eq!(
received_all - received_cash,
k1.rows[i].contributions_property
);
assert_eq!(me.members[i].contributions, d("2770.00"));
assert_eq!(k1.rows[i].contributions_cash, d("400.00"));
}
}
/// Defect 14, second bug (brief §1.4): a member pays the card from their
/// own funds (Dr 2050 / Cr 2100), tagged with the issuer: paid 100.00. Its
/// reversal read as another payment (paid 200.00); now it nets to 0.00,
/// and reversing the reversal brings 100.00 back.
#[test]
fn defect14_a_reversed_member_card_payment_nets_to_zero() {
let mut b = books();
let issuer = b.issuer;
let e1 = b.post(
date(6, 1),
&[(b.card, Dr, "100.00"), (b.due, Cr, "100.00")],
&[whole(issuer)],
);
assert_eq!(b.totals(issuer, Basis::Cash), (d("100.00"), Decimal::ZERO));
let e2 = b.reverse(e1, date(6, 2));
assert_eq!(
b.totals(issuer, Basis::Cash),
(Decimal::ZERO, Decimal::ZERO)
);
// Why parties::flow exists: entry_flow reads the reversal's own lines
// as another payment (its `across` term is the same either way).
let money = b.money();
assert_eq!(
entry_flow(b.l.get_entry(e2).unwrap(), &money).unwrap().net,
d("100.00")
);
let f1 = flow(&b.l, e1, &[whole(issuer)], &money).unwrap();
let f2 = flow(&b.l, e2, &[whole(issuer)], &money).unwrap();
assert_eq!(
(f1.net, f1.what_for.clone()),
(d("100.00"), vec![(b.card, d("100.00"))])
);
assert_eq!(
(f2.net, f2.what_for),
(d("-100.00"), vec![(b.card, d("-100.00"))])
);
let e3 = b.reverse(e2, date(6, 3));
assert_eq!(b.totals(issuer, Basis::Cash), (d("100.00"), Decimal::ZERO));
assert_eq!(flow(&b.l, e3, &[whole(issuer)], &money).unwrap(), f1);
}
/// Defect 24: an accrual through a liability that is not money (accrued
/// expenses, deferred revenue) counts once, when it is settled, not also
/// when it is booked. Before P3 every liability but payables and payroll
/// was money: the vendor read paid 600.00 and the customer received
/// 1,300.00.
#[test]
fn defect24_an_accrual_through_another_liability_counts_once() {
let mut b = books();
let (v, c) = (b.vendor, b.customer);
let accrue = b.post(
date(3, 31),
&[(b.hosting, Dr, "300.00"), (b.accrued, Cr, "300.00")],
&[whole(v)],
);
let (t, _) = b.resolved(accrue);
assert_eq!((t.direction, t.cash), (Direction::Neither, Decimal::ZERO));
b.post(
date(4, 15),
&[(b.accrued, Dr, "300.00"), (b.cash, Cr, "300.00")],
&[whole(v)],
);
assert_eq!(b.totals(v, Basis::All), (d("300.00"), Decimal::ZERO));
// Deferred revenue: 1,200.00 received ahead, 100.00 earned.
b.post(
date(1, 2),
&[(b.cash, Dr, "1200.00"), (b.deferred, Cr, "1200.00")],
&[whole(c)],
);
let earn = b.post(
date(1, 31),
&[(b.deferred, Dr, "100.00"), (b.revenue, Cr, "100.00")],
&[whole(c)],
);
assert_eq!(b.resolved(earn).0.direction, Direction::Neither);
assert_eq!(b.totals(c, Basis::All), (Decimal::ZERO, d("1200.00")));
}
/// Defect 24 and B-35 item 6: a credit to what the company owes a party
/// itself never counts as paying it. A fee on the issuer's own card is a
/// bill from the issuer (counted when the card is paid); a distribution
/// still owed to a member counts when it is paid; equipment bought on the
/// seller's own note is not paid for until the note is (Helvering v.
/// Price); property contributed subject to a loan is the member's
/// property net of the loan, all in kind (Treas. Reg.
/// §1.704-1(b)(2)(iv)(b)(2)).
#[test]
fn defect24_a_credit_to_a_partys_own_liability_never_pays_it() {
let mut b = books();
let (issuer, t, m) = (b.issuer, b.tobin, b.morgan);
// The card fee, and the card paid off: until the books know the card
// is the issuer's, the fee reads as paid twice (150.00).
let fee = b.post(
date(5, 1),
&[(b.fees, Dr, "25.00"), (b.card, Cr, "25.00")],
&[whole(issuer)],
);
b.post(
date(5, 20),
&[(b.card, Dr, "125.00"), (b.cash, Cr, "125.00")],
&[whole(issuer)],
);
assert_eq!(b.totals(issuer, Basis::Cash), (d("150.00"), Decimal::ZERO));
b.l.set_party_creditor(issuer, b.card, true).unwrap();
assert_eq!(b.totals(issuer, Basis::Cash), (d("125.00"), Decimal::ZERO));
assert_eq!(b.totals(issuer, Basis::All), (d("125.00"), Decimal::ZERO));
let (ft, fp) = b.resolved(fee);
assert_eq!(
(ft.direction, fp[0].amount),
(Direction::Neither, d("-25.00"))
);
// A distribution declared through 2100, the members' own: owed, so it
// counts as nothing until it is paid; then 200.00, once.
b.l.set_party_creditor(t, b.due, true).unwrap();
b.l.set_party_creditor(m, b.due, true).unwrap();
let declared = b.post(
date(6, 30),
&[(b.cap_t, Dr, "200.00"), (b.due, Cr, "200.00")],
&[whole(t)],
);
let (dt, dp) = b.resolved(declared);
assert_eq!(dt.direction, Direction::Neither);
assert_eq!(dp, vec![part(t, "200.00", "0", "0")]);
assert_eq!(b.totals(t, Basis::All), (Decimal::ZERO, Decimal::ZERO));
b.post(
date(7, 15),
&[(b.due, Dr, "200.00"), (b.cash, Cr, "200.00")],
&[whole(t)],
);
assert_eq!(b.totals(t, Basis::Cash), (d("200.00"), Decimal::ZERO));
assert_eq!(b.totals(t, Basis::All), (d("200.00"), Decimal::ZERO));
// Equipment on the seller's own note: nothing paid until the note is.
let seller = b.l.add_party("Lakeside Supply", &[]).unwrap();
b.l.set_party_creditor(seller, b.note, true).unwrap();
let bought = b.post(
date(8, 1),
&[(b.equip, Dr, "900.00"), (b.note, Cr, "900.00")],
&[whole(seller)],
);
assert_eq!(
b.resolved(bought).1,
vec![part(seller, "-900.00", "0", "0")]
);
b.post(
date(9, 1),
&[(b.note, Dr, "300.00"), (b.cash, Cr, "300.00")],
&[whole(seller)],
);
assert_eq!(b.totals(seller, Basis::All), (d("300.00"), Decimal::ZERO));
// Property worth 1,000.00 contributed subject to a 200.00 loan: 800.00
// received, in kind (before P3: "paid 200.00").
let lender = b.l.add_party("Harbor Bank Loans", &[]).unwrap();
b.l.set_party_creditor(lender, b.note, true).unwrap();
let e = b.post(
date(9, 15),
&[
(b.equip, Dr, "1000.00"),
(b.note, Cr, "200.00"),
(b.cap_m, Cr, "800.00"),
],
&[whole(m)],
);
let (pt, pp) = b.resolved(e);
assert_eq!(pt.direction, Direction::Received);
assert_eq!(pp, vec![part(m, "-800.00", "0", "-800.00")]);
assert_eq!(b.totals(m, Basis::Cash), (Decimal::ZERO, Decimal::ZERO));
assert_eq!(b.totals(m, Basis::All), (Decimal::ZERO, d("800.00")));
}
/// The known limit B-35 states (and the tool text says): when the
/// members aren't named the creditors of their financing account, a
/// distribution declared through it counts when declared and again when
/// paid. Pinned so a change to it is seen.
#[test]
fn a_distribution_owed_counts_twice_until_the_members_are_named_its_creditors() {
let mut b = books();
let t = b.tobin;
b.post(
date(6, 30),
&[(b.cap_t, Dr, "200.00"), (b.due, Cr, "200.00")],
&[whole(t)],
);
b.post(
date(7, 15),
&[(b.due, Dr, "200.00"), (b.cash, Cr, "200.00")],
&[whole(t)],
);
assert_eq!(b.totals(t, Basis::Cash), (d("400.00"), Decimal::ZERO));
b.l.set_party_creditor(t, b.due, true).unwrap();
assert_eq!(b.totals(t, Basis::Cash), (d("200.00"), Decimal::ZERO));
}
/// Defect 24 in a member's entry (found in review, 2026-10-21): a
/// distribution declared to a liability that isn't money (distributions
/// payable) is a promise. It counted as 200.00 paid when declared and
/// again when paid (400.00); now it counts nothing until it is paid, then
/// 200.00, once, on both bases (Helvering v. Price; party_target
/// INVARIANT 2). The statement of members' equity counts it when declared:
/// party totals catch up when it is paid (B-35 item 4's conditions).
#[test]
fn defect24_a_distribution_declared_to_a_payable_counts_once_when_paid() {
let mut b = books();
let t = b.tobin;
let declared = b.post(
date(6, 30),
&[(b.dist_t, Dr, "200.00"), (b.dist_payable, Cr, "200.00")],
&[whole(t)],
);
let (dt, dp) = b.resolved(declared);
assert_eq!(dt.direction, Direction::Neither);
assert_eq!(dp, vec![part(t, "200.00", "0", "0")]);
for basis in Basis::ALL {
assert_eq!(b.totals(t, basis), (Decimal::ZERO, Decimal::ZERO));
}
b.post(
date(7, 15),
&[(b.dist_payable, Dr, "200.00"), (b.cash, Cr, "200.00")],
&[whole(t)],
);
for basis in Basis::ALL {
assert_eq!(b.totals(t, basis), (d("200.00"), Decimal::ZERO));
}
// Declared and half paid at once: the money counts now, the rest when
// paid.
let half = b.post(
date(8, 31),
&[
(b.dist_t, Dr, "300.00"),
(b.cash, Cr, "100.00"),
(b.dist_payable, Cr, "200.00"),
],
&[whole(t)],
);
assert_eq!(
b.resolved(half),
(
Target {
amount: d("300.00"),
direction: Direction::Paid,
cash: d("100.00"),
in_kind: Decimal::ZERO,
counts_as: Direction::Paid,
},
vec![part(t, "300.00", "100.00", "0")]
)
);
assert_eq!(b.totals(t, Basis::Cash), (d("300.00"), Decimal::ZERO));
}
/// B-35 item 3: a distribution's cash is the money it paid and its in
/// kind the property it gave. Equipment carried at 1,000.00 less 300.00
/// depreciated, and 100.00 in cash, distributed: 100.00 cash and 700.00
/// in kind (it read 800.00 in kind and no cash, the equipment's cost
/// standing in for the money). Basis all is the statement's 800.00.
#[test]
fn a_distribution_of_money_and_depreciated_property_splits_by_what_moved() {
let mut b = books();
let t = b.tobin;
let e = b.post(
date(9, 1),
&[
(b.dist_t, Dr, "800.00"),
(b.accum, Dr, "300.00"),
(b.equip, Cr, "1000.00"),
(b.cash, Cr, "100.00"),
],
&[whole(t)],
);
assert_eq!(b.resolved(e).1, vec![part(t, "800.00", "100.00", "700.00")]);
assert_eq!(b.totals(t, Basis::Cash), (d("100.00"), Decimal::ZERO));
assert_eq!(b.totals(t, Basis::All), (d("800.00"), Decimal::ZERO));
let (start, end) = year();
let me = members_equity(&b.l, start, end, &b.capitals()).unwrap();
assert_eq!(me.members[0].distributions, d("800.00"));
// At fair market value with a gain on it, and no money: all in kind.
let gain = b.post(
date(9, 2),
&[
(b.dist_t, Dr, "900.00"),
(b.accum, Dr, "300.00"),
(b.equip, Cr, "1000.00"),
(b.revenue, Cr, "200.00"),
],
&[whole(t)],
);
assert_eq!(b.resolved(gain).1, vec![part(t, "900.00", "0", "900.00")]);
}
/// Defect 24 and the card rule (B-35 item 5): a gift card the company
/// sold is deferred revenue, a promise, though its account's name says
/// "card". It counted as received when sold and again when redeemed
/// (200.00); now once, when the money came in. A card the company pays
/// with is still a card.
#[test]
fn defect24_a_gift_card_sold_is_received_once() {
let mut b = books();
let c = b.customer;
assert_eq!(ledger_core::parties::card_accounts(&b.l), vec![b.card]);
b.post(
date(3, 1),
&[(b.cash, Dr, "100.00"), (b.gift, Cr, "100.00")],
&[whole(c)],
);
let redeemed = b.post(
date(3, 5),
&[(b.gift, Dr, "100.00"), (b.revenue, Cr, "100.00")],
&[whole(c)],
);
assert_eq!(b.resolved(redeemed).0.direction, Direction::Neither);
assert_eq!(b.totals(c, Basis::All), (Decimal::ZERO, d("100.00")));
}
/// B-35 item 5 and `parties::card_accounts` INVARIANTS 1-4: one rule for
/// what a card is. Before any business card is recorded, the liabilities
/// whose names say "card" but not "gift" (2050 Business Card; never 2410
/// Gift Card Liability), less a member's card's account; once one is
/// recorded, exactly the business cards' liabilities, whatever their
/// names, and a debit card on the bank (an asset) is none. A member's
/// card or a platform is never the company's card, and a card read as a
/// card is money: a charge to it is paid.
#[test]
fn a_card_is_a_recorded_business_card_else_a_liability_named_card() {
use ledger_core::InstrumentKind as K;
let mut b = books();
let cards = |b: &Books| ledger_core::parties::card_accounts(&b.l);
assert_eq!(cards(&b), vec![b.card]);
// A member's own card, on a liability named for it: not the
// company's, before or after a business card is recorded.
let personal =
b.l.add_account("2060", "Tobin's Card", AccountType::Liability, None, false)
.unwrap();
assert_eq!(cards(&b), vec![b.card, personal]);
b.l.add_instrument(K::MemberCard, "Tobin's card", None, Some(b.tobin), personal)
.unwrap();
assert_eq!(
cards(&b),
vec![b.card],
"a member's card is not a card account"
);
// A platform's balance is no card either.
b.l.add_instrument(K::Platform, "Payment platform", None, None, b.prepaid)
.unwrap();
assert_eq!(cards(&b), vec![b.card]);
// A business card on the bank (a debit card, an asset): no card
// account, so the name rule still holds.
b.l.add_instrument(K::BusinessCard, "Debit card", None, None, b.cash)
.unwrap();
assert_eq!(cards(&b), vec![b.card]);
// A business card recorded on a liability not named "card": now
// exactly the recorded cards' liabilities; 2050, named "card" but not
// recorded, no longer is.
let visa =
b.l.add_account("2070", "Harbor Visa", AccountType::Liability, None, false)
.unwrap();
b.l.add_instrument(K::BusinessCard, "Harbor Visa", None, None, visa)
.unwrap();
assert_eq!(cards(&b), vec![visa]);
assert_eq!(
b.l.card_accounts().into_iter().collect::<Vec<_>>(),
vec![visa]
);
// Money follows the rule: a charge to the recorded card is paid; one
// to 2050, a liability like any other now, is a promise.
let v = b.vendor;
let on_visa = b.post(
date(4, 1),
&[(b.hosting, Dr, "30.00"), (visa, Cr, "30.00")],
&[whole(v)],
);
let on_2050 = b.post(
date(4, 2),
&[(b.hosting, Dr, "20.00"), (b.card, Cr, "20.00")],
&[whole(v)],
);
assert_eq!(b.resolved(on_visa).0.direction, Direction::Paid);
assert_eq!(b.resolved(on_2050).0.direction, Direction::Neither);
}
/// B-35 item 3, case 3: E is the movement of equity other than retained
/// earnings, so a members' entry reads as the statement of members' equity
/// reads it. Retained earnings given up for equipment moves no member's
/// capital: neither, as the statement shows no distribution. Retained
/// earnings moved into the members' capital outside a close is, on the
/// statement and the K-1 worksheet, a contribution (credits to capital),
/// and party totals agree: received, cash (an open question for the
/// owner: a CPA would call it neither).
#[test]
fn retained_earnings_is_not_a_members_capital() {
let mut b = books();
let (t, m) = (b.tobin, b.morgan);
let re =
b.l.add_account(
"3999",
"Retained Earnings",
AccountType::Equity,
None,
false,
)
.unwrap();
assert_eq!(b.l.role_account(&ledger_core::RETAINED_EARNINGS), Some(re));
let written_off = b.post(
date(4, 1),
&[(re, Dr, "100.00"), (b.equip, Cr, "100.00")],
&[whole(t)],
);
assert_eq!(b.resolved(written_off).0.direction, Direction::Neither);
let allocated = b.post(
date(4, 2),
&[
(re, Dr, "300.00"),
(b.cap_t, Cr, "150.00"),
(b.cap_m, Cr, "150.00"),
],
&[whole(t), whole(m)],
);
assert_eq!(
b.resolved(allocated).1,
vec![
part(t, "-150.00", "-150.00", "0"),
part(m, "-150.00", "-150.00", "0")
]
);
let (start, end) = year();
let me = members_equity(&b.l, start, end, &b.capitals()).unwrap();
let k1 = k1_detail(&b.l, start, end, &b.capitals(), &[b.cash]).unwrap();
for (i, who) in [t, m].into_iter().enumerate() {
let (paid, received) = b.totals(who, Basis::All);
assert_eq!(
(received, paid),
(me.members[i].contributions, me.members[i].distributions)
);
assert_eq!(b.totals(who, Basis::Cash).1, k1.rows[i].contributions_cash);
}
}
/// Activity's paid is net of reversals of payments; a refund recorded as
/// its own entry (money back from the vendor) is received from the vendor,
/// as it is: Paid out 100.00, Received in 20.00, net 80.00.
#[test]
fn a_refund_is_received_from_the_vendor() {
let mut b = books();
let v = b.vendor;
b.post(
date(3, 1),
&[(b.hosting, Dr, "100.00"), (b.cash, Cr, "100.00")],
&[whole(v)],
);
b.post(
date(3, 5),
&[(b.cash, Dr, "20.00"), (b.hosting, Cr, "20.00")],
&[whole(v)],
);
assert_eq!(b.totals(v, Basis::Cash), (d("100.00"), d("20.00")));
}
/// B-35 item 3 (capital first): a mixed contribution is split into money
/// and property exactly as the K-1 worksheet splits it (its SCENARIO 3),
/// whichever member the shares list first; an expense a member pays
/// straight to capital is cash, as on the K-1 (k1 INVARIANT 3); a debt
/// converted to capital is cash.
#[test]
fn a_members_mixed_entry_splits_as_the_k1_does() {
let mut b = books();
let (t, m) = (b.tobin, b.morgan);
let lines = [
(b.cash, Dr, "600.00"),
(b.equip, Dr, "400.00"),
(b.cap_t, Cr, "500.00"),
(b.cap_m, Cr, "500.00"),
];
let e = b.post(date(2, 1), &lines, &[whole(t), whole(m)]);
assert_eq!(
b.resolved(e).1,
vec![
part(t, "-500.00", "-300.00", "-200.00"),
part(m, "-500.00", "-300.00", "-200.00")
]
);
// Listed the other way round: the same split.
b.l.set_entry_parties(e, &[whole(m), whole(t)]).unwrap();
assert_eq!(
b.resolved(e).1,
vec![
part(m, "-500.00", "-300.00", "-200.00"),
part(t, "-500.00", "-300.00", "-200.00")
]
);
// A tied odd cent goes to the member listed first in the company's
// members (Tobin), as on the K-1 worksheet, whatever the shares' order.
let odd = b.post(
date(2, 2),
&[
(b.cash, Dr, "0.99"),
(b.equip, Dr, "0.01"),
(b.cap_t, Cr, "0.50"),
(b.cap_m, Cr, "0.50"),
],
&[whole(m), whole(t)],
);
assert_eq!(
b.resolved(odd).1,
vec![
part(m, "-0.50", "-0.50", "0"),
part(t, "-0.50", "-0.49", "-0.01")
]
);
let expense = b.post(
date(2, 3),
&[(b.hosting, Dr, "50.00"), (b.cap_m, Cr, "50.00")],
&[whole(m)],
);
assert_eq!(
b.resolved(expense).1,
vec![part(m, "-50.00", "-50.00", "0")]
);
let converted = b.post(
date(2, 4),
&[(b.due, Dr, "70.00"), (b.cap_t, Cr, "70.00")],
&[whole(t)],
);
assert_eq!(
b.resolved(converted).1,
vec![part(t, "-70.00", "-70.00", "0")]
);
// Against the K-1 worksheet, member by member.
let (start, end) = year();
let k1 = k1_detail(&b.l, start, end, &b.capitals(), &[b.cash]).unwrap();
for (i, who) in [t, m].into_iter().enumerate() {
let (_, cash) = b.totals(who, Basis::Cash);
let (_, all) = b.totals(who, Basis::All);
assert_eq!(cash, k1.rows[i].contributions_cash, "member {i}");
assert_eq!(all - cash, k1.rows[i].contributions_property, "member {i}");
}
// Tobin: 300 + 0.49 + 70 cash, 200 + 0.01 property; Morgan: 300 +
// 0.50 + 50 cash, 200 property.
assert_eq!(b.totals(t, Basis::Cash), (Decimal::ZERO, d("370.49")));
assert_eq!(b.totals(m, Basis::Cash), (Decimal::ZERO, d("350.50")));
assert_eq!(b.totals(t, Basis::All), (Decimal::ZERO, d("570.50")));
}
/// R2 of the design review: a member's entry is read from journaled facts
/// (the member role, the chart), not today's settings: it keeps its sign,
/// direction and property after the member leaves the company's members
/// and is archived; and a one-member company's property contribution
/// reads received, in kind, with no members in the settings at all.
#[test]
fn a_members_entry_keeps_its_reading_after_they_leave() {
let mut b = books();
let t = b.tobin;
let e = b.post(
date(3, 1),
&[(b.equip, Dr, "400.00"), (b.cap_t, Cr, "400.00")],
&[whole(t)],
);
let before = b.resolved(e);
b.l.update_party(t, PartyField::Archived(true)).unwrap();
let after = resolve(&b.l, e, b.l.entry_parties(e), &b.money(), &[]).unwrap();
assert_eq!((after.target, after.parts), before);
assert_eq!(before.0.direction, Direction::Received);
// One member, no members in the settings.
let mut l = Ledger::new(Currency::USD);
let equip = l
.add_account("1500", "Equipment", AccountType::Asset, None, false)
.unwrap();
let cash = l
.add_account("1000", "Checking", AccountType::Asset, None, false)
.unwrap();
let capital = l
.add_account("3000", "Owner's Capital", AccountType::Equity, None, false)
.unwrap();
let owner = l.add_party("Morgan Ellis", &[Role::Member]).unwrap();
let e = l.try_create_entry(date(3, 1), "laptop").unwrap();
l.add_line(e, equip, Dr, Money::new(d("900.00"), Currency::USD))
.unwrap();
l.add_line(e, capital, Cr, Money::new(d("900.00"), Currency::USD))
.unwrap();
l.post_entry(e).unwrap();
l.set_entry_party(e, Some(owner)).unwrap();
let money = money_accounts(&l, &[cash], &[]);
let r = resolve(&l, e, l.entry_parties(e), &money, &[]).unwrap();
assert_eq!(r.target.direction, Direction::Received);
assert_eq!(r.parts, vec![part(owner, "-900.00", "0", "-900.00")]);
assert_eq!(
activity_with(&l, owner, year(), &money, &[]).totals(Basis::All),
(Decimal::ZERO, d("900.00"))
);
}
/// B-35 item 3, case 1: a period's closing entry moves nothing with
/// anyone: direction neither, its members' part their income allocated
/// (credited to their capital: negative), counted in no total.
#[test]
fn a_close_tagged_with_its_members_is_neither() {
let mut b = books();
let (t, m) = (b.tobin, b.morgan);
b.post(
date(1, 10),
&[(b.cash, Dr, "100.00"), (b.revenue, Cr, "100.00")],
&[whole(b.customer)],
);
let jan = b.l.create_period("Jan", date(1, 1), date(1, 31)).unwrap();
let close =
b.l.close_period_to(
jan,
&ledger_core::CloseTo::Members(vec![
ledger_core::MemberClose {
capital: b.cap_t,
share: d("0.5"),
distributions: None,
},
ledger_core::MemberClose {
capital: b.cap_m,
share: d("0.5"),
distributions: None,
},
]),
)
.unwrap()
.unwrap();
b.l.set_entry_parties(close, &[whole(t), whole(m)]).unwrap();
let (ct, cp) = b.resolved(close);
assert_eq!(ct.direction, Direction::Neither);
assert_eq!(ct.counts_as, Direction::Neither);
assert_eq!(
cp,
vec![part(t, "-50.00", "0", "0"), part(m, "-50.00", "0", "0")]
);
assert_eq!(b.totals(t, Basis::All), (Decimal::ZERO, Decimal::ZERO));
}
/// Basis::parse and key: "cash" and "all", nothing else (surrounding space
/// ignored); cash is the default.
#[test]
fn a_basis_is_cash_or_all() {
assert_eq!(Basis::parse("cash"), Some(Basis::Cash));
assert_eq!(Basis::parse(" all "), Some(Basis::All));
for bad in ["", "accrual", "Cash", "ALL", "both"] {
assert_eq!(Basis::parse(bad), None, "{bad:?}");
}
assert_eq!(Basis::default(), Basis::Cash);
for b in Basis::ALL {
assert_eq!(Basis::parse(b.key()), Some(b));
}
}
/// Ledger::set_party_creditor SCENARIOS: only a liability that takes
/// postings and has one creditor; a refusal changes nothing; naming twice
/// or stopping one never named changes nothing; an account may have
/// several creditors (both members, for what the members advanced).
#[test]
fn a_creditor_is_named_only_on_a_liability_with_one_owner() {
let mut b = books();
let (t, m, issuer) = (b.tobin, b.morgan, b.issuer);
let header =
b.l.add_account(
"2900",
"Other Liabilities",
AccountType::Liability,
None,
true,
)
.unwrap();
let payables =
b.l.add_account(
"2000",
"Accounts Payable",
AccountType::Liability,
None,
false,
)
.unwrap();
assert_eq!(b.l.role_account(&ledger_core::PAYABLES), Some(payables));
let federal =
b.l.add_account(
"2110",
"Federal Withholding Payable",
AccountType::Liability,
None,
false,
)
.unwrap();
let before = b.l.clone();
for (what, party, account) in [
("an asset", issuer, b.cash),
("equity", issuer, b.cap_t),
("an unknown party", 999, b.card),
("an unknown account", issuer, 999),
("a placeholder", issuer, header),
("payables", issuer, payables),
("a payroll withholding", issuer, federal),
] {
assert!(
b.l.set_party_creditor(party, account, true).is_err(),
"{what}"
);
assert!(b.l == before, "{what}: a refusal changed the ledger");
}
b.l.set_party_creditor(issuer, b.card, true).unwrap();
let once = b.l.clone();
b.l.set_party_creditor(issuer, b.card, true).unwrap();
assert!(b.l == once, "naming twice changes nothing");
assert_eq!(b.l.creditors_of(b.card), vec![issuer]);
b.l.set_party_creditor(t, b.due, true).unwrap();
b.l.set_party_creditor(m, b.due, true).unwrap();
assert_eq!(b.l.creditors_of(b.due), vec![t, m]);
b.l.set_party_creditor(m, b.due, false).unwrap();
let stopped = b.l.clone();
b.l.set_party_creditor(m, b.due, false).unwrap();
assert!(b.l == stopped, "stopping one never named changes nothing");
assert_eq!(b.l.creditors_of(b.due), vec![t]);
assert!(b.l.get_party(t).unwrap().creditor_of.contains(&b.due));
}
/// Defect 11: Party::tin_last4 SCENARIOS 1-5.
#[test]
fn defect11_a_partys_tin_last4_is_its_tin_facts_else_its_w9s() {
let mut l = Ledger::new(Currency::USD);
let member = l.add_party("Tobin Harlow", &[Role::Member]).unwrap();
let contractor = l.add_party("Riley Chen", &[Role::Contractor]).unwrap();
let both = l.add_party("Morgan Ellis", &[Role::Contractor]).unwrap();
let neither = l.add_party("Lakeside Supply", &[]).unwrap();
let tin = |last4: &str| TinFacts {
tin_type: TinType::Ssn,
last4: last4.into(),
vault_record: Some("a".repeat(64)),
purged: None,
};
let w9 = |last4: &str| W9Facts {
received: date(10, 15),
entity_type: "individual/sole proprietor".into(),
exempt_payee_code: None,
fatca_code: None,
tin_last4: last4.into(),
kept_at: "wanderbooks vault".into(),
vault_record: None,
};
l.set_party_tin(member, Some(tin("4321"))).unwrap();
l.set_party_w9(contractor, Some(w9("8765"))).unwrap();
l.set_party_tin(both, Some(tin("1111"))).unwrap();
l.set_party_w9(both, Some(w9("1111"))).unwrap();
let last4 = |l: &Ledger, p| l.get_party(p).unwrap().tin_last4().map(str::to_string);
assert_eq!(last4(&l, member).as_deref(), Some("4321"));
assert_eq!(last4(&l, contractor).as_deref(), Some("8765"));
assert_eq!(last4(&l, both).as_deref(), Some("1111"));
assert_eq!(last4(&l, neither), None);
// Purged from the vault: the facts keep the last four.
let mut purged = tin("4321");
purged.vault_record = None;
purged.purged = Some("purged on 2026-10-20: retention ended".into());
l.set_party_tin(member, Some(purged)).unwrap();
assert_eq!(last4(&l, member).as_deref(), Some("4321"));
// SCENARIO 6: never more than four digits, however a party was built
// (here by hand, past the checking setters).
let mut by_hand = l.get_party(member).unwrap().clone();
by_hand.tin.as_mut().unwrap().last4 = "123456789".into();
assert_eq!(by_hand.tin_last4(), None);
let mut by_hand = l.get_party(both).unwrap().clone();
by_hand.tin.as_mut().unwrap().last4 = "11111".into();
assert_eq!(by_hand.tin_last4(), Some("1111"), "the W-9's four");
by_hand.w9.as_mut().unwrap().tin_last4 = "12-3456789".into();
assert_eq!(by_hand.tin_last4(), None);
}
/// The kernel's B-35 cases, by hand (party_target INVARIANTS 1-6) and
/// reverse_target an involution.
#[test]
fn party_target_cases_by_hand() {
let t = |amount, direction, cash, in_kind| PartyTarget {
amount,
direction,
cash,
in_kind,
};
// A contribution of 800, 1000 of property given (more than |e|): all
// in kind, whatever money the entry moved.
assert_eq!(
kernel::party_target(Some((-800i64, 1000)), 200, 0, 1000),
Some(t(-800, Direction::Received, 0, -800))
);
// A contribution: the property in it, the rest cash.
assert_eq!(
kernel::party_target(Some((-500i64, 200)), -500, 0, 500),
Some(t(-500, Direction::Received, -300, -200))
);
// A contribution with no money at all (an expense booked straight to
// capital): cash, as the K-1 worksheet counts it.
assert_eq!(
kernel::party_target(Some((-50i64, 0)), 0, 0, 50),
Some(t(-50, Direction::Received, -50, 0))
);
// A distribution of 150: 20 paid in money, 100 in property, 30 still
// owed.
assert_eq!(
kernel::party_target(Some((150i64, 100)), 20, 0, 150),
Some(t(150, Direction::Paid, 20, 100))
);
// Money first, then property, each at most what is left.
assert_eq!(
kernel::party_target(Some((800i64, 1000)), 100, 0, 1100),
Some(t(800, Direction::Paid, 100, 700))
);
assert_eq!(
kernel::party_target(Some((100i64, 0)), 150, 0, 150),
Some(t(100, Direction::Paid, 100, 0))
);
// A distribution all owed (declared, no money, no property): nothing
// counts, neither. Money received on it doesn't count either.
assert_eq!(
kernel::party_target(Some((200i64, 0)), 0, 200, 200),
Some(t(200, Direction::Neither, 0, 0))
);
assert_eq!(
kernel::party_target(Some((200i64, 0)), -50, 0, 250),
Some(t(200, Direction::Neither, 0, 0))
);
// Not every party a member, or no equity moved: money, then own, then
// the debits.
assert_eq!(
kernel::party_target(Some((0i64, 0)), -70, 0, 70),
Some(t(-70, Direction::Received, -70, 0))
);
assert_eq!(
kernel::party_target(None, 0i64, -80, 80),
Some(t(-80, Direction::Neither, 0, 0))
);
assert_eq!(
kernel::party_target(None, 0i64, 0, 80),
Some(t(80, Direction::Neither, 0, 0))
);
let x = t(-800i64, Direction::Received, -300, -500);
let r = kernel::reverse_target(x).unwrap();
assert_eq!(r, t(800, Direction::Paid, 300, 500));
assert_eq!(kernel::reverse_target(r), Some(x));
// Past the range: refused, never a panic.
assert_eq!(
kernel::reverse_target(t(i64::MIN, Direction::Received, 0, 0)),
None
);
assert_eq!(kernel::party_target(Some((i64::MIN, 0)), 0, 0, 0), None);
// Never a negative zero on Decimal: a contribution all in kind leaves
// a plain zero of cash (a distribution all in kind read "-0" before
// the review), and so does a reversal of zero.
let all_in_kind = kernel::party_target(
Some((d("-800.00"), d("1000.00"))),
d("0"),
d("0"),
d("1000.00"),
)
.unwrap();
assert_eq!(all_in_kind.cash, Decimal::ZERO);
assert!(!all_in_kind.cash.is_sign_negative());
let given = kernel::party_target(
Some((d("800.00"), d("1000.00"))),
d("0"),
d("0"),
d("1000.00"),
)
.unwrap();
assert_eq!((given.cash, given.in_kind), (Decimal::ZERO, d("800.00")));
assert!(!given.cash.is_sign_negative());
let z = kernel::reverse_target(PartyTarget {
amount: Decimal::ZERO,
direction: Direction::Neither,
cash: Decimal::ZERO,
in_kind: Decimal::ZERO,
})
.unwrap();
assert!(!z.amount.is_sign_negative() && !z.cash.is_sign_negative());
}
// ---------------------------------------------------------- properties
fn cases() -> u32 {
std::env::var("PROPTEST_CASES")
.ok()
.and_then(|n| n.parse().ok())
.unwrap_or(256)
}
/// Who an entry names, in the properties below.
#[derive(Clone, Copy, Debug)]
enum Who {
Vendor,
Issuer,
Tobin,
Members,
MembersByPercent,
VendorAndTobinByPercent,
}
fn arb_who() -> impl Strategy<Value = Who> {
prop_oneof![
Just(Who::Vendor),
Just(Who::Issuer),
Just(Who::Tobin),
Just(Who::Members),
Just(Who::MembersByPercent),
Just(Who::VendorAndTobinByPercent),
]
}
fn shares_for(b: &Books, who: Who) -> Vec<PartyShare> {
let pct = |ps: &[(PartyId, &str)]| -> Vec<PartyShare> {
ps.iter()
.map(|&(party, p)| PartyShare {
party,
share: Share::Percent(d(p)),
})
.collect()
};
match who {
Who::Vendor => vec![whole(b.vendor)],
Who::Issuer => vec![whole(b.issuer)],
Who::Tobin => vec![whole(b.tobin)],
Who::Members => vec![whole(b.tobin), whole(b.morgan)],
Who::MembersByPercent => pct(&[(b.tobin, "60"), (b.morgan, "40")]),
Who::VendorAndTobinByPercent => pct(&[(b.vendor, "70"), (b.tobin, "30")]),
}
}
/// A balanced entry over the fixture's accounts (by index into
/// `Books::pool`): one or two debits, one or two credits, the last credit
/// plugging the debits' total.
fn arb_lines() -> impl Strategy<Value = Vec<(usize, Side, i64)>> {
(
prop::collection::vec((0usize..POOL, 1i64..500_000), 1..3),
prop::collection::vec((0usize..POOL, 1i64..500_000), 0..2),
0usize..POOL,
)
.prop_filter_map("a positive plug", |(debits, credits, last)| {
let total: i64 = debits.iter().map(|(_, c)| c).sum();
let rest: i64 = credits.iter().map(|(_, c)| c).sum();
(total > rest).then(|| {
let mut out: Vec<(usize, Side, i64)> =
debits.into_iter().map(|(a, c)| (a, Dr, c)).collect();
out.extend(credits.into_iter().map(|(a, c)| (a, Cr, c)));
out.push((last, Cr, total - rest));
out
})
})
}
const POOL: usize = 11;
impl Books {
fn pool(&self) -> [AccountId; POOL] {
[
self.cash,
self.card,
self.due,
self.note,
self.accrued,
self.equip,
self.prepaid,
self.cap_t,
self.cap_m,
self.hosting,
self.revenue,
]
}
fn post_cents(&mut self, day: NaiveDate, lines: &[(usize, Side, i64)]) -> EntryId {
let pool = self.pool();
let e = self.l.try_create_entry(day, "x").unwrap();
for &(a, side, c) in lines {
self.l
.add_line(
e,
pool[a],
side,
Money::new(Decimal::new(c, 2), Currency::USD),
)
.unwrap();
}
self.l.post_entry(e).unwrap();
e
}
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(cases()))]
/// Properties 1, 2 and 6 of the brief (shares INVARIANTS 6, 11, 12;
/// parties::flow INVARIANT 2): for any posted entry and any parties,
/// with any of them named its creditors, the parts sum to the target
/// (and their cash and in_kind to its), each has the target's sign and
/// never exceeds its part; a reversal's target and parts are its
/// original's negated, the direction flipped, on the same side; and
/// its flow is the original's negated.
#[test]
fn a_reversal_is_the_exact_opposite(
lines in arb_lines(),
who in arb_who(),
owed in prop::collection::vec((0usize..3, 0usize..4), 0..3),
) {
let mut b = books();
let liabilities = [b.card, b.due, b.note, b.accrued];
for (p, a) in owed {
let party = [b.issuer, b.tobin, b.vendor][p];
b.l.set_party_creditor(party, liabilities[a], true).unwrap();
}
let e = b.post_cents(date(4, 1), &lines);
let shares = shares_for(&b, who);
b.l.set_entry_parties(e, &shares).unwrap();
let r = b.reverse(e, date(4, 2));
let (money, members) = (b.money(), b.members());
let orig = resolve(&b.l, e, &shares, &money, &members);
let rev = resolve(&b.l, r, &shares, &money, &members);
prop_assert_eq!(orig.is_ok(), rev.is_ok());
if let (Ok(o), Ok(v)) = (orig, rev) {
let neg = |x: Decimal| if x.is_zero() { Decimal::ZERO } else { -x };
prop_assert_eq!(v.target.amount, neg(o.target.amount));
prop_assert_eq!(v.target.cash, neg(o.target.cash));
prop_assert_eq!(v.target.in_kind, neg(o.target.in_kind));
prop_assert_eq!(v.target.direction, o.target.direction.flip());
prop_assert_eq!(v.target.counts_as, o.target.counts_as);
prop_assert_eq!(o.target.counts_as, o.target.direction);
for (po, pv) in o.parts.iter().zip(&v.parts) {
prop_assert_eq!(pv.party, po.party);
prop_assert_eq!(pv.amount, neg(po.amount));
prop_assert_eq!(pv.cash, neg(po.cash));
prop_assert_eq!(pv.in_kind, neg(po.in_kind));
}
for res in [&o, &v] {
let t = res.target;
for x in [t.amount, t.cash, t.in_kind] {
prop_assert!(!(x.is_zero() && x.is_sign_negative()), "the target: {:?}", t);
}
let sum = |f: fn(&Part) -> Decimal| res.parts.iter().map(f).sum::<Decimal>();
prop_assert_eq!(sum(|p| p.amount), t.amount);
prop_assert_eq!(sum(|p| p.cash), t.cash);
prop_assert_eq!(sum(|p| p.in_kind), t.in_kind);
prop_assert_eq!(
t.direction == Direction::Neither,
t.cash.is_zero() && t.in_kind.is_zero()
);
for p in &res.parts {
for x in [p.amount, p.cash, p.in_kind] {
prop_assert!(x.is_zero() || (x < Decimal::ZERO) == (t.amount < Decimal::ZERO));
prop_assert!(!(x.is_zero() && x.is_sign_negative()));
}
prop_assert!(p.cash.abs() + p.in_kind.abs() <= p.amount.abs());
}
}
}
let fo = flow(&b.l, e, &shares, &money).unwrap();
let fr = flow(&b.l, r, &shares, &money).unwrap();
prop_assert_eq!(fr.net, -fo.net);
prop_assert_eq!(
fr.what_for,
fo.what_for.iter().map(|(a, x)| (*a, -*x)).collect::<Vec<_>>()
);
}
/// Property 3 of the brief: for an entry naming one party who isn't a
/// member, whose lines move money only through cash, cards and the
/// financing accounts, the party's part is entry_flow's net exactly, as
/// before P3: no money figure changed.
#[test]
fn a_sole_partys_money_part_is_the_flow_as_before(lines in arb_lines()) {
let mut b = books();
let e = b.post_cents(date(4, 1), &lines);
b.l.set_entry_party(e, Some(b.vendor)).unwrap();
let money = b.money();
let net = entry_flow(b.l.get_entry(e).unwrap(), &money).unwrap().net;
let (t, parts) = b.resolved(e);
if !net.is_zero() {
prop_assert_eq!(t.amount, net);
prop_assert_eq!(parts[0].cash, net);
prop_assert_eq!(t.direction, if net > Decimal::ZERO { Direction::Paid } else { Direction::Received });
} else {
prop_assert_eq!(t.direction, Direction::Neither);
}
}
/// Property 5 of the brief (metamorphic, member-only books): members
/// contribute and take out money and property, alone and jointly, some
/// of it subject to a loan, some through the members' financing
/// account converted to capital, some through their distributions
/// accounts, some declared to distributions payable and paid later,
/// and reverse some of it later in the year. For each member, over
/// every span from the year's start to any date (B-35 item 4's
/// conditions hold: a reversal in such a span always has its original
/// in it): basis all is the statement of members' equity's
/// contributions and distributions, but for a distribution declared
/// and not yet paid, which party totals count when it is paid; and
/// basis cash's received is the K-1 worksheet's cash contributions.
/// Over any span at all, received less paid on basis all is the
/// statement's contributions less distributions (the same exception).
#[test]
fn member_totals_are_the_members_equity_statement(
ops in prop::collection::vec(
(0u8..11, 0usize..2, 1i64..200_000, 1i64..200_000, any::<bool>(), 1u32..30),
1..12,
),
) {
let mut b = books();
let caps = [b.cap_t, b.cap_m];
let dists = [b.dist_t, b.dist_m];
let who = [b.tobin, b.morgan];
// (member, declared on, paid on, amount): distributions declared
// to distributions payable.
let mut declared: Vec<(usize, NaiveDate, NaiveDate, Decimal)> = Vec::new();
let mut dates = std::collections::BTreeSet::new();
for (i, &(op, mem, x, y, undo, later)) in ops.iter().enumerate() {
let day = date(1 + (i as u32 % 11), 1 + (i as u32 % 27));
let undo_on = day + chrono::Days::new(u64::from(later));
let (cap, dist, p) = (caps[mem], dists[mem], who[mem]);
let c = |v: i64| Decimal::new(v, 2).to_string();
let (sx, sy, sxy, s2y) = (c(x), c(y), c(x + y), c(2 * y));
let lines: Vec<(AccountId, Side, String)> = match op {
// Cash in.
0 => vec![(b.cash, Dr, sx.clone()), (cap, Cr, sx.clone())],
// Property in.
1 => vec![(b.equip, Dr, sx.clone()), (cap, Cr, sx.clone())],
// Cash and property in, one entry.
2 => vec![(b.cash, Dr, sx.clone()), (b.equip, Dr, sy.clone()), (cap, Cr, sxy.clone())],
// Property subject to a loan: x + y of it, y the loan.
3 => vec![(b.equip, Dr, sxy.clone()), (b.note, Cr, sy.clone()), (cap, Cr, sx.clone())],
// Cash out.
4 => vec![(cap, Dr, sx.clone()), (b.cash, Cr, sx.clone())],
// Property out.
5 => vec![(cap, Dr, sx.clone()), (b.equip, Cr, sx.clone())],
// What the member advanced (tagged with the vendor), then
// converted to capital.
6 => {
b.post(day, &[(b.hosting, Dr, &sx), (b.due, Cr, &sx)], &[whole(b.vendor)]);
vec![(b.due, Dr, sx.clone()), (cap, Cr, sx.clone())]
}
// A joint contribution of cash and property.
7 => vec![
(b.cash, Dr, sx.clone()),
(b.prepaid, Dr, sy.clone()),
(b.cap_t, Cr, sx.clone()),
(b.cap_m, Cr, sy.clone()),
],
// Money and depreciated property out, through the
// distributions account: x of cash, and equipment of cost
// 2y with y depreciated (y carried).
8 => vec![
(dist, Dr, sxy.clone()),
(b.accum, Dr, sy.clone()),
(b.equip, Cr, s2y.clone()),
(b.cash, Cr, sx.clone()),
],
// Declared to distributions payable, paid at the year's
// end.
9 => {
let paid_on = date(12, 31);
declared.push((mem, day, paid_on, Decimal::new(x, 2)));
let pay = b.post(paid_on, &[(b.dist_payable, Dr, &sx), (b.cash, Cr, &sx)], &[whole(p)]);
dates.insert(b.l.get_entry(pay).unwrap().date());
vec![(dist, Dr, sx.clone()), (b.dist_payable, Cr, sx.clone())]
}
// An expense paid straight to capital.
_ => vec![(b.hosting, Dr, sx.clone()), (cap, Cr, sx.clone())],
};
let shares = if op == 7 { vec![whole(b.tobin), whole(b.morgan)] } else { vec![whole(p)] };
let lines: Vec<(AccountId, Side, &str)> =
lines.iter().map(|(a, s, v)| (*a, *s, v.as_str())).collect();
let e = b.post(day, &lines, &shares);
dates.insert(day);
if undo && op != 9 {
b.reverse(e, undo_on);
dates.insert(undo_on);
}
}
let (start, end) = year();
dates.insert(end);
let check = |span: (NaiveDate, NaiveDate), prefix: bool| -> Result<(), TestCaseError> {
let me = members_equity(&b.l, span.0, span.1, &b.capitals()).unwrap();
let k1 = k1_detail(&b.l, span.0, span.1, &b.capitals(), &[b.cash]).unwrap();
let within = |d: NaiveDate| d >= span.0 && d <= span.1;
for (i, p) in [b.tobin, b.morgan].into_iter().enumerate() {
let (paid_all, received_all) = activity_with(&b.l, p, span, &b.money(), &b.members()).totals(Basis::All);
let (_, received_cash) = activity_with(&b.l, p, span, &b.money(), &b.members()).totals(Basis::Cash);
// Declared in the span: in the statement, not yet in party
// totals; paid in the span: in party totals only.
let owed: Decimal = declared.iter().filter(|x| x.0 == i && within(x.1)).map(|x| x.3).sum();
let paid: Decimal = declared.iter().filter(|x| x.0 == i && within(x.2)).map(|x| x.3).sum();
let col = &me.members[i];
prop_assert_eq!(
received_all - paid_all,
col.contributions - col.distributions + owed - paid,
"member {} over {:?}", i, span
);
if prefix {
prop_assert_eq!(received_all, col.contributions, "member {} through {}", i, span.1);
prop_assert_eq!(paid_all, col.distributions - owed + paid, "member {} through {}", i, span.1);
prop_assert_eq!(received_cash, k1.rows[i].contributions_cash, "member {} through {}", i, span.1);
}
}
Ok(())
};
for &through in &dates {
check((start, through), true)?;
}
let ds: Vec<NaiveDate> = dates.iter().copied().collect();
for (n, &from) in ds.iter().enumerate() {
for &to in &ds[n..] {
check((from, to), false)?;
}
}
}
/// Defect 11 property: for a party built by any sequence of TIN and
/// W-9 facts the ledger accepts, tin_last4 is every source present.
#[test]
fn tin_last4_is_every_source_present(
steps in prop::collection::vec((any::<bool>(), 0u16..3, any::<bool>()), 0..8),
) {
let mut l = Ledger::new(Currency::USD);
let p = l.add_party("Riley Chen", &[Role::Contractor]).unwrap();
let fours = ["1234", "5678", "9012"];
for (tin, which, clear) in steps {
let last4 = fours[usize::from(which)].to_string();
let _ = if tin {
l.set_party_tin(p, (!clear).then_some(TinFacts {
tin_type: TinType::Ssn,
last4,
vault_record: None,
purged: None,
}))
} else {
l.set_party_w9(p, (!clear).then_some(W9Facts {
received: date(10, 15),
entity_type: "individual/sole proprietor".into(),
exempt_payee_code: None,
fatca_code: None,
tin_last4: last4,
kept_at: "CPA portal".into(),
vault_record: None,
}))
};
let party = l.get_party(p).unwrap();
let sources: Vec<&str> = party
.tin
.iter()
.map(|t| t.last4.as_str())
.chain(party.w9.iter().map(|w| w.tin_last4.as_str()))
.collect();
match party.tin_last4() {
None => prop_assert!(sources.is_empty()),
Some(l4) => prop_assert!(!sources.is_empty() && sources.iter().all(|s| *s == l4)),
}
}
}
}
/// shares::target refuses an unknown entry; target on a draft works (a
/// preview).
#[test]
fn a_target_needs_an_entry() {
let b = books();
assert_eq!(
target(&b.l, 999, &[], &b.money()),
Err(LedgerError::UnknownEntry(999))
);
}