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
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user