Shared parties with exact shares, member capital statements, TINs for any party, K-1 worksheet
Entries may name several parties, each with an exact amount, a percent, or a share derived from the members' capital lines or [[company.members]] shares (entry.parties_set); shares always sum exactly. The 1099 worksheet counts a vendor's share. A member's party page is their partner capital statement, from the same ledger_core::k1 source as the CPA package. books.set_tin stores any party's TIN (members' SSNs for K-1s) in the vault (party.tin_set, last four in the books). The CPA package carries a K-1 member worksheet (k1-members.csv, with a members' total that ties to Schedule L) and the 1099 worksheet with full TINs, saved outside the books only. files_jointly_with notes spouses' two K-1s land on one 1040. Adversarial review fixes: reversed contributions no longer inflate K-1 contributions and distributions; a K-1 row never takes another party's TIN; TIN detection covers dot/slash/dash separators; refusals redact TINs; vendor tagging can't silently replace shared parties; reversals follow re-apportioning. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
This commit is contained in:
1 parent
ef00ccc242
commit
e1b2509c22
32 files changed
+7232
-375
No files matched your search
@@ -262,3 +262,397 @@ fn payments_come_from_cash_and_financing_only() {
|
||||
let plain = vendor_1099_report(&l, 2026, None).unwrap();
|
||||
assert_eq!(plain.vendors[0].paid, dec("700"));
|
||||
}
|
||||
|
||||
/// Books for the shared-payment tests: bank 1000, card 2050, contract
|
||||
/// labor 6000; Dana (1099-NEC) and member Tobin as parties.
|
||||
struct Shared {
|
||||
l: Ledger,
|
||||
bank: ledger_core::AccountId,
|
||||
card: ledger_core::AccountId,
|
||||
labor: ledger_core::AccountId,
|
||||
dana: ledger_core::VendorId,
|
||||
dana_p: ledger_core::PartyId,
|
||||
tobin: ledger_core::PartyId,
|
||||
}
|
||||
|
||||
fn shared_books() -> Shared {
|
||||
let mut l = Ledger::new(Currency::USD);
|
||||
let bank = l
|
||||
.add_account("1000", "Bank", AccountType::Asset, None, false)
|
||||
.unwrap();
|
||||
let card = l
|
||||
.add_account("2050", "Card", AccountType::Liability, None, false)
|
||||
.unwrap();
|
||||
let labor = l
|
||||
.add_account("6000", "Contract Labor", AccountType::Expense, None, false)
|
||||
.unwrap();
|
||||
let dana = l.add_vendor("Dana").unwrap();
|
||||
l.set_vendor_1099(dana, Some(Form1099::Nec), true).unwrap();
|
||||
let dana_p = l.party_of_vendor(dana).unwrap();
|
||||
let tobin = l
|
||||
.add_party("Tobin", &[ledger_core::Role::Member])
|
||||
.unwrap();
|
||||
Shared {
|
||||
l,
|
||||
bank,
|
||||
card,
|
||||
labor,
|
||||
dana,
|
||||
dana_p,
|
||||
tobin,
|
||||
}
|
||||
}
|
||||
|
||||
/// Post `lines` on `date` and name `shares` on it.
|
||||
fn post_shared(
|
||||
s: &mut Shared,
|
||||
date: NaiveDate,
|
||||
lines: &[(ledger_core::AccountId, Side, &str)],
|
||||
shares: &[(ledger_core::PartyId, ledger_core::Share)],
|
||||
) -> ledger_core::EntryId {
|
||||
let e = s.l.create_entry(date, "shared payment");
|
||||
for (a, side, amt) in lines {
|
||||
s.l.add_line(e, *a, *side, Money::new(dec(amt), Currency::USD))
|
||||
.unwrap();
|
||||
}
|
||||
s.l.post_entry(e).unwrap();
|
||||
let shares: Vec<ledger_core::PartyShare> = shares
|
||||
.iter()
|
||||
.map(|&(party, share)| ledger_core::PartyShare { party, share })
|
||||
.collect();
|
||||
s.l.set_entry_parties(e, &shares).unwrap();
|
||||
e
|
||||
}
|
||||
|
||||
fn dana_paid(s: &Shared) -> (Decimal, Vec<ledger_core::EntryId>) {
|
||||
let r = vendor_1099_report(&s.l, 2026, None).unwrap();
|
||||
let line = r.vendors.iter().find(|v| v.vendor_id == s.dana).unwrap();
|
||||
(line.paid, r.unresolved)
|
||||
}
|
||||
|
||||
/// vendor_1099_report_with_members SCENARIO 3, by hand: $1,000.00 from
|
||||
/// the bank (Dr 6000 / Cr 1000), Dana $600.00 and Tobin $400.00 by
|
||||
/// amount. Payment = target = 1,000.00, so Dana's 1099 counts her share
|
||||
/// unchanged: 600.00 (not the whole 1,000.00).
|
||||
#[test]
|
||||
fn a_shared_payment_counts_the_vendors_share() {
|
||||
use ledger_core::Share::Amount;
|
||||
let mut s = shared_books();
|
||||
let (bank, labor, dana_p, tobin) = (s.bank, s.labor, s.dana_p, s.tobin);
|
||||
let e = post_shared(
|
||||
&mut s,
|
||||
d(2026, 3, 1),
|
||||
&[
|
||||
(labor, Side::Debit, "1000.00"),
|
||||
(bank, Side::Credit, "1000.00"),
|
||||
],
|
||||
&[
|
||||
(dana_p, Amount(dec("600.00"))),
|
||||
(tobin, Amount(dec("400.00"))),
|
||||
],
|
||||
);
|
||||
assert_eq!(
|
||||
s.l.entry_vendor(e),
|
||||
Some(s.dana),
|
||||
"the vendor tag follows Dana"
|
||||
);
|
||||
assert_eq!(dana_paid(&s), (dec("600.00"), vec![]));
|
||||
// With sources and members named, the same.
|
||||
let r = ledger_core::vendor_1099_report_with_members(
|
||||
&s.l,
|
||||
2026,
|
||||
None,
|
||||
&ledger_core::PaymentAccounts {
|
||||
cash: vec![bank],
|
||||
financing: vec![],
|
||||
},
|
||||
&[],
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(r.vendors[0].paid, dec("600.00"));
|
||||
assert!(!r.vendors[0].reportable, "600.00 is under 2,000");
|
||||
}
|
||||
|
||||
/// SCENARIO 4, by hand: $100.01 from the bank split 50% / 50%. Each part
|
||||
/// is 100.01 × 50 / 100 = 50.005, rounded toward zero to 50.00; the cent
|
||||
/// left over goes to the party named first (shares INVARIANT 8). Dana
|
||||
/// first: 50.01. A second payment with Dana named second: 50.00. Total
|
||||
/// 100.01.
|
||||
#[test]
|
||||
fn a_percent_split_gives_the_leftover_cent_to_the_first() {
|
||||
use ledger_core::Share::Percent;
|
||||
let mut s = shared_books();
|
||||
let (bank, labor, dana_p, tobin) = (s.bank, s.labor, s.dana_p, s.tobin);
|
||||
let lines = [
|
||||
(labor, Side::Debit, "100.01"),
|
||||
(bank, Side::Credit, "100.01"),
|
||||
];
|
||||
post_shared(
|
||||
&mut s,
|
||||
d(2026, 4, 1),
|
||||
&lines,
|
||||
&[(dana_p, Percent(dec("50"))), (tobin, Percent(dec("50")))],
|
||||
);
|
||||
assert_eq!(dana_paid(&s).0, dec("50.01"));
|
||||
post_shared(
|
||||
&mut s,
|
||||
d(2026, 4, 2),
|
||||
&lines,
|
||||
&[(tobin, Percent(dec("50"))), (dana_p, Percent(dec("50")))],
|
||||
);
|
||||
assert_eq!(dana_paid(&s).0, dec("100.01"), "50.01 + 50.00");
|
||||
}
|
||||
|
||||
/// SCENARIO 6, by hand: SCENARIO 3's payment reversed the same year. The
|
||||
/// reversal carries the shares; its target is −1,000.00, Dana's amount
|
||||
/// resolves to −600.00, and the payment is −1,000.00 = target, so she
|
||||
/// nets 600.00 − 600.00 = 0.00 (still listed: she is marked for a 1099).
|
||||
#[test]
|
||||
fn a_reversed_shared_payment_nets_to_zero() {
|
||||
use ledger_core::Share::Amount;
|
||||
let mut s = shared_books();
|
||||
let (bank, labor, dana_p, tobin) = (s.bank, s.labor, s.dana_p, s.tobin);
|
||||
let e = post_shared(
|
||||
&mut s,
|
||||
d(2026, 3, 1),
|
||||
&[
|
||||
(labor, Side::Debit, "1000.00"),
|
||||
(bank, Side::Credit, "1000.00"),
|
||||
],
|
||||
&[
|
||||
(dana_p, Amount(dec("600.00"))),
|
||||
(tobin, Amount(dec("400.00"))),
|
||||
],
|
||||
);
|
||||
let rev = s.l.reverse_entry(e, d(2026, 3, 2), "wrong payee").unwrap();
|
||||
assert_eq!(s.l.entry_parties(rev), s.l.entry_parties(e));
|
||||
assert_eq!(dana_paid(&s), (dec("0.00"), vec![]));
|
||||
}
|
||||
|
||||
/// SCENARIO 5, by hand: $1,000.00 of contract labor, 600.00 from the
|
||||
/// bank and 400.00 on the card, Dana 60% / Tobin 40%. Target (the flow
|
||||
/// net, the card counted as money) 1,000.00, Dana's share 600.00, the
|
||||
/// 1099 payment 600.00 (the card is not a payment source): 600.00 ×
|
||||
/// 600.00 / 1,000.00 = 360.00. Then $100.00 with 33.33 from the bank and
|
||||
/// 66.67 on the card, 50/50: share 50.00, 33.33 × 50.00 / 100.00 =
|
||||
/// 16.665, rounded half away from zero to 16.67. Total 376.67.
|
||||
#[test]
|
||||
fn a_shared_payment_partly_on_a_card_scales_to_the_payment() {
|
||||
use ledger_core::Share::Percent;
|
||||
let mut s = shared_books();
|
||||
let (bank, card, labor, dana_p, tobin) = (s.bank, s.card, s.labor, s.dana_p, s.tobin);
|
||||
post_shared(
|
||||
&mut s,
|
||||
d(2026, 5, 1),
|
||||
&[
|
||||
(labor, Side::Debit, "1000.00"),
|
||||
(bank, Side::Credit, "600.00"),
|
||||
(card, Side::Credit, "400.00"),
|
||||
],
|
||||
&[(dana_p, Percent(dec("60"))), (tobin, Percent(dec("40")))],
|
||||
);
|
||||
assert_eq!(dana_paid(&s).0, dec("360.00"));
|
||||
post_shared(
|
||||
&mut s,
|
||||
d(2026, 5, 2),
|
||||
&[
|
||||
(labor, Side::Debit, "100.00"),
|
||||
(bank, Side::Credit, "33.33"),
|
||||
(card, Side::Credit, "66.67"),
|
||||
],
|
||||
&[(dana_p, Percent(dec("50"))), (tobin, Percent(dec("50")))],
|
||||
);
|
||||
assert_eq!(dana_paid(&s).0, dec("376.67"));
|
||||
}
|
||||
|
||||
/// INVARIANT 4, by hand: amounts of 600.00 and 300.00 on a 1,000.00
|
||||
/// payment don't resolve (shares SCENARIO 5), so the whole 1,000.00
|
||||
/// counts toward Dana and the entry is listed. A single-party entry
|
||||
/// (300.00, Dana alone) counts in full, as before shares: 1,300.00.
|
||||
#[test]
|
||||
fn unresolved_shares_count_in_full_and_are_listed() {
|
||||
use ledger_core::Share::{Amount, Derived};
|
||||
let mut s = shared_books();
|
||||
let (bank, labor, dana_p, tobin) = (s.bank, s.labor, s.dana_p, s.tobin);
|
||||
let e = post_shared(
|
||||
&mut s,
|
||||
d(2026, 6, 1),
|
||||
&[
|
||||
(labor, Side::Debit, "1000.00"),
|
||||
(bank, Side::Credit, "1000.00"),
|
||||
],
|
||||
&[
|
||||
(dana_p, Amount(dec("600.00"))),
|
||||
(tobin, Amount(dec("300.00"))),
|
||||
],
|
||||
);
|
||||
post_shared(
|
||||
&mut s,
|
||||
d(2026, 6, 2),
|
||||
&[
|
||||
(labor, Side::Debit, "300.00"),
|
||||
(bank, Side::Credit, "300.00"),
|
||||
],
|
||||
&[(dana_p, Derived)],
|
||||
);
|
||||
assert_eq!(dana_paid(&s), (dec("1300.00"), vec![e]));
|
||||
}
|
||||
|
||||
/// The SCENARIO 3 payment: $1,000.00 from the bank, Dana 600.00 and
|
||||
/// Tobin 400.00 by amount.
|
||||
fn dana_and_tobin(s: &mut Shared) -> ledger_core::EntryId {
|
||||
use ledger_core::Share::Amount;
|
||||
let (bank, labor, dana_p, tobin) = (s.bank, s.labor, s.dana_p, s.tobin);
|
||||
post_shared(
|
||||
s,
|
||||
d(2026, 3, 1),
|
||||
&[
|
||||
(labor, Side::Debit, "1000.00"),
|
||||
(bank, Side::Credit, "1000.00"),
|
||||
],
|
||||
&[
|
||||
(dana_p, Amount(dec("600.00"))),
|
||||
(tobin, Amount(dec("400.00"))),
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
/// set_entry_vendor SCENARIO 4, by hand: re-tagging Dana's vendor on the
|
||||
/// shared payment keeps both shares, so her 1099 stays 600.00 (not the
|
||||
/// whole 1,000.00) and Tobin keeps his part. Tagging a vendor whose
|
||||
/// party isn't named is refused, changing nothing.
|
||||
#[test]
|
||||
fn tagging_the_vendor_keeps_a_shared_entrys_parties() {
|
||||
let mut s = shared_books();
|
||||
let e = dana_and_tobin(&mut s);
|
||||
let before = s.l.entry_parties(e).to_vec();
|
||||
s.l.set_entry_vendor(e, Some(s.dana)).unwrap();
|
||||
assert_eq!(s.l.entry_parties(e), before.as_slice());
|
||||
assert!(s.l.entry_names_party(e, s.tobin));
|
||||
assert_eq!(dana_paid(&s), (dec("600.00"), vec![]));
|
||||
let other = s.l.add_vendor("Other Co").unwrap();
|
||||
let err = s.l.set_entry_vendor(e, Some(other)).unwrap_err();
|
||||
assert!(matches!(err, LedgerError::InvalidParty(_)), "{err:?}");
|
||||
assert_eq!(s.l.entry_parties(e), before.as_slice());
|
||||
assert_eq!(s.l.entry_vendor(e), Some(s.dana));
|
||||
// One party or none: the vendor's party becomes the entry's one party.
|
||||
let (labor, bank, tobin) = (s.labor, s.bank, s.tobin);
|
||||
let lone = post_shared(
|
||||
&mut s,
|
||||
d(2026, 3, 5),
|
||||
&[(labor, Side::Debit, "50.00"), (bank, Side::Credit, "50.00")],
|
||||
&[(tobin, ledger_core::Share::Derived)],
|
||||
);
|
||||
s.l.set_entry_vendor(lone, Some(s.dana)).unwrap();
|
||||
assert_eq!(s.l.entry_party(lone), Some(s.dana_p));
|
||||
}
|
||||
|
||||
/// set_entry_vendor SCENARIO 5: clearing the tag on the shared payment,
|
||||
/// whose vendor's party (Dana) is named, is refused and changes nothing,
|
||||
/// so the 1099 (600.00) and Dana's party activity never disagree.
|
||||
#[test]
|
||||
fn clearing_the_vendor_on_a_shared_entry_is_refused() {
|
||||
let mut s = shared_books();
|
||||
let e = dana_and_tobin(&mut s);
|
||||
let err = s.l.set_entry_vendor(e, None).unwrap_err();
|
||||
assert!(matches!(err, LedgerError::InvalidParty(_)), "{err:?}");
|
||||
assert_eq!(s.l.entry_vendor(e), Some(s.dana));
|
||||
assert!(s.l.entry_names_party(e, s.dana_p));
|
||||
assert_eq!(dana_paid(&s), (dec("600.00"), vec![]));
|
||||
// Dropping Dana from the parties clears the tag with her.
|
||||
s.l.set_entry_parties(e, &[ledger_core::PartyShare::whole(s.tobin)])
|
||||
.unwrap();
|
||||
assert_eq!(s.l.entry_vendor(e), None);
|
||||
}
|
||||
|
||||
/// set_entry_parties SCENARIO 3, by hand: the shared payment reversed,
|
||||
/// then re-apportioned 700.00 / 300.00. The reversal is re-apportioned
|
||||
/// too, so Dana nets 700.00 − 700.00 = 0.00 (not 700.00 − 600.00 =
|
||||
/// 100.00). Re-apportioning the reversal itself does the same for the
|
||||
/// original.
|
||||
#[test]
|
||||
fn re_apportioning_a_reversed_entry_re_apportions_its_reversal() {
|
||||
use ledger_core::Share::Amount;
|
||||
let mut s = shared_books();
|
||||
let e = dana_and_tobin(&mut s);
|
||||
let rev = s.l.reverse_entry(e, d(2026, 3, 2), "wrong payee").unwrap();
|
||||
assert_eq!(s.l.reversal_chain(e), vec![e, rev]);
|
||||
assert_eq!(s.l.reversal_chain(rev), vec![e, rev]);
|
||||
let shares = |a: &str, b: &str| {
|
||||
vec![
|
||||
ledger_core::PartyShare {
|
||||
party: s.dana_p,
|
||||
share: Amount(dec(a)),
|
||||
},
|
||||
ledger_core::PartyShare {
|
||||
party: s.tobin,
|
||||
share: Amount(dec(b)),
|
||||
},
|
||||
]
|
||||
};
|
||||
let new = shares("700.00", "300.00");
|
||||
s.l.set_entry_parties(e, &new).unwrap();
|
||||
assert_eq!(s.l.entry_parties(rev), new.as_slice());
|
||||
assert_eq!(dana_paid(&s), (dec("0.00"), vec![]));
|
||||
let back = shares("550.00", "450.00");
|
||||
s.l.set_entry_parties(rev, &back).unwrap();
|
||||
assert_eq!(s.l.entry_parties(e), back.as_slice());
|
||||
assert_eq!(dana_paid(&s), (dec("0.00"), vec![]));
|
||||
// Re-tagging the vendor on a reversed one-party entry tags both.
|
||||
let (labor, bank, tobin) = (s.labor, s.bank, s.tobin);
|
||||
let lone = post_shared(
|
||||
&mut s,
|
||||
d(2026, 4, 1),
|
||||
&[(labor, Side::Debit, "80.00"), (bank, Side::Credit, "80.00")],
|
||||
&[(tobin, ledger_core::Share::Derived)],
|
||||
);
|
||||
let lone_rev = s.l.reverse_entry(lone, d(2026, 4, 2), "oops").unwrap();
|
||||
s.l.set_entry_vendor(lone, Some(s.dana)).unwrap();
|
||||
assert_eq!(s.l.entry_vendor(lone_rev), Some(s.dana));
|
||||
assert_eq!(dana_paid(&s), (dec("0.00"), vec![]));
|
||||
}
|
||||
|
||||
/// add_vendor INVARIANTS 2-3: a party named on a shared entry beside
|
||||
/// Dana (who has a vendor record) can't gain a vendor record, which
|
||||
/// would leave the entry naming two vendors (shares INVARIANT 5); a party
|
||||
/// named with no vendor party gains the tag with its new record.
|
||||
#[test]
|
||||
fn a_named_party_gaining_a_vendor_record_keeps_one_vendor_per_entry() {
|
||||
use ledger_core::Share::Percent;
|
||||
let mut s = shared_books();
|
||||
let beta = s.l.add_party("Beta", &[]).unwrap();
|
||||
let (bank, labor, dana_p) = (s.bank, s.labor, s.dana_p);
|
||||
let e = post_shared(
|
||||
&mut s,
|
||||
d(2026, 7, 1),
|
||||
&[
|
||||
(labor, Side::Debit, "100.00"),
|
||||
(bank, Side::Credit, "100.00"),
|
||||
],
|
||||
&[(dana_p, Percent(dec("60"))), (beta, Percent(dec("40")))],
|
||||
);
|
||||
let vendors_before = s.l.list_vendors().len();
|
||||
let err = s.l.add_vendor("Beta").unwrap_err();
|
||||
assert!(matches!(err, LedgerError::InvalidParty(_)), "{err:?}");
|
||||
assert_eq!(s.l.list_vendors().len(), vendors_before, "nothing changed");
|
||||
assert_eq!(s.l.get_party(beta).unwrap().vendor, None);
|
||||
// Re-issuing the same shares is still accepted.
|
||||
let same = s.l.entry_parties(e).to_vec();
|
||||
s.l.set_entry_parties(e, &same).unwrap();
|
||||
// Gamma, named only beside Tobin (a member, no vendor record).
|
||||
let gamma = s.l.add_party("Gamma", &[]).unwrap();
|
||||
let tobin = s.tobin;
|
||||
let g = post_shared(
|
||||
&mut s,
|
||||
d(2026, 7, 2),
|
||||
&[
|
||||
(labor, Side::Debit, "100.00"),
|
||||
(bank, Side::Credit, "100.00"),
|
||||
],
|
||||
&[(tobin, Percent(dec("50"))), (gamma, Percent(dec("50")))],
|
||||
);
|
||||
assert_eq!(s.l.entry_vendor(g), None);
|
||||
let gv = s.l.add_vendor("Gamma").unwrap();
|
||||
assert_eq!(s.l.get_party(gamma).unwrap().vendor, Some(gv));
|
||||
assert_eq!(s.l.entry_vendor(g), Some(gv), "the tag follows the party");
|
||||
}
|
||||
@@ -14,7 +14,7 @@ use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::str::FromStr;
|
||||
|
||||
use chrono::NaiveDate;
|
||||
use ledger_core::parties::{check_text, has_full_tin, name_key};
|
||||
use ledger_core::parties::{check_text, has_full_tin, name_key, redact_full_tins};
|
||||
use ledger_core::{
|
||||
AccountId, AccountType, Currency, EntryId, Ledger, Money, Party, PartyField, PartyId,
|
||||
PayrollLine, Role, Side, W4Facts, W9Facts,
|
||||
@@ -129,6 +129,7 @@ fn bare_party(name: &str) -> Party {
|
||||
archived: false,
|
||||
w9: None,
|
||||
w4: None,
|
||||
tin: None,
|
||||
vendor: None,
|
||||
customer: None,
|
||||
}
|
||||
@@ -151,8 +152,9 @@ fn pay_line(name: &str, employee: Option<PartyId>) -> PayrollLine {
|
||||
|
||||
// ------------------------------------------------- has_full_tin truth table
|
||||
|
||||
/// Oracle: `has_full_tin` doc ("a run of nine digits, with at most one
|
||||
/// space or hyphen between neighbors") and INVARIANT 1.
|
||||
/// Oracle: `has_full_tin` doc ("a run of nine digits, with at most three
|
||||
/// separators (space, hyphen, dot, slash, underscore, a Unicode dash or
|
||||
/// no-break space) between neighbors") and INVARIANT 1.
|
||||
#[test]
|
||||
fn has_full_tin_truth_table() {
|
||||
let cases: &[(&str, bool)] = &[
|
||||
@@ -164,11 +166,20 @@ fn has_full_tin_truth_table() {
|
||||
("1-2-3-4-5-6-7-8-9", true),
|
||||
("TIN 123456789 on file", true), // inside longer text
|
||||
("abc123456789def", true),
|
||||
("12 3456789", false), // two spaces break the run: 2 + 7 digits
|
||||
("12--3456789", false), // two hyphens
|
||||
("12 -3456789", false), // space and hyphen between neighbors
|
||||
("12\t3456789", false), // a tab is not a space
|
||||
("12345678", false), // eight digits
|
||||
("12 3456789", true), // two spaces
|
||||
("12--3456789", true), // two hyphens
|
||||
("12 -3456789", true), // space and hyphen between neighbors
|
||||
("241.40.4915", true), // dots
|
||||
("wrong one, was 241.40.4915", true),
|
||||
("291/12/2846", true), // slashes
|
||||
("291_12_2846", true), // underscores
|
||||
("Bob 291 - 12 - 2846", true), // three separators
|
||||
("291\u{2013}12\u{2013}2846", true), // en dashes
|
||||
("291\u{a0}12\u{a0}2846", true), // no-break spaces
|
||||
("12 3456789", false), // four separators break the run: 2 + 7
|
||||
("571, 93, 2846", false), // a comma is not a separator
|
||||
("12\t3456789", false), // a tab is not a space
|
||||
("12345678", false), // eight digits
|
||||
("123-45-678", false),
|
||||
("1234", false),
|
||||
("", false),
|
||||
@@ -181,6 +192,38 @@ fn has_full_tin_truth_table() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Oracle: `redact_full_tins` doc: every run `has_full_tin` finds has its
|
||||
/// digits masked, separators kept; other text is untouched.
|
||||
#[test]
|
||||
fn redact_full_tins_masks_every_full_tin() {
|
||||
assert_eq!(
|
||||
redact_full_tins("unknown party \"291-12-2846\""),
|
||||
"unknown party \"•••-••-••••\""
|
||||
);
|
||||
assert_eq!(
|
||||
redact_full_tins("was 241.40.4915 and 12-3456789, call 801-555"),
|
||||
"was •••.••.•••• and ••-•••••••, call 801-555"
|
||||
);
|
||||
assert_eq!(
|
||||
redact_full_tins("invalid type: integer `291122846`"),
|
||||
"invalid type: integer `•••••••••`"
|
||||
);
|
||||
assert_eq!(redact_full_tins("party 12 of 3"), "party 12 of 3");
|
||||
for t in ["291 - 12 - 2846", "291122846", "1234567890"] {
|
||||
assert!(!has_full_tin(&redact_full_tins(t)), "{t}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Oracle: `check_text` refuses what `has_full_tin` finds, so a purge
|
||||
/// reason or note quoting the old number with dots is refused (B-26: no
|
||||
/// party field may hold a nine-digit number).
|
||||
#[test]
|
||||
fn check_text_refuses_a_dotted_tin() {
|
||||
assert!(check_text("purged", "wrong one, was 241.40.4915").is_err());
|
||||
assert!(check_text("no_party_reason", "ssn 291.12.2846").is_err());
|
||||
assert!(check_text("name", "Bob 291 - 12 - 2846").is_err());
|
||||
}
|
||||
|
||||
/// Oracle: INVARIANT 1 says "any run of nine digits"; a run of ten digits
|
||||
/// holds a run of nine.
|
||||
#[test]
|
||||
@@ -1210,3 +1253,571 @@ fn entry_flow_scenarios() {
|
||||
let f = case(&[(exp, D, "100.00"), (bank, C, "60.00"), (card, C, "40.00")]);
|
||||
assert_eq!(f.net, dec("100.00"));
|
||||
}
|
||||
|
||||
// ------------------------------------------------------- party shares
|
||||
//
|
||||
// Oracles: the spec block of `ledger_core::shares` (DEFINITIONS,
|
||||
// INVARIANTS 1–10, SCENARIOS 1–5), `Ledger::set_entry_parties` (INVARIANTS
|
||||
// 1–4), `parties::activity_with` (INVARIANTS 1–4), and
|
||||
// docs/parties-and-attachments.md "Shares". Values worked by hand.
|
||||
|
||||
mod shares {
|
||||
use super::{d, date, refused, usd};
|
||||
use ledger_core::parties::{activity_with, money_accounts};
|
||||
use ledger_core::shares::{check_shares, resolve};
|
||||
use ledger_core::{
|
||||
AccountId, AccountType, Currency, EntryId, Ledger, PartyId, PartyMember, PartyShare, Role,
|
||||
Share, Side,
|
||||
};
|
||||
use proptest::prelude::*;
|
||||
use rust_decimal::Decimal;
|
||||
|
||||
struct Books {
|
||||
l: Ledger,
|
||||
bank: AccountId,
|
||||
due: AccountId,
|
||||
cap_s: AccountId,
|
||||
cap_k: AccountId,
|
||||
equip: AccountId,
|
||||
exp: AccountId,
|
||||
tobin: PartyId,
|
||||
mo: PartyId,
|
||||
acme: PartyId,
|
||||
members: Vec<PartyMember>,
|
||||
}
|
||||
|
||||
/// Bluevine 1000, Due to Shareholders 2100, capital 3000 and 3001,
|
||||
/// equipment 1500, an expense 6050; members Tobin and Morgan at
|
||||
/// 50/50; a party Acme (no records).
|
||||
fn books() -> Books {
|
||||
let mut l = Ledger::new(Currency::USD);
|
||||
let add = |l: &mut Ledger, c: &str, t| l.add_account(c, c, t, None, false).unwrap();
|
||||
let bank = add(&mut l, "1000", AccountType::Asset);
|
||||
let equip = add(&mut l, "1500", AccountType::Asset);
|
||||
let due = add(&mut l, "2100", AccountType::Liability);
|
||||
let cap_s = add(&mut l, "3000", AccountType::Equity);
|
||||
let cap_k = add(&mut l, "3001", AccountType::Equity);
|
||||
let exp = add(&mut l, "6050", AccountType::Expense);
|
||||
let tobin = l.add_party("Tobin Harlow", &[Role::Member]).unwrap();
|
||||
let mo = l
|
||||
.add_party("Morgan Ellis", &[Role::Member])
|
||||
.unwrap();
|
||||
let acme = l.add_party("Acme", &[Role::Contractor]).unwrap();
|
||||
let half = d("0.5");
|
||||
let members = vec![
|
||||
PartyMember {
|
||||
party: tobin,
|
||||
share: half,
|
||||
capital: cap_s,
|
||||
distributions: None,
|
||||
},
|
||||
PartyMember {
|
||||
party: mo,
|
||||
share: half,
|
||||
capital: cap_k,
|
||||
distributions: None,
|
||||
},
|
||||
];
|
||||
Books {
|
||||
l,
|
||||
bank,
|
||||
due,
|
||||
cap_s,
|
||||
cap_k,
|
||||
equip,
|
||||
exp,
|
||||
tobin,
|
||||
mo,
|
||||
acme,
|
||||
members,
|
||||
}
|
||||
}
|
||||
|
||||
fn post(l: &mut Ledger, lines: &[(AccountId, Side, &str)]) -> EntryId {
|
||||
let e = l.try_create_entry(date(2026, 5, 1), "joint").unwrap();
|
||||
for (a, s, v) in lines {
|
||||
l.add_line(e, *a, *s, usd(v)).unwrap();
|
||||
}
|
||||
l.post_entry(e).unwrap();
|
||||
e
|
||||
}
|
||||
|
||||
fn derived(ps: &[PartyId]) -> Vec<PartyShare> {
|
||||
ps.iter().map(|&p| PartyShare::whole(p)).collect()
|
||||
}
|
||||
|
||||
fn amounts(ps: &[(PartyId, &str)]) -> Vec<PartyShare> {
|
||||
ps.iter()
|
||||
.map(|&(party, v)| PartyShare {
|
||||
party,
|
||||
share: Share::Amount(d(v)),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn percents(ps: &[(PartyId, &str)]) -> Vec<PartyShare> {
|
||||
ps.iter()
|
||||
.map(|&(party, v)| PartyShare {
|
||||
party,
|
||||
share: Share::Percent(d(v)),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
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,
|
||||
)
|
||||
}
|
||||
|
||||
fn year() -> (chrono::NaiveDate, chrono::NaiveDate) {
|
||||
(date(2026, 1, 1), date(2026, 12, 31))
|
||||
}
|
||||
|
||||
fn paid_received(b: &Books, p: PartyId) -> (Decimal, Decimal) {
|
||||
let a = activity_with(&b.l, p, year(), &[b.bank], &b.members);
|
||||
(a.paid, a.received)
|
||||
}
|
||||
|
||||
/// SCENARIO 1: a reimbursement to both members, derived, 50/50.
|
||||
#[test]
|
||||
fn reimbursing_both_members_splits_by_member_share() {
|
||||
let mut b = books();
|
||||
let e = post(
|
||||
&mut b.l,
|
||||
&[
|
||||
(b.due, Side::Debit, "100.00"),
|
||||
(b.bank, Side::Credit, "100.00"),
|
||||
],
|
||||
);
|
||||
b.l.set_entry_parties(e, &derived(&[b.tobin, b.mo]))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
resolved(&b, e).unwrap(),
|
||||
vec![(b.tobin, d("50.00")), (b.mo, d("50.00"))]
|
||||
);
|
||||
assert_eq!(b.l.entry_party(e), None, "two parties: no sole party");
|
||||
assert!(b.l.entry_names_party(e, b.mo));
|
||||
assert_eq!(paid_received(&b, b.tobin), (d("50.00"), Decimal::ZERO));
|
||||
assert_eq!(paid_received(&b, b.mo), (d("50.00"), Decimal::ZERO));
|
||||
}
|
||||
|
||||
/// SCENARIO 2: a joint contribution derives from the capital lines.
|
||||
#[test]
|
||||
fn joint_contribution_derives_from_capital_lines() {
|
||||
let mut b = books();
|
||||
let e = post(
|
||||
&mut b.l,
|
||||
&[
|
||||
(b.bank, Side::Debit, "300.00"),
|
||||
(b.cap_s, Side::Credit, "200.00"),
|
||||
(b.cap_k, Side::Credit, "100.00"),
|
||||
],
|
||||
);
|
||||
b.l.set_entry_parties(e, &derived(&[b.tobin, b.mo]))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
resolved(&b, e).unwrap(),
|
||||
vec![(b.tobin, d("-200.00")), (b.mo, d("-100.00"))]
|
||||
);
|
||||
assert_eq!(paid_received(&b, b.tobin), (Decimal::ZERO, d("200.00")));
|
||||
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.
|
||||
#[test]
|
||||
fn property_contribution_resolves_against_the_total() {
|
||||
let mut b = books();
|
||||
let e = post(
|
||||
&mut b.l,
|
||||
&[
|
||||
(b.equip, Side::Debit, "1000.00"),
|
||||
(b.cap_s, Side::Credit, "500.00"),
|
||||
(b.cap_k, Side::Credit, "500.00"),
|
||||
],
|
||||
);
|
||||
b.l.set_entry_parties(e, &derived(&[b.tobin, b.mo]))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
resolved(&b, e).unwrap(),
|
||||
vec![(b.tobin, d("500.00")), (b.mo, d("500.00"))]
|
||||
);
|
||||
assert_eq!(paid_received(&b, b.tobin), (Decimal::ZERO, Decimal::ZERO));
|
||||
}
|
||||
|
||||
/// Capital lines that don't account for the whole flow are refused.
|
||||
#[test]
|
||||
fn capital_lines_short_of_the_flow_are_refused() {
|
||||
let mut b = books();
|
||||
let e = post(
|
||||
&mut b.l,
|
||||
&[
|
||||
(b.bank, Side::Debit, "300.00"),
|
||||
(b.cap_s, Side::Credit, "200.00"),
|
||||
(b.exp, 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(), &[b.bank], &b.members);
|
||||
assert!(a.unresolved.contains(&e));
|
||||
assert_eq!((a.paid, a.received), (Decimal::ZERO, Decimal::ZERO));
|
||||
}
|
||||
|
||||
/// SCENARIO 4: percent rounding, remainder to the first party.
|
||||
#[test]
|
||||
fn percent_rounding_gives_the_remainder_to_the_first() {
|
||||
let mut b = books();
|
||||
let c = b.l.add_party("Third", &[]).unwrap();
|
||||
let e = post(
|
||||
&mut b.l,
|
||||
&[
|
||||
(b.exp, Side::Debit, "100.00"),
|
||||
(b.bank, Side::Credit, "100.00"),
|
||||
],
|
||||
);
|
||||
b.l.set_entry_parties(
|
||||
e,
|
||||
&percents(&[(b.acme, "33.33"), (b.tobin, "33.33"), (c, "33.34")]),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
resolved(&b, e).unwrap(),
|
||||
vec![
|
||||
(b.acme, d("33.33")),
|
||||
(b.tobin, d("33.33")),
|
||||
(c, d("33.34"))
|
||||
]
|
||||
);
|
||||
b.l.set_entry_parties(
|
||||
e,
|
||||
&percents(&[(b.acme, "33.333"), (b.tobin, "33.333"), (c, "33.334")]),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
resolved(&b, e).unwrap(),
|
||||
vec![
|
||||
(b.acme, d("33.34")),
|
||||
(b.tobin, d("33.33")),
|
||||
(c, d("33.33"))
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/// SCENARIO 5 and INVARIANT 7: amounts must sum to what moved.
|
||||
#[test]
|
||||
fn amounts_must_sum_to_the_flow() {
|
||||
let mut b = books();
|
||||
let e = post(
|
||||
&mut b.l,
|
||||
&[
|
||||
(b.exp, Side::Debit, "100.00"),
|
||||
(b.bank, Side::Credit, "100.00"),
|
||||
],
|
||||
);
|
||||
b.l.set_entry_parties(e, &amounts(&[(b.tobin, "60.00"), (b.mo, "30.00")]))
|
||||
.unwrap();
|
||||
assert!(resolved(&b, e).is_err());
|
||||
b.l.set_entry_parties(e, &amounts(&[(b.tobin, "70.00"), (b.mo, "30.00")]))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
resolved(&b, e).unwrap(),
|
||||
vec![(b.tobin, d("70.00")), (b.mo, d("30.00"))]
|
||||
);
|
||||
assert_eq!(paid_received(&b, b.tobin), (d("70.00"), Decimal::ZERO));
|
||||
}
|
||||
|
||||
/// INVARIANT 10: several derived shares need members.
|
||||
#[test]
|
||||
fn derived_needs_members_for_several_parties() {
|
||||
let mut b = books();
|
||||
let e = post(
|
||||
&mut b.l,
|
||||
&[
|
||||
(b.exp, Side::Debit, "10.00"),
|
||||
(b.bank, Side::Credit, "10.00"),
|
||||
],
|
||||
);
|
||||
b.l.set_entry_parties(e, &derived(&[b.tobin, b.acme]))
|
||||
.unwrap();
|
||||
assert!(resolved(&b, e).is_err());
|
||||
// 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());
|
||||
// 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"))]);
|
||||
}
|
||||
|
||||
/// INVARIANT 10: income shares rescale to the members named.
|
||||
#[test]
|
||||
fn member_shares_rescale_to_those_named() {
|
||||
let mut b = books();
|
||||
let third = b.l.add_party("Third Member", &[Role::Member]).unwrap();
|
||||
let cap_t =
|
||||
b.l.add_account("3002", "Capital T", AccountType::Equity, None, false)
|
||||
.unwrap();
|
||||
b.members[0].share = d("0.5");
|
||||
b.members[1].share = d("0.3");
|
||||
b.members.push(PartyMember {
|
||||
party: third,
|
||||
share: d("0.2"),
|
||||
capital: cap_t,
|
||||
distributions: None,
|
||||
});
|
||||
let e = post(
|
||||
&mut b.l,
|
||||
&[
|
||||
(b.due, Side::Debit, "80.00"),
|
||||
(b.bank, Side::Credit, "80.00"),
|
||||
],
|
||||
);
|
||||
b.l.set_entry_parties(e, &derived(&[b.tobin, b.mo]))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
resolved(&b, e).unwrap(),
|
||||
vec![(b.tobin, d("50.00")), (b.mo, d("30.00"))]
|
||||
);
|
||||
}
|
||||
|
||||
/// INVARIANTS 1–5 of shares and set_entry_parties 1: refusals change
|
||||
/// nothing.
|
||||
#[test]
|
||||
fn bad_shares_are_refused_changing_nothing() {
|
||||
let mut b = books();
|
||||
let e = post(
|
||||
&mut b.l,
|
||||
&[
|
||||
(b.exp, Side::Debit, "10.00"),
|
||||
(b.bank, Side::Credit, "10.00"),
|
||||
],
|
||||
);
|
||||
let (s, k) = (b.tobin, b.mo);
|
||||
refused(&mut b.l, "duplicate", |l| {
|
||||
l.set_entry_parties(e, &derived(&[s, s]))
|
||||
});
|
||||
refused(&mut b.l, "unknown party", |l| {
|
||||
l.set_entry_parties(e, &derived(&[s, 999]))
|
||||
});
|
||||
refused(&mut b.l, "unknown entry", |l| {
|
||||
l.set_entry_parties(999, &derived(&[s]))
|
||||
});
|
||||
refused(&mut b.l, "mixed kinds", |l| {
|
||||
l.set_entry_parties(
|
||||
e,
|
||||
&[
|
||||
PartyShare {
|
||||
party: s,
|
||||
share: Share::Amount(d("5.00")),
|
||||
},
|
||||
PartyShare::whole(k),
|
||||
],
|
||||
)
|
||||
});
|
||||
refused(&mut b.l, "percents sum to 99", |l| {
|
||||
l.set_entry_parties(e, &percents(&[(s, "50"), (k, "49")]))
|
||||
});
|
||||
refused(&mut b.l, "percent over 100", |l| {
|
||||
l.set_entry_parties(e, &percents(&[(s, "101"), (k, "-1")]))
|
||||
});
|
||||
refused(&mut b.l, "finer than a cent", |l| {
|
||||
l.set_entry_parties(e, &amounts(&[(s, "5.005"), (k, "4.995")]))
|
||||
});
|
||||
refused(&mut b.l, "zero amount", |l| {
|
||||
l.set_entry_parties(e, &amounts(&[(s, "10.00"), (k, "0")]))
|
||||
});
|
||||
let v1 = b.l.add_vendor("Vendor One").unwrap();
|
||||
let v2 = b.l.add_vendor("Vendor Two").unwrap();
|
||||
let (p1, p2) = (
|
||||
b.l.party_of_vendor(v1).unwrap(),
|
||||
b.l.party_of_vendor(v2).unwrap(),
|
||||
);
|
||||
refused(&mut b.l, "two vendors", |l| {
|
||||
l.set_entry_parties(e, &amounts(&[(p1, "5.00"), (p2, "5.00")]))
|
||||
});
|
||||
// The checker agrees on its own.
|
||||
assert!(check_shares(&percents(&[(s, "100")]), |_| true).is_ok());
|
||||
assert!(check_shares(&percents(&[(s, "60"), (k, "40.0")]), |_| true).is_ok());
|
||||
}
|
||||
|
||||
/// set_entry_parties INVARIANT 2: the vendor tag follows the one party
|
||||
/// with a vendor record; clearing clears it.
|
||||
#[test]
|
||||
fn vendor_tag_follows_the_one_vendor_party() {
|
||||
let mut b = books();
|
||||
let e = post(
|
||||
&mut b.l,
|
||||
&[
|
||||
(b.exp, Side::Debit, "10.00"),
|
||||
(b.bank, Side::Credit, "10.00"),
|
||||
],
|
||||
);
|
||||
let v = b.l.add_vendor("Vendor One").unwrap();
|
||||
let pv = b.l.party_of_vendor(v).unwrap();
|
||||
b.l.set_entry_parties(e, &amounts(&[(pv, "6.00"), (b.tobin, "4.00")]))
|
||||
.unwrap();
|
||||
assert_eq!(b.l.entry_vendor(e), Some(v));
|
||||
b.l.set_entry_parties(e, &derived(&[b.tobin, b.mo]))
|
||||
.unwrap();
|
||||
assert_eq!(b.l.entry_vendor(e), None);
|
||||
b.l.set_entry_parties(e, &[]).unwrap();
|
||||
assert!(b.l.entry_parties(e).is_empty());
|
||||
}
|
||||
|
||||
/// Single-party tagging is one derived share; INVARIANT 3: naming a
|
||||
/// party clears the no-party mark, and the mark is refused while
|
||||
/// parties are named.
|
||||
#[test]
|
||||
fn single_party_and_no_party_mark() {
|
||||
let mut b = books();
|
||||
let e = post(
|
||||
&mut b.l,
|
||||
&[
|
||||
(b.exp, Side::Debit, "10.00"),
|
||||
(b.bank, Side::Credit, "10.00"),
|
||||
],
|
||||
);
|
||||
b.l.set_entry_party(e, Some(b.acme)).unwrap();
|
||||
assert_eq!(b.l.entry_parties(e), &[PartyShare::whole(b.acme)]);
|
||||
assert_eq!(b.l.entry_party(e), Some(b.acme));
|
||||
b.l.set_entry_party(e, None).unwrap();
|
||||
b.l.set_entry_no_party(e, "own transfer").unwrap();
|
||||
b.l.set_entry_parties(e, &derived(&[b.tobin, b.mo]))
|
||||
.unwrap();
|
||||
assert_eq!(b.l.entry_no_party(e), None);
|
||||
refused(&mut b.l, "no-party while named", |l| {
|
||||
l.set_entry_no_party(e, "none")
|
||||
});
|
||||
}
|
||||
|
||||
/// Reversals carry every share; the pair nets to zero for each party.
|
||||
#[test]
|
||||
fn reversal_carries_all_shares() {
|
||||
let mut b = books();
|
||||
let e = post(
|
||||
&mut b.l,
|
||||
&[
|
||||
(b.exp, Side::Debit, "90.00"),
|
||||
(b.bank, Side::Credit, "90.00"),
|
||||
],
|
||||
);
|
||||
let shares = amounts(&[(b.tobin, "60.00"), (b.mo, "30.00")]);
|
||||
b.l.set_entry_parties(e, &shares).unwrap();
|
||||
let r = b.l.reverse_entry(e, date(2026, 5, 2), "undo").unwrap();
|
||||
assert_eq!(b.l.entry_parties(r), shares.as_slice());
|
||||
assert_eq!(
|
||||
resolved(&b, r).unwrap(),
|
||||
vec![(b.tobin, d("-60.00")), (b.mo, d("-30.00"))]
|
||||
);
|
||||
assert_eq!(paid_received(&b, b.tobin), (Decimal::ZERO, Decimal::ZERO));
|
||||
assert_eq!(paid_received(&b, b.mo), (Decimal::ZERO, Decimal::ZERO));
|
||||
}
|
||||
|
||||
/// Plain `activity` is activity_with no members: a sole party still
|
||||
/// counts the whole net.
|
||||
#[test]
|
||||
fn activity_counts_a_sole_party_in_full() {
|
||||
let mut b = books();
|
||||
let e = post(
|
||||
&mut b.l,
|
||||
&[
|
||||
(b.exp, Side::Debit, "12.34"),
|
||||
(b.bank, Side::Credit, "12.34"),
|
||||
],
|
||||
);
|
||||
b.l.set_entry_party(e, Some(b.acme)).unwrap();
|
||||
let a = ledger_core::parties::activity(&b.l, b.acme, year(), &[b.bank]);
|
||||
assert_eq!(a.paid, d("12.34"));
|
||||
assert!(a.entries.contains(&e));
|
||||
}
|
||||
|
||||
fn cents() -> impl Strategy<Value = Decimal> {
|
||||
(1i64..10_000_000).prop_map(|c| Decimal::new(c, 2))
|
||||
}
|
||||
|
||||
proptest! {
|
||||
/// INVARIANT 6: resolved shares sum to the target exactly, each
|
||||
/// with its sign, for percents (any split summing to 100, to
|
||||
/// three decimals), member-derived splits, and capital lines.
|
||||
#[test]
|
||||
fn resolved_shares_sum_exactly(
|
||||
total in cents(),
|
||||
refund in any::<bool>(),
|
||||
cuts in proptest::collection::vec(1u32..100_000, 1..6),
|
||||
member_share in 1u32..1000,
|
||||
) {
|
||||
let mut b = books();
|
||||
let (dr, cr) = if refund { (b.bank, b.exp) } else { (b.exp, b.bank) };
|
||||
let t = total.to_string();
|
||||
let e = post(&mut b.l, &[(dr, Side::Debit, &t), (cr, Side::Credit, &t)]);
|
||||
let target = if refund { -total } else { total };
|
||||
// Percents: cut 100.000 at sorted points.
|
||||
let mut points: Vec<u32> = cuts.iter().map(|c| c % 100_000).collect();
|
||||
points.sort_unstable();
|
||||
points.dedup();
|
||||
points.retain(|&p| p > 0);
|
||||
let mut bounds = vec![0u32];
|
||||
bounds.extend(points);
|
||||
bounds.push(100_000);
|
||||
let mut ps = Vec::new();
|
||||
for (i, w) in bounds.windows(2).enumerate() {
|
||||
let party = b.l.add_party(&format!("P{i}"), &[]).unwrap();
|
||||
ps.push((party, Decimal::new(i64::from(w[1] - w[0]), 3)));
|
||||
}
|
||||
let shares: Vec<PartyShare> = ps
|
||||
.iter()
|
||||
.map(|&(party, p)| PartyShare { party, share: Share::Percent(p) })
|
||||
.collect();
|
||||
b.l.set_entry_parties(e, &shares).unwrap();
|
||||
let r = resolved(&b, e).unwrap();
|
||||
prop_assert_eq!(r.iter().map(|(_, v)| *v).sum::<Decimal>(), target);
|
||||
prop_assert!(r.iter().all(|(_, v)| v.is_zero() || v.is_sign_negative() == refund));
|
||||
prop_assert!(r.iter().all(|(_, v)| v.normalize().scale() <= 2));
|
||||
// Member-derived with uneven income shares.
|
||||
let s = Decimal::new(i64::from(member_share), 3);
|
||||
b.members[0].share = s;
|
||||
b.members[1].share = Decimal::ONE - s;
|
||||
b.l.set_entry_parties(e, &derived(&[b.tobin, b.mo])).unwrap();
|
||||
let r = resolved(&b, e).unwrap();
|
||||
prop_assert_eq!(r.iter().map(|(_, v)| *v).sum::<Decimal>(), target);
|
||||
// Amounts that sum: split at the first party's resolved part.
|
||||
let first = r[0].1.abs();
|
||||
let rest = total - first;
|
||||
if !first.is_zero() && !rest.is_zero() {
|
||||
b.l.set_entry_parties(e, &[
|
||||
PartyShare { party: b.tobin, share: Share::Amount(first) },
|
||||
PartyShare { party: b.mo, share: Share::Amount(rest) },
|
||||
]).unwrap();
|
||||
let r = resolved(&b, e).unwrap();
|
||||
prop_assert_eq!(r.iter().map(|(_, v)| *v).sum::<Decimal>(), target);
|
||||
}
|
||||
}
|
||||
|
||||
/// Capital-line derivation: two members contribute any amounts in
|
||||
/// one entry; each gets their own line, summing to the flow.
|
||||
#[test]
|
||||
fn capital_line_shares_sum_exactly(a in cents(), c in cents()) {
|
||||
let mut b = books();
|
||||
let total = a + c;
|
||||
let e = post(&mut b.l, &[
|
||||
(b.bank, Side::Debit, &total.to_string()),
|
||||
(b.cap_s, Side::Credit, &a.to_string()),
|
||||
(b.cap_k, Side::Credit, &c.to_string()),
|
||||
]);
|
||||
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);
|
||||
prop_assert_eq!(sa.received + sk.received, total);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user