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
@@ -752,3 +752,190 @@ fn books_from_before_parties_open_and_get_parties_by_replay() {
|
||||
let s = Store::open(&dst).unwrap();
|
||||
assert_eq!(view(s.ledger()), before);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------- party shares
|
||||
|
||||
/// Oracle: Store::set_entry_parties doc (`entry.parties_set {entry_id,
|
||||
/// parties: [{party_id, share}]}`, a share `{"amount": "30.00"}`,
|
||||
/// `{"percent": "60"}`, or `"derived"`; refusals journal nothing) and
|
||||
/// Store::verify (a fresh replay equals the live ledger).
|
||||
mod shares {
|
||||
use super::*;
|
||||
use ledger_core::{PartyShare, Share};
|
||||
|
||||
fn share(party: PartyId, share: Share) -> PartyShare {
|
||||
PartyShare { party, share }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_entry_parties_journals_one_event_with_each_share() {
|
||||
let dir = test_dir("shares-event");
|
||||
let (mut s, fx) = fixture(&dir);
|
||||
let a = s.add_party("Tobin Harlow", &[Role::Member]).unwrap();
|
||||
let b = s
|
||||
.add_party("Morgan Ellis", &[Role::Member])
|
||||
.unwrap();
|
||||
let ((), pl) = one_event(&mut s, &dir, "entry.parties_set", |s| {
|
||||
s.set_entry_parties(fx.entry, &[PartyShare::whole(a), PartyShare::whole(b)])
|
||||
.unwrap()
|
||||
});
|
||||
assert_eq!(
|
||||
pl,
|
||||
json!({"entry_id": fx.entry, "parties": [
|
||||
{"party_id": a, "share": "derived"},
|
||||
{"party_id": b, "share": "derived"},
|
||||
]})
|
||||
);
|
||||
let ((), pl) = one_event(&mut s, &dir, "entry.parties_set", |s| {
|
||||
s.set_entry_parties(
|
||||
fx.entry,
|
||||
&[
|
||||
share(a, Share::Amount(dec("300.00"))),
|
||||
share(b, Share::Amount(dec("200.00"))),
|
||||
],
|
||||
)
|
||||
.unwrap()
|
||||
});
|
||||
assert_eq!(
|
||||
pl,
|
||||
json!({"entry_id": fx.entry, "parties": [
|
||||
{"party_id": a, "share": {"amount": "300.00"}},
|
||||
{"party_id": b, "share": {"amount": "200.00"}},
|
||||
]})
|
||||
);
|
||||
let ((), pl) = one_event(&mut s, &dir, "entry.parties_set", |s| {
|
||||
s.set_entry_parties(
|
||||
fx.entry,
|
||||
&[
|
||||
share(a, Share::Percent(dec("60"))),
|
||||
share(b, Share::Percent(dec("40"))),
|
||||
],
|
||||
)
|
||||
.unwrap()
|
||||
});
|
||||
assert_eq!(
|
||||
pl["parties"][0]["share"],
|
||||
json!({"percent": "60"}),
|
||||
"percent shares journal as strings"
|
||||
);
|
||||
assert_eq!(s.ledger().entry_parties(fx.entry).len(), 2);
|
||||
assert_eq!(s.ledger().entry_party(fx.entry), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refusals_journal_nothing() {
|
||||
let dir = test_dir("shares-refused");
|
||||
let (mut s, fx) = fixture(&dir);
|
||||
let a = s.add_party("A", &[Role::Member]).unwrap();
|
||||
let b = s.add_party("B", &[Role::Member]).unwrap();
|
||||
let v1 = s.add_vendor("V1").unwrap();
|
||||
let v2 = s.add_vendor("V2").unwrap();
|
||||
let (p1, p2) = (
|
||||
s.ledger().party_of_vendor(v1).unwrap(),
|
||||
s.ledger().party_of_vendor(v2).unwrap(),
|
||||
);
|
||||
let cases: Vec<(&str, EntryId, Vec<PartyShare>)> = vec![
|
||||
("unknown entry", fx.entry + 99, vec![PartyShare::whole(a)]),
|
||||
("unknown party", fx.entry, vec![PartyShare::whole(999)]),
|
||||
(
|
||||
"duplicate",
|
||||
fx.entry,
|
||||
vec![PartyShare::whole(a), PartyShare::whole(a)],
|
||||
),
|
||||
(
|
||||
"mixed",
|
||||
fx.entry,
|
||||
vec![share(a, Share::Percent(dec("50"))), PartyShare::whole(b)],
|
||||
),
|
||||
(
|
||||
"percent sum",
|
||||
fx.entry,
|
||||
vec![
|
||||
share(a, Share::Percent(dec("50"))),
|
||||
share(b, Share::Percent(dec("40"))),
|
||||
],
|
||||
),
|
||||
(
|
||||
"sub-cent",
|
||||
fx.entry,
|
||||
vec![
|
||||
share(a, Share::Amount(dec("250.001"))),
|
||||
share(b, Share::Amount(dec("249.999"))),
|
||||
],
|
||||
),
|
||||
(
|
||||
"two vendors",
|
||||
fx.entry,
|
||||
vec![
|
||||
share(p1, Share::Amount(dec("250.00"))),
|
||||
share(p2, Share::Amount(dec("250.00"))),
|
||||
],
|
||||
),
|
||||
];
|
||||
for (what, e, shares) in cases {
|
||||
refused_nothing_journaled(&mut s, &dir, what, |s| s.set_entry_parties(e, &shares));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replay_gives_equal_state() {
|
||||
let dir = test_dir("shares-replay");
|
||||
let (mut s, fx) = fixture(&dir);
|
||||
let a = s.add_party("Tobin Harlow", &[Role::Member]).unwrap();
|
||||
let b = s
|
||||
.add_party("Morgan Ellis", &[Role::Member])
|
||||
.unwrap();
|
||||
let v = s.add_vendor("Contractor").unwrap();
|
||||
let pv = s.ledger().party_of_vendor(v).unwrap();
|
||||
s.set_entry_parties(
|
||||
fx.entry,
|
||||
&[
|
||||
share(a, Share::Amount(dec("300.00"))),
|
||||
share(b, Share::Amount(dec("200.00"))),
|
||||
],
|
||||
)
|
||||
.unwrap();
|
||||
// A second entry shared with a vendor party, then reversed.
|
||||
let other = s.create_entry(date(2026, 2, 1), "Split work").unwrap();
|
||||
let exp = s
|
||||
.add_account("6000", "Work", AccountType::Expense, None, false)
|
||||
.unwrap();
|
||||
s.add_line(other, exp, Side::Debit, usd("90.00")).unwrap();
|
||||
s.add_line(other, fx.cash, Side::Credit, usd("90.00"))
|
||||
.unwrap();
|
||||
s.post_entry(other).unwrap();
|
||||
s.set_entry_parties(
|
||||
other,
|
||||
&[
|
||||
share(pv, Share::Percent(dec("66.67"))),
|
||||
share(a, Share::Percent(dec("33.33"))),
|
||||
],
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(s.ledger().entry_vendor(other), Some(v));
|
||||
let r = s.reverse_entry(other, date(2026, 2, 2), "undo").unwrap();
|
||||
assert_eq!(s.ledger().entry_parties(r), s.ledger().entry_parties(other));
|
||||
// An old-style single tag after a multi-party one.
|
||||
let third = s.create_entry(date(2026, 3, 1), "Draft").unwrap();
|
||||
s.set_entry_parties(third, &[PartyShare::whole(a), PartyShare::whole(b)])
|
||||
.unwrap();
|
||||
s.set_entry_party(third, Some(b)).unwrap();
|
||||
s.verify().unwrap();
|
||||
let live = s.ledger().clone();
|
||||
drop(s);
|
||||
for db in [true, false] {
|
||||
if !db {
|
||||
delete_db(&dir);
|
||||
}
|
||||
let s = Store::open(&dir).unwrap();
|
||||
assert!(
|
||||
*s.ledger() == live,
|
||||
"replay (db {db}): {:?}",
|
||||
s.ledger().differs_from(&live)
|
||||
);
|
||||
assert_eq!(s.ledger().entry_parties(r).len(), 2);
|
||||
assert_eq!(s.ledger().entry_party(third), Some(b));
|
||||
s.verify().unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user