Shared parties with exact shares, member capital statements, TINs for any party, K-1 worksheet
check / check (push) Waiting to run

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 df3fc3f4e4
commit 4c74169d18
32 files changed
+7232 -375

No files matched your search

+5 -1
View File
@@ -270,18 +270,22 @@ fiscal_year_start = "01-01"
# Members of a multi-member LLC, one block each: shares come from the
# operating agreement (IRC §704(a)), "50%" or "0.5", summing to 100%. Each
# names a capital account in the chart, and optionally a distributions
# account that a close zeroes into capital.
# account that a close zeroes into capital. Spouses who file one joint
# return each name the other in files_jointly_with (each still gets their
# own K-1; the CPA package notes both go on one Form 1040).
# [[company.members]]
# name = "Alex Rivera"
# share = "50%"
# capital_account = "3000"
# distribution_account = "3100"
# files_jointly_with = "Sam Chen"
#
# [[company.members]]
# name = "Sam Chen"
# share = "50%"
# capital_account = "3001"
# distribution_account = "3101"
# files_jointly_with = "Alex Rivera"
[books]
# The books' data, relative to this file. The tools take it from --data-dir
+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: "Katherine Pendergast".into(),
share: half,
capital_account: "3001".into(),
distribution_account: None,
files_jointly_with: None,
},
]
);
+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, Stephen,
/// 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 (Stephen, 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("Kat Example".into())),
)
.unwrap();
assert_eq!(member_party(&l, "Kat Example"), None);
let plain = l.add_party("Kat Example", &[]).unwrap();
assert_eq!(member_party(&l, "kat example "), None, "no member role");
l.set_party_role(plain, crate::Role::Member, true).unwrap();
assert_eq!(member_party(&l, "kat 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 (Stephen,
/// 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 (Stephen,
/// 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, Stephen, 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
/// (`571-93-2846`, `571.93.2846`, `571/93/2846`, `571 - 93 - 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 (`571-93-2846`, `571.93.2846`, `571 - 93 - 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 (Stephen,
/// 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
/// (Stephen, 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 (Stephen, 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, Stephen −200.00, Katherine −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())
}
+394
View File
@@ -262,3 +262,397 @@ fn payments_come_from_cash_and_financing_only() {
let plain = vendor_1099_report(&l, 2026, None).unwrap();
assert_eq!(plain.vendors[0].paid, dec("700"));
}
/// Books for the shared-payment tests: bank 1000, card 2050, contract
/// labor 6000; Dana (1099-NEC) and member Stephen as parties.
struct Shared {
l: Ledger,
bank: ledger_core::AccountId,
card: ledger_core::AccountId,
labor: ledger_core::AccountId,
dana: ledger_core::VendorId,
dana_p: ledger_core::PartyId,
stephen: ledger_core::PartyId,
}
fn shared_books() -> Shared {
let mut l = Ledger::new(Currency::USD);
let bank = l
.add_account("1000", "Bank", AccountType::Asset, None, false)
.unwrap();
let card = l
.add_account("2050", "Card", AccountType::Liability, None, false)
.unwrap();
let labor = l
.add_account("6000", "Contract Labor", AccountType::Expense, None, false)
.unwrap();
let dana = l.add_vendor("Dana").unwrap();
l.set_vendor_1099(dana, Some(Form1099::Nec), true).unwrap();
let dana_p = l.party_of_vendor(dana).unwrap();
let stephen = l
.add_party("Stephen", &[ledger_core::Role::Member])
.unwrap();
Shared {
l,
bank,
card,
labor,
dana,
dana_p,
stephen,
}
}
/// Post `lines` on `date` and name `shares` on it.
fn post_shared(
s: &mut Shared,
date: NaiveDate,
lines: &[(ledger_core::AccountId, Side, &str)],
shares: &[(ledger_core::PartyId, ledger_core::Share)],
) -> ledger_core::EntryId {
let e = s.l.create_entry(date, "shared payment");
for (a, side, amt) in lines {
s.l.add_line(e, *a, *side, Money::new(dec(amt), Currency::USD))
.unwrap();
}
s.l.post_entry(e).unwrap();
let shares: Vec<ledger_core::PartyShare> = shares
.iter()
.map(|&(party, share)| ledger_core::PartyShare { party, share })
.collect();
s.l.set_entry_parties(e, &shares).unwrap();
e
}
fn dana_paid(s: &Shared) -> (Decimal, Vec<ledger_core::EntryId>) {
let r = vendor_1099_report(&s.l, 2026, None).unwrap();
let line = r.vendors.iter().find(|v| v.vendor_id == s.dana).unwrap();
(line.paid, r.unresolved)
}
/// vendor_1099_report_with_members SCENARIO 3, by hand: $1,000.00 from
/// the bank (Dr 6000 / Cr 1000), Dana $600.00 and Stephen $400.00 by
/// amount. Payment = target = 1,000.00, so Dana's 1099 counts her share
/// unchanged: 600.00 (not the whole 1,000.00).
#[test]
fn a_shared_payment_counts_the_vendors_share() {
use ledger_core::Share::Amount;
let mut s = shared_books();
let (bank, labor, dana_p, stephen) = (s.bank, s.labor, s.dana_p, s.stephen);
let e = post_shared(
&mut s,
d(2026, 3, 1),
&[
(labor, Side::Debit, "1000.00"),
(bank, Side::Credit, "1000.00"),
],
&[
(dana_p, Amount(dec("600.00"))),
(stephen, Amount(dec("400.00"))),
],
);
assert_eq!(
s.l.entry_vendor(e),
Some(s.dana),
"the vendor tag follows Dana"
);
assert_eq!(dana_paid(&s), (dec("600.00"), vec![]));
// With sources and members named, the same.
let r = ledger_core::vendor_1099_report_with_members(
&s.l,
2026,
None,
&ledger_core::PaymentAccounts {
cash: vec![bank],
financing: vec![],
},
&[],
)
.unwrap();
assert_eq!(r.vendors[0].paid, dec("600.00"));
assert!(!r.vendors[0].reportable, "600.00 is under 2,000");
}
/// SCENARIO 4, by hand: $100.01 from the bank split 50% / 50%. Each part
/// is 100.01 × 50 / 100 = 50.005, rounded toward zero to 50.00; the cent
/// left over goes to the party named first (shares INVARIANT 8). Dana
/// first: 50.01. A second payment with Dana named second: 50.00. Total
/// 100.01.
#[test]
fn a_percent_split_gives_the_leftover_cent_to_the_first() {
use ledger_core::Share::Percent;
let mut s = shared_books();
let (bank, labor, dana_p, stephen) = (s.bank, s.labor, s.dana_p, s.stephen);
let lines = [
(labor, Side::Debit, "100.01"),
(bank, Side::Credit, "100.01"),
];
post_shared(
&mut s,
d(2026, 4, 1),
&lines,
&[(dana_p, Percent(dec("50"))), (stephen, Percent(dec("50")))],
);
assert_eq!(dana_paid(&s).0, dec("50.01"));
post_shared(
&mut s,
d(2026, 4, 2),
&lines,
&[(stephen, Percent(dec("50"))), (dana_p, Percent(dec("50")))],
);
assert_eq!(dana_paid(&s).0, dec("100.01"), "50.01 + 50.00");
}
/// SCENARIO 6, by hand: SCENARIO 3's payment reversed the same year. The
/// reversal carries the shares; its target is −1,000.00, Dana's amount
/// resolves to −600.00, and the payment is −1,000.00 = target, so she
/// nets 600.00 − 600.00 = 0.00 (still listed: she is marked for a 1099).
#[test]
fn a_reversed_shared_payment_nets_to_zero() {
use ledger_core::Share::Amount;
let mut s = shared_books();
let (bank, labor, dana_p, stephen) = (s.bank, s.labor, s.dana_p, s.stephen);
let e = post_shared(
&mut s,
d(2026, 3, 1),
&[
(labor, Side::Debit, "1000.00"),
(bank, Side::Credit, "1000.00"),
],
&[
(dana_p, Amount(dec("600.00"))),
(stephen, Amount(dec("400.00"))),
],
);
let rev = s.l.reverse_entry(e, d(2026, 3, 2), "wrong payee").unwrap();
assert_eq!(s.l.entry_parties(rev), s.l.entry_parties(e));
assert_eq!(dana_paid(&s), (dec("0.00"), vec![]));
}
/// SCENARIO 5, by hand: $1,000.00 of contract labor, 600.00 from the
/// bank and 400.00 on the card, Dana 60% / Stephen 40%. Target (the flow
/// net, the card counted as money) 1,000.00, Dana's share 600.00, the
/// 1099 payment 600.00 (the card is not a payment source): 600.00 ×
/// 600.00 / 1,000.00 = 360.00. Then $100.00 with 33.33 from the bank and
/// 66.67 on the card, 50/50: share 50.00, 33.33 × 50.00 / 100.00 =
/// 16.665, rounded half away from zero to 16.67. Total 376.67.
#[test]
fn a_shared_payment_partly_on_a_card_scales_to_the_payment() {
use ledger_core::Share::Percent;
let mut s = shared_books();
let (bank, card, labor, dana_p, stephen) = (s.bank, s.card, s.labor, s.dana_p, s.stephen);
post_shared(
&mut s,
d(2026, 5, 1),
&[
(labor, Side::Debit, "1000.00"),
(bank, Side::Credit, "600.00"),
(card, Side::Credit, "400.00"),
],
&[(dana_p, Percent(dec("60"))), (stephen, Percent(dec("40")))],
);
assert_eq!(dana_paid(&s).0, dec("360.00"));
post_shared(
&mut s,
d(2026, 5, 2),
&[
(labor, Side::Debit, "100.00"),
(bank, Side::Credit, "33.33"),
(card, Side::Credit, "66.67"),
],
&[(dana_p, Percent(dec("50"))), (stephen, Percent(dec("50")))],
);
assert_eq!(dana_paid(&s).0, dec("376.67"));
}
/// INVARIANT 4, by hand: amounts of 600.00 and 300.00 on a 1,000.00
/// payment don't resolve (shares SCENARIO 5), so the whole 1,000.00
/// counts toward Dana and the entry is listed. A single-party entry
/// (300.00, Dana alone) counts in full, as before shares: 1,300.00.
#[test]
fn unresolved_shares_count_in_full_and_are_listed() {
use ledger_core::Share::{Amount, Derived};
let mut s = shared_books();
let (bank, labor, dana_p, stephen) = (s.bank, s.labor, s.dana_p, s.stephen);
let e = post_shared(
&mut s,
d(2026, 6, 1),
&[
(labor, Side::Debit, "1000.00"),
(bank, Side::Credit, "1000.00"),
],
&[
(dana_p, Amount(dec("600.00"))),
(stephen, Amount(dec("300.00"))),
],
);
post_shared(
&mut s,
d(2026, 6, 2),
&[
(labor, Side::Debit, "300.00"),
(bank, Side::Credit, "300.00"),
],
&[(dana_p, Derived)],
);
assert_eq!(dana_paid(&s), (dec("1300.00"), vec![e]));
}
/// The SCENARIO 3 payment: $1,000.00 from the bank, Dana 600.00 and
/// Stephen 400.00 by amount.
fn dana_and_stephen(s: &mut Shared) -> ledger_core::EntryId {
use ledger_core::Share::Amount;
let (bank, labor, dana_p, stephen) = (s.bank, s.labor, s.dana_p, s.stephen);
post_shared(
s,
d(2026, 3, 1),
&[
(labor, Side::Debit, "1000.00"),
(bank, Side::Credit, "1000.00"),
],
&[
(dana_p, Amount(dec("600.00"))),
(stephen, Amount(dec("400.00"))),
],
)
}
/// set_entry_vendor SCENARIO 4, by hand: re-tagging Dana's vendor on the
/// shared payment keeps both shares, so her 1099 stays 600.00 (not the
/// whole 1,000.00) and Stephen keeps his part. Tagging a vendor whose
/// party isn't named is refused, changing nothing.
#[test]
fn tagging_the_vendor_keeps_a_shared_entrys_parties() {
let mut s = shared_books();
let e = dana_and_stephen(&mut s);
let before = s.l.entry_parties(e).to_vec();
s.l.set_entry_vendor(e, Some(s.dana)).unwrap();
assert_eq!(s.l.entry_parties(e), before.as_slice());
assert!(s.l.entry_names_party(e, s.stephen));
assert_eq!(dana_paid(&s), (dec("600.00"), vec![]));
let other = s.l.add_vendor("Other Co").unwrap();
let err = s.l.set_entry_vendor(e, Some(other)).unwrap_err();
assert!(matches!(err, LedgerError::InvalidParty(_)), "{err:?}");
assert_eq!(s.l.entry_parties(e), before.as_slice());
assert_eq!(s.l.entry_vendor(e), Some(s.dana));
// One party or none: the vendor's party becomes the entry's one party.
let (labor, bank, stephen) = (s.labor, s.bank, s.stephen);
let lone = post_shared(
&mut s,
d(2026, 3, 5),
&[(labor, Side::Debit, "50.00"), (bank, Side::Credit, "50.00")],
&[(stephen, ledger_core::Share::Derived)],
);
s.l.set_entry_vendor(lone, Some(s.dana)).unwrap();
assert_eq!(s.l.entry_party(lone), Some(s.dana_p));
}
/// set_entry_vendor SCENARIO 5: clearing the tag on the shared payment,
/// whose vendor's party (Dana) is named, is refused and changes nothing,
/// so the 1099 (600.00) and Dana's party activity never disagree.
#[test]
fn clearing_the_vendor_on_a_shared_entry_is_refused() {
let mut s = shared_books();
let e = dana_and_stephen(&mut s);
let err = s.l.set_entry_vendor(e, None).unwrap_err();
assert!(matches!(err, LedgerError::InvalidParty(_)), "{err:?}");
assert_eq!(s.l.entry_vendor(e), Some(s.dana));
assert!(s.l.entry_names_party(e, s.dana_p));
assert_eq!(dana_paid(&s), (dec("600.00"), vec![]));
// Dropping Dana from the parties clears the tag with her.
s.l.set_entry_parties(e, &[ledger_core::PartyShare::whole(s.stephen)])
.unwrap();
assert_eq!(s.l.entry_vendor(e), None);
}
/// set_entry_parties SCENARIO 3, by hand: the shared payment reversed,
/// then re-apportioned 700.00 / 300.00. The reversal is re-apportioned
/// too, so Dana nets 700.00 − 700.00 = 0.00 (not 700.00 − 600.00 =
/// 100.00). Re-apportioning the reversal itself does the same for the
/// original.
#[test]
fn re_apportioning_a_reversed_entry_re_apportions_its_reversal() {
use ledger_core::Share::Amount;
let mut s = shared_books();
let e = dana_and_stephen(&mut s);
let rev = s.l.reverse_entry(e, d(2026, 3, 2), "wrong payee").unwrap();
assert_eq!(s.l.reversal_chain(e), vec![e, rev]);
assert_eq!(s.l.reversal_chain(rev), vec![e, rev]);
let shares = |a: &str, b: &str| {
vec![
ledger_core::PartyShare {
party: s.dana_p,
share: Amount(dec(a)),
},
ledger_core::PartyShare {
party: s.stephen,
share: Amount(dec(b)),
},
]
};
let new = shares("700.00", "300.00");
s.l.set_entry_parties(e, &new).unwrap();
assert_eq!(s.l.entry_parties(rev), new.as_slice());
assert_eq!(dana_paid(&s), (dec("0.00"), vec![]));
let back = shares("550.00", "450.00");
s.l.set_entry_parties(rev, &back).unwrap();
assert_eq!(s.l.entry_parties(e), back.as_slice());
assert_eq!(dana_paid(&s), (dec("0.00"), vec![]));
// Re-tagging the vendor on a reversed one-party entry tags both.
let (labor, bank, stephen) = (s.labor, s.bank, s.stephen);
let lone = post_shared(
&mut s,
d(2026, 4, 1),
&[(labor, Side::Debit, "80.00"), (bank, Side::Credit, "80.00")],
&[(stephen, ledger_core::Share::Derived)],
);
let lone_rev = s.l.reverse_entry(lone, d(2026, 4, 2), "oops").unwrap();
s.l.set_entry_vendor(lone, Some(s.dana)).unwrap();
assert_eq!(s.l.entry_vendor(lone_rev), Some(s.dana));
assert_eq!(dana_paid(&s), (dec("0.00"), vec![]));
}
/// add_vendor INVARIANTS 2-3: a party named on a shared entry beside
/// Dana (who has a vendor record) can't gain a vendor record, which
/// would leave the entry naming two vendors (shares INVARIANT 5); a party
/// named with no vendor party gains the tag with its new record.
#[test]
fn a_named_party_gaining_a_vendor_record_keeps_one_vendor_per_entry() {
use ledger_core::Share::Percent;
let mut s = shared_books();
let beta = s.l.add_party("Beta", &[]).unwrap();
let (bank, labor, dana_p) = (s.bank, s.labor, s.dana_p);
let e = post_shared(
&mut s,
d(2026, 7, 1),
&[
(labor, Side::Debit, "100.00"),
(bank, Side::Credit, "100.00"),
],
&[(dana_p, Percent(dec("60"))), (beta, Percent(dec("40")))],
);
let vendors_before = s.l.list_vendors().len();
let err = s.l.add_vendor("Beta").unwrap_err();
assert!(matches!(err, LedgerError::InvalidParty(_)), "{err:?}");
assert_eq!(s.l.list_vendors().len(), vendors_before, "nothing changed");
assert_eq!(s.l.get_party(beta).unwrap().vendor, None);
// Re-issuing the same shares is still accepted.
let same = s.l.entry_parties(e).to_vec();
s.l.set_entry_parties(e, &same).unwrap();
// Gamma, named only beside Stephen (a member, no vendor record).
let gamma = s.l.add_party("Gamma", &[]).unwrap();
let stephen = s.stephen;
let g = post_shared(
&mut s,
d(2026, 7, 2),
&[
(labor, Side::Debit, "100.00"),
(bank, Side::Credit, "100.00"),
],
&[(stephen, Percent(dec("50"))), (gamma, Percent(dec("50")))],
);
assert_eq!(s.l.entry_vendor(g), None);
let gv = s.l.add_vendor("Gamma").unwrap();
assert_eq!(s.l.get_party(gamma).unwrap().vendor, Some(gv));
assert_eq!(s.l.entry_vendor(g), Some(gv), "the tag follows the party");
}
+619 -8
View File
@@ -14,7 +14,7 @@ use std::collections::{BTreeMap, BTreeSet};
use std::str::FromStr;
use chrono::NaiveDate;
use ledger_core::parties::{check_text, has_full_tin, name_key};
use ledger_core::parties::{check_text, has_full_tin, name_key, redact_full_tins};
use ledger_core::{
AccountId, AccountType, Currency, EntryId, Ledger, Money, Party, PartyField, PartyId,
PayrollLine, Role, Side, W4Facts, W9Facts,
@@ -129,6 +129,7 @@ fn bare_party(name: &str) -> Party {
archived: false,
w9: None,
w4: None,
tin: None,
vendor: None,
customer: None,
}
@@ -151,8 +152,9 @@ fn pay_line(name: &str, employee: Option<PartyId>) -> PayrollLine {
// ------------------------------------------------- has_full_tin truth table
/// Oracle: `has_full_tin` doc ("a run of nine digits, with at most one
/// space or hyphen between neighbors") and INVARIANT 1.
/// Oracle: `has_full_tin` doc ("a run of nine digits, with at most three
/// separators (space, hyphen, dot, slash, underscore, a Unicode dash or
/// no-break space) between neighbors") and INVARIANT 1.
#[test]
fn has_full_tin_truth_table() {
let cases: &[(&str, bool)] = &[
@@ -164,11 +166,20 @@ fn has_full_tin_truth_table() {
("1-2-3-4-5-6-7-8-9", true),
("TIN 123456789 on file", true), // inside longer text
("abc123456789def", true),
("12 3456789", false), // two spaces break the run: 2 + 7 digits
("12--3456789", false), // two hyphens
("12 -3456789", false), // space and hyphen between neighbors
("12\t3456789", false), // a tab is not a space
("12345678", false), // eight digits
("12 3456789", true), // two spaces
("12--3456789", true), // two hyphens
("12 -3456789", true), // space and hyphen between neighbors
("638.27.4915", true), // dots
("wrong one, was 638.27.4915", true),
("571/93/2846", true), // slashes
("571_93_2846", true), // underscores
("Bob 571 - 93 - 2846", true), // three separators
("571\u{2013}93\u{2013}2846", true), // en dashes
("571\u{a0}93\u{a0}2846", true), // no-break spaces
("12 3456789", false), // four separators break the run: 2 + 7
("571, 93, 2846", false), // a comma is not a separator
("12\t3456789", false), // a tab is not a space
("12345678", false), // eight digits
("123-45-678", false),
("1234", false),
("", false),
@@ -181,6 +192,38 @@ fn has_full_tin_truth_table() {
}
}
/// Oracle: `redact_full_tins` doc: every run `has_full_tin` finds has its
/// digits masked, separators kept; other text is untouched.
#[test]
fn redact_full_tins_masks_every_full_tin() {
assert_eq!(
redact_full_tins("unknown party \"571-93-2846\""),
"unknown party \"•••-••-••••\""
);
assert_eq!(
redact_full_tins("was 638.27.4915 and 12-3456789, call 801-555"),
"was •••.••.•••• and ••-•••••••, call 801-555"
);
assert_eq!(
redact_full_tins("invalid type: integer `571932846`"),
"invalid type: integer `•••••••••`"
);
assert_eq!(redact_full_tins("party 12 of 3"), "party 12 of 3");
for t in ["571 - 93 - 2846", "571932846", "1234567890"] {
assert!(!has_full_tin(&redact_full_tins(t)), "{t}");
}
}
/// Oracle: `check_text` refuses what `has_full_tin` finds, so a purge
/// reason or note quoting the old number with dots is refused (B-26: no
/// party field may hold a nine-digit number).
#[test]
fn check_text_refuses_a_dotted_tin() {
assert!(check_text("purged", "wrong one, was 638.27.4915").is_err());
assert!(check_text("no_party_reason", "ssn 571.93.2846").is_err());
assert!(check_text("name", "Bob 571 - 93 - 2846").is_err());
}
/// Oracle: INVARIANT 1 says "any run of nine digits"; a run of ten digits
/// holds a run of nine.
#[test]
@@ -1210,3 +1253,571 @@ fn entry_flow_scenarios() {
let f = case(&[(exp, D, "100.00"), (bank, C, "60.00"), (card, C, "40.00")]);
assert_eq!(f.net, dec("100.00"));
}
// ------------------------------------------------------- party shares
//
// Oracles: the spec block of `ledger_core::shares` (DEFINITIONS,
// INVARIANTS 1–10, SCENARIOS 1–5), `Ledger::set_entry_parties` (INVARIANTS
// 1–4), `parties::activity_with` (INVARIANTS 1–4), and
// docs/parties-and-attachments.md "Shares". Values worked by hand.
mod shares {
use super::{d, date, refused, usd};
use ledger_core::parties::{activity_with, money_accounts};
use ledger_core::shares::{check_shares, resolve};
use ledger_core::{
AccountId, AccountType, Currency, EntryId, Ledger, PartyId, PartyMember, PartyShare, Role,
Share, Side,
};
use proptest::prelude::*;
use rust_decimal::Decimal;
struct Books {
l: Ledger,
bank: AccountId,
due: AccountId,
cap_s: AccountId,
cap_k: AccountId,
equip: AccountId,
exp: AccountId,
stephen: PartyId,
kat: PartyId,
acme: PartyId,
members: Vec<PartyMember>,
}
/// Bluevine 1000, Due to Shareholders 2100, capital 3000 and 3001,
/// equipment 1500, an expense 6050; members Stephen and Katherine at
/// 50/50; a party Acme (no records).
fn books() -> Books {
let mut l = Ledger::new(Currency::USD);
let add = |l: &mut Ledger, c: &str, t| l.add_account(c, c, t, None, false).unwrap();
let bank = add(&mut l, "1000", AccountType::Asset);
let equip = add(&mut l, "1500", AccountType::Asset);
let due = add(&mut l, "2100", AccountType::Liability);
let cap_s = add(&mut l, "3000", AccountType::Equity);
let cap_k = add(&mut l, "3001", AccountType::Equity);
let exp = add(&mut l, "6050", AccountType::Expense);
let stephen = l.add_party("Stephen Waits", &[Role::Member]).unwrap();
let kat = l
.add_party("Katherine Pendergast", &[Role::Member])
.unwrap();
let acme = l.add_party("Acme", &[Role::Contractor]).unwrap();
let half = d("0.5");
let members = vec![
PartyMember {
party: stephen,
share: half,
capital: cap_s,
distributions: None,
},
PartyMember {
party: kat,
share: half,
capital: cap_k,
distributions: None,
},
];
Books {
l,
bank,
due,
cap_s,
cap_k,
equip,
exp,
stephen,
kat,
acme,
members,
}
}
fn post(l: &mut Ledger, lines: &[(AccountId, Side, &str)]) -> EntryId {
let e = l.try_create_entry(date(2026, 5, 1), "joint").unwrap();
for (a, s, v) in lines {
l.add_line(e, *a, *s, usd(v)).unwrap();
}
l.post_entry(e).unwrap();
e
}
fn derived(ps: &[PartyId]) -> Vec<PartyShare> {
ps.iter().map(|&p| PartyShare::whole(p)).collect()
}
fn amounts(ps: &[(PartyId, &str)]) -> Vec<PartyShare> {
ps.iter()
.map(|&(party, v)| PartyShare {
party,
share: Share::Amount(d(v)),
})
.collect()
}
fn percents(ps: &[(PartyId, &str)]) -> Vec<PartyShare> {
ps.iter()
.map(|&(party, v)| PartyShare {
party,
share: Share::Percent(d(v)),
})
.collect()
}
fn resolved(b: &Books, e: EntryId) -> Result<Vec<(PartyId, Decimal)>, String> {
let money = money_accounts(&b.l, &[b.bank]);
resolve(
b.l.get_entry(e).unwrap(),
b.l.entry_parties(e),
&money,
&b.members,
)
}
fn year() -> (chrono::NaiveDate, chrono::NaiveDate) {
(date(2026, 1, 1), date(2026, 12, 31))
}
fn paid_received(b: &Books, p: PartyId) -> (Decimal, Decimal) {
let a = activity_with(&b.l, p, year(), &[b.bank], &b.members);
(a.paid, a.received)
}
/// SCENARIO 1: a reimbursement to both members, derived, 50/50.
#[test]
fn reimbursing_both_members_splits_by_member_share() {
let mut b = books();
let e = post(
&mut b.l,
&[
(b.due, Side::Debit, "100.00"),
(b.bank, Side::Credit, "100.00"),
],
);
b.l.set_entry_parties(e, &derived(&[b.stephen, b.kat]))
.unwrap();
assert_eq!(
resolved(&b, e).unwrap(),
vec![(b.stephen, d("50.00")), (b.kat, d("50.00"))]
);
assert_eq!(b.l.entry_party(e), None, "two parties: no sole party");
assert!(b.l.entry_names_party(e, b.kat));
assert_eq!(paid_received(&b, b.stephen), (d("50.00"), Decimal::ZERO));
assert_eq!(paid_received(&b, b.kat), (d("50.00"), Decimal::ZERO));
}
/// SCENARIO 2: a joint contribution derives from the capital lines.
#[test]
fn joint_contribution_derives_from_capital_lines() {
let mut b = books();
let e = post(
&mut b.l,
&[
(b.bank, Side::Debit, "300.00"),
(b.cap_s, Side::Credit, "200.00"),
(b.cap_k, Side::Credit, "100.00"),
],
);
b.l.set_entry_parties(e, &derived(&[b.stephen, b.kat]))
.unwrap();
assert_eq!(
resolved(&b, e).unwrap(),
vec![(b.stephen, d("-200.00")), (b.kat, d("-100.00"))]
);
assert_eq!(paid_received(&b, b.stephen), (Decimal::ZERO, d("200.00")));
assert_eq!(paid_received(&b, b.kat), (Decimal::ZERO, d("100.00")));
}
/// SCENARIO 3: property contributed moves no money; the shares are of
/// the total, and activity counts nothing.
#[test]
fn property_contribution_resolves_against_the_total() {
let mut b = books();
let e = post(
&mut b.l,
&[
(b.equip, Side::Debit, "1000.00"),
(b.cap_s, Side::Credit, "500.00"),
(b.cap_k, Side::Credit, "500.00"),
],
);
b.l.set_entry_parties(e, &derived(&[b.stephen, b.kat]))
.unwrap();
assert_eq!(
resolved(&b, e).unwrap(),
vec![(b.stephen, d("500.00")), (b.kat, d("500.00"))]
);
assert_eq!(paid_received(&b, b.stephen), (Decimal::ZERO, Decimal::ZERO));
}
/// Capital lines that don't account for the whole flow are refused.
#[test]
fn capital_lines_short_of_the_flow_are_refused() {
let mut b = books();
let e = post(
&mut b.l,
&[
(b.bank, Side::Debit, "300.00"),
(b.cap_s, Side::Credit, "200.00"),
(b.exp, Side::Credit, "100.00"),
],
);
b.l.set_entry_parties(e, &derived(&[b.stephen, b.kat]))
.unwrap();
assert!(resolved(&b, e).is_err());
let a = activity_with(&b.l, b.stephen, year(), &[b.bank], &b.members);
assert!(a.unresolved.contains(&e));
assert_eq!((a.paid, a.received), (Decimal::ZERO, Decimal::ZERO));
}
/// SCENARIO 4: percent rounding, remainder to the first party.
#[test]
fn percent_rounding_gives_the_remainder_to_the_first() {
let mut b = books();
let c = b.l.add_party("Third", &[]).unwrap();
let e = post(
&mut b.l,
&[
(b.exp, Side::Debit, "100.00"),
(b.bank, Side::Credit, "100.00"),
],
);
b.l.set_entry_parties(
e,
&percents(&[(b.acme, "33.33"), (b.stephen, "33.33"), (c, "33.34")]),
)
.unwrap();
assert_eq!(
resolved(&b, e).unwrap(),
vec![
(b.acme, d("33.33")),
(b.stephen, d("33.33")),
(c, d("33.34"))
]
);
b.l.set_entry_parties(
e,
&percents(&[(b.acme, "33.333"), (b.stephen, "33.333"), (c, "33.334")]),
)
.unwrap();
assert_eq!(
resolved(&b, e).unwrap(),
vec![
(b.acme, d("33.34")),
(b.stephen, d("33.33")),
(c, d("33.33"))
]
);
}
/// SCENARIO 5 and INVARIANT 7: amounts must sum to what moved.
#[test]
fn amounts_must_sum_to_the_flow() {
let mut b = books();
let e = post(
&mut b.l,
&[
(b.exp, Side::Debit, "100.00"),
(b.bank, Side::Credit, "100.00"),
],
);
b.l.set_entry_parties(e, &amounts(&[(b.stephen, "60.00"), (b.kat, "30.00")]))
.unwrap();
assert!(resolved(&b, e).is_err());
b.l.set_entry_parties(e, &amounts(&[(b.stephen, "70.00"), (b.kat, "30.00")]))
.unwrap();
assert_eq!(
resolved(&b, e).unwrap(),
vec![(b.stephen, d("70.00")), (b.kat, d("30.00"))]
);
assert_eq!(paid_received(&b, b.stephen), (d("70.00"), Decimal::ZERO));
}
/// INVARIANT 10: several derived shares need members.
#[test]
fn derived_needs_members_for_several_parties() {
let mut b = books();
let e = post(
&mut b.l,
&[
(b.exp, Side::Debit, "10.00"),
(b.bank, Side::Credit, "10.00"),
],
);
b.l.set_entry_parties(e, &derived(&[b.stephen, b.acme]))
.unwrap();
assert!(resolved(&b, e).is_err());
// With no members passed (plain activity), two members can't derive.
b.l.set_entry_parties(e, &derived(&[b.stephen, b.kat]))
.unwrap();
let money = money_accounts(&b.l, &[b.bank]);
assert!(resolve(b.l.get_entry(e).unwrap(), b.l.entry_parties(e), &money, &[]).is_err());
// A sole party needs no members.
b.l.set_entry_parties(e, &derived(&[b.acme])).unwrap();
assert_eq!(resolved(&b, e).unwrap(), vec![(b.acme, d("10.00"))]);
}
/// INVARIANT 10: income shares rescale to the members named.
#[test]
fn member_shares_rescale_to_those_named() {
let mut b = books();
let third = b.l.add_party("Third Member", &[Role::Member]).unwrap();
let cap_t =
b.l.add_account("3002", "Capital T", AccountType::Equity, None, false)
.unwrap();
b.members[0].share = d("0.5");
b.members[1].share = d("0.3");
b.members.push(PartyMember {
party: third,
share: d("0.2"),
capital: cap_t,
distributions: None,
});
let e = post(
&mut b.l,
&[
(b.due, Side::Debit, "80.00"),
(b.bank, Side::Credit, "80.00"),
],
);
b.l.set_entry_parties(e, &derived(&[b.stephen, b.kat]))
.unwrap();
assert_eq!(
resolved(&b, e).unwrap(),
vec![(b.stephen, d("50.00")), (b.kat, d("30.00"))]
);
}
/// INVARIANTS 1–5 of shares and set_entry_parties 1: refusals change
/// nothing.
#[test]
fn bad_shares_are_refused_changing_nothing() {
let mut b = books();
let e = post(
&mut b.l,
&[
(b.exp, Side::Debit, "10.00"),
(b.bank, Side::Credit, "10.00"),
],
);
let (s, k) = (b.stephen, b.kat);
refused(&mut b.l, "duplicate", |l| {
l.set_entry_parties(e, &derived(&[s, s]))
});
refused(&mut b.l, "unknown party", |l| {
l.set_entry_parties(e, &derived(&[s, 999]))
});
refused(&mut b.l, "unknown entry", |l| {
l.set_entry_parties(999, &derived(&[s]))
});
refused(&mut b.l, "mixed kinds", |l| {
l.set_entry_parties(
e,
&[
PartyShare {
party: s,
share: Share::Amount(d("5.00")),
},
PartyShare::whole(k),
],
)
});
refused(&mut b.l, "percents sum to 99", |l| {
l.set_entry_parties(e, &percents(&[(s, "50"), (k, "49")]))
});
refused(&mut b.l, "percent over 100", |l| {
l.set_entry_parties(e, &percents(&[(s, "101"), (k, "-1")]))
});
refused(&mut b.l, "finer than a cent", |l| {
l.set_entry_parties(e, &amounts(&[(s, "5.005"), (k, "4.995")]))
});
refused(&mut b.l, "zero amount", |l| {
l.set_entry_parties(e, &amounts(&[(s, "10.00"), (k, "0")]))
});
let v1 = b.l.add_vendor("Vendor One").unwrap();
let v2 = b.l.add_vendor("Vendor Two").unwrap();
let (p1, p2) = (
b.l.party_of_vendor(v1).unwrap(),
b.l.party_of_vendor(v2).unwrap(),
);
refused(&mut b.l, "two vendors", |l| {
l.set_entry_parties(e, &amounts(&[(p1, "5.00"), (p2, "5.00")]))
});
// The checker agrees on its own.
assert!(check_shares(&percents(&[(s, "100")]), |_| true).is_ok());
assert!(check_shares(&percents(&[(s, "60"), (k, "40.0")]), |_| true).is_ok());
}
/// set_entry_parties INVARIANT 2: the vendor tag follows the one party
/// with a vendor record; clearing clears it.
#[test]
fn vendor_tag_follows_the_one_vendor_party() {
let mut b = books();
let e = post(
&mut b.l,
&[
(b.exp, Side::Debit, "10.00"),
(b.bank, Side::Credit, "10.00"),
],
);
let v = b.l.add_vendor("Vendor One").unwrap();
let pv = b.l.party_of_vendor(v).unwrap();
b.l.set_entry_parties(e, &amounts(&[(pv, "6.00"), (b.stephen, "4.00")]))
.unwrap();
assert_eq!(b.l.entry_vendor(e), Some(v));
b.l.set_entry_parties(e, &derived(&[b.stephen, b.kat]))
.unwrap();
assert_eq!(b.l.entry_vendor(e), None);
b.l.set_entry_parties(e, &[]).unwrap();
assert!(b.l.entry_parties(e).is_empty());
}
/// Single-party tagging is one derived share; INVARIANT 3: naming a
/// party clears the no-party mark, and the mark is refused while
/// parties are named.
#[test]
fn single_party_and_no_party_mark() {
let mut b = books();
let e = post(
&mut b.l,
&[
(b.exp, Side::Debit, "10.00"),
(b.bank, Side::Credit, "10.00"),
],
);
b.l.set_entry_party(e, Some(b.acme)).unwrap();
assert_eq!(b.l.entry_parties(e), &[PartyShare::whole(b.acme)]);
assert_eq!(b.l.entry_party(e), Some(b.acme));
b.l.set_entry_party(e, None).unwrap();
b.l.set_entry_no_party(e, "own transfer").unwrap();
b.l.set_entry_parties(e, &derived(&[b.stephen, b.kat]))
.unwrap();
assert_eq!(b.l.entry_no_party(e), None);
refused(&mut b.l, "no-party while named", |l| {
l.set_entry_no_party(e, "none")
});
}
/// Reversals carry every share; the pair nets to zero for each party.
#[test]
fn reversal_carries_all_shares() {
let mut b = books();
let e = post(
&mut b.l,
&[
(b.exp, Side::Debit, "90.00"),
(b.bank, Side::Credit, "90.00"),
],
);
let shares = amounts(&[(b.stephen, "60.00"), (b.kat, "30.00")]);
b.l.set_entry_parties(e, &shares).unwrap();
let r = b.l.reverse_entry(e, date(2026, 5, 2), "undo").unwrap();
assert_eq!(b.l.entry_parties(r), shares.as_slice());
assert_eq!(
resolved(&b, r).unwrap(),
vec![(b.stephen, d("-60.00")), (b.kat, d("-30.00"))]
);
assert_eq!(paid_received(&b, b.stephen), (Decimal::ZERO, Decimal::ZERO));
assert_eq!(paid_received(&b, b.kat), (Decimal::ZERO, Decimal::ZERO));
}
/// Plain `activity` is activity_with no members: a sole party still
/// counts the whole net.
#[test]
fn activity_counts_a_sole_party_in_full() {
let mut b = books();
let e = post(
&mut b.l,
&[
(b.exp, Side::Debit, "12.34"),
(b.bank, Side::Credit, "12.34"),
],
);
b.l.set_entry_party(e, Some(b.acme)).unwrap();
let a = ledger_core::parties::activity(&b.l, b.acme, year(), &[b.bank]);
assert_eq!(a.paid, d("12.34"));
assert!(a.entries.contains(&e));
}
fn cents() -> impl Strategy<Value = Decimal> {
(1i64..10_000_000).prop_map(|c| Decimal::new(c, 2))
}
proptest! {
/// INVARIANT 6: resolved shares sum to the target exactly, each
/// with its sign, for percents (any split summing to 100, to
/// three decimals), member-derived splits, and capital lines.
#[test]
fn resolved_shares_sum_exactly(
total in cents(),
refund in any::<bool>(),
cuts in proptest::collection::vec(1u32..100_000, 1..6),
member_share in 1u32..1000,
) {
let mut b = books();
let (dr, cr) = if refund { (b.bank, b.exp) } else { (b.exp, b.bank) };
let t = total.to_string();
let e = post(&mut b.l, &[(dr, Side::Debit, &t), (cr, Side::Credit, &t)]);
let target = if refund { -total } else { total };
// Percents: cut 100.000 at sorted points.
let mut points: Vec<u32> = cuts.iter().map(|c| c % 100_000).collect();
points.sort_unstable();
points.dedup();
points.retain(|&p| p > 0);
let mut bounds = vec![0u32];
bounds.extend(points);
bounds.push(100_000);
let mut ps = Vec::new();
for (i, w) in bounds.windows(2).enumerate() {
let party = b.l.add_party(&format!("P{i}"), &[]).unwrap();
ps.push((party, Decimal::new(i64::from(w[1] - w[0]), 3)));
}
let shares: Vec<PartyShare> = ps
.iter()
.map(|&(party, p)| PartyShare { party, share: Share::Percent(p) })
.collect();
b.l.set_entry_parties(e, &shares).unwrap();
let r = resolved(&b, e).unwrap();
prop_assert_eq!(r.iter().map(|(_, v)| *v).sum::<Decimal>(), target);
prop_assert!(r.iter().all(|(_, v)| v.is_zero() || v.is_sign_negative() == refund));
prop_assert!(r.iter().all(|(_, v)| v.normalize().scale() <= 2));
// Member-derived with uneven income shares.
let s = Decimal::new(i64::from(member_share), 3);
b.members[0].share = s;
b.members[1].share = Decimal::ONE - s;
b.l.set_entry_parties(e, &derived(&[b.stephen, b.kat])).unwrap();
let r = resolved(&b, e).unwrap();
prop_assert_eq!(r.iter().map(|(_, v)| *v).sum::<Decimal>(), target);
// Amounts that sum: split at the first party's resolved part.
let first = r[0].1.abs();
let rest = total - first;
if !first.is_zero() && !rest.is_zero() {
b.l.set_entry_parties(e, &[
PartyShare { party: b.stephen, share: Share::Amount(first) },
PartyShare { party: b.kat, share: Share::Amount(rest) },
]).unwrap();
let r = resolved(&b, e).unwrap();
prop_assert_eq!(r.iter().map(|(_, v)| *v).sum::<Decimal>(), target);
}
}
/// Capital-line derivation: two members contribute any amounts in
/// one entry; each gets their own line, summing to the flow.
#[test]
fn capital_line_shares_sum_exactly(a in cents(), c in cents()) {
let mut b = books();
let total = a + c;
let e = post(&mut b.l, &[
(b.bank, Side::Debit, &total.to_string()),
(b.cap_s, Side::Credit, &a.to_string()),
(b.cap_k, Side::Credit, &c.to_string()),
]);
b.l.set_entry_parties(e, &derived(&[b.stephen, b.kat])).unwrap();
let r = resolved(&b, e).unwrap();
prop_assert_eq!(r, vec![(b.stephen, -a), (b.kat, -c)]);
let sa = activity_with(&b.l, b.stephen, year(), &[b.bank], &b.members);
let sk = activity_with(&b.l, b.kat, year(), &[b.bank], &b.members);
prop_assert_eq!(sa.received + sk.received, total);
}
}
}
+1 -1
View File
@@ -399,7 +399,7 @@ pub fn find(b: &Books) -> Vec<Attention> {
e.status == EntryStatus::Posted
&& e.date >= year_start
&& e.date <= today
&& e.party_id.is_none()
&& e.parties.is_empty()
&& b.ledger.entry_no_party(e.id).is_none()
&& !matches!(e.origin, "Period close" | "Depreciation" | "Amortization")
})
+422 -36
View File
@@ -47,15 +47,37 @@ pub struct EntryInfo {
/// entry doesn't touch cash.
pub cash_effect: Decimal,
/// The counterparty's name: its directory party's label (see
/// [`PartyInfo`] for which entries are a party's), else its vendor's
/// or invoice customer's name.
/// [`PartyInfo`] for which entries are a party's; several parties'
/// labels joined with " & "), else its vendor's or invoice customer's
/// name.
pub party: Option<String>,
/// The directory party the entry is with (see [`PartyInfo`]).
/// The directory party the entry is with, when it names exactly one
/// (see [`PartyInfo`]).
pub party_id: Option<PartyId>,
/// Every party the entry names, in order, each with its share.
pub parties: Vec<EntryParty>,
/// Lower-cased text for search: description, accounts, vendor, amount.
pub haystack: String,
}
/// One party an entry names, with its share.
///
/// ```text
/// SOURCE: ledger_core::shares (resolve), with Books::cash_accounts and
/// Books::members, as the server's books.get_entry resolves them.
/// INVARIANTS:
/// 1. resolved: the party's part from ledger_core::shares::resolve, None
/// when the entry's shares don't resolve; over an entry's parties the
/// resolved parts sum to what it moved exactly.
/// ```
#[derive(Clone, Debug, PartialEq)]
pub struct EntryParty {
pub id: PartyId,
pub label: String,
pub share: ledger_core::Share,
pub resolved: Option<Decimal>,
}
/// One fixed asset's trail through the journal and the entries.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct AssetTrail {
@@ -99,12 +121,14 @@ impl AssetTrail {
/// module's rule (every number from ledger-core; this indexes and
/// sums); DECISION (the year and the payment accounts, below).
/// DEFINITIONS:
/// The party's entries: Ledger::entry_party names it (core tags hand
/// tags, vendor tags, bills, invoices, their payments, reversals).
/// The party's entries: Ledger::entry_parties names it, alone or among
/// others (core tags hand tags, vendor tags, bills, invoices, their
/// payments, reversals).
/// The year: the calendar year of `today`, Jan 1 through today (the
/// year 1099s are counted in).
/// Money: ledger_core::parties::activity with Books::cash_accounts
/// (the same numbers books.party reports).
/// Money: Books::activity (ledger_core::parties::activity_with with
/// Books::cash_accounts and Books::members: a party's share of an
/// entry naming several; the same numbers books.party reports).
/// INVARIANTS:
/// 1. entries: every entry that is the party's, drafts included, in
/// Books::entries order (newest first).
@@ -125,7 +149,8 @@ impl AssetTrail {
/// or (older runs, no party on the line) whose employee_name is the
/// party's name ignoring case and surrounding space.
/// 7. events: indexes into Books::journal of party.* events with its
/// id, entry.party_set naming it, and its records' vendor.added,
/// id, entry.party_set or entry.parties_set naming it, and its
/// records' vendor.added,
/// customer.added, vendor.1099_set, and entry.vendor_set events.
/// ```
#[derive(Clone, Debug, Default, PartialEq)]
@@ -201,8 +226,24 @@ pub struct Books {
pub entries: Vec<EntryInfo>,
pub entry_index: HashMap<EntryId, usize>,
pub cash_accounts: Vec<AccountId>,
/// The `[[company.members]]` that are parties (role member, matched
/// by name), with their capital accounts: what derived party shares
/// and member capital statements read
/// (ledger_config::Company::party_members).
pub members: Vec<ledger_core::PartyMember>,
/// `[books] financing_accounts`, resolved (empty when not set).
pub financing_accounts: Vec<AccountId>,
/// The cash accounts the member capital statement hands
/// ledger_core::k1 to tell cash from property: `[books]
/// cash_accounts`, resolved; when that is empty, core's fallback
/// (ledger_core::parties::money_accounts: every asset account but
/// receivables), so the viewer reads money as core does. Without the
/// setting every contribution therefore reads as cash: property can't
/// be told from cash without knowing which accounts hold cash.
/// Kept apart from `cash_accounts`, whose name-based guess the Cash
/// page, dashboard and charts keep (changing those is out of scope:
/// "every asset" would put equipment on the Cash page).
pub k1_cash_accounts: Vec<AccountId>,
/// `[books] investing_accounts`, resolved (empty when not set).
pub investing_accounts: Vec<AccountId>,
pub card_accounts: Vec<AccountId>,
@@ -249,16 +290,20 @@ pub enum PartyPeriod {
/// ```text
/// GOAL: answer "how much did we spend with them, and how much did we
/// get back?" (Stephen, 2026-10-15) with the books' own numbers.
/// SOURCE: ledger_core::parties::activity (paid, received) and
/// SOURCE: ledger_core::parties::activity_with (paid, received) and
/// ledger_core::parties::entry_flow (what for), with
/// Books::cash_accounts, as PartyInfo uses them.
/// Books::cash_accounts and Books::members, as PartyInfo uses
/// them.
/// INVARIANTS:
/// 1. paid, received: activity(ledger, party, span, cash accounts)'s,
/// exactly.
/// 1. paid, received: Books::activity(party, span)'s, exactly.
/// 2. net() = paid − received.
/// 3. what_for: over the party's posted entries dated in the span, the
/// sum by account of each entry_flow's what_for (a liability paid
/// down, like a reimbursement, appears as what the money paid for),
/// for an entry naming several parties this party's part of it: a
/// member's own capital and distributions lines when they come to
/// its resolved share exactly, else the what_for apportioned to the
/// share (share_of_what_for; nothing when the shares don't resolve),
/// the accounts whose sum is zero left out, largest magnitude first
/// (then by account id). Positive: what money paid for; negative:
/// where money received came from (or, on payables and
@@ -283,6 +328,102 @@ impl PartyFlow {
}
}
/// What moved a member's capital account in a span, by kind
/// ([`Books::capital_statement`]).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum CapitalKind {
/// Cash put in, or paid for the company (ledger_core::k1's cash part).
CashContribution,
/// Property put in, at the value booked: fair market value
/// (ledger_core::k1's property part).
PropertyContribution,
/// A period close allocating their share of net income (or loss).
IncomeShare,
/// Taken out.
Distribution,
}
/// One entry's effect on a member's capital, split by kind.
#[derive(Clone, Debug, PartialEq)]
pub struct CapitalLine {
pub entry: EntryId,
pub date: NaiveDate,
pub description: String,
pub kind: CapitalKind,
/// Signed: positive raises their capital, negative lowers it.
pub amount: Decimal,
}
/// A member's capital account statement over a span: the partner capital
/// statement, read from ledger_core::k1 (the CPA package's K-1 worksheet).
///
/// ```text
/// GOAL: a member's party page answers "what is my stake?" as a partner
/// capital statement does (Stephen, 2026-10-15), with the same
/// numbers the CPA's K-1 worksheet carries: one source of truth.
/// SOURCE: ledger_core::k1::k1_detail (members_equity's roll-forward,
/// split into cash and property); ledger_config::Company::members
/// ([[company.members]], all of them, in order, as the CPA
/// package builds its MemberCapital list); Books::k1_cash_accounts;
/// Books::financing_accounts; DECISION (the due-to accounts, and
/// the cash fallback, below).
/// DEFINITIONS:
/// row: k1_detail(span, members, k1_cash_accounts).rows[i], i this
/// member's position in [[company.members]] (matched by capital
/// account).
/// INVARIANTS:
/// 1. opening = row.beginning; contributions_cash =
/// row.contributions_cash; contributions_property =
/// row.contributions_property; income_share = row.net_income;
/// distributions_taken = row.distributions; other = row.other;
/// closing = row.ending. Opening and closing include the member's
/// share of earnings not yet closed into capital (members_equity
/// INVARIANT 3), and income_share is their share of the span's net
/// income whether or not a period close has moved it yet.
/// 2. closing = opening + contributions_cash + contributions_property +
/// income_share − distributions_taken + other, exactly.
/// 3. lines: k1_detail's lines for this member. Σ CashContribution =
/// contributions_cash, Σ PropertyContribution =
/// contributions_property, −Σ Distribution = distributions_taken;
/// IncomeShare lines are the closes in the span (they need not sum to
/// income_share: income not yet closed has no entry on capital).
/// 4. share: the member's [[company.members]] share (a fraction).
/// 5. due_to_members: the balance at span.1 of the liabilities the
/// members advance: [books] financing_accounts (Books::
/// financing_accounts, its liability accounts) when set; only when it
/// is empty, the liability accounts whose name says "due to" or
/// "owed to" (DECISION: a name is a guess; the setting is the books'
/// word). owed_to_member: this member's part of it by the members'
/// shares (ledger_core::periods::allocate over every member, so the
/// parts sum to the balance).
/// 6. None when the party isn't a member, a [[company.members]]
/// account isn't in the chart, or k1_detail refuses (shares not
/// summing to 1, accounts not distinct equity accounts).
/// ```
#[derive(Clone, Debug, PartialEq)]
pub struct CapitalStatement {
pub party: PartyId,
pub capital: AccountId,
pub distributions: Option<AccountId>,
pub span: (NaiveDate, NaiveDate),
pub share: Decimal,
pub opening: Decimal,
pub contributions_cash: Decimal,
pub contributions_property: Decimal,
pub income_share: Decimal,
/// Taken out, as a positive amount.
pub distributions_taken: Decimal,
/// Any other change in their capital (members_equity's "other").
pub other: Decimal,
pub closing: Decimal,
pub lines: Vec<CapitalLine>,
pub due_to_members: Decimal,
pub owed_to_member: Decimal,
/// `[[company.members]] files_jointly_with`: the member they file a
/// joint individual return with.
pub files_jointly_with: Option<String>,
}
/// One month of a party's money, for the out-versus-in chart.
#[derive(Clone, Debug, PartialEq)]
pub struct PartyMonth {
@@ -396,12 +537,27 @@ impl Books {
)?;
let receipt_threshold = policy.threshold();
let cash_accounts = cash_accounts(&ledger, &settings);
let members = settings.company.party_members(&ledger);
let share_money = ledger_core::parties::money_accounts(&ledger, &cash_accounts);
let codes = |cs: &[String]| -> Vec<AccountId> {
cs.iter()
.filter_map(|c| ledger.chart().find_by_code(c).map(|a| a.id))
.collect()
};
let financing_accounts = codes(&settings.financing_accounts);
let k1_cash_accounts = {
let configured = codes(&settings.cash_accounts);
if configured.is_empty() {
let mut all: Vec<AccountId> = ledger_core::parties::money_accounts(&ledger, &[])
.cash
.into_iter()
.collect();
all.sort_unstable();
all
} else {
configured
}
};
let investing_accounts = codes(&settings.investing_accounts);
let card_accounts: Vec<AccountId> = ledger
.chart()
@@ -507,10 +663,32 @@ impl Books {
// Core tags every entry with its counterparty (hand tags,
// vendor tags, bills, invoices, their payments, reversals).
let party_id = ledger.entry_party(id);
let party = party_id
.and_then(|p| ledger.get_party(p))
.map(|p| p.label().to_string())
.or_else(|| vendor.clone());
let shares = ledger.entry_parties(id);
let resolved = ledger_core::shares::resolve(e, shares, &share_money, &members).ok();
let parties: Vec<EntryParty> = shares
.iter()
.enumerate()
.map(|(i, s)| EntryParty {
id: s.party,
label: ledger
.get_party(s.party)
.map(|p| p.label().to_string())
.unwrap_or_else(|| format!("party {}", s.party)),
share: s.share,
resolved: resolved.as_ref().map(|r| r[i].1),
})
.collect();
let party = if parties.is_empty() {
vendor.clone()
} else {
Some(
parties
.iter()
.map(|p| p.label.as_str())
.collect::<Vec<_>>()
.join(" & "),
)
};
let mut haystack = format!(
"{} {} #{} {}",
e.description(),
@@ -527,11 +705,11 @@ impl Books {
haystack.push(' ');
haystack.push_str(v);
}
if let Some(p) = party_id.and_then(|p| ledger.get_party(p))
&& p.display_name.is_some()
{
haystack.push(' ');
haystack.push_str(&p.name);
for p in parties.iter().filter_map(|x| ledger.get_party(x.id)) {
if p.display_name.is_some() {
haystack.push(' ');
haystack.push_str(&p.name);
}
}
entries.push(EntryInfo {
id,
@@ -550,6 +728,7 @@ impl Books {
cash_effect,
party,
party_id,
parties,
haystack: haystack.to_lowercase(),
});
}
@@ -574,7 +753,9 @@ impl Books {
entries,
entry_index,
cash_accounts,
members,
financing_accounts,
k1_cash_accounts,
investing_accounts,
card_accounts,
fresh,
@@ -796,12 +977,7 @@ impl Books {
})
.collect();
for (id, info) in out.iter_mut() {
let a = ledger_core::parties::activity(
l,
*id,
(year_start, self.today),
&self.cash_accounts,
);
let a = self.activity(*id, (year_start, self.today));
info.entries = self
.entries
.iter()
@@ -874,6 +1050,20 @@ impl Books {
} else {
match t {
"entry.party_set" => u(p, "party_id"),
"entry.parties_set" => {
let named = p
.get("parties")
.and_then(|v| v.as_array())
.into_iter()
.flatten()
.filter_map(|x| u(x, "party_id"));
for pid in named {
if let Some(info) = out.get_mut(&pid) {
info.events.push(i);
}
}
None
}
"vendor.added" => u(p, "id").and_then(|v| l.party_of_vendor(v)),
"customer.added" => u(p, "id").and_then(|c| l.party_of_customer(c)),
"vendor.1099_set" | "entry.vendor_set" => {
@@ -1192,7 +1382,7 @@ impl Books {
.entries
.iter()
.filter(|e| e.status == EntryStatus::Posted && e.date <= self.today)
.filter(|e| party.is_none_or(|p| e.party_id == Some(p)))
.filter(|e| party.is_none_or(|p| e.parties.iter().any(|x| x.id == p)))
.map(|e| e.date.year())
.filter(|&x| x < y - 1)
.collect();
@@ -1214,7 +1404,7 @@ impl Books {
with_breakdown: bool,
) -> PartyFlow {
let l = &self.ledger;
let a = ledger_core::parties::activity(l, party, span, &self.cash_accounts);
let a = self.activity(party, span);
let posted: Vec<&ledger_core::JournalEntry> = a
.entries
.iter()
@@ -1228,7 +1418,31 @@ impl Books {
let money = ledger_core::parties::money_accounts(l, &self.cash_accounts);
let mut by: BTreeMap<AccountId, Decimal> = BTreeMap::new();
for e in &posted {
for (account, amount) in ledger_core::parties::entry_flow(e, &money).what_for {
let flow = ledger_core::parties::entry_flow(e, &money);
let share = if l.entry_parties(e.id()).len() == 1 {
flow.net
} else {
self.party_share(e, party)
};
// A member's own capital lines are what their share was
// for, when they account for it exactly.
let own: Vec<(AccountId, Decimal)> = self
.member(party)
.map(|m| {
flow.what_for
.iter()
.copied()
.filter(|(a, _)| *a == m.capital || Some(*a) == m.distributions)
.collect()
})
.unwrap_or_default();
let parts =
if !own.is_empty() && own.iter().map(|(_, v)| *v).sum::<Decimal>() == share {
own
} else {
share_of_what_for(&flow, share)
};
for (account, amount) in parts {
*by.entry(account).or_default() += amount;
}
}
@@ -1243,6 +1457,153 @@ impl Books {
}
}
/// ledger_core::parties::activity_with over `span`, with the books'
/// cash accounts and members: the one source of a party's money.
pub fn activity(
&self,
party: PartyId,
span: (NaiveDate, NaiveDate),
) -> ledger_core::parties::Activity {
ledger_core::parties::activity_with(
&self.ledger,
party,
span,
&self.cash_accounts,
&self.members,
)
}
/// A party's resolved share of an entry's flow (its entry_flow net
/// for a sole party; zero when the shares don't resolve or it isn't
/// named).
pub fn party_share(&self, e: &ledger_core::JournalEntry, party: PartyId) -> Decimal {
self.entries
.get(self.entry_index.get(&e.id()).copied().unwrap_or(usize::MAX))
.and_then(|info| info.parties.iter().find(|p| p.id == party))
.and_then(|p| p.resolved)
.unwrap_or_default()
}
/// A member's capital statement over `span` (see
/// [`CapitalStatement`]); None when the party isn't a member.
pub fn capital_statement(
&self,
party: PartyId,
span: (NaiveDate, NaiveDate),
) -> Option<CapitalStatement> {
use ledger_core::k1::K1Kind;
let m = *self.member(party)?;
let l = &self.ledger;
let members = self.member_capitals()?;
let i = members
.iter()
.position(|x| x.capital_account == m.capital)?;
let detail =
ledger_core::k1::k1_detail(l, span.0, span.1, &members, &self.k1_cash_accounts).ok()?;
let row = detail.rows.get(i)?;
let lines: Vec<CapitalLine> = detail
.lines
.iter()
.filter(|x| x.member == i)
.filter_map(|x| {
let e = l.get_entry(x.entry)?;
Some(CapitalLine {
entry: x.entry,
date: e.date(),
description: e.description().to_string(),
kind: match x.kind {
K1Kind::CashContribution => CapitalKind::CashContribution,
K1Kind::PropertyContribution => CapitalKind::PropertyContribution,
K1Kind::Distribution => CapitalKind::Distribution,
K1Kind::IncomeClose => CapitalKind::IncomeShare,
},
amount: x.amount,
})
})
.collect();
let due_accounts: Vec<AccountId> = if self.financing_accounts.is_empty() {
l.chart()
.all()
.filter(|a| {
a.account_type == AccountType::Liability && {
let n = a.name.to_lowercase();
n.contains("due to") || n.contains("owed to")
}
})
.map(|a| a.id)
.collect()
} else {
self.financing_accounts
.iter()
.copied()
.filter(|&a| {
l.chart()
.get(a)
.is_some_and(|a| a.account_type == AccountType::Liability)
})
.collect()
};
let due_to_members: Decimal = due_accounts
.iter()
.map(|&a| self.balance_on(a, span.1))
.sum();
let shares: Vec<Decimal> = members.iter().map(|x| x.share).collect();
let owed_to_member = ledger_core::periods::allocate(due_to_members, &shares)[i];
Some(CapitalStatement {
party,
capital: m.capital,
distributions: m.distributions,
span,
share: row.share,
opening: row.beginning,
contributions_cash: row.contributions_cash,
contributions_property: row.contributions_property,
income_share: row.net_income,
distributions_taken: row.distributions,
other: row.other,
closing: row.ending,
lines,
due_to_members,
owed_to_member,
files_jointly_with: self
.settings
.company
.members
.get(i)
.and_then(|x| x.files_jointly_with.clone()),
})
}
/// Every `[[company.members]]`, in order, as ledger_core's
/// MemberCapital (the CPA package's list); None when an account isn't
/// in the chart or there are no members.
pub fn member_capitals(&self) -> Option<Vec<ledger_core::MemberCapital>> {
let chart = self.ledger.chart();
let members = &self.settings.company.members;
if members.is_empty() {
return None;
}
members
.iter()
.map(|m| {
Some(ledger_core::MemberCapital {
name: m.name.clone(),
capital_account: chart.find_by_code(&m.capital_account)?.id,
distribution_account: match &m.distribution_account {
Some(c) => Some(chart.find_by_code(c)?.id),
None => None,
},
share: m.share,
})
})
.collect()
}
/// The member a party is, if it is one of `[[company.members]]`.
pub fn member(&self, party: PartyId) -> Option<&ledger_core::PartyMember> {
self.members.iter().find(|m| m.party == party)
}
/// A party's 1099 worksheet line for a calendar year and that year's
/// threshold: ledger-core's vendor_1099_report_with (cash and
/// financing accounts) line for its vendor record; None without a
@@ -1269,12 +1630,7 @@ impl Books {
while m <= span.1 {
let end = (m + chrono::Months::new(1) - chrono::Days::new(1)).min(span.1);
let start = m.max(span.0);
let a = ledger_core::parties::activity(
&self.ledger,
party,
(start, end),
&self.cash_accounts,
);
let a = self.activity(party, (start, end));
out.push(PartyMonth {
month: m,
paid: a.paid,
@@ -1291,6 +1647,36 @@ impl Books {
}
}
/// An entry flow's what_for apportioned to one party's `share` of its net:
/// the whole of it when the share is the net; else each account's amount
/// times share / net, rounded toward zero to the cent, the cents left over
/// going to the first account, so the parts sum to `share` exactly.
pub fn share_of_what_for(
flow: &ledger_core::parties::EntryFlow,
share: Decimal,
) -> Vec<(AccountId, Decimal)> {
use rust_decimal::RoundingStrategy::ToZero;
if share == flow.net || flow.what_for.is_empty() {
return flow.what_for.clone();
}
if flow.net.is_zero() || share.is_zero() {
return Vec::new();
}
let mut parts: Vec<(AccountId, Decimal)> = flow
.what_for
.iter()
.map(|(a, v)| {
(
*a,
(*v * share / flow.net).round_dp_with_strategy(2, ToZero),
)
})
.collect();
let left = share - parts.iter().map(|(_, v)| *v).sum::<Decimal>();
parts[0].1 += left;
parts
}
/// Every registered asset's trail, from the journal (depreciation,
/// disposal, its own events) and the entries (the purchase).
fn asset_trails(ledger: &Ledger, journal: &[JournalLine]) -> HashMap<AssetId, AssetTrail> {
+293
View File
@@ -0,0 +1,293 @@
//! A member's capital account statement, on their party page: the partner
//! capital statement (opening balance, contributions of cash and of
//! property, share of net income, distributions, closing balance), their
//! share, and their part of what the company owes the members jointly.
//! Every number is [`crate::books::CapitalStatement`]'s (which reads
//! ledger_core::k1, the CPA package's K-1 worksheet); this module only
//! lays it out.
use crate::books::{Books, CapitalKind, CapitalStatement};
use crate::fmt;
use crate::nav::Route;
use crate::theme::{self, pal};
use crate::widgets;
use egui::{Align, Layout, RichText, Sense, Ui, vec2};
use rust_decimal::Decimal;
/// What a capital line is, in words.
pub fn kind_label(k: CapitalKind) -> &'static str {
match k {
CapitalKind::CashContribution => "Contribution — cash",
CapitalKind::PropertyContribution => "Contribution — property",
CapitalKind::IncomeShare => "Share of net income",
CapitalKind::Distribution => "Distribution",
}
}
/// A share as a percent: 0.5 → "50%", 0.3333 → "33.33%".
pub fn percent(share: Decimal) -> String {
format!("{}%", (share * Decimal::ONE_HUNDRED).normalize())
}
/// One statement row: an indented label, an amount column, and an
/// optional total column; `strong` for the balances.
fn line(
ui: &mut Ui,
label: &str,
indent: f32,
amount: Option<String>,
total: Option<String>,
strong: bool,
) {
let p = pal(ui.ctx());
let col = 130.0;
ui.horizontal(|ui| {
ui.add_space(indent);
let font = if strong {
theme::semibold(14.0)
} else {
theme::regular(14.0)
};
ui.label(RichText::new(label).font(font));
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
let t = total.unwrap_or_default();
widgets::amount_cell(ui, &t, col, p.text, strong, false);
let a = amount.unwrap_or_default();
widgets::amount_cell(ui, &a, col, p.text, false, false);
});
});
}
/// A short rule over the amount column (`double`: two, under a total).
fn underline(ui: &mut Ui, total_col: bool, double: bool) {
let p = pal(ui.ctx());
let col = 130.0;
let w = ui.available_width();
let (rect, _) = ui.allocate_exact_size(vec2(w, if double { 5.0 } else { 3.0 }), Sense::hover());
let right = if total_col {
rect.right()
} else {
rect.right() - col - ui.spacing().item_spacing.x
};
let left = right - col + 30.0;
let stroke = egui::Stroke::new(1.0, p.text);
let y = rect.top() + 1.0;
ui.painter()
.line_segment([egui::pos2(left, y), egui::pos2(right, y)], stroke);
if double {
ui.painter().line_segment(
[egui::pos2(left, y + 3.0), egui::pos2(right, y + 3.0)],
stroke,
);
}
}
/// The member's capital account card.
pub fn capital_card(
ui: &mut Ui,
b: &Books,
st: &CapitalStatement,
name: &str,
go: &mut Option<Route>,
) {
let p = pal(ui.ctx());
let account = b.account_name(st.capital);
let (open_account, ()) = widgets::titled_card(
ui,
"Capital account",
|ui| {
widgets::link(
ui,
RichText::new(&account)
.font(theme::regular(12.0))
.color(p.accent),
)
.on_hover_text("Open the capital account's register")
.clicked()
},
|ui| {
ui.vertical_centered(|ui| {
ui.label(RichText::new(name).font(theme::semibold(15.0)));
ui.label(
RichText::new(format!(
"Statement of partner's capital · {} – {}",
fmt::date(st.span.0),
fmt::date(st.span.1)
))
.font(theme::regular(12.5))
.color(p.muted),
);
if let Some(j) = &st.files_jointly_with {
ui.label(
RichText::new(format!(
"Files a joint return with {j}: a K-1 each, both on one Form 1040"
))
.font(theme::regular(12.0))
.color(p.muted),
);
}
});
ui.add_space(10.0);
let amt = |d: Decimal| Some(fmt::accounting(d));
line(
ui,
&format!("Capital, {}", fmt::date(st.span.0)),
0.0,
None,
Some(format!("${}", fmt::accounting(st.opening))),
true,
);
ui.add_space(4.0);
ui.label(
RichText::new("Add")
.font(theme::regular(12.0))
.color(p.muted),
);
line(
ui,
"Contributions of cash",
16.0,
amt(st.contributions_cash),
None,
false,
);
line(
ui,
"Contributions of property (at fair market value)",
16.0,
amt(st.contributions_property),
None,
false,
);
line(
ui,
&format!("Share of net income ({})", percent(st.share)),
16.0,
amt(st.income_share),
None,
false,
);
ui.label(
RichText::new("Less")
.font(theme::regular(12.0))
.color(p.muted),
);
line(
ui,
"Distributions",
16.0,
Some(fmt::accounting(-st.distributions_taken)),
None,
false,
);
if !st.other.is_zero() {
line(ui, "Other changes", 16.0, amt(st.other), None, false);
}
underline(ui, false, false);
let change = st.contributions_cash + st.contributions_property + st.income_share
- st.distributions_taken
+ st.other;
line(
ui,
"Net change in capital",
0.0,
None,
Some(fmt::accounting(change)),
false,
);
underline(ui, true, false);
line(
ui,
&format!("Capital, {}", fmt::date(st.span.1)),
0.0,
None,
Some(format!("${}", fmt::accounting(st.closing))),
true,
);
underline(ui, true, true);
if !st.income_share.is_zero()
&& !st.lines.iter().any(|l| l.kind == CapitalKind::IncomeShare)
{
ui.label(
RichText::new(
"No period closed in this span: the share of net income is earned but not yet closed into the capital account.",
)
.font(theme::regular(11.5))
.color(p.faint),
);
}
ui.add_space(10.0);
widgets::rule(ui);
ui.add_space(6.0);
line(
ui,
"Share of profit, loss, and capital",
0.0,
None,
Some(percent(st.share)),
false,
);
let owed = format!(
"Owed to them on Due to Shareholders ({} of {})",
percent(st.share),
fmt::dollars(st.due_to_members)
);
line(
ui,
&owed,
0.0,
None,
Some(fmt::dollars(st.owed_to_member)),
false,
);
if !st.lines.is_empty() {
ui.add_space(10.0);
ui.collapsing(
RichText::new(format!(
"{} in this span",
fmt::count(st.lines.len(), "capital entry", "capital entries")
))
.font(theme::regular(12.5)),
|ui| {
for l in &st.lines {
ui.horizontal(|ui| {
ui.label(
RichText::new(fmt::date(l.date))
.font(theme::regular(12.5))
.color(p.muted),
);
if widgets::link(
ui,
RichText::new(format!("#{} {}", l.entry, l.description))
.font(theme::regular(12.5)),
)
.clicked()
{
*go = Some(Route::Entry(l.entry));
}
ui.label(
RichText::new(kind_label(l.kind))
.font(theme::regular(12.0))
.color(p.faint),
);
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
widgets::amount_cell(
ui,
&fmt::accounting(l.amount),
130.0,
p.text,
false,
false,
);
});
});
}
},
);
}
},
);
if open_account {
*go = Some(Route::Account(st.capital));
}
}
+50 -16
View File
@@ -52,7 +52,7 @@ pub fn detail(ui: &mut Ui, b: &Books, e: &EntryInfo, shell: &mut Shell<'_>) {
};
widgets::pill(ui, e.origin, p.muted, p.hover);
if let Some(v) = &e.vendor
&& e.party_id.is_none()
&& e.parties.is_empty()
{
widgets::pill(ui, &format!("{} {v}", icon::STOREFRONT), p.muted, p.hover);
}
@@ -87,27 +87,42 @@ pub fn detail(ui: &mut Ui, b: &Books, e: &EntryInfo, shell: &mut Shell<'_>) {
shell.go(Route::Entry(r));
}
}
if let Some(party) = e.party_id.and_then(|id| b.ledger.get_party(id)) {
let several = e.parties.len() > 1;
for share in &e.parties {
let Some(party) = b.ledger.get_party(share.id) else {
continue;
};
ui.add_space(4.0);
if widgets::link(
ui,
RichText::new(format!(
"{} {}",
super::parties::party_icon(party),
party.label()
let mut text = format!("{} {}", super::parties::party_icon(party), party.label());
if several {
text.push_str(&format!(" · {}", share_text(share)));
}
if widgets::link(ui, RichText::new(text).color(p.accent))
.on_hover_text(format!(
"{} this entry is with ({}): open them",
if several {
"One of the parties"
} else {
"The party"
},
super::parties::roles_text(party).to_lowercase()
))
.color(p.accent),
)
.on_hover_text(format!(
"The party this entry is with ({}): open them",
super::parties::roles_text(party).to_lowercase()
))
.clicked()
.clicked()
{
shell.go(Route::Party(party.id));
}
}
if e.party_id.is_none()
if several && e.parties.iter().any(|x| x.resolved.is_none()) {
ui.add_space(4.0);
ui.label(
RichText::new(format!(
"{} The parties' shares don't add up to what this entry moved",
icon::WARNING
))
.color(p.negative),
);
}
if e.parties.is_empty()
&& let Some(why) = b.ledger.entry_no_party(e.id)
{
ui.add_space(4.0);
@@ -490,3 +505,22 @@ pub fn render(ui: &mut Ui, b: &Books, a: &Attachment, shell: &mut Shell<'_>, wid
}
}
}
/// One party's share of an entry, in words: "$50.00 (derived)", "$70.00",
/// "$33.34 (33.333%)", or the share alone when it doesn't resolve.
pub fn share_text(s: &crate::books::EntryParty) -> String {
let how = match s.share {
ledger_core::Share::Amount(_) => None,
ledger_core::Share::Percent(p) => Some(format!("{}%", p.normalize())),
ledger_core::Share::Derived => Some("derived".to_string()),
};
match (s.resolved, how) {
(Some(v), Some(h)) => format!("{} ({h})", fmt::dollars(v.abs())),
(Some(v), None) => fmt::dollars(v.abs()),
(None, Some(h)) => h,
(None, None) => match s.share {
ledger_core::Share::Amount(a) => fmt::dollars(a),
_ => String::new(),
},
}
}
+77
View File
@@ -473,6 +473,30 @@ pub fn describe(b: &Books, line: &JournalLine) -> Option<Event> {
};
ev(icon::IDENTIFICATION_CARD, text, pid.map(Route::Party))
}
"party.tin_set" => {
let pid = p.get("id").and_then(|v| v.as_u64());
let name = party_name(b, pid);
let text = match p.get("facts").filter(|f| !f.is_null()) {
Some(f) => {
let kind = match f.get("tin_type").and_then(|v| v.as_str()) {
Some("ssn") => "SSN",
Some("ein") => "EIN",
Some("itin") => "ITIN",
_ => "TIN",
};
let l4 = f.get("last4").and_then(|v| v.as_str()).unwrap_or("????");
if f.get("purged").is_some_and(|v| !v.is_null()) {
format!("Purged {name}'s {kind} (••••{l4}) from the vault")
} else {
format!(
"Taxpayer ID for {name} recorded: {kind} ending {l4}, kept in the vault"
)
}
}
None => format!("Taxpayer ID facts for {name} cleared"),
};
ev(icon::IDENTIFICATION_CARD, text, pid.map(Route::Party))
}
"party.w4_set" => {
let pid = p.get("id").and_then(|v| v.as_u64());
let name = party_name(b, pid);
@@ -491,6 +515,59 @@ pub fn describe(b: &Books, line: &JournalLine) -> Option<Event> {
};
ev(icon::IDENTIFICATION_BADGE, text, pid.map(Route::Party))
}
"entry.parties_set" => {
let e = entry_id.unwrap_or(0);
let named: Vec<(u64, String)> = p
.get("parties")
.and_then(|v| v.as_array())
.into_iter()
.flatten()
.filter_map(|x| {
let id = x.get("party_id").and_then(|v| v.as_u64())?;
let share = match x.get("share") {
Some(Value::Object(o)) => o
.get("amount")
.and_then(|v| v.as_str())
.map(|a| {
rust_decimal::Decimal::from_str_exact(a)
.map(fmt::dollars)
.unwrap_or_else(|_| a.to_string())
})
.or_else(|| {
o.get("percent")
.and_then(|v| v.as_str())
.map(|pc| format!("{pc}%"))
}),
_ => None,
};
let name = party_name(b, Some(id));
Some((
id,
match share {
Some(sh) => format!("{name} ({sh})"),
None => name,
},
))
})
.collect();
Some(Event {
icon: icon::ADDRESS_BOOK,
text: if named.is_empty() {
format!("Cleared the parties of #{e}")
} else {
format!(
"Tagged #{e} with {}",
named
.iter()
.map(|(_, n)| n.as_str())
.collect::<Vec<_>>()
.join(" and ")
)
},
route: entry_id.map(Route::Entry),
also: named.first().map(|(id, _)| Route::Party(*id)),
})
}
"entry.party_set" => {
let pid = p.get("party_id").and_then(|v| v.as_u64());
let e = entry_id.unwrap_or(0);
+1
View File
@@ -2,6 +2,7 @@
pub mod accounts;
pub mod assets;
pub mod capital;
pub mod cash;
pub mod company;
pub mod documents;
+54 -2
View File
@@ -159,6 +159,21 @@ pub fn masked_tin(last4: &str) -> String {
format!("•••-••-{last4}")
}
/// A party's taxpayer-ID facts as the viewer shows them: "TIN on file
/// ••••1234 (SSN)", never more than the last four; once purged from the
/// vault, "TIN ••••1234 (SSN), purged from the vault".
pub fn tin_on_file(t: &ledger_core::TinFacts) -> String {
if t.vault_record.is_none() && t.purged.is_some() {
format!(
"TIN ••••{} ({}), purged from the vault",
t.last4,
t.tin_type.label()
)
} else {
format!("TIN on file ••••{} ({})", t.last4, t.tin_type.label())
}
}
/// Where a party stands on its W-9.
///
/// ```text
@@ -741,6 +756,11 @@ pub fn show_party(ui: &mut Ui, b: &Books, id: PartyId, shell: &mut Shell<'_>) {
r.context_menu(|ui| widgets::copy_item(ui, "Copy amount", &value.to_string()));
});
// A member's page is their capital account statement too.
if let Some(st) = b.capital_statement(id, span) {
super::capital::capital_card(ui, b, &st, party.label(), &mut go);
ui.add_space(14.0);
}
super::columns(ui, 860.0, &[1.0, 1.0], |col, ui| {
if col == 0 {
what_for_card(ui, b, &flow, &label, &mut go);
@@ -882,6 +902,19 @@ fn facts_card(ui: &mut Ui, party: &Party, go: &mut Option<Route>) {
&party.id.to_string(),
);
ui.end_row();
k(ui, "Taxpayer ID");
match &party.tin {
Some(t) => {
ui.label(RichText::new(tin_on_file(t)).monospace())
.on_hover_text(
"Only the last four digits are in the books; the full number is kept in the encrypted TIN vault, for the CPA package.",
);
}
None => {
ui.label(RichText::new("not recorded").color(p.faint));
}
}
ui.end_row();
if let Some(v) = party.vendor {
k(ui, "Vendor record");
if widgets::link(ui, RichText::new(format!("vendor {v}")).color(p.accent))
@@ -1141,12 +1174,31 @@ fn entries_card(ui: &mut Ui, b: &Books, info: &PartyInfo, go: &mut Option<Route>
let text = if draft { p.muted } else { p.text };
let accounts: Vec<String> =
e.accounts.iter().map(|a| b.account_name(*a)).collect();
let sub = accounts.join(" → ");
let mut sub = accounts.join(" → ");
let kind = if draft {
format!("{} · draft", e.origin)
} else {
e.origin.to_string()
};
// Shared with other parties: show their part.
let mut amount = e.amount;
if e.parties.len() > 1
&& let Some(mine) = e.parties.iter().find(|x| x.id == info.id)
{
let others: Vec<&str> = e
.parties
.iter()
.filter(|x| x.id != info.id)
.map(|x| x.label.as_str())
.collect();
sub = format!(
"Their share, {} of {}, with {} · {sub}",
super::entry::share_text(mine),
fmt::dollars(e.amount),
others.join(" & ")
);
amount = mine.resolved.map(|v| v.abs()).unwrap_or(e.amount);
}
let resp = row(
ui,
w,
@@ -1155,7 +1207,7 @@ fn entries_card(ui: &mut Ui, b: &Books, info: &PartyInfo, go: &mut Option<Route>
(format!("#{}", e.id), 64.0, false, p.muted),
(e.description.clone(), 0.0, false, text),
(kind, 150.0, false, if draft { p.warning } else { p.muted }),
(fmt::money(e.amount), amt, true, text),
(fmt::money(amount), amt, true, text),
],
Some((sub.as_str(), p.faint)),
false,
+558 -1
View File
@@ -8,7 +8,7 @@ use egui_kittest::Harness;
use ledger_core::arap::DocumentLine;
use ledger_core::{
AccountType, Currency, EntryId, Form1099, Money, PartyField, PartyId, PayrollLine, Role, Side,
W4Facts, W9Facts, vendor_1099_report,
TinFacts, TinType, W4Facts, W9Facts, vendor_1099_report,
};
use ledger_gui::books::Books;
use ledger_gui::nav::{Route, Section};
@@ -577,6 +577,56 @@ fn w9_text_never_shows_more_than_the_last_four() {
);
}
/// pages/journal.rs `party.tin_set`: recorded, purged, and cleared, each
/// in plain language with the TIN's type and last four, never more of it.
#[test]
fn tin_text_never_shows_more_than_the_last_four() {
let f = fixture("tintext");
let mut s = Store::open(&f.dir.0.join("books-data")).unwrap();
let facts = |tin_type, purged: Option<&str>| TinFacts {
tin_type,
last4: "4915".into(),
vault_record: purged.is_none().then(|| "ab".repeat(32)),
purged: purged.map(str::to_string),
};
s.set_party_tin(f.pat, Some(facts(TinType::Ssn, None)))
.unwrap();
s.set_party_tin(
f.pat,
Some(facts(
TinType::Ssn,
Some("purged from the wanderbooks vault on 2026-10-15: retention over"),
)),
)
.unwrap();
s.set_party_tin(f.pat, None).unwrap();
s.set_party_tin(f.pat, Some(facts(TinType::Itin, None)))
.unwrap();
drop(s);
let b = load(&f.dir.0);
let events: Vec<_> = b
.journal
.iter()
.filter(|l| l.line_type == "party.tin_set")
.filter_map(|l| describe(&b, l))
.collect();
let texts: Vec<&str> = events.iter().map(|e| e.text.as_str()).collect();
assert_eq!(
texts,
vec![
"Taxpayer ID for Pat Lee recorded: SSN ending 4915, kept in the vault",
"Purged Pat Lee's SSN (••••4915) from the vault",
"Taxpayer ID facts for Pat Lee cleared",
"Taxpayer ID for Pat Lee recorded: ITIN ending 4915, kept in the vault",
]
);
assert!(events.iter().all(|e| e.route == Some(Route::Party(f.pat))));
// No more than four digits in any of them.
for t in &texts {
assert!(t.chars().filter(|c| c.is_ascii_digit()).count() <= 4, "{t}");
}
}
#[test]
fn attention_asks_for_a_w9_until_its_facts_are_recorded() {
let f = fixture("attention");
@@ -914,3 +964,510 @@ fn entries_without_a_counterparty_need_attention_unless_off() {
let (dir, _s, ..) = counterparty_books("np-off", Some("off"));
assert!(item(&load_with_toml(&dir.0)).is_none());
}
// ------------------------------------------------------- party shares and the capital statement
//
// Oracles: EntryParty (resolved shares), PartyFlow (each party's share),
// CapitalStatement (INVARIANTS 1–6), ledger_core::k1, docs/parties-and-attachments.md
// "Shares" and "A member's capital statement". Values worked by hand.
struct MemberBooks {
dir: TempDir,
stephen: PartyId,
kat: PartyId,
reimburse: EntryId,
property: EntryId,
distribution: EntryId,
}
/// Two 50/50 members. 2025: a joint cash contribution of $1,000.00 each.
/// 2026: equipment contributed ($600.00 each, Feb), an expense paid from
/// joint funds ($300.00 on 2100, Mar), revenue $1,000.00 (Apr), $100.00
/// of 2100 reimbursed to both (May), $200.00 distributed to each (Jun),
/// and Jan–Jun closed to the members ($700.00 net: $350.00 each).
fn member_books(tag: &str) -> MemberBooks {
let dir = TempDir::new(tag);
let mut s = Store::create(&dir.0.join("books-data"), Currency::USD).unwrap();
let acct = |s: &mut Store, c: &str, n: &str, t| s.add_account(c, n, t, None, false).unwrap();
let bank = acct(&mut s, "1000", "Bluevine", AccountType::Asset);
let equip = acct(&mut s, "1500", "Equipment", AccountType::Asset);
let due = acct(
&mut s,
"2100",
"Due to Shareholders",
AccountType::Liability,
);
let cap_s = acct(
&mut s,
"3000",
"Owner Capital — Stephen Waits",
AccountType::Equity,
);
let cap_k = acct(
&mut s,
"3001",
"Owner Capital — Katherine Pendergast",
AccountType::Equity,
);
let rev = acct(&mut s, "4000", "App Revenue", AccountType::Revenue);
let exp = acct(&mut s, "6050", "Domain & Hosting", AccountType::Expense);
let stephen = s.add_party("Stephen Waits", &[Role::Member]).unwrap();
let kat = s
.add_party("Katherine Pendergast", &[Role::Member])
.unwrap();
let both = [
ledger_core::PartyShare::whole(stephen),
ledger_core::PartyShare::whole(kat),
];
let post = |s: &mut Store, date, what: &str, lines: &[(u64, Side, i64)]| {
let e = s.create_entry(date, what).unwrap();
for &(a, side, c) in lines {
s.add_line(e, a, side, usd(c)).unwrap();
}
s.post_entry(e).unwrap();
e
};
let open = post(
&mut s,
d(2025, 6, 1),
"Opening contribution",
&[
(bank, Side::Debit, 200_000),
(cap_s, Side::Credit, 100_000),
(cap_k, Side::Credit, 100_000),
],
);
s.set_entry_parties(open, &both).unwrap();
let property = post(
&mut s,
d(2026, 2, 1),
"Laptop contributed",
&[
(equip, Side::Debit, 120_000),
(cap_s, Side::Credit, 60_000),
(cap_k, Side::Credit, 60_000),
],
);
s.set_entry_parties(property, &both).unwrap();
post(
&mut s,
d(2026, 3, 1),
"Hosting from joint funds",
&[(exp, Side::Debit, 30_000), (due, Side::Credit, 30_000)],
);
post(
&mut s,
d(2026, 4, 1),
"Sales",
&[(bank, Side::Debit, 100_000), (rev, Side::Credit, 100_000)],
);
let reimburse = post(
&mut s,
d(2026, 5, 1),
"Reimburse the household",
&[(due, Side::Debit, 10_000), (bank, Side::Credit, 10_000)],
);
s.set_entry_parties(reimburse, &both).unwrap();
let distribution = post(
&mut s,
d(2026, 6, 1),
"Distribution",
&[
(cap_s, Side::Debit, 20_000),
(cap_k, Side::Debit, 20_000),
(bank, Side::Credit, 40_000),
],
);
s.set_entry_parties(distribution, &both).unwrap();
let period = s
.create_period("H1 2026", d(2026, 1, 1), d(2026, 6, 30))
.unwrap();
let half = Decimal::new(5, 1);
s.close_period_to(
period,
&ledger_core::periods::CloseTo::Members(vec![
ledger_core::MemberClose {
capital: cap_s,
share: half,
distributions: None,
},
ledger_core::MemberClose {
capital: cap_k,
share: half,
distributions: None,
},
]),
)
.unwrap();
write_member_toml(&dir.0, "cash_accounts = [\"1000\"]\n");
MemberBooks {
dir,
stephen,
kat,
reimburse,
property,
distribution,
}
}
/// The fixture's wanderbooks.toml, with `books` as its `[books]` table.
fn write_member_toml(dir: &Path, books: &str) {
std::fs::write(
dir.join("wanderbooks.toml"),
format!(
"[books]\n{books}[company]\nname = \"Wanderland Labs LLC\"\n\
[[company.members]]\nname = \"Stephen Waits\"\nshare = \"50%\"\ncapital_account = \"3000\"\n\
[[company.members]]\nname = \"Katherine Pendergast\"\nshare = \"50%\"\ncapital_account = \"3001\"\n"
),
)
.unwrap();
}
/// Post one entry to the fixture's books after the fact.
fn post_more(m: &MemberBooks, date: NaiveDate, lines: &[(&str, Side, i64)]) -> EntryId {
let mut s = Store::open(&m.dir.0.join("books-data")).unwrap();
let e = s.create_entry(date, "Later").unwrap();
for &(code, side, c) in lines {
let a = s.ledger().chart().find_by_code(code).unwrap().id;
s.add_line(e, a, side, usd(c)).unwrap();
}
s.post_entry(e).unwrap();
e
}
#[test]
fn entries_list_every_party_with_its_resolved_share() {
let m = member_books("shares-entries");
let b = load_with_toml(&m.dir.0);
let e = b.entry(m.reimburse).unwrap();
assert_eq!(e.party_id, None, "two parties: no sole party");
assert_eq!(
e.parties
.iter()
.map(|p| (p.id, p.resolved))
.collect::<Vec<_>>(),
vec![(m.stephen, Some(cents(5_000))), (m.kat, Some(cents(5_000)))]
);
assert_eq!(
e.party.as_deref(),
Some("Stephen Waits & Katherine Pendergast")
);
// Property: net zero, shares of the total.
let e = b.entry(m.property).unwrap();
assert_eq!(e.parties[1].resolved, Some(cents(60_000)));
// The journal says who, for parties_set.
let line = b
.journal
.iter()
.find(|l| l.line_type == "entry.parties_set")
.unwrap();
let ev = describe(&b, line).unwrap();
assert!(
ev.text.contains("Stephen Waits and Katherine Pendergast"),
"{}",
ev.text
);
}
#[test]
fn a_members_money_is_their_share() {
let m = member_books("shares-flow");
let b = load_with_toml(&m.dir.0);
let span = (d(2026, 1, 1), today());
for who in [m.stephen, m.kat] {
let f = b.party_flow(who, span, true);
// Half the reimbursement plus their own distribution; the
// property moved no money.
assert_eq!(f.paid, cents(25_000));
assert_eq!(f.received, Decimal::ZERO);
let what: Decimal = f.what_for.iter().map(|(_, v)| *v).sum();
assert_eq!(what, f.net(), "what for sums to the net");
assert!(b.party(who).unwrap().entries.contains(&m.distribution));
}
assert_eq!(b.party(m.kat).unwrap().paid_ytd, cents(25_000));
}
#[test]
fn a_members_page_is_their_capital_statement() {
let m = member_books("capital");
let b = load_with_toml(&m.dir.0);
let st = b
.capital_statement(m.stephen, (d(2026, 1, 1), today()))
.expect("a member");
assert_eq!(st.share, Decimal::new(5, 1));
assert_eq!(st.opening, cents(100_000));
assert_eq!(st.contributions_cash, Decimal::ZERO);
assert_eq!(st.contributions_property, cents(60_000));
assert_eq!(st.income_share, cents(35_000));
assert_eq!(st.distributions_taken, cents(20_000));
assert_eq!(st.closing, cents(175_000));
assert_eq!(
st.closing,
st.opening + st.contributions_cash + st.contributions_property + st.income_share
- st.distributions_taken
);
assert_eq!(st.other, Decimal::ZERO);
// No [books] financing_accounts: 2100's name says "due to"; $300.00
// advanced less $100.00 reimbursed, half each.
assert_eq!(st.due_to_members, cents(20_000));
assert_eq!(st.owed_to_member, cents(10_000));
// The lines behind it (k1_detail INVARIANT 5), in journal order.
use ledger_gui::books::CapitalKind as K;
let kinds: Vec<(EntryId, K, Decimal)> = st
.lines
.iter()
.map(|l| (l.entry, l.kind, l.amount))
.collect();
assert_eq!(kinds.len(), 3);
assert_eq!(
kinds[0],
(m.property, K::PropertyContribution, cents(60_000))
);
assert_eq!(kinds[1], (m.distribution, K::Distribution, cents(-20_000)));
assert_eq!((kinds[2].1, kinds[2].2), (K::IncomeShare, cents(35_000)));
// Over all time: the 2025 contribution is cash (1000 is
// [books] cash_accounts), the laptop property.
let all = b
.capital_statement(m.kat, (d(2025, 1, 1), today()))
.unwrap();
assert_eq!(all.opening, Decimal::ZERO);
assert_eq!(all.contributions_cash, cents(100_000));
assert_eq!(all.contributions_property, cents(60_000));
assert_eq!(all.income_share, cents(35_000));
assert_eq!(all.distributions_taken, cents(20_000));
assert_eq!(all.closing, cents(175_000));
// Not a member: no statement.
let mut s = Store::open(&m.dir.0.join("books-data")).unwrap();
let other = s.add_party("Riley Chen", &[Role::Contractor]).unwrap();
drop(s);
let b = load_with_toml(&m.dir.0);
assert!(
b.capital_statement(other, (d(2026, 1, 1), today()))
.is_none()
);
// The page shows it.
let mut h = Harness::builder()
.with_size(egui::vec2(1440.0, 1400.0))
.build_eframe(|cc| App::with_books(&cc.egui_ctx, b, false, Route::Party(m.stephen)));
h.run_steps(3);
use egui_kittest::kittest::Queryable;
h.get_by_label("Capital account");
h.get_by_label("Contributions of property (at fair market value)");
}
/// The viewer's statement is the K-1 worksheet's row, field for field,
/// over any span (CapitalStatement INVARIANT 1): ledger_core::k1 called
/// here with the members and cash accounts as the CPA package builds them.
#[test]
fn the_capital_statement_is_the_k1_worksheet_row() {
let m = member_books("capital-k1");
// Income not yet closed, so beginnings carry undistributed earnings.
post_more(
&m,
d(2026, 7, 1),
&[
("1000", Side::Debit, 10_000),
("4000", Side::Credit, 10_000),
],
);
for (books, fallback) in [("cash_accounts = [\"1000\"]\n", false), ("", true)] {
write_member_toml(&m.dir.0, books);
let b = load_with_toml(&m.dir.0);
let chart = b.ledger.chart();
let id = |c: &str| chart.find_by_code(c).unwrap().id;
let members =
[("Stephen Waits", "3000"), ("Katherine Pendergast", "3001")].map(|(name, code)| {
ledger_core::MemberCapital {
name: name.into(),
capital_account: id(code),
distribution_account: None,
share: Decimal::new(5, 1),
}
});
let cash: Vec<_> = if fallback {
let mut all: Vec<_> = ledger_core::parties::money_accounts(&b.ledger, &[])
.cash
.into_iter()
.collect();
all.sort_unstable();
all
} else {
vec![id("1000")]
};
for span in [
(d(2025, 1, 1), today()),
(d(2026, 1, 1), today()),
(d(2026, 2, 1), d(2026, 5, 31)),
(d(2026, 7, 1), today()),
(d(2026, 8, 1), today()),
] {
let rows =
ledger_core::k1::k1_worksheet(&b.ledger, span.0, span.1, &members, &cash).unwrap();
for (who, row) in [m.stephen, m.kat].into_iter().zip(&rows) {
let st = b.capital_statement(who, span).unwrap();
assert_eq!(
(
st.share,
st.opening,
st.contributions_cash,
st.contributions_property,
st.income_share,
st.distributions_taken,
st.other,
st.closing,
),
(
row.share,
row.beginning,
row.contributions_cash,
row.contributions_property,
row.net_income,
row.distributions,
row.other,
row.ending,
),
"{} over {span:?} (fallback {fallback})",
row.name
);
assert_eq!(
st.closing,
st.opening
+ st.contributions_cash
+ st.contributions_property
+ st.income_share
- st.distributions_taken
+ st.other
);
}
}
}
}
/// Income not yet closed is the member's (members_equity INVARIANT 3):
/// $100.00 of July revenue, unclosed, is $50.00 each of income in the
/// span and of capital after it. Stephen, 2026: 1,000 + 600 + (350 + 50)
/// − 200 = 1,800. July on: 1,750 + 50 = 1,800. August on: opening 1,800
/// (the July income carried), nothing moved.
#[test]
fn the_capital_statement_counts_income_not_yet_closed() {
let m = member_books("capital-unclosed");
post_more(
&m,
d(2026, 7, 1),
&[
("1000", Side::Debit, 10_000),
("4000", Side::Credit, 10_000),
],
);
let b = load_with_toml(&m.dir.0);
let st = b
.capital_statement(m.stephen, (d(2026, 1, 1), today()))
.unwrap();
assert_eq!(st.opening, cents(100_000));
assert_eq!(st.income_share, cents(40_000));
assert_eq!(st.closing, cents(180_000));
// The close is still the only income line: July's has no entry on
// capital (INVARIANT 3).
let closes: Decimal = st
.lines
.iter()
.filter(|l| l.kind == ledger_gui::books::CapitalKind::IncomeShare)
.map(|l| l.amount)
.sum();
assert_eq!(closes, cents(35_000));
let july = b
.capital_statement(m.stephen, (d(2026, 7, 1), today()))
.unwrap();
assert_eq!(
(july.opening, july.income_share, july.closing),
(cents(175_000), cents(5_000), cents(180_000))
);
assert!(july.lines.is_empty());
let aug = b
.capital_statement(m.kat, (d(2026, 8, 1), today()))
.unwrap();
assert_eq!(
(aug.opening, aug.income_share, aug.closing),
(cents(180_000), Decimal::ZERO, cents(180_000))
);
}
/// "Owed to them" reads [books] financing_accounts first (INVARIANT 5):
/// with 2200 "Member advances" ($40.00 advanced in July) as the financing
/// account, 2100's "due to" name no longer counts: $40.00, $20.00 each.
/// Without the setting, the name rule: 2100's $200.00 (2200's name says
/// neither "due to" nor "owed to").
#[test]
fn owed_to_members_reads_the_financing_accounts_first() {
let m = member_books("capital-financing");
let mut s = Store::open(&m.dir.0.join("books-data")).unwrap();
s.add_account(
"2200",
"Member advances",
AccountType::Liability,
None,
false,
)
.unwrap();
drop(s);
post_more(
&m,
d(2026, 7, 2),
&[("6050", Side::Debit, 4_000), ("2200", Side::Credit, 4_000)],
);
let span = (d(2026, 1, 1), today());
write_member_toml(
&m.dir.0,
"cash_accounts = [\"1000\"]\nfinancing_accounts = [\"2200\"]\n",
);
let b = load_with_toml(&m.dir.0);
let st = b.capital_statement(m.kat, span).unwrap();
assert_eq!(
(st.due_to_members, st.owed_to_member),
(cents(4_000), cents(2_000))
);
write_member_toml(&m.dir.0, "cash_accounts = [\"1000\"]\n");
let b = load_with_toml(&m.dir.0);
let st = b.capital_statement(m.kat, span).unwrap();
assert_eq!(
(st.due_to_members, st.owed_to_member),
(cents(20_000), cents(10_000))
);
}
/// Without [books] cash_accounts the capital statement reads money as
/// core does (every asset account but receivables: Books::
/// k1_cash_accounts), so the laptop on 1500 reads as cash: Stephen's 2026
/// contributions are $600.00 cash, none property. The Cash page keeps its
/// name-based guess ("Bluevine" names no cash word: no cash accounts).
#[test]
fn without_cash_accounts_the_capital_statement_reads_money_as_core_does() {
let m = member_books("capital-no-cash");
write_member_toml(&m.dir.0, "");
let b = load_with_toml(&m.dir.0);
let id = |c: &str| b.ledger.chart().find_by_code(c).unwrap().id;
assert_eq!(b.k1_cash_accounts, vec![id("1000"), id("1500")]);
assert!(
b.cash_accounts.is_empty(),
"the Cash page's guess is unchanged"
);
let st = b
.capital_statement(m.stephen, (d(2026, 1, 1), today()))
.unwrap();
assert_eq!(
(st.contributions_cash, st.contributions_property),
(cents(60_000), Decimal::ZERO)
);
assert_eq!(
st.lines[0].kind,
ledger_gui::books::CapitalKind::CashContribution
);
assert_eq!(st.closing, cents(175_000));
let all = b
.capital_statement(m.kat, (d(2025, 1, 1), today()))
.unwrap();
assert_eq!(
(all.contributions_cash, all.contributions_property),
(cents(160_000), Decimal::ZERO)
);
}
File diff suppressed because it is too large. Load diff
+141 -10
View File
@@ -550,6 +550,8 @@ pub struct MemberSettingOut {
pub capital_account: String,
/// Distributions account code, if any.
pub distribution_account: Option<String>,
/// The member they file a joint individual return with, if any.
pub files_jointly_with: Option<String>,
}
/// Result of `books.settings`.
@@ -990,8 +992,53 @@ pub struct DraftEntryParams {
pub party: Option<String>,
/// Why the entry has no counterparty (a transfer between the company's
/// own accounts, an accrual), when `[books] parties = "require"` and
/// none applies. Not with `party` or `vendor`.
/// none applies. Not with `party`, `parties`, or `vendor`.
pub no_party_reason: Option<String>,
/// Several parties, each with their share (as `books.set_entry_party`
/// `parties`), in place of `party`. Not with `party`, `vendor`, or
/// `no_party_reason`. Whether the shares add up is checked once the
/// entry has balanced lines (`books.post_entry` refuses until they do).
pub parties: Option<Vec<PartyShareParam>>,
}
/// One party named on an entry, with their share. Give `amount` or
/// `percent`, or neither (derived): a sole party gets all of it; members
/// named together get what their capital (or distributions) lines on the
/// entry say, else their `[[company.members]]` share. Every party on an
/// entry gives the same kind of share.
#[derive(Debug, Clone, Deserialize, JsonSchema)]
pub struct PartyShareParam {
/// The party: id or name.
pub party: String,
/// Exactly this much of what the entry moved, a positive decimal
/// string in cents ("30.00"). The amounts must sum to what the entry
/// moved (its counterparty money, or its total when it moves none).
pub amount: Option<String>,
/// This percent of what the entry moved, a decimal string ("60",
/// "33.333"); the percents must sum to exactly 100. Each part is
/// rounded down to the cent and the leftover cents go to the first
/// party listed.
pub percent: Option<String>,
}
/// One party on an entry, with its share and what it resolves to.
#[derive(Debug, Clone, Serialize, JsonSchema)]
pub struct EntryPartyOut {
/// Party id.
pub party_id: u64,
/// Its label (display name, else name).
pub party: String,
/// "amount", "percent", or "derived".
pub share: String,
/// The amount given, for an amount share, decimal string.
pub amount: Option<String>,
/// The percent given, for a percent share, decimal string.
pub percent: Option<String>,
/// Its exact part of what the entry moved (positive: paid to it;
/// negative: received from it; for an entry moving no money, its part
/// of the total), decimal string. Null when the shares don't resolve
/// (see `parties_error`).
pub resolved: Option<String>,
}
/// Result of `books.draft_entry`.
@@ -1109,10 +1156,17 @@ pub struct PostEntryOut {
pub split_detected: Vec<u64>,
/// `[books] parties`: "off", "suggest", or "require".
pub party_policy: String,
/// The entry's counterparty, when tagged.
/// The entry's counterparty, when it names exactly one.
pub party_id: Option<u64>,
/// Its name.
pub party: Option<String>,
/// Every party the entry names, each with its share resolved to the
/// cent against the entry's lines (they sum to what it moved).
pub parties: Vec<EntryPartyOut>,
/// Why the parties' shares don't resolve against the lines (amounts
/// that don't sum, a derived share for a non-member), when they don't.
/// Posting with confirmed=true fails (party_shares) in this state.
pub parties_error: Option<String>,
/// Why it has none, when marked so.
pub no_party_reason: Option<String>,
/// True when the entry names no party and isn't marked as having none
@@ -1399,10 +1453,16 @@ pub struct GetEntryOut {
pub receipts: Vec<PreviewReceipt>,
/// The vendor the entry is tagged with, for 1099 tracking.
pub vendor: Option<String>,
/// The id of the party the entry is tagged with (its counterparty).
/// The id of the party the entry is tagged with (its counterparty),
/// when it names exactly one.
pub party_id: Option<u64>,
/// That party's label (display name, else name).
pub party: Option<String>,
/// Every party the entry names, with their shares resolved (as in the
/// `books.post_entry` preview).
pub parties: Vec<EntryPartyOut>,
/// Why the shares don't resolve, when they don't.
pub parties_error: Option<String>,
}
// ---------------------------------------------------------------------------
@@ -2474,6 +2534,18 @@ pub struct ExportCpaPackageParams {
/// documents (default true).
#[serde(default = "default_true")]
pub include_receipts: bool,
/// Carry every member's and 1099 payee's full TIN from the TIN vault
/// in the K-1 and 1099 worksheets (default true: the CPA needs them).
/// Then `save_to` is required, outside the books dir, and the package
/// never comes back in the output. False: last fours only, and the
/// zip comes back as base64 unless `save_to` is given.
#[serde(default = "default_true")]
pub include_tins: bool,
/// Where to write the package: a new `.zip` file, or an existing
/// folder (the zip goes in it as `cpa-package-<as_of>.zip`). Written
/// readable by its owner only. With TINs, never inside the books dir
/// (git keeps it forever, `exports/` included).
pub save_to: Option<String>,
}
/// Output of `books.export_cpa_package`.
@@ -2481,8 +2553,21 @@ pub struct ExportCpaPackageParams {
pub struct ExportCpaPackageOut {
/// File name, e.g. "cpa-package-2026-10-11.zip".
pub file_name: String,
/// The zip, base64-encoded.
pub content_base64: String,
/// The zip, base64-encoded: only when it isn't saved (`save_to`
/// absent, which needs include_tins false).
pub content_base64: Option<String>,
/// The path the zip was written to, when saved. Tell the human; never
/// open it, attach it, or copy it into the books.
pub saved_to: Option<String>,
/// Whether the worksheets carry full TINs from the vault.
pub tins_included: bool,
/// With TINs: each member or 1099 payee whose TIN the package lacks,
/// and why (not in the vault, the vault locked, ...). Collect them
/// with books.set_tin or books.set_w9.
#[serde(default)]
pub tin_gaps: Vec<String>,
/// What to tell the human.
pub note: String,
}
// ---------------------------------------------------------------------------
@@ -2624,6 +2709,35 @@ impl<'de> Deserialize<'de> for SecretTin {
}
}
/// Parameters for `books.set_tin`: a party's full taxpayer
/// identification number, for the TIN vault only.
#[derive(Debug, Clone, Deserialize, JsonSchema)]
pub struct SetTinParams {
/// The party: id or name. Any role (a member, a contractor, ...).
pub party: String,
/// The full TIN (nine digits, hyphens allowed), stored only in the
/// encrypted TIN vault; the books keep its type and last four. Never
/// echoed back.
pub tin: SecretTin,
/// The TIN's type: ssn, ein, or itin.
pub tin_type: String,
}
/// A party's taxpayer-ID facts: never more of the number than its last
/// four.
#[derive(Debug, Clone, Serialize, JsonSchema)]
pub struct TinOut {
/// ssn, ein, or itin.
pub tin_type: String,
/// The last four digits.
pub last4: String,
/// True while the TIN vault holds the full number (by the books'
/// record of it).
pub in_vault: bool,
/// When and why the vault's copy was purged, if it was.
pub purged: Option<String>,
}
/// Parameters for `books.get_w9`.
#[derive(Debug, Clone, Deserialize, JsonSchema)]
pub struct GetW9Params {
@@ -2678,9 +2792,10 @@ pub struct PurgeTinOut {
pub w9_file_in_vault: bool,
/// The TIN's last four digits, which the books keep.
pub tin_last4: Option<String>,
/// True when the purge is journaled (`party.w9_set` with the vault
/// record cleared and kept_at saying why); false when the party has no
/// W-9 facts to update.
/// True when the purge is journaled: `party.tin_set` with the vault
/// record cleared and `purged` saying when and why, and `party.w9_set`
/// likewise (kept_at) when the party has W-9 facts; false when the
/// books have no facts to update.
pub journaled: bool,
/// What happens (preview) or happened.
pub note: String,
@@ -2737,6 +2852,16 @@ pub struct SetEntryPartyParams {
/// in `books.vendor_1099`); one without, or clearing, clears the vendor
/// tag too.
pub party: Option<String>,
/// Several parties, each with their share, in place of `party`
/// (journaled as `entry.parties_set`; an empty list clears). Name both
/// members on a joint member transaction and let the shares derive;
/// give amounts (or percents) when a split is uneven or a party isn't a
/// member. Refused: a party named twice, an unknown party, mixed kinds
/// of share, percents not summing to 100, two parties with vendor
/// records (a 1099 counts a payment toward one vendor; the one vendor
/// party's record is tagged), and, once the entry has balanced lines,
/// shares that don't add up to what it moved.
pub parties: Option<Vec<PartyShareParam>>,
/// With `party` null: mark the entry as having no counterparty, with
/// why (journaled as `entry.no_party`). Satisfies `[books] parties =
/// "require"`.
@@ -2804,6 +2929,8 @@ pub struct PartyOut {
pub w9: Option<W9Out>,
/// W-4 facts, if on file.
pub w4: Option<W4Out>,
/// Taxpayer-ID facts (any role), if on file: type and last four.
pub tin: Option<TinOut>,
}
/// One party in `books.parties`.
@@ -2864,8 +2991,12 @@ pub struct ListPartiesOut {
/// negative received from it; a reversal counts against its original's
/// side. A transfer between the company's own cash accounts nets to zero.
/// A bill is not paid until its payment is.
/// (`ledger_core::parties::activity` computes it; the viewer shows the
/// same numbers.)
/// An entry naming several parties counts each one's share of that money
/// (its amount, its percent, or its derived share: see
/// `books.set_entry_party` `parties`); an entry whose shares don't resolve
/// counts for none of them.
/// (`ledger_core::parties::activity_with`, with the `[[company.members]]`,
/// computes it; the viewer shows the same numbers.)
#[derive(Debug, Clone, Serialize, JsonSchema)]
pub struct PartyTotalsOut {
/// Paid to the party, decimal string.
+7 -4
View File
@@ -147,7 +147,7 @@ fn mcp_end_to_end_over_stdio() {
// Tool listing: the narrow tools; chart is read-only.
let list = mcp.request("tools/list", json!({}));
let tools = list["result"]["tools"].as_array().unwrap();
assert_eq!(tools.len(), 91, "expected 91 tools, got {}", tools.len());
assert_eq!(tools.len(), 92, "expected 92 tools, got {}", tools.len());
let chart = tools.iter().find(|t| t["name"] == "books.chart").unwrap();
assert_eq!(chart["annotations"]["readOnlyHint"], true);
assert!(
@@ -875,11 +875,14 @@ fn mcp_statement_pdfs_and_cpa_package_over_stdio() {
assert!(err.contains("not pdf"), "{err}");
// The CPA package carries all three statements.
let err = mcp.call_err("books.export_cpa_package", json!({"as_of": "2026-10-31"}));
let err = mcp.call_err(
"books.export_cpa_package",
json!({"as_of": "2026-10-31", "include_tins": false}),
);
assert!(err.contains("cash_accounts"), "{err}");
let pkg = mcp.call(
"books.export_cpa_package",
json!({"as_of": "2026-10-31", "cash_accounts": ["1000"]}),
json!({"as_of": "2026-10-31", "cash_accounts": ["1000"], "include_tins": false}),
);
let zip_bytes = b64.decode(pkg["content_base64"].as_str().unwrap()).unwrap();
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(zip_bytes)).unwrap();
@@ -931,7 +934,7 @@ fn mcp_statement_pdfs_and_cpa_package_over_stdio() {
// Without receipts, a lighter package.
let light = mcp.call(
"books.export_cpa_package",
json!({"as_of": "2026-10-31", "cash_accounts": ["1000"], "include_receipts": false}),
json!({"as_of": "2026-10-31", "cash_accounts": ["1000"], "include_receipts": false, "include_tins": false}),
);
let light = zip::ZipArchive::new(std::io::Cursor::new(
b64.decode(light["content_base64"].as_str().unwrap())
+2 -2
View File
@@ -2598,7 +2598,7 @@ fn cpa_package_holds_the_year_and_its_evidence() {
let mut m = Mcp::start(&dir, "1000000.00");
let out = m.ok(
"books.export_cpa_package",
json!({"as_of": END, "cash_accounts": ["1000"]}),
json!({"as_of": END, "cash_accounts": ["1000"], "include_tins": false}),
);
assert_eq!(out["file_name"], format!("cpa-package-{END}.zip"));
let files = unzip(&b64d(out["content_base64"].as_str().unwrap()));
@@ -2707,7 +2707,7 @@ fn cpa_package_holds_the_year_and_its_evidence() {
// Without receipts: no evidence at all.
let out = m.ok(
"books.export_cpa_package",
json!({"as_of": END, "cash_accounts": ["1000"], "include_receipts": false}),
json!({"as_of": END, "cash_accounts": ["1000"], "include_receipts": false, "include_tins": false}),
);
let files = unzip(&b64d(out["content_base64"].as_str().unwrap()));
assert!(
+10 -1
View File
@@ -681,6 +681,10 @@ fn happy_path(name: &str) -> Mcp {
"books.set_w4",
json!({"party": "Pat", "received_on": "2026-01-02", "state": "UT", "kept_at": "binder"}),
);
m.ok(
"books.set_tin",
json!({"party": "Pat", "tin": "571-93-2846", "tin_type": "ssn"}),
);
m.ok(
"books.set_party_roles",
json!({"party": "Landlord", "add": ["landlord"]}),
@@ -1069,7 +1073,12 @@ fn happy_path(name: &str) -> Mcp {
}
m.ok(
"books.export_cpa_package",
json!({"as_of": "2026-12-31", "include_receipts": false}),
json!({"as_of": "2026-12-31", "include_receipts": false, "include_tins": false}),
);
m.ok(
"books.export_cpa_package",
json!({"as_of": "2026-12-31", "include_receipts": false,
"save_to": outside(&dir).join("cpa.zip").to_str().unwrap()}),
);
m
+250
View File
@@ -1313,3 +1313,253 @@ fn off_never_raises_it() {
json!({"entry_id": id, "confirmed": true}),
);
}
// ------------------------------------------------- party shares
//
// Oracles: SetEntryPartyParams.parties and PartyShareParam (the declared
// share rules), EntryPartyOut, PostEntryOut.parties / parties_error (code
// party_shares), PartyTotalsOut (each party's share), and
// docs/mcp-interface.md "Several parties". Values worked by hand.
/// Books with both members (50/50, capital 3000 and 3001) in the settings
/// and the directory; `extra` goes under `[books]`.
fn member_books(name: &str, extra: &str) -> Mcp {
let dir = test_dir(name);
let toml = books_toml(
&dir,
"Spec Co",
&format!(
"{extra}\n[[company.members]]\nname = \"Stephen Waits\"\nshare = \"50%\"\ncapital_account = \"3000\"\n\
[[company.members]]\nname = \"Katherine Pendergast\"\nshare = \"50%\"\ncapital_account = \"3001\"\n"
),
);
let mut m = Mcp::start(&dir, &toml);
m.accounts(CHART);
m.accounts(&[("3001", "Capital K", "equity")]);
m.add_party("Stephen Waits", &["member"]);
m.add_party("Katherine Pendergast", &["member"]);
m
}
fn both() -> Value {
json!({"parties": [{"party": "Stephen Waits"}, {"party": "Katherine Pendergast"}]})
}
fn resolved(v: &Value) -> Vec<(String, String)> {
v["parties"]
.as_array()
.unwrap()
.iter()
.map(|p| {
(
p["party"].as_str().unwrap().to_string(),
p["resolved"].as_str().unwrap_or("null").to_string(),
)
})
.collect()
}
#[test]
fn a_joint_reimbursement_names_both_members_and_splits_by_share() {
let mut m = member_books("shares-joint", "");
let id = m.draft(
"2026-04-01",
both(),
&[("2100", "debit", "100.00"), ("1000", "credit", "100.00")],
);
let preview = m.ok("books.post_entry", json!({"entry_id": id}));
assert_eq!(
resolved(&preview),
[
("Stephen Waits".to_string(), "50.00".to_string()),
("Katherine Pendergast".to_string(), "50.00".to_string())
]
);
assert_eq!(preview["parties"][0]["share"], "derived");
assert!(preview["party_id"].is_null(), "no sole party");
assert!(preview["parties_error"].is_null());
assert_eq!(preview["party_missing"], false);
m.ok(
"books.post_entry",
json!({"entry_id": id, "confirmed": true}),
);
assert_eq!(m.count("entry.parties_set"), 1);
for who in ["Stephen Waits", "Katherine Pendergast"] {
let t = m.party(who, 2026)["totals"].clone();
assert_eq!(t["paid"], "50.00", "{who}'s half");
}
let list = m.read_only("books.parties", json!({"year": 2026}));
let paid: Vec<&str> = list["parties"]
.as_array()
.unwrap()
.iter()
.map(|p| p["paid"].as_str().unwrap())
.collect();
assert_eq!(paid, ["50.00", "50.00"]);
}
#[test]
fn a_joint_contribution_derives_from_the_capital_lines() {
let mut m = member_books("shares-capital", "");
let id = m.post(
"2026-01-02",
both(),
&[
("1000", "debit", "300.00"),
("3000", "credit", "200.00"),
("3001", "credit", "100.00"),
],
);
let e = m.read_only("books.get_entry", json!({"entry_id": id}));
assert_eq!(
resolved(&e),
[
("Stephen Waits".to_string(), "-200.00".to_string()),
("Katherine Pendergast".to_string(), "-100.00".to_string())
]
);
assert_eq!(
m.party("Stephen Waits", 2026)["totals"]["received"],
"200.00"
);
assert_eq!(
m.party("Katherine Pendergast", 2026)["totals"]["received"],
"100.00"
);
}
#[test]
fn explicit_amounts_and_percents_apportion_exactly() {
let mut m = member_books("shares-explicit", "");
m.add_party("Riley", &["contractor"]);
let id = m.post(
"2026-05-01",
json!({"party": "Riley"}),
&[("6000", "debit", "100.00"), ("1000", "credit", "100.00")],
);
let e = m.ok(
"books.set_entry_party",
json!({"entry_id": id, "parties": [
{"party": "Riley", "amount": "70.00"},
{"party": "Stephen Waits", "amount": "30.00"},
]}),
);
assert_eq!(e["parties"][0]["share"], "amount");
assert_eq!(e["parties"][0]["amount"], "70.00");
assert_eq!(e["parties"][1]["resolved"], "30.00");
assert_eq!(m.party("Riley", 2026)["totals"]["paid"], "70.00");
let e = m.ok(
"books.set_entry_party",
json!({"entry_id": id, "parties": [
{"party": "Riley", "percent": "33.333"},
{"party": "Stephen Waits", "percent": "66.667"},
]}),
);
// 33.333 → 33.33, 66.667 → 66.66; the leftover cent to the first.
assert_eq!(e["parties"][0]["resolved"], "33.34");
assert_eq!(e["parties"][1]["resolved"], "66.66");
assert_eq!(e["parties"][0]["percent"], "33.333");
assert_eq!(m.count("entry.parties_set"), 2);
}
#[test]
fn shares_that_dont_add_up_are_refused_changing_nothing() {
let mut m = member_books("shares-refused", "");
m.add_party("Riley", &["contractor"]);
let id = m.post(
"2026-05-01",
json!({}),
&[("6000", "debit", "100.00"), ("1000", "credit", "100.00")],
);
let set = |parties: Value| json!({"entry_id": id, "parties": parties});
let why = m.refuse(
"books.set_entry_party",
set(json!([{"party": "Riley", "amount": "60.00"}, {"party": "Stephen Waits", "amount": "30.00"}])),
);
assert!(why.contains("sum"), "{why}");
m.refuse(
"books.set_entry_party",
set(json!([{"party": "Riley", "percent": "50"}, {"party": "Stephen Waits", "percent": "40"}])),
);
m.refuse(
"books.set_entry_party",
set(json!([{"party": "Riley"}, {"party": "Riley"}])),
);
m.refuse(
"books.set_entry_party",
set(json!([{"party": "Riley", "amount": "50.00", "percent": "50"}])),
);
m.refuse(
"books.set_entry_party",
set(json!([{"party": "Riley", "amount": "50.00"}, {"party": "Stephen Waits"}])),
);
// Derived for a non-member among several.
m.refuse(
"books.set_entry_party",
set(json!([{"party": "Riley"}, {"party": "Stephen Waits"}])),
);
m.refuse(
"books.set_entry_party",
json!({"entry_id": id, "party": "Riley", "parties": [{"party": "Riley"}]}),
);
assert_eq!(m.count("entry.parties_set"), 0);
}
#[test]
fn a_draft_whose_shares_dont_add_up_previews_the_error_and_wont_post() {
let mut m = member_books("shares-draft", "");
let id = m.draft(
"2026-06-01",
json!({"parties": [
{"party": "Stephen Waits", "amount": "10.00"},
{"party": "Katherine Pendergast", "amount": "10.00"},
]}),
&[("2100", "debit", "25.00"), ("1000", "credit", "25.00")],
);
let preview = m.ok("books.post_entry", json!({"entry_id": id}));
assert!(preview["parties_error"].is_string(), "{preview}");
assert!(preview["parties"][0]["resolved"].is_null());
let why = m.refuse(
"books.post_entry",
json!({"entry_id": id, "confirmed": true}),
);
assert!(why.contains("party_shares"), "{why}");
m.ok(
"books.set_entry_party",
json!({"entry_id": id, "parties": [
{"party": "Stephen Waits", "amount": "15.00"},
{"party": "Katherine Pendergast", "amount": "10.00"},
]}),
);
m.ok(
"books.post_entry",
json!({"entry_id": id, "confirmed": true}),
);
assert_eq!(m.party("Stephen Waits", 2026)["totals"]["paid"], "15.00");
}
#[test]
fn under_require_several_parties_satisfy_the_policy() {
let mut m = member_books("shares-require", "parties = \"require\"");
let id = m.draft(
"2026-07-01",
both(),
&[("2100", "debit", "40.00"), ("1000", "credit", "40.00")],
);
let preview = m.ok("books.post_entry", json!({"entry_id": id}));
assert_eq!(preview["party_missing"], false);
m.ok(
"books.post_entry",
json!({"entry_id": id, "confirmed": true}),
);
// The reversal carries both shares.
m.ok(
"books.reverse_entry",
json!({"entry_id": id, "date": "2026-07-02", "description": "undo", "confirmed": true}),
);
assert_eq!(
m.party("Katherine Pendergast", 2026)["totals"]["paid"],
"0.00"
);
m.read_only("books.verify", json!({}));
}
+747 -8
View File
@@ -8,7 +8,11 @@
//! (DECISION, Stephen, 2026-10-15): a full TIN is never in the journal, the
//! SQLite projection, the viewer, or any tool output, except the 1099
//! worksheet for the CPA when asked (`books.vendor_1099` with
//! `include_tins`). The harness is `tests/spec_parties.rs`'s, with a
//! `include_tins`); and the vault holds everyone's, members included, for
//! the CPA package, which carries them only in a zip saved outside the
//! books dir (DECISION, Stephen, 2026-10-15: "In the vault, that's the
//! WHOLE reason we built it. And it needs to be in the damn CPA
//! package."). The harness is `tests/spec_parties.rs`'s, with a
//! transcript of every response and the server's stderr kept, and the vault
//! key in a temp file outside the books dir (never `~/.config`).
@@ -355,8 +359,13 @@ const CHART: &[(&str, &str, &str)] = &[
/// Books with the chart (no receipts needed), a contractor "Acme Design"
/// with a vendor record marked NEC, and a landlord "Rent Co".
fn books(name: &str) -> Mcp {
books_with(name, "")
}
/// `books`, with `extra` TOML after `[books]` (members, say).
fn books_with(name: &str, extra: &str) -> Mcp {
let dir = test_dir(name);
let toml = books_toml(&dir, "1000000.00", "");
let toml = books_toml(&dir, "1000000.00", extra);
let mut m = Mcp::start(&dir, &toml);
m.accounts(CHART);
m.add_party("Acme Design", &["contractor"]);
@@ -374,6 +383,54 @@ const SSN: &str = "571-93-2846";
const SSN_DIGITS: &str = "571932846";
const EIN: &str = "47-3829105";
const EIN_DIGITS: &str = "473829105";
/// A member's SSN, unlike the others.
const MEMBER_SSN: &str = "638-27-4915";
const MEMBER_SSN_DIGITS: &str = "638274915";
/// Every file in a zip, by name.
fn unzip(path: &Path) -> HashMap<String, Vec<u8>> {
let mut z = zip::ZipArchive::new(std::fs::File::open(path).unwrap()).unwrap();
let mut out = HashMap::new();
for i in 0..z.len() {
let mut f = z.by_index(i).unwrap();
let mut bytes = Vec::new();
std::io::Read::read_to_end(&mut f, &mut bytes).unwrap();
out.insert(f.name().unwrap().to_string(), bytes);
}
out
}
/// A CSV's rows (header first) as maps from column name to cell, cells
/// split on commas outside double quotes.
fn csv_rows(bytes: &[u8]) -> Vec<HashMap<String, String>> {
let split = |l: &str| {
let (mut cells, mut cur, mut quoted) = (Vec::new(), String::new(), false);
let mut chars = l.chars().peekable();
while let Some(c) = chars.next() {
match c {
'"' if quoted && chars.peek() == Some(&'"') => {
cur.push('"');
chars.next();
}
'"' => quoted = !quoted,
',' if !quoted => cells.push(std::mem::take(&mut cur)),
c => cur.push(c),
}
}
cells.push(cur);
cells
};
let text = String::from_utf8(bytes.to_vec()).unwrap();
let mut lines = text.lines();
let head = split(lines.next().expect("a header"));
lines
.map(|l| {
let cells = split(l);
assert_eq!(cells.len(), head.len(), "{l}");
head.iter().cloned().zip(cells).collect()
})
.collect()
}
impl Mcp {
fn books_dir(&self) -> PathBuf {
@@ -921,7 +978,10 @@ fn purge_deletes_the_vault_records_and_journals_why() {
/// encrypted.
#[test]
fn no_full_tin_leaves_the_vault_except_the_worksheet_when_asked() {
let mut m = books("no-leak");
let mut m = books_with(
"no-leak",
"[[company.members]]\nname = \"Kat\"\nshare = \"100%\"\ncapital_account = \"3000\"\n",
);
let src = m.w9_file("acme-w9.pdf", SSN);
m.w9(
"Acme Design",
@@ -958,6 +1018,21 @@ fn no_full_tin_leaves_the_vault_except_the_worksheet_when_asked() {
json!({"party": "Acme Design", "notes": format!("ssn {SSN}")}),
);
m.refuse("books.add_party", json!({"name": format!("Acme {EIN}")}));
// A member's SSN, by books.set_tin, and refusals of it.
m.add_party("Kat", &["member"]);
m.ok(
"books.set_tin",
json!({"party": "Kat", "tin": MEMBER_SSN, "tin_type": "ssn"}),
);
for args in [
json!({"party": "Kat", "tin": MEMBER_SSN, "tin_type": "itin"}),
json!({"party": "Kat", "tin": MEMBER_SSN_DIGITS.parse::<u64>().unwrap() as f64,
"tin_type": "ssn"}),
json!({"party": "Nobody", "tin": MEMBER_SSN, "tin_type": "ssn"}),
json!({"party": "Rent Co", "tin": MEMBER_SSN, "tin_type": "ssn"}),
] {
m.refuse("books.set_tin", args);
}
// Everything else, read and write.
for (tool, args) in [
("books.parties", json!({})),
@@ -988,8 +1063,15 @@ fn no_full_tin_leaves_the_vault_except_the_worksheet_when_asked() {
),
(
"books.export_cpa_package",
json!({"as_of": "2026-12-31", "cash_accounts": ["1000"]}),
json!({"as_of": "2026-12-31", "cash_accounts": ["1000"], "include_tins": false}),
),
(
"books.export_cpa_package",
json!({"as_of": "2026-12-31", "cash_accounts": ["1000"],
"save_to": m.outside().join("cpa.zip").to_str().unwrap()}),
),
("books.party", json!({"party": "Kat"})),
("books.parties", json!({"role": "member"})),
(
"books.purge_tin",
json!({"party": "Rent Co", "reason": "preview only"}),
@@ -1029,7 +1111,8 @@ fn no_full_tin_leaves_the_vault_except_the_worksheet_when_asked() {
tool == "books.vendor_1099" && resp.contains("\"warning\":\"This worksheet");
!worksheet
&& (holds_tin(resp.as_bytes(), SSN_DIGITS)
|| holds_tin(resp.as_bytes(), EIN_DIGITS))
|| holds_tin(resp.as_bytes(), EIN_DIGITS)
|| holds_tin(resp.as_bytes(), MEMBER_SSN_DIGITS))
})
.collect();
assert!(leaks.is_empty(), "full TINs in tool output: {leaks:?}");
@@ -1037,6 +1120,23 @@ fn no_full_tin_leaves_the_vault_except_the_worksheet_when_asked() {
// The server's log.
let log = std::fs::read(m.outside().join("stderr.log")).unwrap();
assert!(!holds_tin(&log, SSN_DIGITS) && !holds_tin(&log, EIN_DIGITS));
assert!(!holds_tin(&log, MEMBER_SSN_DIGITS));
// The package saved outside the books carries them all: the member's
// in the K-1 worksheet, the payees' in the 1099 worksheet.
let pkg = unzip(&m.outside().join("cpa.zip"));
assert!(holds_tin(&pkg["k1-members.csv"], MEMBER_SSN_DIGITS));
let w1099 = &pkg["1099-worksheet-2026.csv"];
assert!(holds_tin(w1099, SSN_DIGITS) && holds_tin(w1099, EIN_DIGITS));
for (name, bytes) in &pkg {
if name != "k1-members.csv" && name != "1099-worksheet-2026.csv" {
assert!(
!holds_tin(bytes, SSN_DIGITS)
&& !holds_tin(bytes, EIN_DIGITS)
&& !holds_tin(bytes, MEMBER_SSN_DIGITS),
"{name} in the package holds a TIN"
);
}
}
// Every byte in the books dir outside the vault: the journal, the
// database (and its WAL), the stored files, the settings.
let books = m.books_dir();
@@ -1047,7 +1147,9 @@ fn no_full_tin_leaves_the_vault_except_the_worksheet_when_asked() {
}
let bytes = std::fs::read(&f).unwrap();
assert!(
!holds_tin(&bytes, SSN_DIGITS) && !holds_tin(&bytes, EIN_DIGITS),
!holds_tin(&bytes, SSN_DIGITS)
&& !holds_tin(&bytes, EIN_DIGITS)
&& !holds_tin(&bytes, MEMBER_SSN_DIGITS),
"{} holds a full TIN",
f.display()
);
@@ -1058,11 +1160,13 @@ fn no_full_tin_leaves_the_vault_except_the_worksheet_when_asked() {
}
// The vault holds them encrypted only.
let vault_files = files_under(&m.vault());
assert!(vault_files.len() >= 3, "{vault_files:?}");
assert!(vault_files.len() >= 4, "{vault_files:?}");
for f in vault_files {
let bytes = std::fs::read(&f).unwrap();
assert!(
!holds_tin(&bytes, SSN_DIGITS) && !holds_tin(&bytes, EIN_DIGITS),
!holds_tin(&bytes, SSN_DIGITS)
&& !holds_tin(&bytes, EIN_DIGITS)
&& !holds_tin(&bytes, MEMBER_SSN_DIGITS),
"{} holds a TIN in the clear",
f.display()
);
@@ -1094,3 +1198,638 @@ fn books_in_git_must_ignore_the_vault() {
.refused()
);
}
// ------------------------------------------------- books.set_tin, any role
/// The TIN facts a party's last `party.tin_set` journaled.
impl Mcp {
fn journaled_tin(&self, party: u64) -> Value {
String::from_utf8(self.journal())
.unwrap()
.lines()
.filter_map(|l| serde_json::from_str::<Value>(l).ok())
.filter(|v| v["type"] == "party.tin_set" || v["line_type"] == "party.tin_set")
.rfind(|v| v["payload"]["id"] == json!(party))
.map(|v| v["payload"]["facts"].clone())
.expect("a party.tin_set")
}
}
/// Any role may have its TIN in the vault: a member (for the K-1), an
/// employee, a customer, a party with no role at all. The books journal
/// its type, last four, and record hash; the output shows the last four.
#[test]
fn set_tin_takes_any_party_members_included() {
let mut m = books("set-tin");
for (name, roles) in [
("Kat", vec!["member"]),
("Pat", vec!["employee"]),
("Nobody In Particular", vec![]),
] {
let id = m.add_party(name, &roles);
let p = m.ok(
"books.set_tin",
json!({"party": name, "tin": MEMBER_SSN, "tin_type": "ssn"}),
);
assert_eq!(p["tin"]["last4"], "4915", "{p}");
assert_eq!(p["tin"]["tin_type"], "ssn");
assert_eq!(p["tin"]["in_vault"], true);
assert!(!holds_tin(p.to_string().as_bytes(), MEMBER_SSN_DIGITS));
assert!(m.vault().join(format!("tin-{id}.bin")).exists());
let facts = m.journaled_tin(id);
assert_eq!(facts["last4"], "4915");
assert_eq!(facts["tin_type"], "ssn");
assert_eq!(facts["vault_record"].as_str().unwrap().len(), 64);
assert_eq!(m.party(name, 2026)["party"]["tin"]["last4"], "4915");
}
// A customer (its role comes with its record).
m.ok("books.add_customer", json!({"name": "Client"}));
let p = m.ok(
"books.set_tin",
json!({"party": "Client", "tin": EIN, "tin_type": "ein"}),
);
assert_eq!(p["tin"]["last4"], "9105");
assert!(p["roles"].as_array().unwrap().contains(&json!("customer")));
// An EIN for a member that is a company; a replacement TIN.
let kat = m.party_id("Kat");
let first = m.journaled_tin(kat)["vault_record"].clone();
m.ok(
"books.set_tin",
json!({"party": "Kat", "tin": EIN, "tin_type": "ein"}),
);
let facts = m.journaled_tin(kat);
assert_eq!(
(facts["last4"].clone(), facts["tin_type"].clone()),
(json!("9105"), json!("ein"))
);
assert_ne!(facts["vault_record"], first);
assert!(!holds_tin(&m.journal(), EIN_DIGITS));
assert_eq!(m.ok("books.verify", json!({}))["status"], "ok");
}
#[test]
fn set_tin_refusals_write_nothing() {
let mut m = books("set-tin-refusals");
m.add_party("Kat", &["member"]);
// A W-9 payee whose W-9 facts name another last four.
m.w9(
"Acme Design",
json!({"tin_last4": "1111", "kept_at": "binder"}),
);
let acme = m.party_id("Acme Design");
for (party, tin, kind) in [
("Kat", json!("57193284"), "ssn"), // eight digits
("Kat", json!("971-93-2846"), "ssn"), // SSN starting 9
("Kat", json!(SSN), "itin"), // not an ITIN
("Kat", json!(SSN), "passport"), // no such type
("Kat", json!(571932846.0), "ssn"), // a float
("Nobody", json!(SSN), "ssn"), // no such party
("Acme Design", json!(SSN), "ssn"), // W-9 says 1111
] {
let why = m.refuse(
"books.set_tin",
json!({"party": party, "tin": tin, "tin_type": kind}),
);
assert!(!holds_tin(why.as_bytes(), SSN_DIGITS), "{why}");
}
m.refuse("books.set_tin", json!({"party": "Kat", "tin_type": "ssn"}));
assert!(!m.vault().join(format!("tin-{acme}.bin")).exists());
assert!(!m.key().exists(), "no key made for a refusal");
assert_eq!(m.count("party.tin_set"), 0);
}
/// A W-9 with a TIN records the party's TIN facts too, and the two never
/// disagree: set_tin agreeing with the W-9 moves its vault record along; a
/// W-9 with a new last four replaces the TIN facts.
#[test]
fn w9_and_tin_facts_agree() {
let mut m = books("w9-tin");
let acme = m.party_id("Acme Design");
m.w9("Acme Design", json!({"tin": SSN, "tin_type": "ssn"}));
let p = m.party("Acme Design", 2026);
assert_eq!(p["party"]["tin"]["last4"], "2846");
assert_eq!(p["party"]["tin"]["in_vault"], true);
let (w9, tin) = (m.journaled_w9(acme), m.journaled_tin(acme));
assert_eq!(w9["vault_record"], tin["vault_record"]);
// books.set_tin with the same number: both facts follow the new record.
m.ok(
"books.set_tin",
json!({"party": "Acme Design", "tin": SSN, "tin_type": "ssn"}),
);
let (w9b, tinb) = (m.journaled_w9(acme), m.journaled_tin(acme));
assert_ne!(tinb["vault_record"], tin["vault_record"], "a fresh nonce");
assert_eq!(w9b["vault_record"], tinb["vault_record"]);
// W-9 facts without a TIN that disagree with the TIN facts: refused.
m.refuse(
"books.set_w9",
json!({"party": "Acme Design", "received_on": "2026-01-05",
"entity_type": "x", "tin_last4": "1111", "kept_at": "binder"}),
);
// A new W-9 with a new TIN: the TIN facts are replaced (cleared first).
let before = m.count("party.tin_set");
m.w9(
"Acme Design",
json!({"tin": "571-93-1111", "tin_type": "ssn"}),
);
assert_eq!(m.count("party.tin_set"), before + 2, "cleared, then set");
assert_eq!(m.journaled_tin(acme)["last4"], "1111");
assert_eq!(m.journaled_w9(acme)["tin_last4"], "1111");
m.pay("Acme Design", "2500.00");
let v = m.read_only(
"books.vendor_1099",
json!({"year": 2026, "include_tins": true}),
);
assert_eq!(v["vendors"][0]["tin"], "571-93-1111");
assert_eq!(m.ok("books.verify", json!({}))["status"], "ok");
}
/// books.purge_tin covers TIN facts: a member's record goes, the books
/// keep the last four and say when and why.
#[test]
fn purge_covers_tin_facts() {
let mut m = books("purge-tin-facts");
let kat = m.add_party("Kat", &["member"]);
m.ok(
"books.set_tin",
json!({"party": "Kat", "tin": MEMBER_SSN, "tin_type": "ssn"}),
);
let p = m.ok(
"books.purge_tin",
json!({"party": "Kat", "reason": "left the company"}),
);
assert_eq!(p["status"], "preview");
assert_eq!(p["tin_in_vault"], true);
assert_eq!(p["tin_last4"], "4915");
let p = m.ok(
"books.purge_tin",
json!({"party": "Kat", "reason": "left the company", "confirmed": true}),
);
assert_eq!(p["journaled"], true);
assert!(!m.vault().join(format!("tin-{kat}.bin")).exists());
let facts = m.journaled_tin(kat);
assert!(facts["vault_record"].is_null(), "{facts}");
assert_eq!(facts["last4"], "4915");
assert!(
facts["purged"]
.as_str()
.unwrap()
.contains("left the company")
);
let party = m.party("Kat", 2026);
assert_eq!(party["party"]["tin"]["in_vault"], false);
m.refuse(
"books.purge_tin",
json!({"party": "Kat", "reason": "again", "confirmed": true}),
);
assert_eq!(m.ok("books.verify", json!({}))["status"], "ok");
}
// ------------------------------------------------- the CPA package
const MEMBERS: &str = "[[company.members]]\nname = \"Stephen Example\"\nshare = \"50%\"\ncapital_account = \"3000\"\n\
[[company.members]]\nname = \"Kat Example\"\nshare = \"50%\"\ncapital_account = \"3001\"\n";
/// Books for the K-1 worksheet, hand-worked:
///
/// ```text
/// 2025-06-01 Dr 1000 2000 / Cr 3000 1000, Cr 3001 1000 both contribute cash
/// 2025-07-01 Dr 1000 400 / Cr 4000 400 2025 income, 200 each
/// 2026-02-01 Dr 1500 600 / Cr 3000 600 Stephen: equipment
/// 2026-03-01 Dr 1000 500 / Cr 3001 500 Kat: cash
/// 2026-03-15 Dr 6999 100 / Cr 3000 100 Stephen paid an expense
/// 2026-04-01 Dr 1000 1000 / Cr 4000 1000 revenue
/// 2026-05-01 Dr 3000 150, Dr 3001 150 / Cr 1000 300 distributions
/// 2026-03-01 Dr 6000 2500 / Cr 1000 2500 Acme Design (m.pay)
/// 2026: net income 1000 - 100 - 2500 = -1600, -800 each.
/// Stephen: beginning 1200, cash 100, property 600, income -800,
/// distributions 150, ending 950.
/// Kat: beginning 1200, cash 500, property 0, income -800,
/// distributions 150, ending 750.
/// Check: bank 1100 + equipment 600 = 1700 = 950 + 750.
/// ```
///
/// Stephen's party has his SSN in the vault; Kat's has none. Acme Design
/// (NEC, an EIN in the vault) is paid 2,500.00 in 2026.
fn k1_books(name: &str) -> Mcp {
let mut m = books_with(name, MEMBERS);
m.accounts(&[
("1500", "Equipment", "asset"),
("3001", "Capital Kat", "equity"),
("4000", "App Revenue", "revenue"),
]);
m.add_party("Stephen Example", &["member"]);
m.add_party("Kat Example", &["member"]);
m.ok(
"books.set_tin",
json!({"party": "Stephen Example", "tin": MEMBER_SSN, "tin_type": "ssn"}),
);
m.w9("Acme Design", json!({"tin": EIN, "tin_type": "ein"}));
let none = json!({});
m.post(
"2025-06-01",
none.clone(),
&[
("1000", "debit", "2000.00"),
("3000", "credit", "1000.00"),
("3001", "credit", "1000.00"),
],
);
m.post(
"2025-07-01",
none.clone(),
&[("1000", "debit", "400.00"), ("4000", "credit", "400.00")],
);
m.post(
"2026-02-01",
none.clone(),
&[("1500", "debit", "600.00"), ("3000", "credit", "600.00")],
);
m.post(
"2026-03-01",
none.clone(),
&[("1000", "debit", "500.00"), ("3001", "credit", "500.00")],
);
m.post(
"2026-03-15",
none.clone(),
&[("6999", "debit", "100.00"), ("3000", "credit", "100.00")],
);
m.post(
"2026-04-01",
none.clone(),
&[("1000", "debit", "1000.00"), ("4000", "credit", "1000.00")],
);
m.post(
"2026-05-01",
none,
&[
("3000", "debit", "150.00"),
("3001", "debit", "150.00"),
("1000", "credit", "300.00"),
],
);
m.pay("Acme Design", "2500.00");
m
}
/// The package carries the K-1 worksheet (one row per member: party,
/// share, capital account, TIN, and the hand-worked capital figures) and
/// the 1099 worksheet with full TINs, saved outside the books only; a
/// missing TIN is a gap, never a failure.
#[test]
fn the_cpa_package_carries_the_k1_and_1099_worksheets_with_tins() {
let mut m = k1_books("k1");
let to = m.outside().join("cpa-2026.zip");
let out = m.read_only(
"books.export_cpa_package",
json!({"as_of": "2026-12-31", "cash_accounts": ["1000"],
"save_to": to.to_str().unwrap()}),
);
assert_eq!(out["saved_to"], json!(to.display().to_string()));
assert!(out["content_base64"].is_null(), "never the bytes: {out}");
assert_eq!(out["tins_included"], true);
assert!(
out["note"]
.as_str()
.unwrap()
.contains(&to.display().to_string())
);
let gaps: Vec<&str> = out["tin_gaps"]
.as_array()
.unwrap()
.iter()
.map(|g| g.as_str().unwrap())
.collect();
assert_eq!(gaps.len(), 1, "{gaps:?}");
assert!(gaps[0].contains("Kat Example") && gaps[0].contains("TIN not in the vault"));
assert!(!holds_tin(out.to_string().as_bytes(), MEMBER_SSN_DIGITS));
assert!(!holds_tin(out.to_string().as_bytes(), EIN_DIGITS));
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = std::fs::metadata(&to).unwrap().permissions().mode();
assert_eq!(mode & 0o777, 0o600, "the package is the owner's only");
}
let pkg = unzip(&to);
let k1 = csv_rows(&pkg["k1-members.csv"]);
assert_eq!(k1.len(), 3, "two members and their total");
let (stephen, kat) = (&k1[0], &k1[1]);
// The total row: not a K-1; ties to the balance sheet (1,700.00).
let total = &k1[2];
assert_eq!(total["member"], "Total, all members (not a K-1)");
assert_eq!(total["tin"], "");
for (col, v) in [
("beginning_capital", "2400.00"),
("contributions_cash", "600.00"),
("contributions_property", "600.00"),
("net_income", "-1600.00"),
("distributions", "300.00"),
("other", "0.00"),
("ending_capital", "1700.00"),
] {
assert_eq!(total[col], v, "{col}");
}
let sid = m.party_id("Stephen Example");
let kid = m.party_id("Kat Example");
for (row, want) in [
(
stephen,
[
("member", "Stephen Example"),
("party_id", sid.to_string().as_str()),
("share", "0.50"),
("capital_account", "3000"),
("tin", MEMBER_SSN),
("tin_type", "ssn"),
("tin_note", ""),
("beginning_capital", "1200.00"),
("contributions_cash", "100.00"),
("contributions_property", "600.00"),
("net_income", "-800.00"),
("distributions", "150.00"),
("other", "0.00"),
("ending_capital", "950.00"),
],
),
(
kat,
[
("member", "Kat Example"),
("party_id", kid.to_string().as_str()),
("share", "0.50"),
("capital_account", "3001"),
("tin", ""),
("tin_type", ""),
("tin_note", "TIN not in the vault"),
("beginning_capital", "1200.00"),
("contributions_cash", "500.00"),
("contributions_property", "0.00"),
("net_income", "-800.00"),
("distributions", "150.00"),
("other", "0.00"),
("ending_capital", "750.00"),
],
),
] {
for (col, v) in want {
let got = row
.get(col)
.unwrap_or_else(|| panic!("no column {col}: {row:?}"));
if col == "tin_note" && !v.is_empty() {
assert!(got.starts_with(v), "{col}: {got}");
} else if col == "share" {
assert_eq!(
got.trim_end_matches('0').trim_end_matches('.'),
"0.5",
"{col}"
);
} else {
assert_eq!(got, v, "{col} of {row:?}");
}
}
}
// The 1099 worksheet: the payee's full TIN.
let w = csv_rows(&pkg["1099-worksheet-2026.csv"]);
let acme = w.iter().find(|r| r["vendor"] == "Acme Design").unwrap();
assert_eq!(acme["tin"], EIN);
assert_eq!(acme["tin_type"], "ein");
assert_eq!(acme["tin_last4"], "9105");
assert_eq!(acme["reportable"], "true");
let readme = String::from_utf8(pkg["README.txt"].clone()).unwrap();
assert!(
readme.contains("k1-members.csv") && readme.contains("Kat Example"),
"{readme}"
);
assert!(!holds_tin(readme.as_bytes(), MEMBER_SSN_DIGITS));
// Nothing of it in the books dir.
for f in files_under(&m.books_dir()) {
if f.starts_with(m.vault()) {
continue;
}
let b = std::fs::read(&f).unwrap();
assert!(
!holds_tin(&b, MEMBER_SSN_DIGITS) && !holds_tin(&b, EIN_DIGITS),
"{}",
f.display()
);
assert!(!f.to_string_lossy().ends_with(".zip"), "{}", f.display());
}
// A folder: the zip goes in it, by its name.
let folder = m.outside().join("for-krista");
std::fs::create_dir_all(&folder).unwrap();
let out = m.read_only(
"books.export_cpa_package",
json!({"as_of": "2026-12-31", "cash_accounts": ["1000"],
"save_to": folder.to_str().unwrap()}),
);
assert_eq!(
out["saved_to"],
json!(
folder
.join("cpa-package-2026-12-31.zip")
.display()
.to_string()
)
);
// Without TINs: last fours only, returned as base64.
let out = m.read_only(
"books.export_cpa_package",
json!({"as_of": "2026-12-31", "cash_accounts": ["1000"], "include_tins": false}),
);
assert_eq!(out["tins_included"], false);
let zip = base64::Engine::decode(
&base64::engine::general_purpose::STANDARD,
out["content_base64"].as_str().unwrap(),
)
.unwrap();
let plain_path = m.outside().join("plain.zip");
std::fs::write(&plain_path, &zip).unwrap();
let plain = unzip(&plain_path);
let k1 = csv_rows(&plain["k1-members.csv"]);
assert_eq!(k1[0]["tin_last4"], "4915");
assert!(!k1[0].contains_key("tin"));
assert_eq!(k1[0]["ending_capital"], "950.00");
for bytes in plain.values() {
assert!(!holds_tin(bytes, MEMBER_SSN_DIGITS) && !holds_tin(bytes, EIN_DIGITS));
}
}
/// With TINs the package is never returned and never lands in the books:
/// no save_to, a path in the books dir or its exports/, an existing file,
/// or a path that is neither a folder nor a .zip is refused.
#[test]
fn a_package_with_tins_goes_only_outside_the_books() {
let mut m = k1_books("k1-where");
let why = m.refuse(
"books.export_cpa_package",
json!({"as_of": "2026-12-31", "cash_accounts": ["1000"]}),
);
assert!(why.contains("save_to"), "{why}");
let books = m.books_dir();
std::fs::create_dir_all(books.join("exports")).unwrap();
let existing = m.outside().join("taken.zip");
std::fs::write(&existing, b"x").unwrap();
for to in [
books.join("cpa.zip"),
books.join("exports"),
books.join("exports/cpa.zip"),
m.data.join("cpa.zip"),
books.join("vault/../cpa.zip"),
existing.clone(),
m.outside().join("cpa.txt"),
m.outside().join("no/such/dir/cpa.zip"),
] {
m.refuse(
"books.export_cpa_package",
json!({"as_of": "2026-12-31", "cash_accounts": ["1000"],
"save_to": to.to_str().unwrap()}),
);
}
assert_eq!(std::fs::read(&existing).unwrap(), b"x");
assert!(
std::fs::read_dir(books.join("exports"))
.unwrap()
.next()
.is_none()
);
assert!(!books.join("cpa.zip").exists());
}
/// A locked vault never fails the package: every TIN row says why, and
/// tin_gaps lists each member and payee.
#[test]
fn a_locked_vault_still_builds_the_package() {
let mut m = k1_books("k1-locked");
std::fs::remove_file(m.key()).unwrap();
let to = m.outside().join("cpa.zip");
let out = m.read_only(
"books.export_cpa_package",
json!({"as_of": "2026-12-31", "cash_accounts": ["1000"],
"save_to": to.to_str().unwrap()}),
);
let gaps = out["tin_gaps"].as_array().unwrap();
assert_eq!(gaps.len(), 3, "two members and Acme: {gaps:?}");
assert!(
gaps.iter().all(|g| g.as_str().unwrap().contains("locked")),
"{gaps:?}"
);
assert!(!m.key().exists(), "reading never makes a key");
let pkg = unzip(&to);
let k1 = csv_rows(&pkg["k1-members.csv"]);
let members: Vec<_> = k1
.iter()
.filter(|r| !r["member"].starts_with("Total"))
.collect();
assert_eq!(members.len(), 2);
assert!(
members
.iter()
.all(|r| r["tin"].is_empty() && r["tin_note"].contains("locked"))
);
assert_eq!(k1[0]["ending_capital"], "950.00", "the numbers are there");
let w = csv_rows(&pkg["1099-worksheet-2026.csv"]);
assert!(w.iter().all(|r| r["tin"].is_empty()));
}
/// B-26 ("No tool output, error, or log holds a full TIN"): a TIN put in
/// the wrong field is refused, and the refusal never repeats it, however
/// it was written: as a party name, a bare number where a string goes, or
/// a share's percent.
#[test]
fn refusals_never_echo_a_tin() {
let mut m = books("echo");
let id = m.post(
"2026-02-01",
json!({}),
&[("6999", "debit", "100.00"), ("1000", "credit", "100.00")],
);
for (tool, args) in [
(
"books.set_tin",
json!({"party": MEMBER_SSN, "tin": MEMBER_SSN, "tin_type": "ssn"}),
),
(
"books.set_tin",
json!({"party": MEMBER_SSN_DIGITS.parse::<u64>().unwrap(), "tin": MEMBER_SSN,
"tin_type": "ssn"}),
),
(
"books.purge_tin",
json!({"party": "638.27.4915", "reason": "x", "confirmed": true}),
),
(
"books.set_entry_party",
json!({"entry_id": id, "parties": [
{"party": "Acme Design", "percent": MEMBER_SSN},
{"party": MEMBER_SSN, "amount": "1.00"},
]}),
),
] {
let why = m.refuse(tool, args.clone());
assert!(
!holds_tin(why.as_bytes(), MEMBER_SSN_DIGITS),
"{tool} {args} echoed the TIN: {why}"
);
}
}
/// k1::member_party: a vendor whose display name is a member's name, with
/// a W-9 EIN in the vault, never puts its TIN on the member's K-1 row:
/// the member's TIN is a gap.
#[test]
fn a_k1_row_never_carries_another_partys_tin() {
let mut m = k1_books("k1-lookalike");
m.ok(
"books.update_party",
json!({"party": "Kat Example", "archived": true}),
);
m.add_party("KE Consulting LLC", &["contractor"]);
m.ok(
"books.update_party",
json!({"party": "KE Consulting LLC", "display_name": "Kat Example"}),
);
m.w9(
"KE Consulting LLC",
json!({"tin": "12-3456789", "tin_type": "ein"}),
);
let to = m.outside().join("cpa-lookalike.zip");
let out = m.read_only(
"books.export_cpa_package",
json!({"as_of": "2026-12-31", "cash_accounts": ["1000"],
"save_to": to.to_str().unwrap()}),
);
let gaps = out["tin_gaps"].to_string();
assert!(gaps.contains("member Kat Example"), "{gaps}");
let pkg = unzip(&to);
let k1 = csv_rows(&pkg["k1-members.csv"]);
let kat = k1.iter().find(|r| r["member"] == "Kat Example").unwrap();
assert_eq!(kat["tin"], "", "{kat:?}");
assert_eq!(kat["party_id"], "", "{kat:?}");
assert!(!holds_tin(&pkg["k1-members.csv"], "123456789"));
}
/// Members who file a joint individual return (files_jointly_with on
/// both): the README says each gets their own K-1 and both go on one Form
/// 1040; books.settings reports it; a one-sided setting is a settings error.
#[test]
fn joint_filers_are_noted_in_the_package() {
let joint = "[[company.members]]\nname = \"Stephen Example\"\nshare = \"50%\"\ncapital_account = \"3000\"\nfiles_jointly_with = \"Kat Example\"\n\
[[company.members]]\nname = \"Kat Example\"\nshare = \"50%\"\ncapital_account = \"3001\"\nfiles_jointly_with = \"Stephen Example\"\n";
let mut m = books_with("joint", joint);
m.accounts(&[("3001", "Capital Kat", "equity")]);
let s = m.read_only("books.settings", json!({}));
assert_eq!(s["members"][0]["files_jointly_with"], "Kat Example");
assert_eq!(s["members"][1]["files_jointly_with"], "Stephen Example");
let to = m.outside().join("joint.zip");
m.read_only(
"books.export_cpa_package",
json!({"as_of": "2026-12-31", "cash_accounts": ["1000"],
"save_to": to.to_str().unwrap()}),
);
let pkg = unzip(&to);
let readme = String::from_utf8(pkg["README.txt"].clone()).unwrap();
let note = "Stephen Example and Kat Example file a joint individual return: each receives their own Schedule K-1; both are reported on the one Form 1040 (Schedule E, Part II).";
assert_eq!(readme.matches(note).count(), 1, "once per pair: {readme}");
}
+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("Stephen Waits", &[Role::Member]).unwrap();
let b = s
.add_party("Katherine Pendergast", &[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("Stephen Waits", &[Role::Member]).unwrap();
let b = s
.add_party("Katherine Pendergast", &[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("Kat", &[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("571932846".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");
}
}
+133 -26
View File
@@ -33,7 +33,10 @@ to 100%) drives the statement of members' equity, `[books]
accounting_basis` ("accrual" or "cash", default accrual) labels report
footers, and `[books] receipt_threshold` sets the receipt policy unless
`config.json` sets `receipt_policy`. Members may name a
`distribution_account`; single-owner businesses may set `[books] close_into`
`distribution_account`, and `files_jointly_with` (spouses filing one joint
return; the CPA package's README notes their two K-1s go on one Form 1040,
and k1-members.csv ends with a members' total, not a K-1, that ties to
Schedule L); single-owner businesses may set `[books] close_into`
(the equity account a close puts net income in; default Retained Earnings)
and `close_sweep` (draw, distribution, or dividend accounts closed into it).
The cash flow statement's account roles are `[books] cash_accounts` (what
@@ -112,21 +115,31 @@ below.
- `books.chart` → `{ accounts: [{ id, code, name, type, parent, placeholder,
balance }] }`. Balances included: one call answers "what do we have."
- `books.draft_entry { date: "2026-10-10", description: "...", vendor?,
party? }` → `{ entry_id, status: "draft" }`. `party` tags the
counterparty (see "Tools — parties"); given with `vendor`, both must name
the same vendor record, else nothing is drafted.
party?, parties?, no_party_reason? }` → `{ entry_id, status: "draft" }`.
`party` tags the counterparty (see "Tools — parties"); given with
`vendor`, both must name the same vendor record, else nothing is
drafted. `parties` names several, each with a share (as
`books.set_entry_party`), in place of `party` and `vendor`.
- `books.add_line { entry_id, account: "1000" | <id>, side: "debit" | "credit",
amount: "123.45" }` → `{ ok: true }`. A refused line (unknown account or
class, bad amount) adds nothing.
- `books.post_entry { entry_id, confirmed }` → `{ status, entry_id, date,
description, total, lines (with resulting balances), receipts,
receipt_required, receipt_missing, split_detected }`. Without
`confirmed: true`, `status` is `"preview"` and nothing changes; with it,
the receipt policy is enforced and `status` is `"posted"`.
receipt_required, receipt_missing, split_detected, party_policy,
party_id?, party?, parties, parties_error?, no_party_reason?,
party_missing, party_suggestions }`. Without `confirmed: true`, `status`
is `"preview"` and nothing changes; with it, the receipt policy is
enforced and `status` is `"posted"`. `parties` lists every party the
entry names with its share resolved to the cent (`{ party_id, party,
share: "amount" | "percent" | "derived", amount?, percent?, resolved }`);
when the shares don't add up to what the entry moved, `parties_error`
says why and confirming fails with code `party_shares`.
- `books.reverse_entry { entry_id, date, description, confirmed }` → same
preview/confirm pattern as post.
- `books.get_entry { entry_id }` → full entry with lines, receipts, and
its `vendor`, `party_id`, and `party` (the party's label) tags.
its `vendor`, `party_id`, and `party` (the party's label, when it names
exactly one) tags, and `parties` / `parties_error` as in the
`post_entry` preview.
- `books.search_entries { account?, date_from?, date_to?, text?, limit?, offset? }`
→ entries with lines, newest first. `limit` caps at 100.
- `books.trial_balance { as_of? }` → `as_of`, lines, totals, `balanced`
@@ -290,14 +303,22 @@ is on file and the TIN's last four; full TINs live only in the TIN vault
the tag. Metadata only: posted entries may be tagged. `books.draft_entry`
takes `vendor` too; bills and their payments are tagged automatically.
`books.set_entry_party` tags the vendor too when the party has a vendor
record, so tagging by party counts for 1099s the same way.
record, so tagging by party counts for 1099s the same way. On an entry
naming several parties the parties are `books.set_entry_party`'s:
tagging the vendor of a party already named keeps every share as it
is; tagging a vendor whose party isn't named, or clearing the tag while
its party is named, is refused (drop or add the party with `parties`
instead). An entry and its reversal always carry the same tags.
- `books.vendor_1099 { year, threshold?, include_tins?, save_csv_to? }` →
`{ year, threshold, vendors: [{ vendor_id, name, form_1099?, tin_on_file,
paid, reportable, party_id?, tin_last4?, tin?, tin_type?, tin_note? }],
missing_w9, notes, warning?, saved_csv? }`. For a year whose threshold
wanderbooks doesn't know (after 2026 until the indexed amount is added)
it refuses with what to do; pass `threshold` explicitly once the figure
is confirmed. `tin_last4` comes from the party's W-9 facts. With
is confirmed. On an entry naming several parties, `paid` counts only
the vendor's resolved share of the payment (rules.md B-19); an entry
whose shares don't resolve counts in full and `notes` names it.
`tin_last4` comes from the party's W-9 facts. With
`include_tins: true` (default false) each line carries `tin`, the full
TIN formatted as the IRS writes it (123-45-6789; 12-3456789 for an
EIN), and `tin_type`, read from the vault, or `tin_note` saying why not
@@ -359,11 +380,44 @@ texts are at most 500 characters, no control characters.
`"wanderbooks vault"`.
- `books.set_w4 { party, received_on, state, kept_at }` or `{ party, clear:
true }` → the party. Journaled as `party.w4_set`.
- `books.set_entry_party { entry_id, party? }` → the entry (as
`books.get_entry`). `party` null or omitted clears the tag. A party with
a vendor record also tags the entry's vendor; one without, or clearing,
clears the vendor tag. Metadata only; posted entries may be tagged.
Journaled as `entry.party_set`.
- `books.set_entry_party { entry_id, party?, parties?, no_party_reason? }`
→ the entry (as `books.get_entry`). `party` null or omitted clears the
tag. A party with a vendor record also tags the entry's vendor; one
without, or clearing, clears the vendor tag. Metadata only; posted
entries may be tagged. Tagging an entry that was reversed, or a
reversal, tags the other too (the whole reversal chain), so each
party's part of the pair still nets to zero. Journaled as
`entry.party_set`.
`no_party_reason` (with no party) marks an entry as having no
counterparty (`entry.no_party`).
- **Several parties:** `parties: [{ party, amount? | percent? }]`, in
place of `party`, names every counterparty with its share; `[]` clears.
Journaled as `entry.parties_set { entry_id, parties: [{ party_id, share
}] }`, `share` being `{"amount": "30.00"}`, `{"percent": "60"}`, or
`"derived"`. Each share, of what the entry moved (its `entry_flow` net;
for an entry moving no money, like property contributed, its total):
- `amount`: exactly that, positive, in cents; the amounts must sum to
what it moved.
- `percent`: that percent; the percents must sum to exactly 100. Each
part is rounded down to the cent and the leftover cents go to the
first party listed.
- neither (derived): a sole party gets all of it. Members named
together (parties with the member role named in `[[company.members]]`)
get what their own capital and distributions lines on the entry say,
when it has any (these must account for all it moved); else their
`[[company.members]]` shares, rescaled to the members named, rounded as
percents are. A derived share for a non-member among several is
refused.
Every party gives the same kind of share. Refused, journaling nothing: a
party named twice, an unknown party, mixed kinds, percents not summing
to 100, an amount finer than a cent, two parties with vendor records (a
1099 counts a payment toward one vendor; the one vendor party's record
is tagged and counts the whole payment), and, once the entry has
balanced lines, shares that don't resolve. A draft's shares are checked
against its lines again at `post_entry`. Under `[books] parties =
"require"`, any party (or the no-party mark) satisfies the check.
Reversals carry every share. There is no "joint" party: a joint member
transaction names both members and lets the shares derive.
- `books.parties { role?, include_archived?, year? }` → `{ year, parties:
[{ party_id, name, label, roles, vendor_id?, customer_id?, w9_on_file,
tin_last4?, w4_on_file, archived, paid, received }] }`, by id.
@@ -397,7 +451,8 @@ include vendor and customer when it has those records.
positive net was paid to the party, a negative one received from it; a
reversal counts against its original's side. A bill isn't paid until its
payment is; an entry only moving money between money accounts nets to
zero.
zero. An entry naming several parties counts this party's resolved
share only (an entry whose shares don't resolve counts for none).
- `invoiced` / `billed`: its invoices' and bills' totals dated in the year.
`owed` / `receivable`: its open bills' and invoices' balances now (every
year).
@@ -408,9 +463,12 @@ include vendor and customer when it has those records.
## Tools — the TIN vault
Full TINs (SSN, EIN, ITIN) and signed W-9 forms, kept so the CPA's 1099
worksheet is complete, never in the books (`ledger-vault`; design in
`docs/parties-and-attachments.md` "The TIN vault"; B-26).
Full TINs (SSN, EIN, ITIN) and signed W-9 forms, kept so what the CPA
gets is complete (every member's TIN for their K-1, every payee's for
their 1099), never in the books (`ledger-vault`; design in
`docs/parties-and-attachments.md` "The TIN vault"; B-26). DECISION
(Stephen, 2026-10-15): the vault holds everyone's TIN, members included,
and the CPA package carries them.
- **Where:** `vault/` in the books dir (the parent of the data dir, beside
`wanderbooks.toml`; git-ignored), one file per record, each encrypted
@@ -424,14 +482,31 @@ worksheet is complete, never in the books (`ledger-vault`; design in
locked: the books open and verify; the worksheet says the TINs are
missing.
- **Never out:** no tool output, error, or log holds more than a TIN's
last four, except `books.vendor_1099 { include_tins: true }`. The tool
arguments that take a TIN never echo it, even when refused.
last four, except `books.vendor_1099 { include_tins: true }`; the CPA
package carries them only in its zip, written outside the books dir.
The tool arguments that take a TIN never echo it, even when refused.
- **TIN facts:** any party (any role) may have `tin` facts:
`{ tin_type: "ssn" | "ein" | "itin", last4, vault_record?, purged? }`,
journaled as `party.tin_set {id, facts|null}` and shown in every party
output as `tin: { tin_type, last4, in_vault, purged? }`. They never
disagree with the party's W-9 facts: both on file means the same last
four (the engine refuses otherwise).
- `books.set_tin { party, tin, tin_type }` → the party. Validates the TIN
as `books.set_w9` does, checks the facts on a copy of the ledger, writes
the TIN to the vault (the same key and git-ignore checks), and journals
`party.tin_set` with the record's hash. Any role. A party whose W-9
facts name another last four is refused (a payee's new TIN comes with
its W-9: `books.set_w9`); one whose W-9 facts agree gets their
`vault_record` updated too (`party.w9_set`).
- `books.set_w9 { ..., tin, tin_type, w9_file_path? }` (above): validates
the TIN against its type (nine digits, hyphens allowed; an ITIN starts
with 9 and has an IRS group in digits 4-5; an SSN or EIN doesn't start
with 9), checks the facts on a copy of the ledger, then writes the vault
and journals the facts with `vault_record`, the SHA-256 of the TIN
record (of the form's record when only a form is given). The form (PDF,
record (of the form's record when only a form is given). With a TIN it
also journals the party's TIN facts (`party.tin_set`, as
`books.set_tin`); a TIN with a new last four clears the old TIN facts
first (`party.tin_set` null), so the two never disagree. The form (PDF,
PNG, JPEG; 25 MB at most) goes to the vault only, never the attachment
store. Facts given later without a TIN keep the vault record when their
last four agree with the vault's TIN, and are refused when they don't
@@ -447,8 +522,10 @@ worksheet is complete, never in the books (`ledger-vault`; design in
journaled, note }`. Without `confirmed`, a preview that changes nothing.
Confirmed, deletes the party's vault records (needs no key) and, when the
party has W-9 facts, journals `party.w9_set` with `vault_record` cleared,
the last four kept, and `kept_at` saying when and why it was purged.
Refused when the reason is blank or there is nothing to purge.
the last four kept, and `kept_at` saying when and why it was purged;
when it has TIN facts, `party.tin_set` likewise, with `purged` saying
when and why. Refused when the reason is blank or there is nothing to
purge.
## Tools — attachments
@@ -794,8 +871,38 @@ entries.
## Tools — CPA handoff
- `books.export_cpa_package { as_of?, cash_accounts?, financing_accounts?, investing_accounts? }`
→ `{ file_name, content_base64 }`. A zip with the full set, each
- `books.export_cpa_package { as_of?, cash_accounts?, financing_accounts?,
investing_accounts?, include_receipts?, include_tins?, save_to? }` →
`{ file_name, content_base64?, saved_to?, tins_included, tin_gaps, note }`.
**TINs** (`include_tins`, default true; DECISION, Stephen, 2026-10-15):
the K-1 worksheet and the 1099 worksheet carry every member's and
payee's full TIN from the vault, so the zip is written to `save_to` (a
new `.zip` path, or an existing folder it goes in as
`cpa-package-<as_of>.zip`; outside the books dir, `exports/` included;
mode 0600) and the output is its path, never its bytes; without
`save_to` it is refused. A locked vault or a missing TIN never fails the
package: the row says why ("TIN not in the vault", "the TIN vault is
locked", a record that disagrees with the books), the README lists it,
and `tin_gaps` names each member and each reportable payee without one.
`include_tins: false`: last fours only, returned as `content_base64`
unless `save_to` is given. **K-1 worksheet** (`k1-members.csv`, with
`[[company.members]]`): one row per member, in order: `member,
party_id, share, capital_account, tin, tin_type, tin_note` (without
TINs: `tin_last4, tin_type`), then `beginning_capital,
contributions_cash, contributions_property, net_income, distributions,
other, ending_capital` for the fiscal year to date: the statement of
members' equity's figures (`ledger_core::k1`), contributions split into
property (what a contribution debits to asset accounts other than cash,
receivables, and prepaid accounts: a prepaid schedule's account, or one
named "prepaid") and cash (the rest, including a service the members
paid ahead). Property in an entry crediting several members is shared
in proportion to their credits. An entry and its reversal both in the
year cancel: neither is a contribution or a distribution. A member's
party is the party with the member's name (not a display name) and the
member role, as the viewer's capital statement finds it; with none, the
member's TIN is a gap.
**1099 worksheet** with TINs adds `party_id, tin_last4, tin, tin_type,
tin_note`. A zip with the full set, each
statement as PDF and XLSX: `profit-and-loss`, `balance-sheet`,
`cash-flow` (reconciled to `cash_accounts`), `members-equity`
(multi-member LLCs), `trial-balance`; plus `general-ledger.xlsx` and
+127 -15
View File
@@ -62,7 +62,8 @@ Rules (to vendor as B-26 with sources):
### Links
- Entries: `entry.party_set {entry_id, party_id|null}` generalizes
`entry.vendor_set`; an entry has one counterparty.
`entry.vendor_set` (one counterparty); `entry.parties_set` names
several, each with a share (below).
- Invoices and bills already name a customer or vendor id: those are
party ids after migration.
- Payroll lines name an employee party id (old runs keep their name
@@ -74,9 +75,94 @@ Rules (to vendor as B-26 with sources):
`PartyField` per event, `{field, value}`: name, display_name, website,
address, notes, archived), `party.role_set {id, role, on}`,
`party.w9_set {id, facts|null}`, `party.w4_set {id, facts|null}`,
`entry.party_set {entry_id, party_id|null}`. Replayed and projected like
`party.tin_set {id, facts|null}` (TIN facts: type, last four, vault
record, when purged), `entry.party_set {entry_id, party_id|null}`,
`entry.parties_set {entry_id, parties: [{party_id, share}]}` (a share is
`{"amount": "30.00"}`, `{"percent": "60"}`, or `"derived"`; `[]`
clears). Replayed and projected like
the rest; every event gets viewer journal text (the GUI test enforces it).
### Shares: an entry with several parties
DECISION (Stephen, 2026-10-15): there is no "joint" party. A joint
transaction is with both members and shows on both members' pages, each
with their share; apportioning is explicit and exact when it isn't
derived. Spec: `ledger_core::shares`.
- An entry names a list of `PartyShare {party, share}`; `Share` is
`Amount(d)`, `Percent(p)`, or `Derived`. Tagging one party
(`set_entry_party`) is one derived share: all of it.
`Ledger::entry_party` means "the sole party"; `entry_parties` lists
them all.
- **What a share is of:** the entry's `entry_flow` net (what it moved
with its counterparties), or its total debits when the net is zero (a
member contributing property moves no money but is still theirs).
- **Amount:** exactly that, positive, in cents; the amounts must sum to
what the entry moved.
- **Percent:** more than 0, at most 100, summing to exactly 100. Each part
is rounded toward zero to the cent and the leftover cents (fewer than
one per party) go to the first party in order.
- **Derived:** a sole party gets all of it. Several parties must all be
members (`[[company.members]]` matched by name to a party with the
member role; core is handed them as `PartyMember`s). If the entry has
lines on a named member's capital or distributions account, each
member gets what their own lines say (and those must account for all
it moved); otherwise each gets their income share, rescaled to the
members named, rounded as percents are.
- **Refused** (journaling nothing): a party named twice, an unknown
party, mixed kinds of share, percents not summing to 100, an amount
finer than a cent, and two parties with vendor records. A 1099 counts
a payment toward a single vendor: the vendor tag follows the one party
with a vendor record, and the 1099 worksheet counts the whole payment
toward it, so name a vendor among others only when the whole payment
was its (otherwise record its part as its own entry). Whether shares
add up depends on the settings (cash accounts, members), so core
checks only their form; the MCP server checks they resolve once the
entry has balanced lines, and `post_entry` refuses (`party_shares`)
until they do. Replay never consults settings.
- **Money:** `parties::activity_with` counts each party's resolved share;
an entry moving no money counts nothing; an entry whose shares don't
resolve counts for no one (listed as unresolved). Reversals carry
every share, so a reversal subtracts each party's part; re-tagging
either one (parties, shares, or vendor) re-tags the other, so the pair
always nets to zero.
- **For the agent:** a joint member transaction (a reimbursement on Due
to Shareholders, a contribution or distribution from joint funds)
names both members with no shares and lets them derive; an uneven
split gives amounts.
### A member's capital statement
A member's party page (role member, named in `[[company.members]]`,
whose capital account is in the chart) is their capital account
statement over the page's period (`Books::capital_statement`). Every
figure is the CPA package's K-1 worksheet row for that member and span
(`ledger_core::k1::k1_detail`, itself the statement of changes in
members' equity split into cash and property), so the page and the
K-1 never disagree:
- capital at the start of the period (including their share of earnings
not yet closed into capital);
- add contributions of cash (deposits, and what they paid for the
company) and of property (debits to asset accounts that are neither
cash nor receivables, at the value booked: fair market value);
- add their share of the period's net income, closed or not;
- less distributions;
- any other change in their capital, when there is one;
- capital at the end of the period (always opening plus the movements,
exactly);
- their share of profit, loss, and capital; and their part of what the
company owes the members jointly, split by the members' shares: the
balance of the `[books] financing_accounts` liabilities (the
liabilities members advance), or, only when that is not set, of the
liability accounts named "due to" / "owed to" (such as 2100 Due to
Shareholders).
Cash and property are told apart by `[books] cash_accounts`. Without it
the statement reads money as the engine does (every asset account but
receivables), so every contribution reads as cash; set `cash_accounts`
to see property apart. (The Cash page keeps its own name-based guess.)
### Migration
Old journals replay unchanged. `vendor.added` and `customer.added` keep
@@ -87,7 +173,13 @@ same name share one party; a second vendor of a taken name gets a new
party named "Name (2)" (names stay unique). Vendor and customer names
obey the party text rules (no full TIN). `entry.vendor_set` also sets the
entry's party, and `entry.party_set` to a party with a vendor record sets
the vendor tag: one counterparty.
the vendor tag: one counterparty. On an entry naming several parties the
vendor tag follows the parties, never the other way: `entry.vendor_set`
for the vendor of a party already named keeps every share; for any other
vendor, or clearing the tag while its party is named, it is refused. A
vendor record can't link to a party named beside another party with a
vendor record (refused: the entry would name two vendors); linking one
tags the entries naming the party that have no vendor tag yet.
### Tools
@@ -115,13 +207,22 @@ year. The directory shows the same columns, sortable.
## The TIN vault
Decision (Stephen, 2026-10-15): wanderbooks holds full TINs, so the 1099
worksheet it hands the CPA is complete, but never in the journal.
Decision (Stephen, 2026-10-15): wanderbooks holds full TINs, so what it
hands the CPA is complete, but never in the journal. The vault holds
everyone's, members included, and the CPA package carries them: "In the
vault, that's the WHOLE reason we built it. And it needs to be in the
damn CPA package."
- **Where:** `vault/` in the books dir, listed in `.gitignore`. Never in
the hash-chained journal, the SQLite projection, git, an export other
than the 1099 worksheet, or the viewer. Nothing outside the vault holds
more than the last four digits.
than the 1099 worksheet and the CPA package (saved outside the books
dir), or the viewer. Nothing outside the vault holds more than the last
four digits.
- **Who:** any party, whatever its roles: every member (their K-1 needs
it) and every 1099 payee. The books keep the party's TIN facts (type,
last four, the vault record's hash; `party.tin_set`), which never
disagree with its W-9 facts' last four. The viewer shows "TIN on file
••••1234 (SSN)" on the party's page, never more.
- **What:** per party, the full TIN, its type (SSN, EIN, ITIN), and the
W-9 file itself, each encrypted at rest (XChaCha20-Poly1305, one random
nonce per record; the file format carries a version and the party id as
@@ -139,24 +240,35 @@ worksheet it hands the CPA is complete, but never in the journal.
- **Purge:** `books.purge_tin {party, reason}` deletes the vault record
(after the retention period, or on request) and journals that it was
purged; the journal's facts stay.
- **Use:** only `books.vendor_1099` with `include_tins: true` (and the CPA
package, when asked) decrypts them. The MCP server never returns a TIN
in any other tool, log, or error. Tests check that no other output
contains a nine-digit run.
- **Use:** `books.vendor_1099` with `include_tins: true` and
`books.export_cpa_package` (its K-1 worksheet, `k1-members.csv`, and its
1099 worksheet; `include_tins` defaults to true there) decrypt them.
The package is written only to `save_to`, outside the books dir, and
the tool returns its path; it is refused without one. The MCP server
never returns a TIN in any other tool, log, or error. Tests check that
no other output contains a nine-digit run.
- **Books without a vault** (another machine, a restored backup) still
open and verify; the 1099 worksheet then says which TINs are missing.
open and verify; the 1099 worksheet and the CPA package then say which
TINs are missing (`tin_gaps`), and the package is still built.
- **Tools** (built; `docs/mcp-interface.md` "Tools — the TIN vault"):
`books.set_w9` takes `tin` + `tin_type` and `w9_file_path` (the form
goes to the vault, never the attachment store) and journals the last
four with `vault_record`; facts that disagree with the vault's TIN are
refused, and clearing facts with a vault record needs a purge first.
With a TIN it also records the party's TIN facts. `books.set_tin
{party, tin, tin_type}` stores any party's TIN (a member's SSN, say) the
same way and journals `party.tin_set`; a party whose W-9 facts name
another last four is refused (a payee's new TIN comes with its W-9).
`books.get_w9 {party, save_to}` writes the form to a new file outside the
books dir and returns only the path. `books.purge_tin {party, reason,
confirmed?}` previews, then deletes the records (no key needed) and
journals the purge as `party.w9_set` with `vault_record` cleared and
`kept_at` saying when and why. `books.vendor_1099 {include_tins: true}`
is the one output with full TINs (with a warning; `save_csv_to` writes
the CSV outside the books dir). A key file inside the books dir is
`kept_at` saying when and why, and `party.tin_set` with `vault_record`
cleared and `purged` saying when and why. `books.vendor_1099 {include_tins: true}`
is the one tool output with full TINs (with a warning; `save_csv_to`
writes the CSV outside the books dir); `books.export_cpa_package
{save_to}` writes them in its zip, outside the books dir, and returns
only the path. A key file inside the books dir is
refused. `tests/spec_vault.rs` scans every tool response, the server's
log, and every file in the books dir outside `vault/` for the TINs in
any formatting.
+66 -11
View File
@@ -234,6 +234,14 @@ closed period).
period at a time: a member's ending on one statement is their beginning
on the next, to the cent, and a member's period income is the
roll-forward's remainder (their share within a rounding cent).
- A correction undoes what it corrects (B-9): an entry and its reversal
both in a statement's span are neither contributions nor
distributions (`reports::cancelled_in_span`; the K-1 worksheet reads
the same). The K-1 worksheet's contributed property (Item L) is what
the member owned and handed over, with its own basis (§723): never a
prepaid service they paid for the company (cash), and shared among
members credited in one entry in proportion to their credits
(`k1` INVARIANTS 3, 4, 6).
**Enforced:** `CloseTo::Members`, `periods::allocate`,
`kernel::apportion`, `members_equity`. **Checked:** Kani
@@ -387,6 +395,38 @@ bill is not paid until it is (`form1099::vendor_1099_report_with`,
checked by `payments_come_from_cash_and_financing_only`). A W-9 is on
file by the vendor's flag or its party's W-9 facts.
An entry naming several parties (a payment shared between a contractor
and a member; `ledger_core::shares`) counts only the vendor's share
toward its 1099: what was paid to the payee (§6041(a)), not what the
entry moved for others. The share is resolved (`shares::resolve`)
against the entry's flow, the same target the MCP server checks shares
against, then scaled to the 1099 payment: the share unchanged when the
payment equals the target (the usual case, an expense paid from cash or
by a member), else payment × share ÷ target, rounded to the nearest cent,
half away from zero (so a part charged to a card scales out, 1099-K). A
percent split's leftover cent goes to the party named first (shares
INVARIANT 8). A reversal carries the shares and subtracts the same part,
and re-apportioning either one re-apportions the other
(`Ledger::set_entry_parties` INVARIANT 5), so the pair nets to zero. The
vendor tag follows the parties: on an entry naming several,
`set_entry_vendor` never replaces their shares (refused unless the
vendor's party is named), and a vendor record never links to a party
named beside another vendor (`add_vendor`), checked by
`tagging_the_vendor_keeps_a_shared_entrys_parties`,
`clearing_the_vendor_on_a_shared_entry_is_refused`,
`re_apportioning_a_reversed_entry_re_apportions_its_reversal`,
`a_named_party_gaining_a_vendor_record_keeps_one_vendor_per_entry`.
Shares that don't resolve count the whole payment toward the vendor (the
conservative answer) and the entry is listed (`Vendor1099Report::
unresolved`; a `books.vendor_1099` note). An entry naming one party
counts as before (`form1099::vendor_1099_report_with_members`, checked
by `a_shared_payment_counts_the_vendors_share`,
`a_percent_split_gives_the_leftover_cent_to_the_first`,
`a_reversed_shared_payment_nets_to_zero`,
`a_shared_payment_partly_on_a_card_scales_to_the_payment`,
`unresolved_shares_count_in_full_and_are_listed`). DECISION (Stephen,
2026-10-15: shares).
**Enforced:** `form1099::default_threshold`, a dated rule
(`ledger_core::rules::THRESHOLD_1099`, known through 2026, review from
2026-11-01). For a later year the engine refuses with `RuleNotKnown`
@@ -525,20 +565,35 @@ The full TIN, when the books need it for the 1099 worksheet, lives in the
TIN vault (`vault/` in the books dir, git-ignored; encrypted under a key
file outside the books dir): `books.set_w9` `tin` writes it there and
journals only the last four and the vault record's hash; the signed W-9
goes there too, never to the attachment store. No tool output, error, or
log holds a full TIN except `books.vendor_1099` with `include_tins`
(DECISION, Stephen, 2026-10-15: for the CPA, when asked); files holding
one (`books.get_w9`, `save_csv_to`) are written only outside the books
dir. `books.purge_tin` deletes a party's vault records and journals that
it did.
goes there too, never to the attachment store. Any party may have its
TIN in the vault, whatever its roles: every member's (for their K-1) and
every 1099 payee's belongs there (DECISION, Stephen, 2026-10-15: "In the
vault, that's the WHOLE reason we built it. And it needs to be in the
damn CPA package."). `books.set_tin` stores one for any party; the books
journal its TIN facts (type, last four, the record's hash:
`party.tin_set`), which never disagree with its W-9 facts' last four. No
tool output, error, or log holds a full TIN except `books.vendor_1099`
with `include_tins` (DECISION, Stephen, 2026-10-15: for the CPA, when
asked): a refusal that quotes its input masks any TIN in it
(`parties::redact_full_tins`, checked by `refusals_never_echo_a_tin`),
and a party text field refuses nine digits however they are separated
(space, hyphen, dot, slash, underscore; `has_full_tin`). Files holding one (`books.get_w9`, `save_csv_to`, and the CPA
package, whose K-1 and 1099 worksheets carry every member's and payee's
TIN by default) are written only outside the books dir, and the package
comes back only as its path. A locked vault or a missing TIN never fails
the package: it says which TINs are missing. `books.purge_tin` deletes a
party's vault records and journals that it did.
**Enforced:** `crate::parties` (has_full_tin, check_text, W9Facts::check,
W4Facts::check), `Ledger::add_party`, `update_party`, `set_party_role`,
`set_party_w9`, `set_party_w4`, `set_entry_party`, `validate_payroll`;
`ledger-vault` (Tin::new, the encrypted records, records_held,
purge_records); `ledger-mcp` `books.set_w9`, `books.get_w9`,
`books.purge_tin`, `books.vendor_1099` (open_vault refuses a key file in
the books dir; outside_books for files with a TIN).
`set_party_w9`, `set_party_w4`, `set_party_tin` (TinFacts::check; TIN and
W-9 facts agree), `set_entry_party`, `validate_payroll`; `ledger-core`
`k1` (the K-1 worksheet); `ledger-vault` (Tin::new, the encrypted
records, records_held, purge_records); `ledger-mcp` `books.set_w9`,
`books.set_tin`, `books.get_w9`, `books.purge_tin`, `books.vendor_1099`,
`books.export_cpa_package` (open_vault refuses a key file in the books
dir; outside_books for files with a TIN; vault_tin checks the vault
against the books).
**Checked:** `ledger-core` `tests/spec_parties.rs`; `ledger-store`
`tests/parties.rs`; `ledger-vault` `tests/vault.rs`; `ledger-mcp`
`tests/spec_parties.rs`, `tests/spec_vault.rs` (every response, the log,