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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
//! Spec-derived tests for a party's taxpayer-ID facts in the store.
|
||||
//!
|
||||
//! Oracles: `Store::set_party_tin` ("journaled as `party.tin_set`" {id,
|
||||
//! facts}), the `ledger_core::parties` spec (INVARIANTS 1, 2, 2a: any role,
|
||||
//! never a full TIN, the TIN and W-9 facts agree), and `Store::verify` (a
|
||||
//! fresh replay equals the live ledger). Values worked by hand.
|
||||
|
||||
use chrono::NaiveDate;
|
||||
use ledger_core::{Currency, Role, TinFacts, TinType, W9Facts};
|
||||
use ledger_store::journal::read_and_verify_journal;
|
||||
use ledger_store::{DB_FILE, JOURNAL_FILE, JournalWriter, Store};
|
||||
use serde_json::{Value, json};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
fn test_dir(name: &str) -> PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("wb-party-tin-{name}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
fn journal(dir: &Path) -> Vec<(String, Value)> {
|
||||
read_and_verify_journal(&dir.join(JOURNAL_FILE))
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.map(|l| (l.line_type, l.payload))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn tin(last4: &str) -> TinFacts {
|
||||
TinFacts {
|
||||
tin_type: TinType::Ssn,
|
||||
last4: last4.into(),
|
||||
vault_record: Some("ab".repeat(32)),
|
||||
purged: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn w9(last4: &str) -> W9Facts {
|
||||
W9Facts {
|
||||
received: NaiveDate::from_ymd_opt(2026, 1, 5).unwrap(),
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
fn delete_db(dir: &Path) {
|
||||
for suffix in ["", "-wal", "-shm", "-journal"] {
|
||||
let _ = std::fs::remove_file(dir.join(format!("{DB_FILE}{suffix}")));
|
||||
}
|
||||
}
|
||||
|
||||
/// Any role, journaled as one `party.tin_set {id, facts}` line, replayed
|
||||
/// on open (with and without the database), and cleared with null.
|
||||
#[test]
|
||||
fn tin_facts_journal_and_replay_for_any_role() {
|
||||
let dir = test_dir("replay");
|
||||
let mut s = Store::create(&dir, Currency::USD).unwrap();
|
||||
let member = s.add_party("Mo", &[Role::Member]).unwrap();
|
||||
let nobody = s.add_party("Plain", &[]).unwrap();
|
||||
s.set_party_tin(member, Some(tin("4915"))).unwrap();
|
||||
s.set_party_tin(nobody, Some(tin("1234"))).unwrap();
|
||||
s.set_party_tin(nobody, None).unwrap();
|
||||
let j = journal(&dir);
|
||||
let tins: Vec<&(String, Value)> = j.iter().filter(|(t, _)| t == "party.tin_set").collect();
|
||||
assert_eq!(tins.len(), 3);
|
||||
assert_eq!(tins[0].1["id"], json!(member));
|
||||
assert_eq!(tins[0].1["facts"]["last4"], "4915");
|
||||
assert_eq!(tins[0].1["facts"]["tin_type"], "ssn");
|
||||
assert!(tins[2].1["facts"].is_null());
|
||||
s.verify().unwrap();
|
||||
drop(s);
|
||||
for without_db in [false, true] {
|
||||
if without_db {
|
||||
delete_db(&dir);
|
||||
}
|
||||
let s = Store::open(&dir).unwrap();
|
||||
assert_eq!(s.ledger().get_party(member).unwrap().tin, Some(tin("4915")));
|
||||
assert_eq!(s.ledger().get_party(nobody).unwrap().tin, None);
|
||||
}
|
||||
}
|
||||
|
||||
/// The TIN and W-9 facts never disagree (INVARIANT 2a), and a refusal
|
||||
/// journals nothing.
|
||||
#[test]
|
||||
fn tin_and_w9_facts_agree() {
|
||||
let dir = test_dir("agree");
|
||||
let mut s = Store::create(&dir, Currency::USD).unwrap();
|
||||
let c = s.add_party("Acme", &[Role::Contractor]).unwrap();
|
||||
s.set_party_w9(c, Some(w9("2846"))).unwrap();
|
||||
let before = journal(&dir).len();
|
||||
assert!(s.set_party_tin(c, Some(tin("1111"))).is_err());
|
||||
assert!(s.set_party_tin(c, Some(tin("12"))).is_err(), "four digits");
|
||||
assert!(
|
||||
s.set_party_tin(
|
||||
c,
|
||||
Some(TinFacts {
|
||||
purged: Some("291122846".into()),
|
||||
..tin("2846")
|
||||
})
|
||||
)
|
||||
.is_err(),
|
||||
"never a full TIN"
|
||||
);
|
||||
assert!(s.set_party_tin(99, Some(tin("2846"))).is_err());
|
||||
assert_eq!(journal(&dir).len(), before);
|
||||
s.set_party_tin(c, Some(tin("2846"))).unwrap();
|
||||
assert!(s.set_party_w9(c, Some(w9("1111"))).is_err());
|
||||
// Cleared first, the W-9 may change, then the TIN facts follow.
|
||||
s.set_party_tin(c, None).unwrap();
|
||||
s.set_party_w9(c, Some(w9("1111"))).unwrap();
|
||||
s.set_party_tin(c, Some(tin("1111"))).unwrap();
|
||||
s.verify().unwrap();
|
||||
}
|
||||
|
||||
/// A `party.tin_set` line that can't replay is refused on open.
|
||||
#[test]
|
||||
fn corrupt_tin_payloads_are_refused_on_open() {
|
||||
for (i, facts) in [
|
||||
json!({"tin_type": "passport", "last4": "1234"}),
|
||||
json!({"tin_type": "ssn", "last4": "123456789"}),
|
||||
json!({"tin_type": "ssn", "last4": "1234", "vault_record": "nothex"}),
|
||||
json!({"last4": "1234"}),
|
||||
]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
let dir = test_dir(&format!("corrupt-{i}"));
|
||||
let mut s = Store::create(&dir, Currency::USD).unwrap();
|
||||
let id = s.add_party("A", &[Role::Member]).unwrap();
|
||||
drop(s);
|
||||
let mut w = JournalWriter::open(&dir.join(JOURNAL_FILE)).unwrap();
|
||||
w.append("party.tin_set", json!({"id": id, "facts": facts}))
|
||||
.unwrap();
|
||||
drop(w);
|
||||
assert!(Store::open(&dir).is_err(), "{facts} opened");
|
||||
delete_db(&dir);
|
||||
assert!(Store::open(&dir).is_err(), "{facts} opened without the db");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user