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