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

+105
View File
@@ -55,6 +55,12 @@ pub struct Member {
/// Code of their distributions account, if distributions are recorded
/// separately from capital (zeroed into capital at close).
pub distribution_account: Option<String>,
/// `files_jointly_with`: the other member this member files a joint
/// individual return with (spouses). Each still gets their own K-1
/// (a partnership reports each partner separately, IRS Instructions
/// for Form 1065, Schedule K-1); both K-1s go on the one Form 1040
/// (Schedule E, Part II). Informational: the CPA package says so.
pub files_jointly_with: Option<String>,
}
impl Default for Company {
@@ -73,6 +79,34 @@ impl Default for Company {
}
impl Company {
/// The members as party shares see them ([`ledger_core::PartyMember`]):
/// each member whose name (ignoring case and surrounding space) is a
/// party with the member role not archived, and whose capital account
/// is in the chart. Members missing either are left out, so their
/// shares can't be derived (and the viewer shows no capital
/// statement for them).
pub fn party_members(&self, ledger: &ledger_core::Ledger) -> Vec<ledger_core::PartyMember> {
self.members
.iter()
.filter_map(|m| {
let party = ledger.party_by_name(&m.name)?;
if !party.roles.contains(&ledger_core::Role::Member) {
return None;
}
Some(ledger_core::PartyMember {
party: party.id,
share: m.share,
capital: ledger.chart().find_by_code(&m.capital_account)?.id,
distributions: m
.distribution_account
.as_deref()
.and_then(|c| ledger.chart().find_by_code(c))
.map(|a| a.id),
})
})
.collect()
}
/// The fiscal year containing `d`: `(first day, last day)`, inclusive.
pub fn fiscal_year_containing(&self, d: NaiveDate) -> (NaiveDate, NaiveDate) {
let (m, day) = self.fiscal_year_start;
@@ -199,6 +233,7 @@ struct MemberToml {
share: String,
capital_account: Option<String>,
distribution_account: Option<String>,
files_jointly_with: Option<String>,
}
#[derive(Default, Deserialize)]
@@ -411,6 +446,10 @@ pub fn parse(text: &str) -> Result<BooksSettings, String> {
.distribution_account
.map(|d| d.trim().to_string())
.filter(|d| !d.is_empty()),
files_jointly_with: m
.files_jointly_with
.map(|d| d.trim().to_string())
.filter(|d| !d.is_empty()),
});
}
for (i, m) in company.members.iter().enumerate() {
@@ -437,6 +476,32 @@ pub fn parse(text: &str) -> Result<BooksSettings, String> {
}
}
}
// files_jointly_with names another member, and both say so.
for m in &company.members {
if let Some(j) = &m.files_jointly_with {
if *j == m.name {
return Err(format!(
"member {}: files_jointly_with names themself",
m.name
));
}
match company.members.iter().find(|o| o.name == *j) {
None => {
return Err(format!(
"member {}: files_jointly_with {j:?} isn't a member",
m.name
));
}
Some(o) if o.files_jointly_with.as_deref() != Some(m.name.as_str()) => {
return Err(format!(
"member {} files jointly with {j}, but {j}'s files_jointly_with doesn't name {}: set it on both",
m.name, m.name
));
}
Some(_) => {}
}
}
}
if !company.members.is_empty() {
let total: Decimal = company.members.iter().map(|m| m.share).sum();
if total != Decimal::ONE {
@@ -586,6 +651,44 @@ pub fn load(path: &Path) -> Result<BooksSettings, String> {
mod tests {
use super::*;
/// files_jointly_with: names another member, on both sides.
#[test]
fn files_jointly_with() {
let m = |a: &str, b: &str| {
format!(
"[company]\nname = \"X\"\n[[company.members]]\nname = \"A\"\nshare = \"50%\"\ncapital_account = \"3000\"\n{a}\n[[company.members]]\nname = \"B\"\nshare = \"50%\"\ncapital_account = \"3001\"\n{b}\n"
)
};
let ok = parse(&m(
"files_jointly_with = \"B\"",
"files_jointly_with = \"A\"",
))
.unwrap();
assert_eq!(
ok.company.members[0].files_jointly_with.as_deref(),
Some("B")
);
assert!(
parse(&m("files_jointly_with = \"B\"", ""))
.unwrap_err()
.contains("set it on both")
);
assert!(
parse(&m("files_jointly_with = \"C\"", ""))
.unwrap_err()
.contains("isn't a member")
);
assert!(
parse(&m("files_jointly_with = \"A\"", ""))
.unwrap_err()
.contains("themself")
);
assert_eq!(
parse(&m("", "")).unwrap().company.members[1].files_jointly_with,
None
);
}
/// `[books] parties`: suggest by default; off, suggest, require (and
/// "required") parse; anything else is refused, naming the choices.
#[test]
@@ -691,12 +794,14 @@ mod tests {
share: half,
capital_account: "3000".into(),
distribution_account: None,
files_jointly_with: None,
},
Member {
name: "Morgan Ellis".into(),
share: half,
capital_account: "3001".into(),
distribution_account: None,
files_jointly_with: None,
},
]
);