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:
swaitsandClaude Opus 5.5 committed 2026-10-15 18:20:18 -06:00
1 parent ef00ccc242
commit e1b2509c22
32 files changed
+7232 -375

No files matched your search

+51 -1
View File
@@ -790,6 +790,21 @@ impl Store {
Ok(())
}
/// Record or clear a party's taxpayer-ID facts (the TIN's type, last
/// four, and vault record; never the number). Journaled as
/// `party.tin_set`.
pub fn set_party_tin(
&mut self,
id: ledger_core::PartyId,
facts: Option<ledger_core::TinFacts>,
) -> Result<(), StoreError> {
self.ledger.check_set_party_tin(id, facts.as_ref())?;
let line = self.append_journal("party.tin_set", json!({"id": id, "facts": facts}))?;
self.ledger.set_party_tin(id, facts)?;
apply_line_to_sqlite(&self.conn, &line)?;
Ok(())
}
/// Record or clear a party's W-4 facts. Journaled as `party.w4_set`.
pub fn set_party_w4(
&mut self,
@@ -821,6 +836,28 @@ impl Store {
Ok(())
}
/// Name the parties an entry was with, each with their share, or clear
/// them (an empty list): `Ledger::set_entry_parties`. Checked before
/// journaling, so a refusal journals nothing. Journaled as
/// `entry.parties_set {entry_id, parties: [{party_id, share}]}`, a share
/// being `{"amount": "30.00"}`, `{"percent": "60"}`, or `"derived"`.
/// Whether the shares resolve against the entry's lines depends on the
/// settings: callers check `ledger_core::shares::resolve` first.
pub fn set_entry_parties(
&mut self,
entry: EntryId,
parties: &[ledger_core::PartyShare],
) -> Result<(), StoreError> {
self.ledger.check_set_entry_parties(entry, parties)?;
let line = self.append_journal(
"entry.parties_set",
json!({"entry_id": entry, "parties": parties}),
)?;
self.ledger.set_entry_parties(entry, parties)?;
apply_line_to_sqlite(&self.conn, &line)?;
Ok(())
}
/// Mark an entry as having no counterparty, with why
/// (`Ledger::set_entry_no_party`). Journaled as `entry.no_party
/// {entry_id, reason}`.
@@ -4072,6 +4109,12 @@ pub(crate) fn replay_line_into_ledger(
.map_err(|_| StoreError::JournalCorrupt("party.w4_set facts".into()))?;
ledger.set_party_w4(get_u64(p, "id")?, facts)?;
}
"party.tin_set" => {
let facts: Option<ledger_core::TinFacts> =
serde_json::from_value(p["facts"].clone())
.map_err(|_| StoreError::JournalCorrupt("party.tin_set facts".into()))?;
ledger.set_party_tin(get_u64(p, "id")?, facts)?;
}
"entry.party_set" => {
let party = match p.get("party_id") {
None | Some(Value::Null) => None,
@@ -4081,6 +4124,12 @@ pub(crate) fn replay_line_into_ledger(
};
ledger.set_entry_party(get_u64(p, "entry_id")?, party)?;
}
"entry.parties_set" => {
let parties: Vec<ledger_core::PartyShare> =
serde_json::from_value(p["parties"].clone())
.map_err(|_| StoreError::JournalCorrupt("entry.parties_set parties".into()))?;
ledger.set_entry_parties(get_u64(p, "entry_id")?, &parties)?;
}
"entry.no_party" => {
ledger.set_entry_no_party(get_u64(p, "entry_id")?, get_str(p, "reason")?)?;
}
@@ -4656,7 +4705,8 @@ fn apply_line_rows(conn: &Connection, line: &JournalLine) -> Result<(), StoreErr
)?;
}
"role.assigned" | "party.added" | "party.updated" | "party.role_set" | "party.w9_set"
| "party.w4_set" | "entry.party_set" | "entry.no_party" => {
| "party.w4_set" | "party.tin_set" | "entry.party_set" | "entry.parties_set"
| "entry.no_party" => {
// Control roles and the party directory live in the ledger
// (rebuilt by replay); the events mirror below keeps the
// history.
+187
View File
@@ -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();
}
}
}
+144
View File
@@ -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");
}
}