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

+127 -25
View File
@@ -13,7 +13,9 @@
//! are reported by the card processor on 1099-K, not by the payer. A bill
//! (Cr payables) is not a payment until it is paid, and a prepayment
//! (Dr prepaid, Cr cash) counts in full when made: §6041 reports payments
//! made in the year.
//! made in the year. An entry with several parties (a payment shared
//! between a contractor and a member) counts only the vendor's share of
//! that payment ([`vendor_1099_report_with_members`]).
//!
//! Thresholds are set by law and change; [`default_threshold`] encodes
//! them and the report takes an override. Confirm with the CPA.
@@ -115,6 +117,10 @@ pub struct Vendor1099Report {
pub threshold: Decimal,
/// Every vendor marked for a 1099 or paid in the year, by name.
pub vendors: Vec<Vendor1099Line>,
/// Entries naming several parties whose shares didn't resolve, in
/// posting order: each counted in full toward its vendor until its
/// shares are fixed ([`vendor_1099_report_with_members`] INVARIANT 4).
pub unresolved: Vec<crate::EntryId>,
}
/// Cash paid per vendor in calendar `year`, from posted entries tagged
@@ -155,31 +161,109 @@ impl PaymentAccounts {
}
}
/// [`vendor_1099_report`], counting payments out of `sources`.
///
/// ```text
/// SOURCE: docs/spec/rules.md B-19 (26 U.S.C. §6041(a): payments made in
/// the year; card payments go on 1099-K).
/// INVARIANTS:
/// 1. A vendor's amount is credits minus debits to `sources` on its
/// tagged posted entries dated in `year`.
/// SCENARIOS:
/// 1. Paid from cash, or by a member through a financing liability:
/// counts. Charged to a card, or billed and unpaid: doesn't.
/// 2. A prepayment (Dr prepaid, Cr cash) counts in full; a refund to
/// cash and a reversal subtract.
/// ```
/// [`vendor_1099_report`], counting payments out of `sources`, with no
/// members: [`vendor_1099_report_with_members`] with none (an entry naming
/// several parties then needs amount or percent shares to resolve, which
/// a vendor beside anyone always does, since a vendor is not a member).
pub fn vendor_1099_report_with(
ledger: &Ledger,
year: i32,
threshold: Option<Decimal>,
sources: &PaymentAccounts,
) -> Result<Vendor1099Report, LedgerError> {
vendor_1099_report_with_members(ledger, year, threshold, sources, &[])
}
/// What an entry paid out of `sources`: credits minus debits to them.
fn payment(ledger: &Ledger, entry: &crate::JournalEntry, sources: &PaymentAccounts) -> Decimal {
entry
.lines()
.iter()
.filter(|l| sources.includes(ledger, l.account))
.map(|l| match l.side {
crate::Side::Credit => l.amount.amount,
crate::Side::Debit => -l.amount.amount,
})
.sum()
}
/// The vendor's part of `payment` when the entry names several parties:
/// its resolved share scaled from the share target to the payment (the
/// SCENARIOS of [`vendor_1099_report_with_members`], INVARIANT 3).
fn vendor_part(payment: Decimal, share: Decimal, target: Decimal) -> Decimal {
if payment == target {
share
} else if target.is_zero() {
Decimal::ZERO
} else {
(payment * share / target)
.round_dp_with_strategy(2, rust_decimal::RoundingStrategy::MidpointAwayFromZero)
}
}
/// [`vendor_1099_report`], counting payments out of `sources`, each
/// vendor's part of an entry with several parties resolved with `members`
/// (the company's members, from the settings).
///
/// ```text
/// SOURCE: docs/spec/rules.md B-19 (26 U.S.C. §6041(a): payments made in
/// the year; card payments go on 1099-K); crate::shares (an entry
/// with several parties, each with their share; DECISION, owner,
/// 2026-10-15).
/// DEFINITIONS:
/// payment(e): credits minus debits to `sources` on entry e.
/// money: crate::parties::money_accounts(ledger, sources.cash), what the
/// shares resolve against (the MCP server checks them with the same).
/// target(e): crate::shares::share_target(e, money): the entry_flow net,
/// else the total debits.
/// INVARIANTS:
/// 1. Only posted entries dated in `year` and tagged with a vendor
/// (Ledger::entry_vendor) count, toward that vendor.
/// 2. An entry naming at most one party (Ledger::entry_parties): the
/// vendor's amount is payment(e), exactly as before shares.
/// 3. An entry naming several parties (one of them the vendor's party,
/// crate::shares INVARIANT 5): the vendor's amount is its resolved
/// share s (crate::shares::resolve with money and `members`) scaled
/// to the payment: s when payment(e) = target(e) (no rounding: an
/// expense paid from cash or by a member), else
/// payment(e) × s / target(e) rounded to the nearest cent, half away
/// from zero (0 when the payment is 0: charged to a card; 0 when the
/// target is 0). The other parties' parts are not anyone's 1099.
/// 4. An entry naming several parties whose shares don't resolve (the
/// members changed in the settings, say): counts payment(e) in full
/// toward the vendor, the conservative answer, and is listed in
/// `unresolved` so the human fixes the shares.
/// SCENARIOS:
/// 1. Paid from cash, or by a member through a financing liability:
/// counts. Charged to a card, or billed and unpaid: doesn't.
/// 2. A prepayment (Dr prepaid, Cr cash) counts in full; a refund to
/// cash and a reversal subtract.
/// 3. $1,000.00 from the bank (Dr contract labor / Cr 1000) shared 60/40
/// by amount between a contractor and a member: the contractor's
/// 1099 counts 600.00.
/// 4. $100.01 from the bank, 50% / 50% contractor first: the
/// contractor gets 50.01 (the leftover cent goes to the first party,
/// crate::shares INVARIANT 8); named second, 50.00.
/// 5. $1,000.00 split 60/40, 600.00 of it from the bank and 400.00 on
/// the card: payment 600.00, target 1,000.00, the contractor counts
/// 600.00 × 600.00 / 1,000.00 = 360.00.
/// 6. Reversing scenario 3: the reversal carries the shares, resolves
/// to −600.00, and the year nets to 0.00.
/// ```
pub fn vendor_1099_report_with_members(
ledger: &Ledger,
year: i32,
threshold: Option<Decimal>,
sources: &PaymentAccounts,
members: &[crate::PartyMember],
) -> Result<Vendor1099Report, LedgerError> {
let threshold = match threshold {
Some(t) => t,
None => default_threshold(year)?,
};
let money = crate::parties::money_accounts(ledger, &sources.cash);
let mut paid: std::collections::HashMap<VendorId, Decimal> = Default::default();
let mut unresolved = Vec::new();
for entry in ledger.posted_entries() {
if entry.date().year() != year {
continue;
@@ -187,16 +271,33 @@ pub fn vendor_1099_report_with(
let Some(vendor) = ledger.entry_vendor(entry.id()) else {
continue;
};
let cash: Decimal = entry
.lines()
.iter()
.filter(|l| sources.includes(ledger, l.account))
.map(|l| match l.side {
crate::Side::Credit => l.amount.amount,
crate::Side::Debit => -l.amount.amount,
})
.sum();
*paid.entry(vendor).or_default() += cash;
let cash = payment(ledger, entry, sources);
let shares = ledger.entry_parties(entry.id());
let amount = if shares.len() < 2 {
cash
} else {
let party = ledger.party_of_vendor(vendor);
match crate::shares::resolve(entry, shares, &money, members) {
Ok(parts) => {
match parts.into_iter().find(|(p, _)| Some(*p) == party) {
Some((_, share)) => {
vendor_part(cash, share, crate::shares::share_target(entry, &money))
}
// Tagged by hand with a vendor none of the parties
// is: nothing to scale by, so the whole payment.
None => {
unresolved.push(entry.id());
cash
}
}
}
Err(_) => {
unresolved.push(entry.id());
cash
}
}
};
*paid.entry(vendor).or_default() += amount;
}
let mut vendors: Vec<Vendor1099Line> = ledger
.list_vendors()
@@ -224,5 +325,6 @@ pub fn vendor_1099_report_with(
year,
threshold,
vendors,
unresolved,
})
}
+825
View File
@@ -0,0 +1,825 @@
//! The members' K-1 worksheet: each member's capital account for the
//! year, as Schedule K-1 (Form 1065) Item L reports it, with property
//! contributed apart from cash.
//!
//! ```text
//! GOAL: hand the CPA, per member, what the K-1 needs from the books:
//! beginning capital, contributions (cash and property separately),
//! their share of income, distributions, ending capital.
//! SOURCE: Form 1065 Schedule K-1 instructions, Item L ("Partner's capital
//! account analysis", on the partnership's books); B-13;
//! DECISION (owner, 2026-10-15): the CPA package carries a K-1
//! worksheet per member.
//! INVARIANTS:
//! 1. Every figure but the contribution split is the statement of
//! changes in members' equity's (reports::members_equity) for the
//! same span, so the two never disagree.
//! 2. contributions_cash + contributions_property = contributions,
//! exactly, per member.
//! 3. Property is what an entry crediting a member's capital account
//! debits to asset accounts that are neither cash (`cash`),
//! receivables, nor prepaid: equipment, a vehicle, software a member
//! hands the company. The rest of a contribution is cash: deposits,
//! and what a member paid for the company (an expense, a debt they
//! took on, e.g. Dr 2100 / Cr capital, or a service paid in advance).
//! A prepaid account is the balance account of a prepaid-expense
//! schedule, or an asset account whose name says "prepaid" (1300
//! Prepaid Expenses before its schedule exists): a member paying the
//! company's service in advance paid cash on its behalf, and it reads
//! the same booked to capital directly or through 2100. Item L
//! property is property the member owned, with its own basis
//! (§723), never cash they spent for the company.
//! 4. Within one entry, the property (at most the members' credits in
//! it) is shared among the members credited in proportion to their
//! credits, to the cent, leftover cents by largest remainder
//! (kernel::apportion, ties to the earlier member): each takes at
//! most its own credit, and the split depends on the order the
//! members are given only for a tied leftover cent (never on line
//! order). Joint property is joint (B-13's
//! shares, the house convention that joint funds are 50/50).
//! 5. k1_detail's lines explain each row: per member, its cash lines
//! sum to contributions_cash, its property lines to
//! contributions_property, and its distribution lines to
//! −distributions, exactly (the same entries members_equity reads:
//! posted, in the span, not a period's closing entry, not cancelled
//! by its reversal in the span).
//! 6. An entry and its reversal both dated in the span cancel
//! (reports::cancelled_in_span; members_equity INVARIANT 6): neither
//! gives a line, so a reversed contribution is neither a contribution
//! nor a distribution, and a reversed distribution neither a
//! distribution nor a contribution.
//! SCENARIOS:
//! 1. A joint 1,000.00 contribution (Dr 1000 / Cr 3000 500, Cr 3001
//! 500) reversed the next day: each member 0.00 contributed, 0.00
//! distributed.
//! 2. Members prepay an annual service from joint funds straight to
//! capital (Dr 1300 Prepaid 3,000 / Cr 3000 1,500, Cr 3001 1,500):
//! 1,500.00 cash each, no property.
//! 3. A joint mixed contribution (Dr 1000 600, Dr 1500 400 / Cr 3000
//! 500, Cr 3001 500): each member 300.00 cash and 200.00 property,
//! whichever member is listed first.
//! ```
use crate::reports::{MemberCapital, members_equity};
use crate::{AccountId, AccountType, EntryId, Ledger, LedgerError, PartyId, Side};
use chrono::NaiveDate;
use rust_decimal::Decimal;
/// One member's row on the K-1 worksheet.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct K1Row {
/// The member's name, as in wanderbooks.toml.
pub name: String,
/// Share of income, e.g. 0.5.
pub share: Decimal,
/// Capital at the start of the span.
pub beginning: Decimal,
/// Contributed in cash (or paid for the company) in the span.
pub contributions_cash: Decimal,
/// Contributed as property (book value credited) in the span.
pub contributions_property: Decimal,
/// Their share of the span's net income (loss).
pub net_income: Decimal,
/// Distributions in the span (positive, subtracted).
pub distributions: Decimal,
/// Any other change, as on the statement of members' equity.
pub other: Decimal,
/// Capital at the end of the span.
pub ending: Decimal,
}
/// How one entry moved one member's capital ([`K1Line`]).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum K1Kind {
/// The cash part of a contribution (INVARIANT 3).
CashContribution,
/// The property part of a contribution (INVARIANTS 3-4).
PropertyContribution,
/// Taken out: debits to capital, net debits to distributions.
Distribution,
/// A period close allocating income into capital. Informational:
/// the row's `net_income` is the roll-forward's (INVARIANT 1), which
/// also counts income not yet closed.
IncomeClose,
}
/// One entry's effect on one member's capital in the span, by kind
/// ([`k1_detail`]).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct K1Line {
/// The member's index in the `members` given.
pub member: usize,
/// The entry.
pub entry: EntryId,
/// What it was.
pub kind: K1Kind,
/// Signed: positive raises their capital, negative lowers it.
pub amount: Decimal,
}
/// The K-1 worksheet with the entries behind it ([`k1_detail`]).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct K1Detail {
/// One row per member, in order: [`k1_worksheet`]'s.
pub rows: Vec<K1Row>,
/// Every entry dated in the span that moved a member's capital
/// account (or distributions account), in journal order, a kind per
/// line (an entry with more than one kind for a member is more than
/// one line). Per member (INVARIANT 5):
/// Σ CashContribution = contributions_cash,
/// Σ PropertyContribution = contributions_property, and
/// −Σ Distribution = distributions, exactly.
pub lines: Vec<K1Line>,
}
/// The K-1 worksheet for `start..=end`: one row per member, in order
/// (INVARIANTS 1-4). `cash` are the company's cash accounts; it must not
/// be empty (without it a bank deposit would read as property).
pub fn k1_worksheet(
ledger: &Ledger,
start: NaiveDate,
end: NaiveDate,
members: &[MemberCapital],
cash: &[AccountId],
) -> Result<Vec<K1Row>, LedgerError> {
k1_detail(ledger, start, end, members, cash).map(|d| d.rows)
}
/// [`k1_worksheet`] with the per-entry lines behind each row's
/// contributions and distributions (INVARIANT 5): the one source the
/// viewer's member capital statement reads, so it never disagrees with
/// the CPA package.
pub fn k1_detail(
ledger: &Ledger,
start: NaiveDate,
end: NaiveDate,
members: &[MemberCapital],
cash: &[AccountId],
) -> Result<K1Detail, LedgerError> {
if cash.is_empty() {
return Err(LedgerError::InvalidMembers(
"the K-1 worksheet needs the cash accounts".into(),
));
}
let me = members_equity(ledger, start, end, members)?;
let receivables = ledger.role_account(&crate::RECEIVABLES);
let closings = ledger.closing_entry_ids();
let cancelled = crate::reports::cancelled_in_span(ledger, start, end);
let prepaid: Vec<AccountId> = ledger
.schedules()
.filter(|s| s.kind == crate::schedules::ScheduleKind::PrepaidExpense)
.map(|s| s.balance_account)
.collect();
let property_account = |id: AccountId| {
!cash.contains(&id)
&& Some(id) != receivables
&& !prepaid.contains(&id)
&& ledger.chart().get(id).is_some_and(|a| {
a.account_type == AccountType::Asset && !a.name.to_lowercase().contains("prepaid")
})
};
let sum = |e: &crate::JournalEntry, side: Side, account: AccountId| -> Decimal {
e.lines()
.iter()
.filter(|l| l.side == side && l.account == account)
.map(|l| l.amount.amount)
.sum()
};
let mut lines = Vec::new();
for e in ledger
.posted_entries()
.filter(|e| e.date() >= start && e.date() <= end && !cancelled.contains(&e.id()))
{
let line = |member, kind, amount| K1Line {
member,
entry: e.id(),
kind,
amount,
};
if closings.contains(&e.id()) {
for (i, m) in members.iter().enumerate() {
let effect: Decimal = std::iter::once(m.capital_account)
.chain(m.distribution_account)
.map(|a| sum(e, Side::Credit, a) - sum(e, Side::Debit, a))
.sum();
if !effect.is_zero() {
lines.push(line(i, K1Kind::IncomeClose, effect));
}
}
continue;
}
let pool: Decimal = e
.lines()
.iter()
.filter(|l| l.side == Side::Debit && property_account(l.account))
.map(|l| l.amount.amount)
.sum();
// Contributions are credits to capital (members_equity's).
let credits: Vec<Decimal> = members
.iter()
.map(|m| sum(e, Side::Credit, m.capital_account))
.collect();
let takes = share_property(pool, &credits);
for (i, m) in members.iter().enumerate() {
let (credit, take) = (credits[i], takes[i]);
if !(credit - take).is_zero() {
lines.push(line(i, K1Kind::CashContribution, credit - take));
}
if !take.is_zero() {
lines.push(line(i, K1Kind::PropertyContribution, take));
}
// Distributions: debits to capital, net debits to the
// distributions account (members_equity's).
let taken = sum(e, Side::Debit, m.capital_account)
+ m.distribution_account.map_or(Decimal::ZERO, |d| {
sum(e, Side::Debit, d) - sum(e, Side::Credit, d)
});
if !taken.is_zero() {
lines.push(line(i, K1Kind::Distribution, -taken));
}
}
}
let total = |i: usize, k: K1Kind| -> Decimal {
lines
.iter()
.filter(|l| l.member == i && l.kind == k)
.map(|l| l.amount)
.sum()
};
let rows = me
.members
.into_iter()
.zip(members)
.enumerate()
.map(|(i, (col, m))| {
// Each entry's property is at most that member's credit in it,
// and contributions are those credits' sum: property never
// exceeds contributions, and is never negative.
let prop = total(i, K1Kind::PropertyContribution);
K1Row {
name: col.name,
share: m.share,
beginning: col.beginning,
contributions_cash: col.contributions - prop,
contributions_property: prop,
net_income: col.net_income,
distributions: col.distributions,
other: col.other,
ending: col.ending,
}
})
.collect();
Ok(K1Detail { rows, lines })
}
/// One entry's property shared among the members' credits in it
/// (INVARIANT 4): at most their total credit, in proportion to each
/// credit, rounded toward zero to the cent, the leftover cents by largest
/// remainder. Each part is at most its own credit.
fn share_property(pool: Decimal, credits: &[Decimal]) -> Vec<Decimal> {
use rust_decimal::RoundingStrategy::ToZero;
let total: Decimal = credits.iter().sum();
let whole = pool.min(total);
if whole <= Decimal::ZERO || total <= Decimal::ZERO {
return vec![Decimal::ZERO; credits.len()];
}
// whole × credit is exact; ÷ total is exact whenever the share is a
// whole number of cents, so truncating never loses a cent it is owed.
let exact: Vec<Decimal> = credits.iter().map(|c| whole * c / total).collect();
let mut parts: Vec<Decimal> = exact
.iter()
.map(|x| x.round_dp_with_strategy(2, ToZero))
.collect();
let remainders: Vec<Decimal> = exact.iter().zip(&parts).map(|(x, p)| x - p).collect();
crate::kernel::apportion(whole, &mut parts, &remainders, Decimal::new(1, 2))
.expect("each part is short by under a cent, so the shortfall is under a cent per member");
parts
}
/// The party a member (by name, as in wanderbooks.toml) is in the
/// directory: the party not archived with that name, ignoring case and
/// surrounding space ([`Ledger::party_by_name`]), and only when it has
/// the member role. The same rule as ledger-config's
/// `Company::party_members`, so the K-1 worksheet and the viewer's
/// capital statement always name the same party. A display name never
/// matches, and a party without the member role is never a member's: a
/// vendor whose display name is a member's name must never put its TIN
/// on that member's K-1 (the caller reports the member's TIN as a gap).
pub fn member_party(ledger: &Ledger, name: &str) -> Option<PartyId> {
ledger
.party_by_name(name)
.filter(|p| p.roles.contains(&crate::Role::Member))
.map(|p| p.id)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{AccountType, Currency, Money};
use std::str::FromStr;
fn d(s: &str) -> Decimal {
Decimal::from_str(s).unwrap()
}
/// Hand-worked: A contributes 300 cash and a 700 laptop in one entry
/// (Dr 1000 300, Dr 1500 700 / Cr 3000 1000); B contributes a 200
/// receivable-backed promise (Dr 1200 200 / Cr 3001 200, cash by
/// INVARIANT 3) and pays a 50 expense (Dr 6999 50 / Cr 3001 50).
#[test]
fn contributions_split_into_cash_and_property() {
let mut l = Ledger::new(Currency::USD);
let add =
|l: &mut Ledger, code: &str, t| l.add_account(code, code, t, None, false).unwrap();
let cash = add(&mut l, "1000", AccountType::Asset);
let ar = add(&mut l, "1200", AccountType::Asset);
let equip = add(&mut l, "1500", AccountType::Asset);
let a = add(&mut l, "3000", AccountType::Equity);
let b = add(&mut l, "3001", AccountType::Equity);
let exp = add(&mut l, "6999", AccountType::Expense);
assert_eq!(
l.role_account(&crate::RECEIVABLES),
Some(ar),
"1200 by default"
);
let day = NaiveDate::from_ymd_opt(2026, 2, 1).unwrap();
let mut post = |lines: &[(AccountId, Side, &str)]| {
let e = l.create_entry(day, "x");
for (acct, side, amt) in lines {
l.add_line(e, *acct, *side, Money::new(d(amt), Currency::USD))
.unwrap();
}
l.post_entry(e).unwrap();
};
post(&[
(cash, Side::Debit, "300"),
(equip, Side::Debit, "700"),
(a, Side::Credit, "1000"),
]);
post(&[(ar, Side::Debit, "200"), (b, Side::Credit, "200")]);
post(&[(exp, Side::Debit, "50"), (b, Side::Credit, "50")]);
let members = [
MemberCapital {
name: "A".into(),
capital_account: a,
distribution_account: None,
share: d("0.5"),
},
MemberCapital {
name: "B".into(),
capital_account: b,
distribution_account: None,
share: d("0.5"),
},
];
let start = NaiveDate::from_ymd_opt(2026, 1, 1).unwrap();
let end = NaiveDate::from_ymd_opt(2026, 12, 31).unwrap();
let rows = k1_worksheet(&l, start, end, &members, &[cash]).unwrap();
assert_eq!(
(rows[0].contributions_cash, rows[0].contributions_property),
(d("300"), d("700"))
);
assert_eq!(
(rows[1].contributions_cash, rows[1].contributions_property),
(d("250"), d("0"))
);
// Net income -50, split -25 each.
assert_eq!(rows[0].ending, d("975"));
assert_eq!(rows[1].ending, d("225"));
assert!(k1_worksheet(&l, start, end, &members, &[]).is_err());
}
/// Hand-worked: A (capital 3000, distributions 3100) contributes 100
/// cash and 40 of equipment in one entry and takes 30 through 3100; B
/// takes 10 straight from 3001; revenue 60; Jan closed 50/50 (30
/// each, A's 3100 zeroed into 3000). A: 140 in (100 cash, 40
/// property), 30 out, 30 income, ending 140. B: 10 out, 30 income,
/// ending 20. The lines explain the rows (INVARIANT 5).
#[test]
fn detail_lines_explain_each_row() {
let mut l = Ledger::new(Currency::USD);
let add =
|l: &mut Ledger, code: &str, t| l.add_account(code, code, t, None, false).unwrap();
let cash = add(&mut l, "1000", AccountType::Asset);
let equip = add(&mut l, "1500", AccountType::Asset);
let a = add(&mut l, "3000", AccountType::Equity);
let b = add(&mut l, "3001", AccountType::Equity);
let da = add(&mut l, "3100", AccountType::Equity);
let rev = add(&mut l, "4000", AccountType::Revenue);
let day = |m, d| NaiveDate::from_ymd_opt(2026, m, d).unwrap();
let mut post = |date, lines: &[(AccountId, Side, &str)]| {
let e = l.create_entry(date, "x");
for (acct, side, amt) in lines {
l.add_line(e, *acct, *side, Money::new(d(amt), Currency::USD))
.unwrap();
}
l.post_entry(e).unwrap();
e
};
let put_in = post(
day(1, 2),
&[
(cash, Side::Debit, "100"),
(equip, Side::Debit, "40"),
(a, Side::Credit, "140"),
],
);
let a_out = post(
day(1, 3),
&[(da, Side::Debit, "30"), (cash, Side::Credit, "30")],
);
let b_out = post(
day(1, 4),
&[(b, Side::Debit, "10"), (cash, Side::Credit, "10")],
);
post(
day(1, 5),
&[(cash, Side::Debit, "60"), (rev, Side::Credit, "60")],
);
let jan = l.create_period("Jan", day(1, 1), day(1, 31)).unwrap();
let close = l
.close_period_to(
jan,
&crate::periods::CloseTo::Members(vec![
crate::MemberClose {
capital: a,
share: d("0.5"),
distributions: Some(da),
},
crate::MemberClose {
capital: b,
share: d("0.5"),
distributions: None,
},
]),
)
.unwrap()
.unwrap();
let members = [
MemberCapital {
name: "A".into(),
capital_account: a,
distribution_account: Some(da),
share: d("0.5"),
},
MemberCapital {
name: "B".into(),
capital_account: b,
distribution_account: None,
share: d("0.5"),
},
];
let (start, end) = (day(1, 1), day(12, 31));
let detail = k1_detail(&l, start, end, &members, &[cash]).unwrap();
assert_eq!(
detail.rows,
k1_worksheet(&l, start, end, &members, &[cash]).unwrap()
);
let line = |member, entry, kind, amount: &str| K1Line {
member,
entry,
kind,
amount: d(amount),
};
assert_eq!(
detail.lines,
vec![
line(0, put_in, K1Kind::CashContribution, "100"),
line(0, put_in, K1Kind::PropertyContribution, "40"),
line(0, a_out, K1Kind::Distribution, "-30"),
line(1, b_out, K1Kind::Distribution, "-10"),
line(0, close, K1Kind::IncomeClose, "30"),
line(1, close, K1Kind::IncomeClose, "30"),
]
);
let ra = &detail.rows[0];
assert_eq!(
(
ra.beginning,
ra.contributions_cash,
ra.contributions_property
),
(d("0"), d("100"), d("40"))
);
assert_eq!(
(ra.net_income, ra.distributions, ra.ending),
(d("30"), d("30"), d("140"))
);
let rb = &detail.rows[1];
assert_eq!(
(rb.net_income, rb.distributions, rb.ending),
(d("30"), d("10"), d("20"))
);
for (i, row) in detail.rows.iter().enumerate() {
let sum = |k| -> Decimal {
detail
.lines
.iter()
.filter(|x| x.member == i && x.kind == k)
.map(|x| x.amount)
.sum()
};
assert_eq!(sum(K1Kind::CashContribution), row.contributions_cash);
assert_eq!(
sum(K1Kind::PropertyContribution),
row.contributions_property
);
assert_eq!(-sum(K1Kind::Distribution), row.distributions);
assert_eq!(
row.beginning
+ row.contributions_cash
+ row.contributions_property
+ row.net_income
- row.distributions
+ row.other,
row.ending
);
}
// February alone: nothing moved; the capital carries over.
let feb = k1_detail(&l, day(2, 1), day(2, 28), &members, &[cash]).unwrap();
assert!(feb.lines.is_empty());
assert_eq!(
(feb.rows[0].beginning, feb.rows[0].ending),
(d("140"), d("140"))
);
}
/// Two members A (3000) and B (3001), cash 1000, equipment 1500,
/// prepaid 1300, and a `post` that returns the entry.
struct Books {
l: Ledger,
cash: AccountId,
equip: AccountId,
prepaid: AccountId,
a: AccountId,
b: AccountId,
}
fn books() -> Books {
let mut l = Ledger::new(Currency::USD);
let mut add =
|code: &str, name: &str, t| l.add_account(code, name, t, None, false).unwrap();
let cash = add("1000", "Bluevine", AccountType::Asset);
let prepaid = add("1300", "Prepaid Expenses", AccountType::Asset);
let equip = add("1500", "Equipment", AccountType::Asset);
let a = add("3000", "Capital A", AccountType::Equity);
let b = add("3001", "Capital B", AccountType::Equity);
Books {
l,
cash,
equip,
prepaid,
a,
b,
}
}
fn post(l: &mut Ledger, m: u32, day: u32, lines: &[(AccountId, Side, &str)]) -> EntryId {
let e = l.create_entry(NaiveDate::from_ymd_opt(2026, m, day).unwrap(), "x");
for (acct, side, amt) in lines {
l.add_line(e, *acct, *side, Money::new(d(amt), Currency::USD))
.unwrap();
}
l.post_entry(e).unwrap();
e
}
fn members(bk: &Books) -> [MemberCapital; 2] {
[
MemberCapital {
name: "A".into(),
capital_account: bk.a,
distribution_account: None,
share: d("0.5"),
},
MemberCapital {
name: "B".into(),
capital_account: bk.b,
distribution_account: None,
share: d("0.5"),
},
]
}
/// (cash, property, distributions) per member for 2026.
fn split(bk: &Books, members: &[MemberCapital]) -> Vec<(Decimal, Decimal, Decimal)> {
let year = |m, day| NaiveDate::from_ymd_opt(2026, m, day).unwrap();
let detail = k1_detail(&bk.l, year(1, 1), year(12, 31), members, &[bk.cash]).unwrap();
detail
.rows
.iter()
.map(|r| {
(
r.contributions_cash,
r.contributions_property,
r.distributions,
)
})
.collect()
}
/// INVARIANT 6 and SCENARIO 1, by hand. Case 1: a joint 1,000 cash
/// contribution reversed the next day: 0 in, 0 out each (not 500 of
/// each). Case 2: a 700 laptop contribution reversed: A 0 property, 0
/// out. Case 3: a real 500 + 500 contribution, then a 100 + 100
/// distribution reversed: 500 cash each, 0 out (not 600 in, 100 out).
#[test]
fn a_reversed_contribution_or_distribution_cancels() {
let mut bk = books();
let (cash, equip, a, b) = (bk.cash, bk.equip, bk.a, bk.b);
let joint = post(
&mut bk.l,
1,
2,
&[
(cash, Side::Debit, "1000"),
(a, Side::Credit, "500"),
(b, Side::Credit, "500"),
],
);
let r =
bk.l.reverse_entry(joint, NaiveDate::from_ymd_opt(2026, 1, 3).unwrap(), "x")
.unwrap();
let ms = members(&bk);
let zero = (d("0"), d("0"), d("0"));
assert_eq!(split(&bk, &ms), vec![zero, zero]);
let year = |m, day| NaiveDate::from_ymd_opt(2026, m, day).unwrap();
let detail = k1_detail(&bk.l, year(1, 1), year(12, 31), &ms, &[cash]).unwrap();
assert!(
detail
.lines
.iter()
.all(|l| l.entry != joint && l.entry != r),
"{:?}",
detail.lines
);
// The statement of members' equity agrees (INVARIANT 1).
let me = members_equity(&bk.l, year(1, 1), year(12, 31), &ms).unwrap();
assert_eq!(
(me.members[0].contributions, me.members[0].distributions),
(d("0"), d("0"))
);
// Only the reversal in the span: by side, a distribution.
let feb_on = members_equity(&bk.l, year(1, 3), year(12, 31), &ms).unwrap();
assert_eq!(feb_on.members[0].distributions, d("500"));
let laptop = post(
&mut bk.l,
2,
1,
&[(equip, Side::Debit, "700"), (a, Side::Credit, "700")],
);
bk.l.reverse_entry(laptop, year(2, 2), "x").unwrap();
assert_eq!(split(&bk, &ms), vec![zero, zero]);
post(
&mut bk.l,
3,
1,
&[
(cash, Side::Debit, "1000"),
(a, Side::Credit, "500"),
(b, Side::Credit, "500"),
],
);
let out = post(
&mut bk.l,
3,
2,
&[
(a, Side::Debit, "100"),
(b, Side::Debit, "100"),
(cash, Side::Credit, "200"),
],
);
let undo = bk.l.reverse_entry(out, year(3, 3), "x").unwrap();
let five = (d("500"), d("0"), d("0"));
assert_eq!(split(&bk, &ms), vec![five, five]);
// Reversing the reversal reinstates the distribution.
bk.l.reverse_entry(undo, year(3, 4), "x").unwrap();
let back = (d("500"), d("0"), d("100"));
assert_eq!(split(&bk, &ms), vec![back, back]);
}
/// INVARIANT 3 and SCENARIO 2, by hand: members prepay a 3,000 annual
/// service straight to capital (Dr 1300 Prepaid / Cr 3000 1,500, Cr
/// 3001 1,500): 1,500 cash each, no property, as when routed through
/// 2100. A prepaid-expense schedule's account is prepaid whatever its
/// name.
#[test]
fn a_prepaid_service_paid_by_members_is_cash() {
let mut bk = books();
let (prepaid, a, b) = (bk.prepaid, bk.a, bk.b);
post(
&mut bk.l,
1,
5,
&[
(prepaid, Side::Debit, "3000"),
(a, Side::Credit, "1500"),
(b, Side::Credit, "1500"),
],
);
let ms = members(&bk);
let each = (d("1500"), d("0"), d("0"));
assert_eq!(split(&bk, &ms), vec![each, each]);
// An account named otherwise, backing a prepaid schedule.
let ins =
bk.l.add_account(
"1310",
"Insurance paid ahead",
AccountType::Asset,
None,
false,
)
.unwrap();
let exp =
bk.l.add_account("6300", "Insurance", AccountType::Expense, None, false)
.unwrap();
post(
&mut bk.l,
2,
1,
&[(ins, Side::Debit, "1200"), (a, Side::Credit, "1200")],
);
assert_eq!(split(&bk, &ms)[0].1, d("1200"), "no schedule yet: property");
bk.l.register_schedule(crate::schedules::NewSchedule {
description: "2026 insurance".into(),
kind: crate::schedules::ScheduleKind::PrepaidExpense,
total: Money::new(d("1200"), Currency::USD),
start: NaiveDate::from_ymd_opt(2026, 2, 1).unwrap(),
months: 12,
balance_account: ins,
pl_account: exp,
})
.unwrap();
assert_eq!(split(&bk, &ms)[0], (d("2700"), d("0"), d("0")));
}
/// INVARIANT 4 and SCENARIO 3, by hand: Dr 1000 600, Dr 1500 400 /
/// Cr 3000 500, Cr 3001 500. Property 400 split by credit (500:500):
/// 200 each; cash 300 each, whichever member is listed first. Unequal
/// credits (Cr 3000 750, Cr 3001 250, property 401): 401 × 0.75 =
/// 300.75, 401 × 0.25 = 100.25, exact. Odd cents: property 0.01 on
/// equal credits goes to the earlier member on a tie.
#[test]
fn joint_property_is_shared_by_credit() {
let mut bk = books();
let (cash, equip, a, b) = (bk.cash, bk.equip, bk.a, bk.b);
post(
&mut bk.l,
1,
5,
&[
(cash, Side::Debit, "600"),
(equip, Side::Debit, "400"),
(a, Side::Credit, "500"),
(b, Side::Credit, "500"),
],
);
let ms = members(&bk);
let each = (d("300"), d("200"), d("0"));
assert_eq!(split(&bk, &ms), vec![each, each]);
let mut flipped = ms.clone();
flipped.reverse();
assert_eq!(split(&bk, &flipped), vec![each, each]);
assert_eq!(
share_property(d("401"), &[d("750"), d("250")]),
vec![d("300.75"), d("100.25")]
);
assert_eq!(
share_property(d("0.01"), &[d("5"), d("5")]),
vec![d("0.01"), d("0.00")]
);
// More property than credit (part financed): at most the credits.
assert_eq!(
share_property(d("900"), &[d("300"), d("100")]),
vec![d("300"), d("100")]
);
assert_eq!(
share_property(d("50"), &[d("0"), d("0")]),
vec![d("0"), d("0")]
);
}
/// member_party doc: the party of that name with the member role only.
/// A contractor whose display name is the member's name, or a party
/// of the name without the member role, is never the member's.
#[test]
fn member_party_needs_the_member_role_and_the_name() {
let mut l = Ledger::new(Currency::USD);
let ke = l
.add_party("KE Consulting LLC", &[crate::Role::Contractor])
.unwrap();
l.update_party(
ke,
crate::PartyField::DisplayName(Some("Mo Example".into())),
)
.unwrap();
assert_eq!(member_party(&l, "Mo Example"), None);
let plain = l.add_party("Mo Example", &[]).unwrap();
assert_eq!(member_party(&l, "mo example "), None, "no member role");
l.set_party_role(plain, crate::Role::Member, true).unwrap();
assert_eq!(member_party(&l, "mo example "), Some(plain));
}
}
+346 -47
View File
@@ -264,8 +264,9 @@ pub struct Ledger {
/// The party directory ([`crate::parties`]).
parties: std::collections::BTreeMap<crate::PartyId, crate::Party>,
next_party_id: crate::PartyId,
/// The party each entry is with (the counterparty).
entry_parties: HashMap<EntryId, crate::PartyId>,
/// The parties each entry is with (its counterparties), each with
/// their share ([`crate::shares`]); never an empty list.
entry_parties: HashMap<EntryId, Vec<crate::PartyShare>>,
/// Entries marked as having no counterparty, with why.
entry_no_party: HashMap<EntryId, String>,
}
@@ -1381,7 +1382,8 @@ impl Ledger {
if let Some(v) = self.entry_vendor(entry) {
self.entry_vendors.insert(id, v);
}
if let Some(p) = self.entry_party(entry) {
// Every party and share: the reversal undoes each one's part.
if let Some(p) = self.entry_parties.get(&entry).cloned() {
self.entry_parties.insert(id, p);
}
Ok(id)
@@ -2349,6 +2351,23 @@ impl Ledger {
}
/// Add a vendor. Returns its id.
///
/// ```text
/// GOAL: a vendor record, linked to its party in the directory.
/// SOURCE: docs/parties-and-attachments.md; crate::shares INVARIANT 5
/// (at most one party on an entry has a vendor record);
/// Self::set_entry_parties INVARIANT 2 (the vendor tag follows
/// the one party with a vendor record).
/// INVARIANTS:
/// 1. The record links to the live party of that name without one,
/// else a new party (Self::link_record_party).
/// 2. IF that party is named on an entry beside another party with a
/// vendor record: SHOULD refuse, changing nothing (the entry would
/// name two vendors). Record each vendor's part as its own entry
/// first.
/// 3. Every entry naming that party with no vendor tag is tagged with
/// the new vendor, as set_entry_parties would have tagged it.
/// ```
pub fn add_vendor(&mut self, name: &str) -> Result<VendorId, LedgerError> {
if name.trim().is_empty() {
return Err(LedgerError::EmptyAssetDescription);
@@ -2356,6 +2375,22 @@ impl Ledger {
// Its party takes this name, so the name obeys party rules (no
// full TIN; parties INVARIANT 1).
crate::parties::check_text("name", name).map_err(LedgerError::InvalidParty)?;
if let Some(p) = self.record_party_match(name, true) {
let mut ids: Vec<EntryId> = self.entry_parties.keys().copied().collect();
ids.sort_unstable();
if let Some(e) = ids.into_iter().find(|e| {
let named = self.entry_parties(*e);
named.iter().any(|s| s.party == p)
&& named
.iter()
.any(|s| s.party != p && self.parties[&s.party].vendor.is_some())
}) {
return Err(LedgerError::InvalidParty(format!(
"party {p} ({}) is named on entry {e} beside another party with a vendor record; a 1099 counts a payment toward one vendor, so record each vendor's part as its own entry before adding this vendor",
self.parties[&p].name
)));
}
}
let id = self.next_vendor_id;
self.next_vendor_id += 1;
self.vendors.insert(
@@ -2368,6 +2403,20 @@ impl Ledger {
},
);
self.link_record_party(name, true, id);
// INVARIANT 3: the tag follows the party's new vendor record.
if let Some(p) = self.party_of_vendor(id) {
let untagged: Vec<EntryId> = self
.entry_parties
.iter()
.filter(|(e, named)| {
named.iter().any(|s| s.party == p) && !self.entry_vendors.contains_key(e)
})
.map(|(e, _)| *e)
.collect();
for e in untagged {
self.entry_vendors.insert(e, id);
}
}
Ok(id)
}
@@ -2404,40 +2453,134 @@ impl Ledger {
///
/// ```text
/// GOAL: tag an entry with the vendor it paid, for 1099s.
/// SOURCE: docs/spec/rules.md B-19.
/// SOURCE: docs/spec/rules.md B-19; Self::set_entry_parties
/// INVARIANTS 2 and 5 (the vendor tag follows the parties, never
/// the other way round, on an entry naming several).
/// SCENARIOS:
/// 1. IF the entry or vendor is unknown: SHOULD refuse.
/// 2. Tagging MUST change no amount (it's metadata, allowed on posted
/// entries).
/// 3. On an entry naming no party or one: Some(v) makes v's party the
/// entry's one party (all of it); None clears the party too when
/// it was the vendor's.
/// 4. On an entry naming several parties, Some(v) with v's party
/// among them sets the tag and keeps every party and share as
/// they are. IF v's party is not among them (or v has none):
/// SHOULD refuse, changing nothing: naming v alone would drop the
/// others' shares and count all of it toward v's 1099. The list
/// is Self::set_entry_parties's to change.
/// 5. On an entry naming several parties, one of them the tagged
/// vendor's party, None SHOULD refuse, changing nothing: the tag
/// follows that party (set_entry_parties INVARIANT 2), so the
/// 1099 would drop a payment the party still shows. Drop the party
/// with Self::set_entry_parties instead.
/// 6. The entry's reversal chain (an entry, its reversal, theirs)
/// carries the same tags (set_entry_parties INVARIANT 5).
/// ```
pub fn set_entry_vendor(
&mut self,
entry: EntryId,
vendor: Option<VendorId>,
) -> Result<(), LedgerError> {
self.check_set_entry_vendor(entry, vendor)?;
for e in self.reversal_chain(entry) {
self.tag_entry_vendor(e, vendor);
}
Ok(())
}
/// The validation behind [`Self::set_entry_vendor`] (SCENARIOS 1, 4,
/// 5).
pub fn check_set_entry_vendor(
&self,
entry: EntryId,
vendor: Option<VendorId>,
) -> Result<(), LedgerError> {
if !self.entries.contains_key(&entry) {
return Err(LedgerError::UnknownEntry(entry));
}
let named = self.entry_parties(entry);
match vendor {
Some(v) => {
if !self.vendors.contains_key(&v) {
return Err(LedgerError::UnknownVendor(v));
}
let p = self.party_of_vendor(v);
if named.len() >= 2 && !named.iter().any(|s| Some(s.party) == p) {
return Err(LedgerError::InvalidParty(format!(
"entry {entry} names {} parties and vendor {v}'s party isn't one of them; tagging the vendor would drop their shares: name the parties with books.set_entry_party (parties) instead",
named.len()
)));
}
}
None => {
if named.len() >= 2
&& let Some(p) = self
.entry_vendor(entry)
.and_then(|v| self.party_of_vendor(v))
&& named.iter().any(|s| s.party == p)
{
return Err(LedgerError::InvalidParty(format!(
"entry {entry} names party {p}, whose vendor record is its vendor tag, among {} parties; the tag follows that party: drop it from the parties with books.set_entry_party (parties) instead",
named.len()
)));
}
}
}
Ok(())
}
/// One entry's half of [`Self::set_entry_vendor`], checked already.
fn tag_entry_vendor(&mut self, entry: EntryId, vendor: Option<VendorId>) {
match vendor {
Some(v) => {
self.entry_vendors.insert(entry, v);
if let Some(p) = self.party_of_vendor(v) {
self.entry_parties.insert(entry, p);
// Several parties: the list stays (SCENARIO 4).
if self.entry_parties(entry).len() < 2
&& let Some(p) = self.party_of_vendor(v)
{
self.entry_parties
.insert(entry, vec![crate::PartyShare::whole(p)]);
}
}
None => {
// The party tag goes too only when it was the vendor's.
if let Some(v) = self.entry_vendors.remove(&entry)
&& self.entry_parties.get(&entry).copied() == self.party_of_vendor(v)
&& self.party_of_vendor(v).is_some()
&& self.entry_party(entry) == self.party_of_vendor(v)
{
self.entry_parties.remove(&entry);
}
}
}
Ok(())
}
/// An entry's reversal chain, in order: the original it reverses (and
/// so on up), itself, the entry reversing it (and so on down). An
/// entry reversed by none and reversing none is its own chain. Each
/// entry is reversed at most once ([`Self::check_reversal`]).
pub fn reversal_chain(&self, entry: EntryId) -> Vec<EntryId> {
let mut top = entry;
while let Some(o) = self.entries.get(&top).and_then(JournalEntry::reversal_of) {
if !self.entries.contains_key(&o) || o == entry {
break;
}
top = o;
}
let mut chain = vec![top];
let mut cur = top;
while let Some(r) = self
.posted_entries()
.find(|e| e.reversal_of() == Some(cur))
.map(JournalEntry::id)
{
if chain.contains(&r) {
break;
}
chain.push(r);
cur = r;
}
chain
}
/// Tag an entry with a vendor and that vendor's party (bills, bill
@@ -2445,14 +2588,16 @@ impl Ledger {
fn tag_vendor(&mut self, entry: EntryId, vendor: VendorId) {
self.entry_vendors.insert(entry, vendor);
if let Some(p) = self.party_of_vendor(vendor) {
self.entry_parties.insert(entry, p);
self.entry_parties
.insert(entry, vec![crate::PartyShare::whole(p)]);
}
}
/// Tag an entry with a customer's party (invoices, invoice payments).
fn tag_customer(&mut self, entry: EntryId, customer: CustomerId) {
if let Some(p) = self.party_of_customer(customer) {
self.entry_parties.insert(entry, p);
self.entry_parties
.insert(entry, vec![crate::PartyShare::whole(p)]);
}
}
@@ -2462,14 +2607,7 @@ impl Ledger {
/// ... when a live party already has the name). Deterministic, so replay
/// rebuilds the same directory from the same records.
fn link_record_party(&mut self, name: &str, vendor: bool, record: u64) {
let key = crate::parties::name_key(name);
let slot = |p: &crate::Party| if vendor { p.vendor } else { p.customer };
let found = self
.parties
.values()
.find(|p| !p.archived && crate::parties::name_key(&p.name) == key && slot(p).is_none())
.map(|p| p.id);
let id = match found {
let id = match self.record_party_match(name, vendor) {
Some(id) => id,
None => {
let id = self.next_party_id;
@@ -2502,6 +2640,7 @@ impl Ledger {
archived: false,
w9: None,
w4: None,
tin: None,
vendor: None,
customer: None,
},
@@ -2517,6 +2656,18 @@ impl Ledger {
}
}
/// The existing party a new vendor (or customer) record named `name`
/// links to ([`Self::link_record_party`]): the party not archived with
/// that name and no such record yet.
fn record_party_match(&self, name: &str, vendor: bool) -> Option<crate::PartyId> {
let key = crate::parties::name_key(name);
let slot = |p: &crate::Party| if vendor { p.vendor } else { p.customer };
self.parties
.values()
.find(|p| !p.archived && crate::parties::name_key(&p.name) == key && slot(p).is_none())
.map(|p| p.id)
}
/// The party a vendor record belongs to.
pub fn party_of_vendor(&self, vendor: VendorId) -> Option<crate::PartyId> {
self.parties
@@ -2552,9 +2703,28 @@ impl Ledger {
.find(|p| !p.archived && crate::parties::name_key(&p.name) == key)
}
/// The party an entry is with, if tagged.
/// The sole party an entry is with: Some only when it names exactly
/// one ([`Self::entry_parties`] lists them all).
pub fn entry_party(&self, entry: EntryId) -> Option<crate::PartyId> {
self.entry_parties.get(&entry).copied()
match self.entry_parties.get(&entry).map(Vec::as_slice) {
Some([one]) => Some(one.party),
_ => None,
}
}
/// Every party an entry is with, each with their share, in the order
/// named (empty when it names none). Resolve the shares to amounts with
/// [`crate::shares::resolve`].
pub fn entry_parties(&self, entry: EntryId) -> &[crate::PartyShare] {
self.entry_parties
.get(&entry)
.map(Vec::as_slice)
.unwrap_or_default()
}
/// Whether an entry names `party`, alone or among others.
pub fn entry_names_party(&self, entry: EntryId, party: crate::PartyId) -> bool {
self.entry_parties(entry).iter().any(|s| s.party == party)
}
/// The next party id the directory will assign.
@@ -2595,6 +2765,7 @@ impl Ledger {
archived: false,
w9: None,
w4: None,
tin: None,
vendor: None,
customer: None,
},
@@ -2798,6 +2969,57 @@ impl Ledger {
));
}
f.check().map_err(LedgerError::InvalidParty)?;
if let Some(t) = &p.tin
&& t.last4 != f.tin_last4
{
return Err(LedgerError::InvalidParty(format!(
"the party's TIN on file ends in {}, the W-9 facts say {}: they must agree (record the W-9's TIN with it)",
t.last4, f.tin_last4
)));
}
}
Ok(())
}
/// Record (or clear) a party's taxpayer-ID facts: the TIN's type, last
/// four, and vault record, never the number.
///
/// ```text
/// SOURCE: crate::parties (INVARIANTS 1, 2, 2a); DECISION (owner,
/// 2026-10-15): every member's and payee's TIN in the vault.
/// SCENARIOS:
/// 1. Any role may have them (members need theirs for K-1s).
/// 2. IF the facts' form is wrong (TinFacts::check): SHOULD refuse.
/// 3. IF the party's W-9 facts name another last four: SHOULD refuse
/// (the two never disagree).
/// ```
pub fn set_party_tin(
&mut self,
id: crate::PartyId,
facts: Option<crate::TinFacts>,
) -> Result<(), LedgerError> {
self.check_set_party_tin(id, facts.as_ref())?;
self.parties.get_mut(&id).expect("checked").tin = facts;
Ok(())
}
/// The validation behind [`Self::set_party_tin`].
pub fn check_set_party_tin(
&self,
id: crate::PartyId,
facts: Option<&crate::TinFacts>,
) -> Result<(), LedgerError> {
let p = self.parties.get(&id).ok_or(LedgerError::UnknownParty(id))?;
if let Some(f) = facts {
f.check().map_err(LedgerError::InvalidParty)?;
if let Some(w) = &p.w9
&& w.tin_last4 != f.last4
{
return Err(LedgerError::InvalidParty(format!(
"the party's W-9 facts say the TIN ends in {}, not {}: a new TIN for a W-9 payee comes with its W-9 (books.set_w9)",
w.tin_last4, f.last4
)));
}
}
Ok(())
}
@@ -2831,44 +3053,121 @@ impl Ledger {
Ok(())
}
/// Tag an entry with the party it was with, or clear the tag. A party
/// with a vendor record also tags the entry's vendor (for 1099s), as
/// [`Self::set_entry_vendor`] does the other way round. Metadata: no
/// amount changes, so posted entries may be tagged.
/// Tag an entry with the party it was with, or clear the tag: one
/// party with all of it ([`Self::set_entry_parties`] with one derived
/// share). A party with a vendor record also tags the entry's vendor
/// (for 1099s), as [`Self::set_entry_vendor`] does the other way round.
/// Metadata: no amount changes, so posted entries may be tagged.
pub fn set_entry_party(
&mut self,
entry: EntryId,
party: Option<crate::PartyId>,
) -> Result<(), LedgerError> {
if !self.entries.contains_key(&entry) {
return Err(LedgerError::UnknownEntry(entry));
}
match party {
Some(p) => {
let vendor = self
.parties
.get(&p)
.ok_or(LedgerError::UnknownParty(p))?
.vendor;
self.entry_parties.insert(entry, p);
self.entry_no_party.remove(&entry);
match vendor {
Some(v) => {
self.entry_vendors.insert(entry, v);
}
None => {
self.entry_vendors.remove(&entry);
}
}
let shares: Vec<crate::PartyShare> =
party.into_iter().map(crate::PartyShare::whole).collect();
self.set_entry_parties(entry, &shares)
}
/// Name the parties an entry was with, each with their share, or
/// clear them (an empty list). Metadata: no amount changes, so posted
/// entries may be tagged.
///
/// ```text
/// GOAL: one entry with several counterparties (both members, two
/// contractors), apportioned exactly.
/// SOURCE: crate::shares (INVARIANTS 1–5); DECISION (owner,
/// 2026-10-15).
/// INVARIANTS:
/// 1. The shares are checked as crate::shares::check_shares says, and
/// at most one party has a vendor record; refused otherwise,
/// changing nothing. Whether they resolve against the entry's lines
/// depends on settings (cash accounts, members) core doesn't hold:
/// callers check crate::shares::resolve before journaling.
/// 2. The entry's vendor tag follows the one party with a vendor
/// record; with none, it is cleared.
/// 3. Naming any party clears a no-party mark.
/// 4. entry_party(entry) is Some only for exactly one party.
/// 5. An entry and its reversal (and theirs: Self::reversal_chain)
/// name the same parties, shares, and vendor tag: tagging any one
/// of them tags the whole chain, so each party's part of an entry
/// and its reversal still nets to zero (crate::shares INVARIANT 6;
/// form1099 SCENARIO 6), however it is re-apportioned later.
/// SCENARIOS:
/// 1. IF the entry or a party is unknown, a party is named twice, or
/// two parties have vendor records: SHOULD refuse.
/// 2. Both members, no shares: two derived shares.
/// 3. A reversed $1,000.00 payment named 600/400, then re-apportioned
/// 700/300: the reversal is 700/300 too, and the pair nets to 0.00
/// for each party.
/// ```
pub fn set_entry_parties(
&mut self,
entry: EntryId,
shares: &[crate::PartyShare],
) -> Result<(), LedgerError> {
let vendor = self.check_set_entry_parties(entry, shares)?;
for e in self.reversal_chain(entry) {
if shares.is_empty() {
self.entry_parties.remove(&e);
self.entry_vendors.remove(&e);
continue;
}
None => {
self.entry_parties.remove(&entry);
self.entry_vendors.remove(&entry);
self.entry_parties.insert(e, shares.to_vec());
self.entry_no_party.remove(&e);
match vendor {
Some(v) => {
self.entry_vendors.insert(e, v);
}
None => {
self.entry_vendors.remove(&e);
}
}
}
Ok(())
}
/// The validation behind [`Self::set_entry_parties`]: returns the
/// vendor record the entry's vendor tag follows, if any.
pub fn check_set_entry_parties(
&self,
entry: EntryId,
shares: &[crate::PartyShare],
) -> Result<Option<VendorId>, LedgerError> {
if !self.entries.contains_key(&entry) {
return Err(LedgerError::UnknownEntry(entry));
}
self.check_party_shares(shares)
}
/// The checks [`Self::check_set_entry_parties`] makes of the shares
/// themselves, for an entry not yet created: returns the vendor record
/// the vendor tag would follow, if any.
pub fn check_party_shares(
&self,
shares: &[crate::PartyShare],
) -> Result<Option<VendorId>, LedgerError> {
if let Some(s) = shares.iter().find(|s| !self.parties.contains_key(&s.party)) {
return Err(LedgerError::UnknownParty(s.party));
}
crate::shares::check_shares(shares, |p| self.parties.contains_key(&p))
.map_err(LedgerError::InvalidParty)?;
let vendors: Vec<(crate::PartyId, VendorId)> = shares
.iter()
.filter_map(|s| self.parties[&s.party].vendor.map(|v| (s.party, v)))
.collect();
if let [_, _, ..] = vendors.as_slice() {
return Err(LedgerError::InvalidParty(format!(
"parties {} have vendor records; a 1099 counts a payment toward one vendor, so record each vendor's part as its own entry",
vendors
.iter()
.map(|(p, _)| p.to_string())
.collect::<Vec<_>>()
.join(" and ")
)));
}
Ok(vendors.first().map(|(_, v)| *v))
}
/// Mark an entry as having no counterparty, with why (a transfer
/// between the company's own accounts, an accrual, a correction), so a
/// books policy that requires parties can tell "none" from "not yet".
+5 -2
View File
@@ -24,6 +24,7 @@ pub mod classes;
pub mod error;
pub mod form1099;
pub mod journal;
pub mod k1;
pub mod kernel;
pub mod ledger;
pub mod money;
@@ -36,6 +37,7 @@ mod proofs;
pub mod reports;
pub mod rules;
pub mod schedules;
pub mod shares;
pub mod tax;
pub use account::{Account, AccountId, AccountType, ChartOfAccounts, Side};
@@ -52,7 +54,7 @@ pub use classes::{Class, ClassId};
pub use error::LedgerError;
pub use form1099::{
Form1099, PaymentAccounts, Vendor1099Line, Vendor1099Report, vendor_1099_report,
vendor_1099_report_with,
vendor_1099_report_with, vendor_1099_report_with_members,
};
pub use journal::{EntryId, EntryStatus, JournalEntry, JournalLine};
pub use ledger::{
@@ -60,7 +62,7 @@ pub use ledger::{
REVENUE, ReconciliationReport, UnclearedLine, control_account, control_accounts,
};
pub use money::{Currency, Money};
pub use parties::{Party, PartyField, PartyId, Role, W4Facts, W9Facts};
pub use parties::{Party, PartyField, PartyId, Role, TinFacts, TinType, W4Facts, W9Facts};
pub use payouts::{Payout, PayoutAdjustment, PayoutLine, PayoutTotals, payout_plan};
pub use payroll::{PayrollLine, PayrollRun, PayrollRunId};
pub use periods::{
@@ -78,4 +80,5 @@ pub use schedules::{
AmortizationSchedule, Frequency, NewRecurring, NewSchedule, RecurringEntry, RecurringId,
ScheduleId, ScheduleKind, TemplateLine,
};
pub use shares::{PartyMember, PartyShare, Share};
pub use tax::{TaxCategory, TaxForm, TaxKind, TaxSummary, TaxSummaryLine, tax_summary};
+214 -37
View File
@@ -16,7 +16,11 @@
//! run of nine digits (single spaces or hyphens between them allowed)
//! is refused. A W-9's TIN is kept as its last four digits only.
//! 2. W-9 facts need a 1099 role (contractor or landlord); W-4 facts need
//! the employee role.
//! the employee role. Taxpayer-ID facts (`TinFacts`: the TIN's type,
//! last four, and vault record) are allowed for any role: members
//! need theirs for K-1s.
//! 2a. A party's TIN facts and W-9 facts never disagree: both on file
//! means the same last four (DECISION, owner, 2026-10-15).
//! 3. Names are unique, ignoring case and surrounding space, among parties
//! not archived.
//! 4. Every vendor and customer record links to exactly one party.
@@ -116,6 +120,75 @@ pub struct W9Facts {
pub vault_record: Option<String>,
}
/// A taxpayer identification number's type.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum TinType {
/// Social Security number (an individual).
Ssn,
/// Employer identification number (a business).
Ein,
/// IRS individual taxpayer identification number.
Itin,
}
impl TinType {
/// Its wire name: ssn, ein, or itin.
pub fn key(self) -> &'static str {
match self {
TinType::Ssn => "ssn",
TinType::Ein => "ein",
TinType::Itin => "itin",
}
}
/// How a form names it: SSN, EIN, or ITIN.
pub fn label(self) -> &'static str {
match self {
TinType::Ssn => "SSN",
TinType::Ein => "EIN",
TinType::Itin => "ITIN",
}
}
}
/// What the books know of a party's taxpayer identification number,
/// without the number: its type, its last four digits, and the hash of
/// the TIN vault record that holds it in full. Any role may have one
/// (a member's SSN for their K-1, a contractor's from their W-9).
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct TinFacts {
/// SSN, EIN, or ITIN.
pub tin_type: TinType,
/// The last four digits (exactly four digits).
pub last4: String,
/// SHA-256 of the vault record holding the full TIN; `None` once the
/// record is purged (or when the vault never held it).
#[serde(default)]
pub vault_record: Option<String>,
/// When and why the vault's copy was purged, if it was.
#[serde(default)]
pub purged: Option<String>,
}
impl TinFacts {
/// Check the facts' form. Returns why not.
pub fn check(&self) -> Result<(), String> {
if self.last4.len() != 4 || !self.last4.chars().all(|c| c.is_ascii_digit()) {
return Err("a TIN's last four is exactly four digits".into());
}
if let Some(h) = &self.vault_record
&& (h.len() != 64 || !h.chars().all(|c| c.is_ascii_hexdigit()))
{
return Err("vault_record is a SHA-256 in hex".into());
}
if let Some(t) = &self.purged {
check_text("purged", t)?;
}
Ok(())
}
}
/// What a W-4 said, without the SSN.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct W4Facts {
@@ -157,6 +230,10 @@ pub struct Party {
/// W-4 facts (employee).
#[serde(default)]
pub w4: Option<W4Facts>,
/// Taxpayer-ID facts (any role): the TIN's type and last four, and
/// its vault record. The full number is only ever in the vault.
#[serde(default)]
pub tin: Option<TinFacts>,
/// Its vendor record, if it bills the company.
#[serde(default)]
pub vendor: Option<crate::VendorId>,
@@ -190,35 +267,94 @@ pub fn name_key(name: &str) -> String {
name.trim().to_lowercase()
}
/// Whether `text` holds a run of nine digits, with at most one space or
/// hyphen between neighbors (an SSN, EIN, or ITIN however it's written).
/// Any Unicode numeral counts as a digit (full-width, Arabic-Indic, ...),
/// so a number can't slip through in another script.
pub fn has_full_tin(text: &str) -> bool {
let chars: Vec<char> = text.chars().collect();
/// The characters that may sit between a TIN's digits: space, hyphen,
/// dot, slash, underscore, and the Unicode dashes and no-break space
/// (`291-12-2846`, `291.12.2846`, `291/12/2846`, `291 - 12 - 2846`).
fn tin_separator(c: char) -> bool {
matches!(
c,
' ' | '-'
| '.'
| '/'
| '_'
| '\u{a0}'
| '\u{2010}'
| '\u{2011}'
| '\u{2012}'
| '\u{2013}'
| '\u{2014}'
| '\u{2212}'
)
}
/// The most separators that may sit between two of a TIN's digits
/// (`" - "` is three).
const MAX_TIN_GAP: usize = 3;
/// Each run in `chars` that holds nine digits or more, as (first, last)
/// char indices of its digits: digits with at most [`MAX_TIN_GAP`]
/// separators ([`tin_separator`]) between neighbors.
fn full_tin_runs(chars: &[char]) -> Vec<(usize, usize)> {
let mut runs = Vec::new();
let mut i = 0;
while i < chars.len() {
if chars[i].is_numeric() {
let (mut digits, mut j) = (1, i + 1);
while j < chars.len() && digits < 9 {
if chars[j].is_numeric() {
digits += 1;
j += 1;
} else if matches!(chars[j], ' ' | '-')
&& chars.get(j + 1).is_some_and(|c| c.is_numeric())
{
j += 1;
} else {
break;
}
if !chars[i].is_numeric() {
i += 1;
continue;
}
let (mut digits, mut last, mut j) = (1, i, i + 1);
while j < chars.len() {
if chars[j].is_numeric() {
digits += 1;
last = j;
j += 1;
continue;
}
if digits >= 9 {
return true;
let gap = chars[j..].iter().take_while(|c| tin_separator(**c)).count();
if (1..=MAX_TIN_GAP).contains(&gap)
&& chars.get(j + gap).is_some_and(|c| c.is_numeric())
{
j += gap;
} else {
break;
}
}
i += 1;
if digits >= 9 {
runs.push((i, last));
}
i = last + 1;
}
false
runs
}
/// Whether `text` holds a run of nine digits, with at most three
/// separators (space, hyphen, dot, slash, underscore, a Unicode dash or
/// no-break space) between neighbors: an SSN, EIN, or ITIN however it's
/// written (`291-12-2846`, `291.12.2846`, `291 - 12 - 2846`). Any Unicode
/// numeral counts as a digit (full-width, Arabic-Indic, ...), so a number
/// can't slip through in another script.
pub fn has_full_tin(text: &str) -> bool {
let chars: Vec<char> = text.chars().collect();
!full_tin_runs(&chars).is_empty()
}
/// `text` with every run [`has_full_tin`] finds masked: each of its
/// digits becomes `•`, separators kept. For refusals that quote what they
/// were given, so no error echoes a full TIN (docs/spec/rules.md B-26).
pub fn redact_full_tins(text: &str) -> std::borrow::Cow<'_, str> {
let mut chars: Vec<char> = text.chars().collect();
let runs = full_tin_runs(&chars);
if runs.is_empty() {
return std::borrow::Cow::Borrowed(text);
}
for (first, last) in runs {
for c in &mut chars[first..=last] {
if c.is_numeric() {
*c = '•';
}
}
}
std::borrow::Cow::Owned(chars.into_iter().collect())
}
/// Check one party text field: not a full TIN, at most 500 characters, no
@@ -312,6 +448,10 @@ pub struct Activity {
pub paid: rust_decimal::Decimal,
/// Received from it in the span (net of reversals of receipts).
pub received: rust_decimal::Decimal,
/// Posted entries in the span naming it among several parties whose
/// shares don't resolve against the entry and the members given
/// ([`crate::shares::resolve`]): counted in neither total.
pub unresolved: BTreeSet<crate::EntryId>,
}
/// The accounts [`entry_flow`] reads money from: the cash accounts (every
@@ -457,37 +597,59 @@ pub fn entry_flow(entry: &crate::JournalEntry, money: &MoneyAccounts) -> EntryFl
}
/// One party's entries, and the money paid to and received from it in
/// `start..=end`.
/// `start..=end`, with no members ([`activity_with`] with none: an entry
/// naming several parties then needs explicit shares to count).
pub fn activity(
ledger: &crate::Ledger,
party: PartyId,
span: (NaiveDate, NaiveDate),
cash: &[crate::AccountId],
) -> Activity {
activity_with(ledger, party, span, cash, &[])
}
/// One party's entries, and its share of the money paid to and received
/// from the entries' parties in `start..=end`.
///
/// ```text
/// GOAL: one answer to "what did we pay them this year?" for the MCP
/// server and the viewer alike.
/// SOURCE: docs/parties-and-attachments.md (Parties); DECISION (owner,
/// 2026-10-15).
/// server and the viewer alike, right when an entry was with
/// several parties.
/// SOURCE: docs/parties-and-attachments.md (Parties, Shares); DECISION
/// (owner, 2026-10-15).
/// INVARIANTS:
/// 1. entries: every entry Ledger::entry_party names the party
/// 1. entries: every entry Ledger::entry_parties names the party in
/// (hand tags, vendor tags, bills and their payments, invoices and
/// their payments, and reversals of any of these carry it).
/// 2. Each posted entry dated in the span counts its entry_flow net:
/// positive is a payment, negative a receipt. A reversal counts on
/// its original's side, so it subtracts.
/// 3. A card charge counts as paid (the company paid them, by card); a
/// 2. Each posted entry dated in the span whose entry_flow net is not
/// zero counts the party's resolved share of it
/// (crate::shares::resolve with `members`; the whole net for a sole
/// party): positive is a payment, negative a receipt. A reversal
/// counts on its original's side, so it subtracts.
/// 3. An entry moving no money (net zero, like property contributed)
/// counts nothing. An entry whose shares don't resolve counts
/// nothing and is listed in `unresolved`.
/// 4. Over all the parties an entry names, the shares counted sum to its
/// net exactly (crate::shares INVARIANT 6).
/// 5. A card charge counts as paid (the company paid them, by card); a
/// bill doesn't until it is paid. Not the 1099 amount: that is
/// form1099::vendor_1099_report_with.
/// ```
pub fn activity(
pub fn activity_with(
ledger: &crate::Ledger,
party: PartyId,
(start, end): (NaiveDate, NaiveDate),
cash: &[crate::AccountId],
members: &[crate::PartyMember],
) -> Activity {
use rust_decimal::Decimal;
let entries: BTreeSet<crate::EntryId> = (0..ledger.next_entry_id())
.filter(|&id| ledger.entry_party(id) == Some(party))
.filter(|&id| ledger.entry_names_party(id, party))
.collect();
let money = money_accounts(ledger, cash);
let net_out = |e: &crate::JournalEntry| entry_flow(e, &money).net;
let (mut paid, mut received) = (Decimal::ZERO, Decimal::ZERO);
let mut unresolved = BTreeSet::new();
for &id in &entries {
let Some(e) = ledger.get_entry(id) else {
continue;
@@ -496,19 +658,34 @@ pub fn activity(
continue;
}
let net = net_out(e);
if net.is_zero() {
continue;
}
let share = match crate::shares::resolve(e, ledger.entry_parties(id), &money, members) {
Ok(parts) => parts
.into_iter()
.find(|(p, _)| *p == party)
.map(|(_, v)| v)
.unwrap_or_default(),
Err(_) => {
unresolved.insert(id);
continue;
}
};
let side = match e.reversal_of().and_then(|o| ledger.get_entry(o)) {
Some(original) => net_out(original),
None => net,
};
if side > Decimal::ZERO {
paid += net;
paid += share;
} else {
received -= net;
received -= share;
}
}
Activity {
entries,
paid,
received,
unresolved,
}
}
+42 -4
View File
@@ -809,6 +809,33 @@ pub struct MembersEquityStatement {
pub total: MemberEquityColumn,
}
/// The posted entries in `[start, end]` that cancel: each reversal dated
/// in the span with the original it reverses, when that is dated in the
/// span too. Paired in journal order, an entry at most once, so in a chain
/// (an entry, its reversal, the reversal's reversal) the first two cancel
/// and the third, which reinstates the original, counts.
pub fn cancelled_in_span(
ledger: &Ledger,
start: NaiveDate,
end: NaiveDate,
) -> BTreeSet<crate::EntryId> {
let in_span = |d: NaiveDate| d >= start && d <= end;
let mut out = BTreeSet::new();
for e in ledger.posted_entries().filter(|e| in_span(e.date())) {
if let Some(o) = e.reversal_of()
&& ledger
.get_entry(o)
.is_some_and(|x| x.status() == crate::EntryStatus::Posted && in_span(x.date()))
&& !out.contains(&o)
&& !out.contains(&e.id())
{
out.insert(o);
out.insert(e.id());
}
}
out
}
/// Build the statement of changes in members' equity for `[start, end]`.
///
/// Each member's capital account supplies contributions (credits) and
@@ -843,8 +870,16 @@ pub struct MembersEquityStatement {
/// already moved into capital were rounded by that close, B-13.)
/// 4. NEVER hide an unexplained change: it goes in "other".
/// 5. An entry dated `start` is period activity, not beginning capital.
/// 6. An entry and its reversal both dated in the span cancel
/// ([`cancelled_in_span`]): neither is a contribution or a
/// distribution, since the correction undoes the original (B-9: "it
/// is just as if you had written nothing"). A reversal whose original
/// is outside the span counts by side, like any entry.
/// SCENARIOS:
/// 1. IF shares don't sum to 1, or are not positive: SHOULD refuse.
/// 2. A joint 1,000.00 contribution (500.00 each) reversed the next day:
/// contributions 0.00 and distributions 0.00 for each member, not
/// 500.00 of each.
/// ```
pub fn members_equity(
ledger: &Ledger,
@@ -901,12 +936,15 @@ pub fn members_equity(
// Contributions and distributions move a capital account; a closing
// entry allocating earnings into it is income, already counted below.
let closings = ledger.closing_entry_ids();
let cancelled = cancelled_in_span(ledger, start, end);
let flows = |id: crate::AccountId| -> (Decimal, Decimal) {
let (mut credits, mut debits) = (Decimal::ZERO, Decimal::ZERO);
for e in ledger
.posted_entries()
.filter(|e| e.date() >= start && e.date() <= end && !closings.contains(&e.id()))
{
for e in ledger.posted_entries().filter(|e| {
e.date() >= start
&& e.date() <= end
&& !closings.contains(&e.id())
&& !cancelled.contains(&e.id())
}) {
for l in e.lines().iter().filter(|l| l.account == id) {
match l.side {
crate::Side::Credit => credits += l.amount.amount,
+314
View File
@@ -0,0 +1,314 @@
//! Party shares: an entry with more than one party, each with their part of
//! what it moved.
//!
//! An entry names its counterparties as a list of [`PartyShare`]s. Most
//! name one party, who had all of it. A joint transaction names several:
//! the two members of a partnership reimbursed together, a payment split
//! between two contractors, a contribution from both members' joint funds.
//! Each share is an exact amount, a percentage, or derived from the entry
//! itself ([`Share::Derived`]).
//!
//! ```text
//! GOAL: "what did we pay each of them?" right when one entry was with
//! several parties, with apportioning the human controls exactly.
//! SOURCE: DECISION (owner, 2026-10-15): no "joint" party; a joint
//! transaction is with both members, each with their share, and
//! shares can be apportioned explicitly ("do you allow for
//! controlled apportioning?" Yes). docs/parties-and-attachments.md
//! "Shares".
//! DEFINITIONS:
//! flow: crate::parties::entry_flow(entry, money).
//! target: flow.net when it is not zero, else the entry's total debits
//! (a member contributing property moves no money but is still all
//! theirs). magnitude = |target|; sign = the sign of target (positive
//! when net is zero).
//! A member: a PartyMember (party, income share, capital account,
//! distributions account), passed in by the caller from the company's
//! settings ([[company.members]]); core doesn't read the settings.
//! INVARIANTS (of an entry's shares, checked by check_shares, journaled):
//! 1. No party appears twice; every party is in the directory.
//! 2. All shares are one kind: all Amount, all Percent, or all Derived.
//! 3. An Amount is positive with at most two decimal places (cents).
//! 4. A Percent is positive, at most 100, and the percents sum to exactly
//! 100.
//! 5. At most one party has a vendor record: a 1099 counts a payment
//! toward a single vendor.
//! INVARIANTS (of resolve, against the entry's lines and the members):
//! 6. Resolved amounts sum to target exactly, and each has target's sign.
//! 7. Amount: each party gets sign × its amount; refused unless the
//! amounts sum to magnitude.
//! 8. Percent: each party gets magnitude × percent / 100 rounded toward
//! zero to the cent; the cents left over (fewer than one per party)
//! go to the first party in order. Then the sign.
//! 9. Derived, one party: it gets target.
//! 10. Derived, several parties: every one must be a member (else
//! refused: give amounts or percents). If the entry has a line on any
//! named member's capital or distributions account, each member gets
//! |credits − debits| on their own two accounts, and those must sum
//! to magnitude (else refused). Otherwise each gets their income share
//! of magnitude, the shares rescaled to the members named, rounded as
//! in 8 (remainder to the first).
//! SCENARIOS:
//! 1. Reimbursing both members $100.00 (Dr 2100 / Cr 1000), both named,
//! derived, 50/50: $50.00 each.
//! 2. A joint contribution (Dr 1000 300 / Cr 3000 200 / Cr 3001 100):
//! derived from the capital lines, Tobin −200.00, Morgan −100.00
//! (received).
//! 3. A property contribution (Dr Equipment 1000 / Cr 3000 500 / Cr 3001
//! 500): net is zero; target is the total, 500.00 each.
//! 4. $100.00 split 1/3 each three ways by percent 33.33, 33.33, 33.34:
//! 33.33, 33.33, 33.34. By 33.333/33.333/33.334: 33.34, 33.33, 33.33
//! (the leftover cent to the first).
//! 5. Amounts of $60.00 and $30.00 on a $100.00 payment: refused.
//! ```
use crate::{AccountId, JournalEntry, PartyId, Side};
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
/// What part of an entry one party had.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Share {
/// Exactly this much of what the entry moved (a positive amount, in
/// cents; the sign follows the entry).
Amount(Decimal),
/// This percent of what the entry moved (0 < p ≤ 100).
Percent(Decimal),
/// From the entry itself: all of it for a sole party; for members,
/// their capital lines or their income share (see the module's
/// INVARIANT 10).
Derived,
}
/// One party named on an entry, with their share.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct PartyShare {
/// The party.
#[serde(rename = "party_id")]
pub party: PartyId,
/// Their share.
pub share: Share,
}
impl PartyShare {
/// The sole party, with all of it.
pub fn whole(party: PartyId) -> Self {
PartyShare {
party,
share: Share::Derived,
}
}
}
/// A member of the company as shares see them: their party, their share of
/// income (a fraction, e.g. 0.50), and their capital and distributions
/// accounts. Built by the caller from the settings' members.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct PartyMember {
/// The member's party in the directory.
pub party: PartyId,
/// Their share of income, as a fraction (the members' sum to 1).
pub share: Decimal,
/// Their capital account.
pub capital: AccountId,
/// Their distributions account, if they have one.
pub distributions: Option<AccountId>,
}
/// The structural checks on an entry's shares (the module's INVARIANTS 1–4;
/// 5 needs the directory and is [`crate::Ledger::check_set_entry_parties`]'s).
/// `known` says whether a party is in the directory. Returns why not.
pub fn check_shares(shares: &[PartyShare], known: impl Fn(PartyId) -> bool) -> Result<(), String> {
let mut seen = std::collections::BTreeSet::new();
for s in shares {
if !known(s.party) {
return Err(format!("unknown party {}", s.party));
}
if !seen.insert(s.party) {
return Err(format!("party {} is named twice", s.party));
}
}
let kind = |s: &Share| match s {
Share::Amount(_) => 0,
Share::Percent(_) => 1,
Share::Derived => 2,
};
if let Some(first) = shares.first()
&& shares.iter().any(|s| kind(&s.share) != kind(&first.share))
{
return Err(
"give every party the same kind of share: all amounts, all percents, or none (derived)"
.into(),
);
}
let mut percent = Decimal::ZERO;
let mut any_percent = false;
for s in shares {
match s.share {
Share::Amount(a) => {
if a <= Decimal::ZERO {
return Err(format!("party {}'s amount must be positive", s.party));
}
if a.normalize().scale() > 2 {
return Err(format!(
"party {}'s amount {a} is finer than a cent",
s.party
));
}
}
Share::Percent(p) => {
if p <= Decimal::ZERO || p > Decimal::ONE_HUNDRED {
return Err(format!(
"party {}'s percent must be more than 0 and at most 100",
s.party
));
}
any_percent = true;
percent += p;
}
Share::Derived => {}
}
}
if any_percent && percent != Decimal::ONE_HUNDRED {
return Err(format!("the percents sum to {percent}, not 100"));
}
Ok(())
}
/// What the shares are split over: the entry's flow net, else its total
/// debits (the module's DEFINITIONS).
pub fn share_target(entry: &JournalEntry, money: &crate::parties::MoneyAccounts) -> Decimal {
let net = crate::parties::entry_flow(entry, money).net;
if net.is_zero() {
entry
.lines()
.iter()
.filter(|l| l.side == Side::Debit)
.map(|l| l.amount.amount)
.sum()
} else {
net
}
}
/// Split `magnitude` (non-negative) by `weights` (positive, any sum): each
/// part rounded toward zero to the cent, the cents left over to the first.
fn split_first(magnitude: Decimal, weights: &[Decimal]) -> Vec<Decimal> {
use rust_decimal::RoundingStrategy::ToZero;
let total: Decimal = weights.iter().copied().sum();
if weights.is_empty() || total.is_zero() {
return vec![Decimal::ZERO; weights.len()];
}
let mut parts: Vec<Decimal> = weights
.iter()
.map(|w| (magnitude * w / total).round_dp_with_strategy(2, ToZero))
.collect();
let left = magnitude - parts.iter().copied().sum::<Decimal>();
parts[0] += left;
parts
}
/// Each party's exact part of what `entry` moved (the module's INVARIANTS
/// 6–10), in the shares' order. Refused, with why, when the shares can't
/// resolve against these lines and members.
pub fn resolve(
entry: &JournalEntry,
shares: &[PartyShare],
money: &crate::parties::MoneyAccounts,
members: &[PartyMember],
) -> Result<Vec<(PartyId, Decimal)>, String> {
let target = share_target(entry, money);
let magnitude = target.abs();
let signed = |v: Decimal| if target.is_sign_negative() { -v } else { v };
let Some(first) = shares.first() else {
return Ok(Vec::new());
};
let parts: Vec<Decimal> = match first.share {
Share::Amount(_) => {
let amounts: Vec<Decimal> = shares
.iter()
.map(|s| match s.share {
Share::Amount(a) => a,
_ => Decimal::ZERO,
})
.collect();
let sum: Decimal = amounts.iter().copied().sum();
if sum != magnitude {
return Err(format!(
"the parties' amounts sum to {sum}, but entry {} moved {magnitude}",
entry.id()
));
}
amounts
}
Share::Percent(_) => {
let weights: Vec<Decimal> = shares
.iter()
.map(|s| match s.share {
Share::Percent(p) => p,
_ => Decimal::ZERO,
})
.collect();
split_first(magnitude, &weights)
}
Share::Derived if shares.len() == 1 => vec![magnitude],
Share::Derived => {
let named: Vec<&PartyMember> = shares
.iter()
.map(|s| {
members.iter().find(|m| m.party == s.party).ok_or_else(|| {
format!(
"party {} isn't a member, so its share can't be derived: give each party an amount or a percent",
s.party
)
})
})
.collect::<Result<_, _>>()?;
let on = |m: &PartyMember, a: AccountId| a == m.capital || Some(a) == m.distributions;
let touches = entry
.lines()
.iter()
.any(|l| named.iter().any(|m| on(m, l.account)));
if touches {
let parts: Vec<Decimal> = named
.iter()
.map(|m| {
entry
.lines()
.iter()
.filter(|l| on(m, l.account))
.map(|l| match l.side {
Side::Credit => l.amount.amount,
Side::Debit => -l.amount.amount,
})
.sum::<Decimal>()
.abs()
})
.collect();
let sum: Decimal = parts.iter().copied().sum();
if sum != magnitude {
return Err(format!(
"the members' capital lines on entry {} come to {sum}, but it moved {magnitude}: give each member an amount",
entry.id()
));
}
parts
} else {
let weights: Vec<Decimal> = named.iter().map(|m| m.share).collect();
if weights.iter().any(|w| *w <= Decimal::ZERO) {
return Err(
"a member named has no income share: give each party an amount or a percent"
.into(),
);
}
split_first(magnitude, &weights)
}
}
};
Ok(shares
.iter()
.zip(parts)
.map(|(s, v)| (s.party, signed(v)))
.collect())
}