Shared parties with exact shares, member capital statements, TINs for any party, K-1 worksheet

Entries may name several parties, each with an exact amount, a percent,
or a share derived from the members' capital lines or [[company.members]]
shares (entry.parties_set); shares always sum exactly. The 1099
worksheet counts a vendor's share. A member's party page is their
partner capital statement, from the same ledger_core::k1 source as the
CPA package.

books.set_tin stores any party's TIN (members' SSNs for K-1s) in the
vault (party.tin_set, last four in the books). The CPA package carries a
K-1 member worksheet (k1-members.csv, with a members' total that ties to
Schedule L) and the 1099 worksheet with full TINs, saved outside the
books only. files_jointly_with notes spouses' two K-1s land on one 1040.

Adversarial review fixes: reversed contributions no longer inflate K-1
contributions and distributions; a K-1 row never takes another party's
TIN; TIN detection covers dot/slash/dash separators; refusals redact
TINs; vendor tagging can't silently replace shared parties; reversals
follow re-apportioning.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
This commit is contained in:
swaitsandClaude Opus 5.5 committed 2026-10-15 18:20:18 -06:00
1 parent ef00ccc242
commit e1b2509c22
32 files changed
+7232 -375

No files matched your search

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