diff --git a/crates/ledger-cli/src/main.rs b/crates/ledger-cli/src/main.rs index 10be145..62e4903 100644 --- a/crates/ledger-cli/src/main.rs +++ b/crates/ledger-cli/src/main.rs @@ -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 diff --git a/crates/ledger-config/src/lib.rs b/crates/ledger-config/src/lib.rs index c451275..31a36eb 100644 --- a/crates/ledger-config/src/lib.rs +++ b/crates/ledger-config/src/lib.rs @@ -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, + /// `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, } 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 { + 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, distribution_account: Option, + files_jointly_with: Option, } #[derive(Default, Deserialize)] @@ -411,6 +446,10 @@ pub fn parse(text: &str) -> Result { .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 { } } } + // 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 { mod tests { use super::*; + /// files_jointly_with: names another member, on both sides. + #[test] + fn files_jointly_with() { + let m = |a: &str, b: &str| { + format!( + "[company]\nname = \"X\"\n[[company.members]]\nname = \"A\"\nshare = \"50%\"\ncapital_account = \"3000\"\n{a}\n[[company.members]]\nname = \"B\"\nshare = \"50%\"\ncapital_account = \"3001\"\n{b}\n" + ) + }; + let ok = parse(&m( + "files_jointly_with = \"B\"", + "files_jointly_with = \"A\"", + )) + .unwrap(); + assert_eq!( + ok.company.members[0].files_jointly_with.as_deref(), + Some("B") + ); + assert!( + parse(&m("files_jointly_with = \"B\"", "")) + .unwrap_err() + .contains("set it on both") + ); + assert!( + parse(&m("files_jointly_with = \"C\"", "")) + .unwrap_err() + .contains("isn't a member") + ); + assert!( + parse(&m("files_jointly_with = \"A\"", "")) + .unwrap_err() + .contains("themself") + ); + assert_eq!( + parse(&m("", "")).unwrap().company.members[1].files_jointly_with, + None + ); + } + /// `[books] parties`: suggest by default; off, suggest, require (and /// "required") parse; anything else is refused, naming the choices. #[test] @@ -691,12 +794,14 @@ mod tests { share: half, capital_account: "3000".into(), distribution_account: None, + files_jointly_with: None, }, Member { name: "Katherine Pendergast".into(), share: half, capital_account: "3001".into(), distribution_account: None, + files_jointly_with: None, }, ] ); diff --git a/crates/ledger-core/src/form1099.rs b/crates/ledger-core/src/form1099.rs index eee1388..3d24bde 100644 --- a/crates/ledger-core/src/form1099.rs +++ b/crates/ledger-core/src/form1099.rs @@ -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, + /// 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, } /// 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, sources: &PaymentAccounts, +) -> Result { + vendor_1099_report_with_members(ledger, year, threshold, sources, &[]) +} + +/// What an entry paid out of `sources`: credits minus debits to them. +fn payment(ledger: &Ledger, entry: &crate::JournalEntry, sources: &PaymentAccounts) -> Decimal { + entry + .lines() + .iter() + .filter(|l| sources.includes(ledger, l.account)) + .map(|l| match l.side { + crate::Side::Credit => l.amount.amount, + crate::Side::Debit => -l.amount.amount, + }) + .sum() +} + +/// The vendor's part of `payment` when the entry names several parties: +/// its resolved share scaled from the share target to the payment (the +/// SCENARIOS of [`vendor_1099_report_with_members`], INVARIANT 3). +fn vendor_part(payment: Decimal, share: Decimal, target: Decimal) -> Decimal { + if payment == target { + share + } else if target.is_zero() { + Decimal::ZERO + } else { + (payment * share / target) + .round_dp_with_strategy(2, rust_decimal::RoundingStrategy::MidpointAwayFromZero) + } +} + +/// [`vendor_1099_report`], counting payments out of `sources`, each +/// vendor's part of an entry with several parties resolved with `members` +/// (the company's members, from the settings). +/// +/// ```text +/// SOURCE: docs/spec/rules.md B-19 (26 U.S.C. §6041(a): payments made in +/// the year; card payments go on 1099-K); crate::shares (an entry +/// with several parties, each with their share; DECISION, Stephen, +/// 2026-10-15). +/// DEFINITIONS: +/// payment(e): credits minus debits to `sources` on entry e. +/// money: crate::parties::money_accounts(ledger, sources.cash), what the +/// shares resolve against (the MCP server checks them with the same). +/// target(e): crate::shares::share_target(e, money): the entry_flow net, +/// else the total debits. +/// INVARIANTS: +/// 1. Only posted entries dated in `year` and tagged with a vendor +/// (Ledger::entry_vendor) count, toward that vendor. +/// 2. An entry naming at most one party (Ledger::entry_parties): the +/// vendor's amount is payment(e), exactly as before shares. +/// 3. An entry naming several parties (one of them the vendor's party, +/// crate::shares INVARIANT 5): the vendor's amount is its resolved +/// share s (crate::shares::resolve with money and `members`) scaled +/// to the payment: s when payment(e) = target(e) (no rounding: an +/// expense paid from cash or by a member), else +/// payment(e) × s / target(e) rounded to the nearest cent, half away +/// from zero (0 when the payment is 0: charged to a card; 0 when the +/// target is 0). The other parties' parts are not anyone's 1099. +/// 4. An entry naming several parties whose shares don't resolve (the +/// members changed in the settings, say): counts payment(e) in full +/// toward the vendor, the conservative answer, and is listed in +/// `unresolved` so the human fixes the shares. +/// SCENARIOS: +/// 1. Paid from cash, or by a member through a financing liability: +/// counts. Charged to a card, or billed and unpaid: doesn't. +/// 2. A prepayment (Dr prepaid, Cr cash) counts in full; a refund to +/// cash and a reversal subtract. +/// 3. $1,000.00 from the bank (Dr contract labor / Cr 1000) shared 60/40 +/// by amount between a contractor and a member: the contractor's +/// 1099 counts 600.00. +/// 4. $100.01 from the bank, 50% / 50% contractor first: the +/// contractor gets 50.01 (the leftover cent goes to the first party, +/// crate::shares INVARIANT 8); named second, 50.00. +/// 5. $1,000.00 split 60/40, 600.00 of it from the bank and 400.00 on +/// the card: payment 600.00, target 1,000.00, the contractor counts +/// 600.00 × 600.00 / 1,000.00 = 360.00. +/// 6. Reversing scenario 3: the reversal carries the shares, resolves +/// to −600.00, and the year nets to 0.00. +/// ``` +pub fn vendor_1099_report_with_members( + ledger: &Ledger, + year: i32, + threshold: Option, + sources: &PaymentAccounts, + members: &[crate::PartyMember], ) -> Result { 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 = 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 = ledger .list_vendors() @@ -224,5 +325,6 @@ pub fn vendor_1099_report_with( year, threshold, vendors, + unresolved, }) } diff --git a/crates/ledger-core/src/k1.rs b/crates/ledger-core/src/k1.rs new file mode 100644 index 0000000..5721375 --- /dev/null +++ b/crates/ledger-core/src/k1.rs @@ -0,0 +1,825 @@ +//! The members' K-1 worksheet: each member's capital account for the +//! year, as Schedule K-1 (Form 1065) Item L reports it, with property +//! contributed apart from cash. +//! +//! ```text +//! GOAL: hand the CPA, per member, what the K-1 needs from the books: +//! beginning capital, contributions (cash and property separately), +//! their share of income, distributions, ending capital. +//! SOURCE: Form 1065 Schedule K-1 instructions, Item L ("Partner's capital +//! account analysis", on the partnership's books); B-13; +//! DECISION (Stephen, 2026-10-15): the CPA package carries a K-1 +//! worksheet per member. +//! INVARIANTS: +//! 1. Every figure but the contribution split is the statement of +//! changes in members' equity's (reports::members_equity) for the +//! same span, so the two never disagree. +//! 2. contributions_cash + contributions_property = contributions, +//! exactly, per member. +//! 3. Property is what an entry crediting a member's capital account +//! debits to asset accounts that are neither cash (`cash`), +//! receivables, nor prepaid: equipment, a vehicle, software a member +//! hands the company. The rest of a contribution is cash: deposits, +//! and what a member paid for the company (an expense, a debt they +//! took on, e.g. Dr 2100 / Cr capital, or a service paid in advance). +//! A prepaid account is the balance account of a prepaid-expense +//! schedule, or an asset account whose name says "prepaid" (1300 +//! Prepaid Expenses before its schedule exists): a member paying the +//! company's service in advance paid cash on its behalf, and it reads +//! the same booked to capital directly or through 2100. Item L +//! property is property the member owned, with its own basis +//! (§723), never cash they spent for the company. +//! 4. Within one entry, the property (at most the members' credits in +//! it) is shared among the members credited in proportion to their +//! credits, to the cent, leftover cents by largest remainder +//! (kernel::apportion, ties to the earlier member): each takes at +//! most its own credit, and the split depends on the order the +//! members are given only for a tied leftover cent (never on line +//! order). Joint property is joint (B-13's +//! shares, the house convention that joint funds are 50/50). +//! 5. k1_detail's lines explain each row: per member, its cash lines +//! sum to contributions_cash, its property lines to +//! contributions_property, and its distribution lines to +//! −distributions, exactly (the same entries members_equity reads: +//! posted, in the span, not a period's closing entry, not cancelled +//! by its reversal in the span). +//! 6. An entry and its reversal both dated in the span cancel +//! (reports::cancelled_in_span; members_equity INVARIANT 6): neither +//! gives a line, so a reversed contribution is neither a contribution +//! nor a distribution, and a reversed distribution neither a +//! distribution nor a contribution. +//! SCENARIOS: +//! 1. A joint 1,000.00 contribution (Dr 1000 / Cr 3000 500, Cr 3001 +//! 500) reversed the next day: each member 0.00 contributed, 0.00 +//! distributed. +//! 2. Members prepay an annual service from joint funds straight to +//! capital (Dr 1300 Prepaid 3,000 / Cr 3000 1,500, Cr 3001 1,500): +//! 1,500.00 cash each, no property. +//! 3. A joint mixed contribution (Dr 1000 600, Dr 1500 400 / Cr 3000 +//! 500, Cr 3001 500): each member 300.00 cash and 200.00 property, +//! whichever member is listed first. +//! ``` + +use crate::reports::{MemberCapital, members_equity}; +use crate::{AccountId, AccountType, EntryId, Ledger, LedgerError, PartyId, Side}; +use chrono::NaiveDate; +use rust_decimal::Decimal; + +/// One member's row on the K-1 worksheet. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct K1Row { + /// The member's name, as in wanderbooks.toml. + pub name: String, + /// Share of income, e.g. 0.5. + pub share: Decimal, + /// Capital at the start of the span. + pub beginning: Decimal, + /// Contributed in cash (or paid for the company) in the span. + pub contributions_cash: Decimal, + /// Contributed as property (book value credited) in the span. + pub contributions_property: Decimal, + /// Their share of the span's net income (loss). + pub net_income: Decimal, + /// Distributions in the span (positive, subtracted). + pub distributions: Decimal, + /// Any other change, as on the statement of members' equity. + pub other: Decimal, + /// Capital at the end of the span. + pub ending: Decimal, +} + +/// How one entry moved one member's capital ([`K1Line`]). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum K1Kind { + /// The cash part of a contribution (INVARIANT 3). + CashContribution, + /// The property part of a contribution (INVARIANTS 3-4). + PropertyContribution, + /// Taken out: debits to capital, net debits to distributions. + Distribution, + /// A period close allocating income into capital. Informational: + /// the row's `net_income` is the roll-forward's (INVARIANT 1), which + /// also counts income not yet closed. + IncomeClose, +} + +/// One entry's effect on one member's capital in the span, by kind +/// ([`k1_detail`]). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct K1Line { + /// The member's index in the `members` given. + pub member: usize, + /// The entry. + pub entry: EntryId, + /// What it was. + pub kind: K1Kind, + /// Signed: positive raises their capital, negative lowers it. + pub amount: Decimal, +} + +/// The K-1 worksheet with the entries behind it ([`k1_detail`]). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct K1Detail { + /// One row per member, in order: [`k1_worksheet`]'s. + pub rows: Vec, + /// 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, +} + +/// 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, 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 { + 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 = 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 = 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 { + 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 = credits.iter().map(|c| whole * c / total).collect(); + let mut parts: Vec = exact + .iter() + .map(|x| x.round_dp_with_strategy(2, ToZero)) + .collect(); + let remainders: Vec = 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 { + ledger + .party_by_name(name) + .filter(|p| p.roles.contains(&crate::Role::Member)) + .map(|p| p.id) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{AccountType, Currency, Money}; + use std::str::FromStr; + + fn d(s: &str) -> Decimal { + Decimal::from_str(s).unwrap() + } + + /// Hand-worked: A contributes 300 cash and a 700 laptop in one entry + /// (Dr 1000 300, Dr 1500 700 / Cr 3000 1000); B contributes a 200 + /// receivable-backed promise (Dr 1200 200 / Cr 3001 200, cash by + /// INVARIANT 3) and pays a 50 expense (Dr 6999 50 / Cr 3001 50). + #[test] + fn contributions_split_into_cash_and_property() { + let mut l = Ledger::new(Currency::USD); + let add = + |l: &mut Ledger, code: &str, t| l.add_account(code, code, t, None, false).unwrap(); + let cash = add(&mut l, "1000", AccountType::Asset); + let ar = add(&mut l, "1200", AccountType::Asset); + let equip = add(&mut l, "1500", AccountType::Asset); + let a = add(&mut l, "3000", AccountType::Equity); + let b = add(&mut l, "3001", AccountType::Equity); + let exp = add(&mut l, "6999", AccountType::Expense); + assert_eq!( + l.role_account(&crate::RECEIVABLES), + Some(ar), + "1200 by default" + ); + let day = NaiveDate::from_ymd_opt(2026, 2, 1).unwrap(); + let mut post = |lines: &[(AccountId, Side, &str)]| { + let e = l.create_entry(day, "x"); + for (acct, side, amt) in lines { + l.add_line(e, *acct, *side, Money::new(d(amt), Currency::USD)) + .unwrap(); + } + l.post_entry(e).unwrap(); + }; + post(&[ + (cash, Side::Debit, "300"), + (equip, Side::Debit, "700"), + (a, Side::Credit, "1000"), + ]); + post(&[(ar, Side::Debit, "200"), (b, Side::Credit, "200")]); + post(&[(exp, Side::Debit, "50"), (b, Side::Credit, "50")]); + let members = [ + MemberCapital { + name: "A".into(), + capital_account: a, + distribution_account: None, + share: d("0.5"), + }, + MemberCapital { + name: "B".into(), + capital_account: b, + distribution_account: None, + share: d("0.5"), + }, + ]; + let start = NaiveDate::from_ymd_opt(2026, 1, 1).unwrap(); + let end = NaiveDate::from_ymd_opt(2026, 12, 31).unwrap(); + let rows = k1_worksheet(&l, start, end, &members, &[cash]).unwrap(); + assert_eq!( + (rows[0].contributions_cash, rows[0].contributions_property), + (d("300"), d("700")) + ); + assert_eq!( + (rows[1].contributions_cash, rows[1].contributions_property), + (d("250"), d("0")) + ); + // Net income -50, split -25 each. + assert_eq!(rows[0].ending, d("975")); + assert_eq!(rows[1].ending, d("225")); + assert!(k1_worksheet(&l, start, end, &members, &[]).is_err()); + } + + /// Hand-worked: A (capital 3000, distributions 3100) contributes 100 + /// cash and 40 of equipment in one entry and takes 30 through 3100; B + /// takes 10 straight from 3001; revenue 60; Jan closed 50/50 (30 + /// each, A's 3100 zeroed into 3000). A: 140 in (100 cash, 40 + /// property), 30 out, 30 income, ending 140. B: 10 out, 30 income, + /// ending 20. The lines explain the rows (INVARIANT 5). + #[test] + fn detail_lines_explain_each_row() { + let mut l = Ledger::new(Currency::USD); + let add = + |l: &mut Ledger, code: &str, t| l.add_account(code, code, t, None, false).unwrap(); + let cash = add(&mut l, "1000", AccountType::Asset); + let equip = add(&mut l, "1500", AccountType::Asset); + let a = add(&mut l, "3000", AccountType::Equity); + let b = add(&mut l, "3001", AccountType::Equity); + let da = add(&mut l, "3100", AccountType::Equity); + let rev = add(&mut l, "4000", AccountType::Revenue); + let day = |m, d| NaiveDate::from_ymd_opt(2026, m, d).unwrap(); + let mut post = |date, lines: &[(AccountId, Side, &str)]| { + let e = l.create_entry(date, "x"); + for (acct, side, amt) in lines { + l.add_line(e, *acct, *side, Money::new(d(amt), Currency::USD)) + .unwrap(); + } + l.post_entry(e).unwrap(); + e + }; + let put_in = post( + day(1, 2), + &[ + (cash, Side::Debit, "100"), + (equip, Side::Debit, "40"), + (a, Side::Credit, "140"), + ], + ); + let a_out = post( + day(1, 3), + &[(da, Side::Debit, "30"), (cash, Side::Credit, "30")], + ); + let b_out = post( + day(1, 4), + &[(b, Side::Debit, "10"), (cash, Side::Credit, "10")], + ); + post( + day(1, 5), + &[(cash, Side::Debit, "60"), (rev, Side::Credit, "60")], + ); + let jan = l.create_period("Jan", day(1, 1), day(1, 31)).unwrap(); + let close = l + .close_period_to( + jan, + &crate::periods::CloseTo::Members(vec![ + crate::MemberClose { + capital: a, + share: d("0.5"), + distributions: Some(da), + }, + crate::MemberClose { + capital: b, + share: d("0.5"), + distributions: None, + }, + ]), + ) + .unwrap() + .unwrap(); + let members = [ + MemberCapital { + name: "A".into(), + capital_account: a, + distribution_account: Some(da), + share: d("0.5"), + }, + MemberCapital { + name: "B".into(), + capital_account: b, + distribution_account: None, + share: d("0.5"), + }, + ]; + let (start, end) = (day(1, 1), day(12, 31)); + let detail = k1_detail(&l, start, end, &members, &[cash]).unwrap(); + assert_eq!( + detail.rows, + k1_worksheet(&l, start, end, &members, &[cash]).unwrap() + ); + let line = |member, entry, kind, amount: &str| K1Line { + member, + entry, + kind, + amount: d(amount), + }; + assert_eq!( + detail.lines, + vec![ + line(0, put_in, K1Kind::CashContribution, "100"), + line(0, put_in, K1Kind::PropertyContribution, "40"), + line(0, a_out, K1Kind::Distribution, "-30"), + line(1, b_out, K1Kind::Distribution, "-10"), + line(0, close, K1Kind::IncomeClose, "30"), + line(1, close, K1Kind::IncomeClose, "30"), + ] + ); + let ra = &detail.rows[0]; + assert_eq!( + ( + ra.beginning, + ra.contributions_cash, + ra.contributions_property + ), + (d("0"), d("100"), d("40")) + ); + assert_eq!( + (ra.net_income, ra.distributions, ra.ending), + (d("30"), d("30"), d("140")) + ); + let rb = &detail.rows[1]; + assert_eq!( + (rb.net_income, rb.distributions, rb.ending), + (d("30"), d("10"), d("20")) + ); + for (i, row) in detail.rows.iter().enumerate() { + let sum = |k| -> Decimal { + detail + .lines + .iter() + .filter(|x| x.member == i && x.kind == k) + .map(|x| x.amount) + .sum() + }; + assert_eq!(sum(K1Kind::CashContribution), row.contributions_cash); + assert_eq!( + sum(K1Kind::PropertyContribution), + row.contributions_property + ); + assert_eq!(-sum(K1Kind::Distribution), row.distributions); + assert_eq!( + row.beginning + + row.contributions_cash + + row.contributions_property + + row.net_income + - row.distributions + + row.other, + row.ending + ); + } + // February alone: nothing moved; the capital carries over. + let feb = k1_detail(&l, day(2, 1), day(2, 28), &members, &[cash]).unwrap(); + assert!(feb.lines.is_empty()); + assert_eq!( + (feb.rows[0].beginning, feb.rows[0].ending), + (d("140"), d("140")) + ); + } + + /// Two members A (3000) and B (3001), cash 1000, equipment 1500, + /// prepaid 1300, and a `post` that returns the entry. + struct Books { + l: Ledger, + cash: AccountId, + equip: AccountId, + prepaid: AccountId, + a: AccountId, + b: AccountId, + } + + fn books() -> Books { + let mut l = Ledger::new(Currency::USD); + let mut add = + |code: &str, name: &str, t| l.add_account(code, name, t, None, false).unwrap(); + let cash = add("1000", "Bluevine", AccountType::Asset); + let prepaid = add("1300", "Prepaid Expenses", AccountType::Asset); + let equip = add("1500", "Equipment", AccountType::Asset); + let a = add("3000", "Capital A", AccountType::Equity); + let b = add("3001", "Capital B", AccountType::Equity); + Books { + l, + cash, + equip, + prepaid, + a, + b, + } + } + + fn post(l: &mut Ledger, m: u32, day: u32, lines: &[(AccountId, Side, &str)]) -> EntryId { + let e = l.create_entry(NaiveDate::from_ymd_opt(2026, m, day).unwrap(), "x"); + for (acct, side, amt) in lines { + l.add_line(e, *acct, *side, Money::new(d(amt), Currency::USD)) + .unwrap(); + } + l.post_entry(e).unwrap(); + e + } + + fn members(bk: &Books) -> [MemberCapital; 2] { + [ + MemberCapital { + name: "A".into(), + capital_account: bk.a, + distribution_account: None, + share: d("0.5"), + }, + MemberCapital { + name: "B".into(), + capital_account: bk.b, + distribution_account: None, + share: d("0.5"), + }, + ] + } + + /// (cash, property, distributions) per member for 2026. + fn split(bk: &Books, members: &[MemberCapital]) -> Vec<(Decimal, Decimal, Decimal)> { + let year = |m, day| NaiveDate::from_ymd_opt(2026, m, day).unwrap(); + let detail = k1_detail(&bk.l, year(1, 1), year(12, 31), members, &[bk.cash]).unwrap(); + detail + .rows + .iter() + .map(|r| { + ( + r.contributions_cash, + r.contributions_property, + r.distributions, + ) + }) + .collect() + } + + /// INVARIANT 6 and SCENARIO 1, by hand. Case 1: a joint 1,000 cash + /// contribution reversed the next day: 0 in, 0 out each (not 500 of + /// each). Case 2: a 700 laptop contribution reversed: A 0 property, 0 + /// out. Case 3: a real 500 + 500 contribution, then a 100 + 100 + /// distribution reversed: 500 cash each, 0 out (not 600 in, 100 out). + #[test] + fn a_reversed_contribution_or_distribution_cancels() { + let mut bk = books(); + let (cash, equip, a, b) = (bk.cash, bk.equip, bk.a, bk.b); + let joint = post( + &mut bk.l, + 1, + 2, + &[ + (cash, Side::Debit, "1000"), + (a, Side::Credit, "500"), + (b, Side::Credit, "500"), + ], + ); + let r = + bk.l.reverse_entry(joint, NaiveDate::from_ymd_opt(2026, 1, 3).unwrap(), "x") + .unwrap(); + let ms = members(&bk); + let zero = (d("0"), d("0"), d("0")); + assert_eq!(split(&bk, &ms), vec![zero, zero]); + let year = |m, day| NaiveDate::from_ymd_opt(2026, m, day).unwrap(); + let detail = k1_detail(&bk.l, year(1, 1), year(12, 31), &ms, &[cash]).unwrap(); + assert!( + detail + .lines + .iter() + .all(|l| l.entry != joint && l.entry != r), + "{:?}", + detail.lines + ); + // The statement of members' equity agrees (INVARIANT 1). + let me = members_equity(&bk.l, year(1, 1), year(12, 31), &ms).unwrap(); + assert_eq!( + (me.members[0].contributions, me.members[0].distributions), + (d("0"), d("0")) + ); + // Only the reversal in the span: by side, a distribution. + let feb_on = members_equity(&bk.l, year(1, 3), year(12, 31), &ms).unwrap(); + assert_eq!(feb_on.members[0].distributions, d("500")); + + let laptop = post( + &mut bk.l, + 2, + 1, + &[(equip, Side::Debit, "700"), (a, Side::Credit, "700")], + ); + bk.l.reverse_entry(laptop, year(2, 2), "x").unwrap(); + assert_eq!(split(&bk, &ms), vec![zero, zero]); + + post( + &mut bk.l, + 3, + 1, + &[ + (cash, Side::Debit, "1000"), + (a, Side::Credit, "500"), + (b, Side::Credit, "500"), + ], + ); + let out = post( + &mut bk.l, + 3, + 2, + &[ + (a, Side::Debit, "100"), + (b, Side::Debit, "100"), + (cash, Side::Credit, "200"), + ], + ); + let undo = bk.l.reverse_entry(out, year(3, 3), "x").unwrap(); + let five = (d("500"), d("0"), d("0")); + assert_eq!(split(&bk, &ms), vec![five, five]); + // Reversing the reversal reinstates the distribution. + bk.l.reverse_entry(undo, year(3, 4), "x").unwrap(); + let back = (d("500"), d("0"), d("100")); + assert_eq!(split(&bk, &ms), vec![back, back]); + } + + /// INVARIANT 3 and SCENARIO 2, by hand: members prepay a 3,000 annual + /// service straight to capital (Dr 1300 Prepaid / Cr 3000 1,500, Cr + /// 3001 1,500): 1,500 cash each, no property, as when routed through + /// 2100. A prepaid-expense schedule's account is prepaid whatever its + /// name. + #[test] + fn a_prepaid_service_paid_by_members_is_cash() { + let mut bk = books(); + let (prepaid, a, b) = (bk.prepaid, bk.a, bk.b); + post( + &mut bk.l, + 1, + 5, + &[ + (prepaid, Side::Debit, "3000"), + (a, Side::Credit, "1500"), + (b, Side::Credit, "1500"), + ], + ); + let ms = members(&bk); + let each = (d("1500"), d("0"), d("0")); + assert_eq!(split(&bk, &ms), vec![each, each]); + // An account named otherwise, backing a prepaid schedule. + let ins = + bk.l.add_account( + "1310", + "Insurance paid ahead", + AccountType::Asset, + None, + false, + ) + .unwrap(); + let exp = + bk.l.add_account("6300", "Insurance", AccountType::Expense, None, false) + .unwrap(); + post( + &mut bk.l, + 2, + 1, + &[(ins, Side::Debit, "1200"), (a, Side::Credit, "1200")], + ); + assert_eq!(split(&bk, &ms)[0].1, d("1200"), "no schedule yet: property"); + bk.l.register_schedule(crate::schedules::NewSchedule { + description: "2026 insurance".into(), + kind: crate::schedules::ScheduleKind::PrepaidExpense, + total: Money::new(d("1200"), Currency::USD), + start: NaiveDate::from_ymd_opt(2026, 2, 1).unwrap(), + months: 12, + balance_account: ins, + pl_account: exp, + }) + .unwrap(); + assert_eq!(split(&bk, &ms)[0], (d("2700"), d("0"), d("0"))); + } + + /// INVARIANT 4 and SCENARIO 3, by hand: Dr 1000 600, Dr 1500 400 / + /// Cr 3000 500, Cr 3001 500. Property 400 split by credit (500:500): + /// 200 each; cash 300 each, whichever member is listed first. Unequal + /// credits (Cr 3000 750, Cr 3001 250, property 401): 401 × 0.75 = + /// 300.75, 401 × 0.25 = 100.25, exact. Odd cents: property 0.01 on + /// equal credits goes to the earlier member on a tie. + #[test] + fn joint_property_is_shared_by_credit() { + let mut bk = books(); + let (cash, equip, a, b) = (bk.cash, bk.equip, bk.a, bk.b); + post( + &mut bk.l, + 1, + 5, + &[ + (cash, Side::Debit, "600"), + (equip, Side::Debit, "400"), + (a, Side::Credit, "500"), + (b, Side::Credit, "500"), + ], + ); + let ms = members(&bk); + let each = (d("300"), d("200"), d("0")); + assert_eq!(split(&bk, &ms), vec![each, each]); + let mut flipped = ms.clone(); + flipped.reverse(); + assert_eq!(split(&bk, &flipped), vec![each, each]); + assert_eq!( + share_property(d("401"), &[d("750"), d("250")]), + vec![d("300.75"), d("100.25")] + ); + assert_eq!( + share_property(d("0.01"), &[d("5"), d("5")]), + vec![d("0.01"), d("0.00")] + ); + // More property than credit (part financed): at most the credits. + assert_eq!( + share_property(d("900"), &[d("300"), d("100")]), + vec![d("300"), d("100")] + ); + assert_eq!( + share_property(d("50"), &[d("0"), d("0")]), + vec![d("0"), d("0")] + ); + } + + /// member_party doc: the party of that name with the member role only. + /// A contractor whose display name is the member's name, or a party + /// of the name without the member role, is never the member's. + #[test] + fn member_party_needs_the_member_role_and_the_name() { + let mut l = Ledger::new(Currency::USD); + let ke = l + .add_party("KE Consulting LLC", &[crate::Role::Contractor]) + .unwrap(); + l.update_party( + ke, + crate::PartyField::DisplayName(Some("Kat Example".into())), + ) + .unwrap(); + assert_eq!(member_party(&l, "Kat Example"), None); + let plain = l.add_party("Kat Example", &[]).unwrap(); + assert_eq!(member_party(&l, "kat example "), None, "no member role"); + l.set_party_role(plain, crate::Role::Member, true).unwrap(); + assert_eq!(member_party(&l, "kat example "), Some(plain)); + } +} diff --git a/crates/ledger-core/src/ledger.rs b/crates/ledger-core/src/ledger.rs index 3e3f4d1..9b2e1a3 100644 --- a/crates/ledger-core/src/ledger.rs +++ b/crates/ledger-core/src/ledger.rs @@ -264,8 +264,9 @@ pub struct Ledger { /// The party directory ([`crate::parties`]). parties: std::collections::BTreeMap, next_party_id: crate::PartyId, - /// The party each entry is with (the counterparty). - entry_parties: HashMap, + /// The parties each entry is with (its counterparties), each with + /// their share ([`crate::shares`]); never an empty list. + entry_parties: HashMap>, /// Entries marked as having no counterparty, with why. entry_no_party: HashMap, } @@ -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 { 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 = 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 = 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, + ) -> 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, ) -> 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) { + 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 { + 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 { + 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 { 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 { - self.entry_parties.get(&entry).copied() + match self.entry_parties.get(&entry).map(Vec::as_slice) { + Some([one]) => Some(one.party), + _ => None, + } + } + + /// Every party an entry is with, each with their share, in the order + /// named (empty when it names none). Resolve the shares to amounts with + /// [`crate::shares::resolve`]. + pub fn entry_parties(&self, entry: EntryId) -> &[crate::PartyShare] { + self.entry_parties + .get(&entry) + .map(Vec::as_slice) + .unwrap_or_default() + } + + /// Whether an entry names `party`, alone or among others. + pub fn entry_names_party(&self, entry: EntryId, party: crate::PartyId) -> bool { + self.entry_parties(entry).iter().any(|s| s.party == party) } /// The next party id the directory will assign. @@ -2595,6 +2765,7 @@ impl Ledger { archived: false, w9: None, w4: None, + tin: None, vendor: None, customer: None, }, @@ -2798,6 +2969,57 @@ impl Ledger { )); } f.check().map_err(LedgerError::InvalidParty)?; + if let Some(t) = &p.tin + && t.last4 != f.tin_last4 + { + return Err(LedgerError::InvalidParty(format!( + "the party's TIN on file ends in {}, the W-9 facts say {}: they must agree (record the W-9's TIN with it)", + t.last4, f.tin_last4 + ))); + } + } + Ok(()) + } + + /// Record (or clear) a party's taxpayer-ID facts: the TIN's type, last + /// four, and vault record, never the number. + /// + /// ```text + /// SOURCE: crate::parties (INVARIANTS 1, 2, 2a); DECISION (Stephen, + /// 2026-10-15): every member's and payee's TIN in the vault. + /// SCENARIOS: + /// 1. Any role may have them (members need theirs for K-1s). + /// 2. IF the facts' form is wrong (TinFacts::check): SHOULD refuse. + /// 3. IF the party's W-9 facts name another last four: SHOULD refuse + /// (the two never disagree). + /// ``` + pub fn set_party_tin( + &mut self, + id: crate::PartyId, + facts: Option, + ) -> 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, ) -> 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 = + party.into_iter().map(crate::PartyShare::whole).collect(); + self.set_entry_parties(entry, &shares) + } + + /// Name the parties an entry was with, each with their share, or + /// clear them (an empty list). Metadata: no amount changes, so posted + /// entries may be tagged. + /// + /// ```text + /// GOAL: one entry with several counterparties (both members, two + /// contractors), apportioned exactly. + /// SOURCE: crate::shares (INVARIANTS 1–5); DECISION (Stephen, + /// 2026-10-15). + /// INVARIANTS: + /// 1. The shares are checked as crate::shares::check_shares says, and + /// at most one party has a vendor record; refused otherwise, + /// changing nothing. Whether they resolve against the entry's lines + /// depends on settings (cash accounts, members) core doesn't hold: + /// callers check crate::shares::resolve before journaling. + /// 2. The entry's vendor tag follows the one party with a vendor + /// record; with none, it is cleared. + /// 3. Naming any party clears a no-party mark. + /// 4. entry_party(entry) is Some only for exactly one party. + /// 5. An entry and its reversal (and theirs: Self::reversal_chain) + /// name the same parties, shares, and vendor tag: tagging any one + /// of them tags the whole chain, so each party's part of an entry + /// and its reversal still nets to zero (crate::shares INVARIANT 6; + /// form1099 SCENARIO 6), however it is re-apportioned later. + /// SCENARIOS: + /// 1. IF the entry or a party is unknown, a party is named twice, or + /// two parties have vendor records: SHOULD refuse. + /// 2. Both members, no shares: two derived shares. + /// 3. A reversed $1,000.00 payment named 600/400, then re-apportioned + /// 700/300: the reversal is 700/300 too, and the pair nets to 0.00 + /// for each party. + /// ``` + pub fn set_entry_parties( + &mut self, + entry: EntryId, + shares: &[crate::PartyShare], + ) -> Result<(), LedgerError> { + let vendor = self.check_set_entry_parties(entry, shares)?; + for e in self.reversal_chain(entry) { + if shares.is_empty() { + self.entry_parties.remove(&e); + self.entry_vendors.remove(&e); + continue; } - None => { - self.entry_parties.remove(&entry); - self.entry_vendors.remove(&entry); + self.entry_parties.insert(e, shares.to_vec()); + self.entry_no_party.remove(&e); + match vendor { + Some(v) => { + self.entry_vendors.insert(e, v); + } + None => { + self.entry_vendors.remove(&e); + } } } Ok(()) } + /// The validation behind [`Self::set_entry_parties`]: returns the + /// vendor record the entry's vendor tag follows, if any. + pub fn check_set_entry_parties( + &self, + entry: EntryId, + shares: &[crate::PartyShare], + ) -> Result, 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, 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::>() + .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". diff --git a/crates/ledger-core/src/lib.rs b/crates/ledger-core/src/lib.rs index 79be8f2..d6e6cf3 100644 --- a/crates/ledger-core/src/lib.rs +++ b/crates/ledger-core/src/lib.rs @@ -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}; diff --git a/crates/ledger-core/src/parties.rs b/crates/ledger-core/src/parties.rs index 77dd2c2..0e5065b 100644 --- a/crates/ledger-core/src/parties.rs +++ b/crates/ledger-core/src/parties.rs @@ -16,7 +16,11 @@ //! run of nine digits (single spaces or hyphens between them allowed) //! is refused. A W-9's TIN is kept as its last four digits only. //! 2. W-9 facts need a 1099 role (contractor or landlord); W-4 facts need -//! the employee role. +//! the employee role. Taxpayer-ID facts (`TinFacts`: the TIN's type, +//! last four, and vault record) are allowed for any role: members +//! need theirs for K-1s. +//! 2a. A party's TIN facts and W-9 facts never disagree: both on file +//! means the same last four (DECISION, Stephen, 2026-10-15). //! 3. Names are unique, ignoring case and surrounding space, among parties //! not archived. //! 4. Every vendor and customer record links to exactly one party. @@ -116,6 +120,75 @@ pub struct W9Facts { pub vault_record: Option, } +/// 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, + /// When and why the vault's copy was purged, if it was. + #[serde(default)] + pub purged: Option, +} + +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, + /// 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, /// Its vendor record, if it bills the company. #[serde(default)] pub vendor: Option, @@ -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 = text.chars().collect(); +/// The characters that may sit between a TIN's digits: space, hyphen, +/// dot, slash, underscore, and the Unicode dashes and no-break space +/// (`571-93-2846`, `571.93.2846`, `571/93/2846`, `571 - 93 - 2846`). +fn tin_separator(c: char) -> bool { + matches!( + c, + ' ' | '-' + | '.' + | '/' + | '_' + | '\u{a0}' + | '\u{2010}' + | '\u{2011}' + | '\u{2012}' + | '\u{2013}' + | '\u{2014}' + | '\u{2212}' + ) +} + +/// The most separators that may sit between two of a TIN's digits +/// (`" - "` is three). +const MAX_TIN_GAP: usize = 3; + +/// Each run in `chars` that holds nine digits or more, as (first, last) +/// char indices of its digits: digits with at most [`MAX_TIN_GAP`] +/// separators ([`tin_separator`]) between neighbors. +fn full_tin_runs(chars: &[char]) -> Vec<(usize, usize)> { + let mut runs = Vec::new(); let mut i = 0; while i < chars.len() { - if chars[i].is_numeric() { - let (mut digits, mut j) = (1, i + 1); - while j < chars.len() && digits < 9 { - if chars[j].is_numeric() { - digits += 1; - j += 1; - } else if matches!(chars[j], ' ' | '-') - && chars.get(j + 1).is_some_and(|c| c.is_numeric()) - { - j += 1; - } else { - break; - } + if !chars[i].is_numeric() { + i += 1; + continue; + } + let (mut digits, mut last, mut j) = (1, i, i + 1); + while j < chars.len() { + if chars[j].is_numeric() { + digits += 1; + last = j; + j += 1; + continue; } - if digits >= 9 { - return true; + let gap = chars[j..].iter().take_while(|c| tin_separator(**c)).count(); + if (1..=MAX_TIN_GAP).contains(&gap) + && chars.get(j + gap).is_some_and(|c| c.is_numeric()) + { + j += gap; + } else { + break; } } - i += 1; + if digits >= 9 { + runs.push((i, last)); + } + i = last + 1; } - false + runs +} + +/// Whether `text` holds a run of nine digits, with at most three +/// separators (space, hyphen, dot, slash, underscore, a Unicode dash or +/// no-break space) between neighbors: an SSN, EIN, or ITIN however it's +/// written (`571-93-2846`, `571.93.2846`, `571 - 93 - 2846`). Any Unicode +/// numeral counts as a digit (full-width, Arabic-Indic, ...), so a number +/// can't slip through in another script. +pub fn has_full_tin(text: &str) -> bool { + let chars: Vec = 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 = 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, } /// The accounts [`entry_flow`] reads money from: the cash accounts (every @@ -457,37 +597,59 @@ pub fn entry_flow(entry: &crate::JournalEntry, money: &MoneyAccounts) -> EntryFl } /// One party's entries, and the money paid to and received from it in -/// `start..=end`. +/// `start..=end`, with no members ([`activity_with`] with none: an entry +/// naming several parties then needs explicit shares to count). +pub fn activity( + ledger: &crate::Ledger, + party: PartyId, + span: (NaiveDate, NaiveDate), + cash: &[crate::AccountId], +) -> Activity { + activity_with(ledger, party, span, cash, &[]) +} + +/// One party's entries, and its share of the money paid to and received +/// from the entries' parties in `start..=end`. /// /// ```text /// GOAL: one answer to "what did we pay them this year?" for the MCP -/// server and the viewer alike. -/// SOURCE: docs/parties-and-attachments.md (Parties); DECISION (Stephen, -/// 2026-10-15). +/// server and the viewer alike, right when an entry was with +/// several parties. +/// SOURCE: docs/parties-and-attachments.md (Parties, Shares); DECISION +/// (Stephen, 2026-10-15). /// INVARIANTS: -/// 1. entries: every entry Ledger::entry_party names the party +/// 1. entries: every entry Ledger::entry_parties names the party in /// (hand tags, vendor tags, bills and their payments, invoices and /// their payments, and reversals of any of these carry it). -/// 2. Each posted entry dated in the span counts its entry_flow net: -/// positive is a payment, negative a receipt. A reversal counts on -/// its original's side, so it subtracts. -/// 3. A card charge counts as paid (the company paid them, by card); a +/// 2. Each posted entry dated in the span whose entry_flow net is not +/// zero counts the party's resolved share of it +/// (crate::shares::resolve with `members`; the whole net for a sole +/// party): positive is a payment, negative a receipt. A reversal +/// counts on its original's side, so it subtracts. +/// 3. An entry moving no money (net zero, like property contributed) +/// counts nothing. An entry whose shares don't resolve counts +/// nothing and is listed in `unresolved`. +/// 4. Over all the parties an entry names, the shares counted sum to its +/// net exactly (crate::shares INVARIANT 6). +/// 5. A card charge counts as paid (the company paid them, by card); a /// bill doesn't until it is paid. Not the 1099 amount: that is /// form1099::vendor_1099_report_with. /// ``` -pub fn activity( +pub fn activity_with( ledger: &crate::Ledger, party: PartyId, (start, end): (NaiveDate, NaiveDate), cash: &[crate::AccountId], + members: &[crate::PartyMember], ) -> Activity { use rust_decimal::Decimal; let entries: BTreeSet = (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, } } diff --git a/crates/ledger-core/src/reports.rs b/crates/ledger-core/src/reports.rs index 3a16d5d..1664029 100644 --- a/crates/ledger-core/src/reports.rs +++ b/crates/ledger-core/src/reports.rs @@ -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 { + 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, diff --git a/crates/ledger-core/src/shares.rs b/crates/ledger-core/src/shares.rs new file mode 100644 index 0000000..b87de24 --- /dev/null +++ b/crates/ledger-core/src/shares.rs @@ -0,0 +1,314 @@ +//! Party shares: an entry with more than one party, each with their part of +//! what it moved. +//! +//! An entry names its counterparties as a list of [`PartyShare`]s. Most +//! name one party, who had all of it. A joint transaction names several: +//! the two members of a partnership reimbursed together, a payment split +//! between two contractors, a contribution from both members' joint funds. +//! Each share is an exact amount, a percentage, or derived from the entry +//! itself ([`Share::Derived`]). +//! +//! ```text +//! GOAL: "what did we pay each of them?" right when one entry was with +//! several parties, with apportioning the human controls exactly. +//! SOURCE: DECISION (Stephen, 2026-10-15): no "joint" party; a joint +//! transaction is with both members, each with their share, and +//! shares can be apportioned explicitly ("do you allow for +//! controlled apportioning?" Yes). docs/parties-and-attachments.md +//! "Shares". +//! DEFINITIONS: +//! flow: crate::parties::entry_flow(entry, money). +//! target: flow.net when it is not zero, else the entry's total debits +//! (a member contributing property moves no money but is still all +//! theirs). magnitude = |target|; sign = the sign of target (positive +//! when net is zero). +//! A member: a PartyMember (party, income share, capital account, +//! distributions account), passed in by the caller from the company's +//! settings ([[company.members]]); core doesn't read the settings. +//! INVARIANTS (of an entry's shares, checked by check_shares, journaled): +//! 1. No party appears twice; every party is in the directory. +//! 2. All shares are one kind: all Amount, all Percent, or all Derived. +//! 3. An Amount is positive with at most two decimal places (cents). +//! 4. A Percent is positive, at most 100, and the percents sum to exactly +//! 100. +//! 5. At most one party has a vendor record: a 1099 counts a payment +//! toward a single vendor. +//! INVARIANTS (of resolve, against the entry's lines and the members): +//! 6. Resolved amounts sum to target exactly, and each has target's sign. +//! 7. Amount: each party gets sign × its amount; refused unless the +//! amounts sum to magnitude. +//! 8. Percent: each party gets magnitude × percent / 100 rounded toward +//! zero to the cent; the cents left over (fewer than one per party) +//! go to the first party in order. Then the sign. +//! 9. Derived, one party: it gets target. +//! 10. Derived, several parties: every one must be a member (else +//! refused: give amounts or percents). If the entry has a line on any +//! named member's capital or distributions account, each member gets +//! |credits − debits| on their own two accounts, and those must sum +//! to magnitude (else refused). Otherwise each gets their income share +//! of magnitude, the shares rescaled to the members named, rounded as +//! in 8 (remainder to the first). +//! SCENARIOS: +//! 1. Reimbursing both members $100.00 (Dr 2100 / Cr 1000), both named, +//! derived, 50/50: $50.00 each. +//! 2. A joint contribution (Dr 1000 300 / Cr 3000 200 / Cr 3001 100): +//! derived from the capital lines, Stephen −200.00, Katherine −100.00 +//! (received). +//! 3. A property contribution (Dr Equipment 1000 / Cr 3000 500 / Cr 3001 +//! 500): net is zero; target is the total, 500.00 each. +//! 4. $100.00 split 1/3 each three ways by percent 33.33, 33.33, 33.34: +//! 33.33, 33.33, 33.34. By 33.333/33.333/33.334: 33.34, 33.33, 33.33 +//! (the leftover cent to the first). +//! 5. Amounts of $60.00 and $30.00 on a $100.00 payment: refused. +//! ``` + +use crate::{AccountId, JournalEntry, PartyId, Side}; +use rust_decimal::Decimal; +use serde::{Deserialize, Serialize}; + +/// What part of an entry one party had. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Share { + /// Exactly this much of what the entry moved (a positive amount, in + /// cents; the sign follows the entry). + Amount(Decimal), + /// This percent of what the entry moved (0 < p ≤ 100). + Percent(Decimal), + /// From the entry itself: all of it for a sole party; for members, + /// their capital lines or their income share (see the module's + /// INVARIANT 10). + Derived, +} + +/// One party named on an entry, with their share. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct PartyShare { + /// The party. + #[serde(rename = "party_id")] + pub party: PartyId, + /// Their share. + pub share: Share, +} + +impl PartyShare { + /// The sole party, with all of it. + pub fn whole(party: PartyId) -> Self { + PartyShare { + party, + share: Share::Derived, + } + } +} + +/// A member of the company as shares see them: their party, their share of +/// income (a fraction, e.g. 0.50), and their capital and distributions +/// accounts. Built by the caller from the settings' members. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct PartyMember { + /// The member's party in the directory. + pub party: PartyId, + /// Their share of income, as a fraction (the members' sum to 1). + pub share: Decimal, + /// Their capital account. + pub capital: AccountId, + /// Their distributions account, if they have one. + pub distributions: Option, +} + +/// 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 { + 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 = weights + .iter() + .map(|w| (magnitude * w / total).round_dp_with_strategy(2, ToZero)) + .collect(); + let left = magnitude - parts.iter().copied().sum::(); + 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, 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 = match first.share { + Share::Amount(_) => { + let amounts: Vec = 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 = 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::>()?; + 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 = 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::() + .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 = 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()) +} diff --git a/crates/ledger-core/tests/form1099.rs b/crates/ledger-core/tests/form1099.rs index 64ba38f..cb72f7f 100644 --- a/crates/ledger-core/tests/form1099.rs +++ b/crates/ledger-core/tests/form1099.rs @@ -262,3 +262,397 @@ fn payments_come_from_cash_and_financing_only() { let plain = vendor_1099_report(&l, 2026, None).unwrap(); assert_eq!(plain.vendors[0].paid, dec("700")); } + +/// Books for the shared-payment tests: bank 1000, card 2050, contract +/// labor 6000; Dana (1099-NEC) and member Stephen as parties. +struct Shared { + l: Ledger, + bank: ledger_core::AccountId, + card: ledger_core::AccountId, + labor: ledger_core::AccountId, + dana: ledger_core::VendorId, + dana_p: ledger_core::PartyId, + stephen: ledger_core::PartyId, +} + +fn shared_books() -> Shared { + let mut l = Ledger::new(Currency::USD); + let bank = l + .add_account("1000", "Bank", AccountType::Asset, None, false) + .unwrap(); + let card = l + .add_account("2050", "Card", AccountType::Liability, None, false) + .unwrap(); + let labor = l + .add_account("6000", "Contract Labor", AccountType::Expense, None, false) + .unwrap(); + let dana = l.add_vendor("Dana").unwrap(); + l.set_vendor_1099(dana, Some(Form1099::Nec), true).unwrap(); + let dana_p = l.party_of_vendor(dana).unwrap(); + let stephen = l + .add_party("Stephen", &[ledger_core::Role::Member]) + .unwrap(); + Shared { + l, + bank, + card, + labor, + dana, + dana_p, + stephen, + } +} + +/// Post `lines` on `date` and name `shares` on it. +fn post_shared( + s: &mut Shared, + date: NaiveDate, + lines: &[(ledger_core::AccountId, Side, &str)], + shares: &[(ledger_core::PartyId, ledger_core::Share)], +) -> ledger_core::EntryId { + let e = s.l.create_entry(date, "shared payment"); + for (a, side, amt) in lines { + s.l.add_line(e, *a, *side, Money::new(dec(amt), Currency::USD)) + .unwrap(); + } + s.l.post_entry(e).unwrap(); + let shares: Vec = 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) { + let r = vendor_1099_report(&s.l, 2026, None).unwrap(); + let line = r.vendors.iter().find(|v| v.vendor_id == s.dana).unwrap(); + (line.paid, r.unresolved) +} + +/// vendor_1099_report_with_members SCENARIO 3, by hand: $1,000.00 from +/// the bank (Dr 6000 / Cr 1000), Dana $600.00 and Stephen $400.00 by +/// amount. Payment = target = 1,000.00, so Dana's 1099 counts her share +/// unchanged: 600.00 (not the whole 1,000.00). +#[test] +fn a_shared_payment_counts_the_vendors_share() { + use ledger_core::Share::Amount; + let mut s = shared_books(); + let (bank, labor, dana_p, stephen) = (s.bank, s.labor, s.dana_p, s.stephen); + let e = post_shared( + &mut s, + d(2026, 3, 1), + &[ + (labor, Side::Debit, "1000.00"), + (bank, Side::Credit, "1000.00"), + ], + &[ + (dana_p, Amount(dec("600.00"))), + (stephen, Amount(dec("400.00"))), + ], + ); + assert_eq!( + s.l.entry_vendor(e), + Some(s.dana), + "the vendor tag follows Dana" + ); + assert_eq!(dana_paid(&s), (dec("600.00"), vec![])); + // With sources and members named, the same. + let r = ledger_core::vendor_1099_report_with_members( + &s.l, + 2026, + None, + &ledger_core::PaymentAccounts { + cash: vec![bank], + financing: vec![], + }, + &[], + ) + .unwrap(); + assert_eq!(r.vendors[0].paid, dec("600.00")); + assert!(!r.vendors[0].reportable, "600.00 is under 2,000"); +} + +/// SCENARIO 4, by hand: $100.01 from the bank split 50% / 50%. Each part +/// is 100.01 × 50 / 100 = 50.005, rounded toward zero to 50.00; the cent +/// left over goes to the party named first (shares INVARIANT 8). Dana +/// first: 50.01. A second payment with Dana named second: 50.00. Total +/// 100.01. +#[test] +fn a_percent_split_gives_the_leftover_cent_to_the_first() { + use ledger_core::Share::Percent; + let mut s = shared_books(); + let (bank, labor, dana_p, stephen) = (s.bank, s.labor, s.dana_p, s.stephen); + let lines = [ + (labor, Side::Debit, "100.01"), + (bank, Side::Credit, "100.01"), + ]; + post_shared( + &mut s, + d(2026, 4, 1), + &lines, + &[(dana_p, Percent(dec("50"))), (stephen, Percent(dec("50")))], + ); + assert_eq!(dana_paid(&s).0, dec("50.01")); + post_shared( + &mut s, + d(2026, 4, 2), + &lines, + &[(stephen, Percent(dec("50"))), (dana_p, Percent(dec("50")))], + ); + assert_eq!(dana_paid(&s).0, dec("100.01"), "50.01 + 50.00"); +} + +/// SCENARIO 6, by hand: SCENARIO 3's payment reversed the same year. The +/// reversal carries the shares; its target is −1,000.00, Dana's amount +/// resolves to −600.00, and the payment is −1,000.00 = target, so she +/// nets 600.00 − 600.00 = 0.00 (still listed: she is marked for a 1099). +#[test] +fn a_reversed_shared_payment_nets_to_zero() { + use ledger_core::Share::Amount; + let mut s = shared_books(); + let (bank, labor, dana_p, stephen) = (s.bank, s.labor, s.dana_p, s.stephen); + let e = post_shared( + &mut s, + d(2026, 3, 1), + &[ + (labor, Side::Debit, "1000.00"), + (bank, Side::Credit, "1000.00"), + ], + &[ + (dana_p, Amount(dec("600.00"))), + (stephen, Amount(dec("400.00"))), + ], + ); + let rev = s.l.reverse_entry(e, d(2026, 3, 2), "wrong payee").unwrap(); + assert_eq!(s.l.entry_parties(rev), s.l.entry_parties(e)); + assert_eq!(dana_paid(&s), (dec("0.00"), vec![])); +} + +/// SCENARIO 5, by hand: $1,000.00 of contract labor, 600.00 from the +/// bank and 400.00 on the card, Dana 60% / Stephen 40%. Target (the flow +/// net, the card counted as money) 1,000.00, Dana's share 600.00, the +/// 1099 payment 600.00 (the card is not a payment source): 600.00 × +/// 600.00 / 1,000.00 = 360.00. Then $100.00 with 33.33 from the bank and +/// 66.67 on the card, 50/50: share 50.00, 33.33 × 50.00 / 100.00 = +/// 16.665, rounded half away from zero to 16.67. Total 376.67. +#[test] +fn a_shared_payment_partly_on_a_card_scales_to_the_payment() { + use ledger_core::Share::Percent; + let mut s = shared_books(); + let (bank, card, labor, dana_p, stephen) = (s.bank, s.card, s.labor, s.dana_p, s.stephen); + post_shared( + &mut s, + d(2026, 5, 1), + &[ + (labor, Side::Debit, "1000.00"), + (bank, Side::Credit, "600.00"), + (card, Side::Credit, "400.00"), + ], + &[(dana_p, Percent(dec("60"))), (stephen, Percent(dec("40")))], + ); + assert_eq!(dana_paid(&s).0, dec("360.00")); + post_shared( + &mut s, + d(2026, 5, 2), + &[ + (labor, Side::Debit, "100.00"), + (bank, Side::Credit, "33.33"), + (card, Side::Credit, "66.67"), + ], + &[(dana_p, Percent(dec("50"))), (stephen, Percent(dec("50")))], + ); + assert_eq!(dana_paid(&s).0, dec("376.67")); +} + +/// INVARIANT 4, by hand: amounts of 600.00 and 300.00 on a 1,000.00 +/// payment don't resolve (shares SCENARIO 5), so the whole 1,000.00 +/// counts toward Dana and the entry is listed. A single-party entry +/// (300.00, Dana alone) counts in full, as before shares: 1,300.00. +#[test] +fn unresolved_shares_count_in_full_and_are_listed() { + use ledger_core::Share::{Amount, Derived}; + let mut s = shared_books(); + let (bank, labor, dana_p, stephen) = (s.bank, s.labor, s.dana_p, s.stephen); + let e = post_shared( + &mut s, + d(2026, 6, 1), + &[ + (labor, Side::Debit, "1000.00"), + (bank, Side::Credit, "1000.00"), + ], + &[ + (dana_p, Amount(dec("600.00"))), + (stephen, Amount(dec("300.00"))), + ], + ); + post_shared( + &mut s, + d(2026, 6, 2), + &[ + (labor, Side::Debit, "300.00"), + (bank, Side::Credit, "300.00"), + ], + &[(dana_p, Derived)], + ); + assert_eq!(dana_paid(&s), (dec("1300.00"), vec![e])); +} + +/// The SCENARIO 3 payment: $1,000.00 from the bank, Dana 600.00 and +/// Stephen 400.00 by amount. +fn dana_and_stephen(s: &mut Shared) -> ledger_core::EntryId { + use ledger_core::Share::Amount; + let (bank, labor, dana_p, stephen) = (s.bank, s.labor, s.dana_p, s.stephen); + post_shared( + s, + d(2026, 3, 1), + &[ + (labor, Side::Debit, "1000.00"), + (bank, Side::Credit, "1000.00"), + ], + &[ + (dana_p, Amount(dec("600.00"))), + (stephen, Amount(dec("400.00"))), + ], + ) +} + +/// set_entry_vendor SCENARIO 4, by hand: re-tagging Dana's vendor on the +/// shared payment keeps both shares, so her 1099 stays 600.00 (not the +/// whole 1,000.00) and Stephen keeps his part. Tagging a vendor whose +/// party isn't named is refused, changing nothing. +#[test] +fn tagging_the_vendor_keeps_a_shared_entrys_parties() { + let mut s = shared_books(); + let e = dana_and_stephen(&mut s); + let before = s.l.entry_parties(e).to_vec(); + s.l.set_entry_vendor(e, Some(s.dana)).unwrap(); + assert_eq!(s.l.entry_parties(e), before.as_slice()); + assert!(s.l.entry_names_party(e, s.stephen)); + assert_eq!(dana_paid(&s), (dec("600.00"), vec![])); + let other = s.l.add_vendor("Other Co").unwrap(); + let err = s.l.set_entry_vendor(e, Some(other)).unwrap_err(); + assert!(matches!(err, LedgerError::InvalidParty(_)), "{err:?}"); + assert_eq!(s.l.entry_parties(e), before.as_slice()); + assert_eq!(s.l.entry_vendor(e), Some(s.dana)); + // One party or none: the vendor's party becomes the entry's one party. + let (labor, bank, stephen) = (s.labor, s.bank, s.stephen); + let lone = post_shared( + &mut s, + d(2026, 3, 5), + &[(labor, Side::Debit, "50.00"), (bank, Side::Credit, "50.00")], + &[(stephen, ledger_core::Share::Derived)], + ); + s.l.set_entry_vendor(lone, Some(s.dana)).unwrap(); + assert_eq!(s.l.entry_party(lone), Some(s.dana_p)); +} + +/// set_entry_vendor SCENARIO 5: clearing the tag on the shared payment, +/// whose vendor's party (Dana) is named, is refused and changes nothing, +/// so the 1099 (600.00) and Dana's party activity never disagree. +#[test] +fn clearing_the_vendor_on_a_shared_entry_is_refused() { + let mut s = shared_books(); + let e = dana_and_stephen(&mut s); + let err = s.l.set_entry_vendor(e, None).unwrap_err(); + assert!(matches!(err, LedgerError::InvalidParty(_)), "{err:?}"); + assert_eq!(s.l.entry_vendor(e), Some(s.dana)); + assert!(s.l.entry_names_party(e, s.dana_p)); + assert_eq!(dana_paid(&s), (dec("600.00"), vec![])); + // Dropping Dana from the parties clears the tag with her. + s.l.set_entry_parties(e, &[ledger_core::PartyShare::whole(s.stephen)]) + .unwrap(); + assert_eq!(s.l.entry_vendor(e), None); +} + +/// set_entry_parties SCENARIO 3, by hand: the shared payment reversed, +/// then re-apportioned 700.00 / 300.00. The reversal is re-apportioned +/// too, so Dana nets 700.00 − 700.00 = 0.00 (not 700.00 − 600.00 = +/// 100.00). Re-apportioning the reversal itself does the same for the +/// original. +#[test] +fn re_apportioning_a_reversed_entry_re_apportions_its_reversal() { + use ledger_core::Share::Amount; + let mut s = shared_books(); + let e = dana_and_stephen(&mut s); + let rev = s.l.reverse_entry(e, d(2026, 3, 2), "wrong payee").unwrap(); + assert_eq!(s.l.reversal_chain(e), vec![e, rev]); + assert_eq!(s.l.reversal_chain(rev), vec![e, rev]); + let shares = |a: &str, b: &str| { + vec![ + ledger_core::PartyShare { + party: s.dana_p, + share: Amount(dec(a)), + }, + ledger_core::PartyShare { + party: s.stephen, + share: Amount(dec(b)), + }, + ] + }; + let new = shares("700.00", "300.00"); + s.l.set_entry_parties(e, &new).unwrap(); + assert_eq!(s.l.entry_parties(rev), new.as_slice()); + assert_eq!(dana_paid(&s), (dec("0.00"), vec![])); + let back = shares("550.00", "450.00"); + s.l.set_entry_parties(rev, &back).unwrap(); + assert_eq!(s.l.entry_parties(e), back.as_slice()); + assert_eq!(dana_paid(&s), (dec("0.00"), vec![])); + // Re-tagging the vendor on a reversed one-party entry tags both. + let (labor, bank, stephen) = (s.labor, s.bank, s.stephen); + let lone = post_shared( + &mut s, + d(2026, 4, 1), + &[(labor, Side::Debit, "80.00"), (bank, Side::Credit, "80.00")], + &[(stephen, ledger_core::Share::Derived)], + ); + let lone_rev = s.l.reverse_entry(lone, d(2026, 4, 2), "oops").unwrap(); + s.l.set_entry_vendor(lone, Some(s.dana)).unwrap(); + assert_eq!(s.l.entry_vendor(lone_rev), Some(s.dana)); + assert_eq!(dana_paid(&s), (dec("0.00"), vec![])); +} + +/// add_vendor INVARIANTS 2-3: a party named on a shared entry beside +/// Dana (who has a vendor record) can't gain a vendor record, which +/// would leave the entry naming two vendors (shares INVARIANT 5); a party +/// named with no vendor party gains the tag with its new record. +#[test] +fn a_named_party_gaining_a_vendor_record_keeps_one_vendor_per_entry() { + use ledger_core::Share::Percent; + let mut s = shared_books(); + let beta = s.l.add_party("Beta", &[]).unwrap(); + let (bank, labor, dana_p) = (s.bank, s.labor, s.dana_p); + let e = post_shared( + &mut s, + d(2026, 7, 1), + &[ + (labor, Side::Debit, "100.00"), + (bank, Side::Credit, "100.00"), + ], + &[(dana_p, Percent(dec("60"))), (beta, Percent(dec("40")))], + ); + let vendors_before = s.l.list_vendors().len(); + let err = s.l.add_vendor("Beta").unwrap_err(); + assert!(matches!(err, LedgerError::InvalidParty(_)), "{err:?}"); + assert_eq!(s.l.list_vendors().len(), vendors_before, "nothing changed"); + assert_eq!(s.l.get_party(beta).unwrap().vendor, None); + // Re-issuing the same shares is still accepted. + let same = s.l.entry_parties(e).to_vec(); + s.l.set_entry_parties(e, &same).unwrap(); + // Gamma, named only beside Stephen (a member, no vendor record). + let gamma = s.l.add_party("Gamma", &[]).unwrap(); + let stephen = s.stephen; + let g = post_shared( + &mut s, + d(2026, 7, 2), + &[ + (labor, Side::Debit, "100.00"), + (bank, Side::Credit, "100.00"), + ], + &[(stephen, Percent(dec("50"))), (gamma, Percent(dec("50")))], + ); + assert_eq!(s.l.entry_vendor(g), None); + let gv = s.l.add_vendor("Gamma").unwrap(); + assert_eq!(s.l.get_party(gamma).unwrap().vendor, Some(gv)); + assert_eq!(s.l.entry_vendor(g), Some(gv), "the tag follows the party"); +} diff --git a/crates/ledger-core/tests/spec_parties.rs b/crates/ledger-core/tests/spec_parties.rs index 20af308..5cec67f 100644 --- a/crates/ledger-core/tests/spec_parties.rs +++ b/crates/ledger-core/tests/spec_parties.rs @@ -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) -> PayrollLine { // ------------------------------------------------- has_full_tin truth table -/// Oracle: `has_full_tin` doc ("a run of nine digits, with at most one -/// space or hyphen between neighbors") and INVARIANT 1. +/// Oracle: `has_full_tin` doc ("a run of nine digits, with at most three +/// separators (space, hyphen, dot, slash, underscore, a Unicode dash or +/// no-break space) between neighbors") and INVARIANT 1. #[test] fn has_full_tin_truth_table() { let cases: &[(&str, bool)] = &[ @@ -164,11 +166,20 @@ fn has_full_tin_truth_table() { ("1-2-3-4-5-6-7-8-9", true), ("TIN 123456789 on file", true), // inside longer text ("abc123456789def", true), - ("12 3456789", false), // two spaces break the run: 2 + 7 digits - ("12--3456789", false), // two hyphens - ("12 -3456789", false), // space and hyphen between neighbors - ("12\t3456789", false), // a tab is not a space - ("12345678", false), // eight digits + ("12 3456789", true), // two spaces + ("12--3456789", true), // two hyphens + ("12 -3456789", true), // space and hyphen between neighbors + ("638.27.4915", true), // dots + ("wrong one, was 638.27.4915", true), + ("571/93/2846", true), // slashes + ("571_93_2846", true), // underscores + ("Bob 571 - 93 - 2846", true), // three separators + ("571\u{2013}93\u{2013}2846", true), // en dashes + ("571\u{a0}93\u{a0}2846", true), // no-break spaces + ("12 3456789", false), // four separators break the run: 2 + 7 + ("571, 93, 2846", false), // a comma is not a separator + ("12\t3456789", false), // a tab is not a space + ("12345678", false), // eight digits ("123-45-678", false), ("1234", false), ("", false), @@ -181,6 +192,38 @@ fn has_full_tin_truth_table() { } } +/// Oracle: `redact_full_tins` doc: every run `has_full_tin` finds has its +/// digits masked, separators kept; other text is untouched. +#[test] +fn redact_full_tins_masks_every_full_tin() { + assert_eq!( + redact_full_tins("unknown party \"571-93-2846\""), + "unknown party \"•••-••-••••\"" + ); + assert_eq!( + redact_full_tins("was 638.27.4915 and 12-3456789, call 801-555"), + "was •••.••.•••• and ••-•••••••, call 801-555" + ); + assert_eq!( + redact_full_tins("invalid type: integer `571932846`"), + "invalid type: integer `•••••••••`" + ); + assert_eq!(redact_full_tins("party 12 of 3"), "party 12 of 3"); + for t in ["571 - 93 - 2846", "571932846", "1234567890"] { + assert!(!has_full_tin(&redact_full_tins(t)), "{t}"); + } +} + +/// Oracle: `check_text` refuses what `has_full_tin` finds, so a purge +/// reason or note quoting the old number with dots is refused (B-26: no +/// party field may hold a nine-digit number). +#[test] +fn check_text_refuses_a_dotted_tin() { + assert!(check_text("purged", "wrong one, was 638.27.4915").is_err()); + assert!(check_text("no_party_reason", "ssn 571.93.2846").is_err()); + assert!(check_text("name", "Bob 571 - 93 - 2846").is_err()); +} + /// Oracle: INVARIANT 1 says "any run of nine digits"; a run of ten digits /// holds a run of nine. #[test] @@ -1210,3 +1253,571 @@ fn entry_flow_scenarios() { let f = case(&[(exp, D, "100.00"), (bank, C, "60.00"), (card, C, "40.00")]); assert_eq!(f.net, dec("100.00")); } + +// ------------------------------------------------------- party shares +// +// Oracles: the spec block of `ledger_core::shares` (DEFINITIONS, +// INVARIANTS 1–10, SCENARIOS 1–5), `Ledger::set_entry_parties` (INVARIANTS +// 1–4), `parties::activity_with` (INVARIANTS 1–4), and +// docs/parties-and-attachments.md "Shares". Values worked by hand. + +mod shares { + use super::{d, date, refused, usd}; + use ledger_core::parties::{activity_with, money_accounts}; + use ledger_core::shares::{check_shares, resolve}; + use ledger_core::{ + AccountId, AccountType, Currency, EntryId, Ledger, PartyId, PartyMember, PartyShare, Role, + Share, Side, + }; + use proptest::prelude::*; + use rust_decimal::Decimal; + + struct Books { + l: Ledger, + bank: AccountId, + due: AccountId, + cap_s: AccountId, + cap_k: AccountId, + equip: AccountId, + exp: AccountId, + stephen: PartyId, + kat: PartyId, + acme: PartyId, + members: Vec, + } + + /// Bluevine 1000, Due to Shareholders 2100, capital 3000 and 3001, + /// equipment 1500, an expense 6050; members Stephen and Katherine at + /// 50/50; a party Acme (no records). + fn books() -> Books { + let mut l = Ledger::new(Currency::USD); + let add = |l: &mut Ledger, c: &str, t| l.add_account(c, c, t, None, false).unwrap(); + let bank = add(&mut l, "1000", AccountType::Asset); + let equip = add(&mut l, "1500", AccountType::Asset); + let due = add(&mut l, "2100", AccountType::Liability); + let cap_s = add(&mut l, "3000", AccountType::Equity); + let cap_k = add(&mut l, "3001", AccountType::Equity); + let exp = add(&mut l, "6050", AccountType::Expense); + let stephen = l.add_party("Stephen Waits", &[Role::Member]).unwrap(); + let kat = l + .add_party("Katherine Pendergast", &[Role::Member]) + .unwrap(); + let acme = l.add_party("Acme", &[Role::Contractor]).unwrap(); + let half = d("0.5"); + let members = vec![ + PartyMember { + party: stephen, + share: half, + capital: cap_s, + distributions: None, + }, + PartyMember { + party: kat, + share: half, + capital: cap_k, + distributions: None, + }, + ]; + Books { + l, + bank, + due, + cap_s, + cap_k, + equip, + exp, + stephen, + kat, + acme, + members, + } + } + + fn post(l: &mut Ledger, lines: &[(AccountId, Side, &str)]) -> EntryId { + let e = l.try_create_entry(date(2026, 5, 1), "joint").unwrap(); + for (a, s, v) in lines { + l.add_line(e, *a, *s, usd(v)).unwrap(); + } + l.post_entry(e).unwrap(); + e + } + + fn derived(ps: &[PartyId]) -> Vec { + ps.iter().map(|&p| PartyShare::whole(p)).collect() + } + + fn amounts(ps: &[(PartyId, &str)]) -> Vec { + ps.iter() + .map(|&(party, v)| PartyShare { + party, + share: Share::Amount(d(v)), + }) + .collect() + } + + fn percents(ps: &[(PartyId, &str)]) -> Vec { + ps.iter() + .map(|&(party, v)| PartyShare { + party, + share: Share::Percent(d(v)), + }) + .collect() + } + + fn resolved(b: &Books, e: EntryId) -> Result, String> { + let money = money_accounts(&b.l, &[b.bank]); + resolve( + b.l.get_entry(e).unwrap(), + b.l.entry_parties(e), + &money, + &b.members, + ) + } + + fn year() -> (chrono::NaiveDate, chrono::NaiveDate) { + (date(2026, 1, 1), date(2026, 12, 31)) + } + + fn paid_received(b: &Books, p: PartyId) -> (Decimal, Decimal) { + let a = activity_with(&b.l, p, year(), &[b.bank], &b.members); + (a.paid, a.received) + } + + /// SCENARIO 1: a reimbursement to both members, derived, 50/50. + #[test] + fn reimbursing_both_members_splits_by_member_share() { + let mut b = books(); + let e = post( + &mut b.l, + &[ + (b.due, Side::Debit, "100.00"), + (b.bank, Side::Credit, "100.00"), + ], + ); + b.l.set_entry_parties(e, &derived(&[b.stephen, b.kat])) + .unwrap(); + assert_eq!( + resolved(&b, e).unwrap(), + vec![(b.stephen, d("50.00")), (b.kat, d("50.00"))] + ); + assert_eq!(b.l.entry_party(e), None, "two parties: no sole party"); + assert!(b.l.entry_names_party(e, b.kat)); + assert_eq!(paid_received(&b, b.stephen), (d("50.00"), Decimal::ZERO)); + assert_eq!(paid_received(&b, b.kat), (d("50.00"), Decimal::ZERO)); + } + + /// SCENARIO 2: a joint contribution derives from the capital lines. + #[test] + fn joint_contribution_derives_from_capital_lines() { + let mut b = books(); + let e = post( + &mut b.l, + &[ + (b.bank, Side::Debit, "300.00"), + (b.cap_s, Side::Credit, "200.00"), + (b.cap_k, Side::Credit, "100.00"), + ], + ); + b.l.set_entry_parties(e, &derived(&[b.stephen, b.kat])) + .unwrap(); + assert_eq!( + resolved(&b, e).unwrap(), + vec![(b.stephen, d("-200.00")), (b.kat, d("-100.00"))] + ); + assert_eq!(paid_received(&b, b.stephen), (Decimal::ZERO, d("200.00"))); + assert_eq!(paid_received(&b, b.kat), (Decimal::ZERO, d("100.00"))); + } + + /// SCENARIO 3: property contributed moves no money; the shares are of + /// the total, and activity counts nothing. + #[test] + fn property_contribution_resolves_against_the_total() { + let mut b = books(); + let e = post( + &mut b.l, + &[ + (b.equip, Side::Debit, "1000.00"), + (b.cap_s, Side::Credit, "500.00"), + (b.cap_k, Side::Credit, "500.00"), + ], + ); + b.l.set_entry_parties(e, &derived(&[b.stephen, b.kat])) + .unwrap(); + assert_eq!( + resolved(&b, e).unwrap(), + vec![(b.stephen, d("500.00")), (b.kat, d("500.00"))] + ); + assert_eq!(paid_received(&b, b.stephen), (Decimal::ZERO, Decimal::ZERO)); + } + + /// Capital lines that don't account for the whole flow are refused. + #[test] + fn capital_lines_short_of_the_flow_are_refused() { + let mut b = books(); + let e = post( + &mut b.l, + &[ + (b.bank, Side::Debit, "300.00"), + (b.cap_s, Side::Credit, "200.00"), + (b.exp, Side::Credit, "100.00"), + ], + ); + b.l.set_entry_parties(e, &derived(&[b.stephen, b.kat])) + .unwrap(); + assert!(resolved(&b, e).is_err()); + let a = activity_with(&b.l, b.stephen, year(), &[b.bank], &b.members); + assert!(a.unresolved.contains(&e)); + assert_eq!((a.paid, a.received), (Decimal::ZERO, Decimal::ZERO)); + } + + /// SCENARIO 4: percent rounding, remainder to the first party. + #[test] + fn percent_rounding_gives_the_remainder_to_the_first() { + let mut b = books(); + let c = b.l.add_party("Third", &[]).unwrap(); + let e = post( + &mut b.l, + &[ + (b.exp, Side::Debit, "100.00"), + (b.bank, Side::Credit, "100.00"), + ], + ); + b.l.set_entry_parties( + e, + &percents(&[(b.acme, "33.33"), (b.stephen, "33.33"), (c, "33.34")]), + ) + .unwrap(); + assert_eq!( + resolved(&b, e).unwrap(), + vec![ + (b.acme, d("33.33")), + (b.stephen, d("33.33")), + (c, d("33.34")) + ] + ); + b.l.set_entry_parties( + e, + &percents(&[(b.acme, "33.333"), (b.stephen, "33.333"), (c, "33.334")]), + ) + .unwrap(); + assert_eq!( + resolved(&b, e).unwrap(), + vec![ + (b.acme, d("33.34")), + (b.stephen, d("33.33")), + (c, d("33.33")) + ] + ); + } + + /// SCENARIO 5 and INVARIANT 7: amounts must sum to what moved. + #[test] + fn amounts_must_sum_to_the_flow() { + let mut b = books(); + let e = post( + &mut b.l, + &[ + (b.exp, Side::Debit, "100.00"), + (b.bank, Side::Credit, "100.00"), + ], + ); + b.l.set_entry_parties(e, &amounts(&[(b.stephen, "60.00"), (b.kat, "30.00")])) + .unwrap(); + assert!(resolved(&b, e).is_err()); + b.l.set_entry_parties(e, &amounts(&[(b.stephen, "70.00"), (b.kat, "30.00")])) + .unwrap(); + assert_eq!( + resolved(&b, e).unwrap(), + vec![(b.stephen, d("70.00")), (b.kat, d("30.00"))] + ); + assert_eq!(paid_received(&b, b.stephen), (d("70.00"), Decimal::ZERO)); + } + + /// INVARIANT 10: several derived shares need members. + #[test] + fn derived_needs_members_for_several_parties() { + let mut b = books(); + let e = post( + &mut b.l, + &[ + (b.exp, Side::Debit, "10.00"), + (b.bank, Side::Credit, "10.00"), + ], + ); + b.l.set_entry_parties(e, &derived(&[b.stephen, b.acme])) + .unwrap(); + assert!(resolved(&b, e).is_err()); + // With no members passed (plain activity), two members can't derive. + b.l.set_entry_parties(e, &derived(&[b.stephen, b.kat])) + .unwrap(); + let money = money_accounts(&b.l, &[b.bank]); + assert!(resolve(b.l.get_entry(e).unwrap(), b.l.entry_parties(e), &money, &[]).is_err()); + // A sole party needs no members. + b.l.set_entry_parties(e, &derived(&[b.acme])).unwrap(); + assert_eq!(resolved(&b, e).unwrap(), vec![(b.acme, d("10.00"))]); + } + + /// INVARIANT 10: income shares rescale to the members named. + #[test] + fn member_shares_rescale_to_those_named() { + let mut b = books(); + let third = b.l.add_party("Third Member", &[Role::Member]).unwrap(); + let cap_t = + b.l.add_account("3002", "Capital T", AccountType::Equity, None, false) + .unwrap(); + b.members[0].share = d("0.5"); + b.members[1].share = d("0.3"); + b.members.push(PartyMember { + party: third, + share: d("0.2"), + capital: cap_t, + distributions: None, + }); + let e = post( + &mut b.l, + &[ + (b.due, Side::Debit, "80.00"), + (b.bank, Side::Credit, "80.00"), + ], + ); + b.l.set_entry_parties(e, &derived(&[b.stephen, b.kat])) + .unwrap(); + assert_eq!( + resolved(&b, e).unwrap(), + vec![(b.stephen, d("50.00")), (b.kat, d("30.00"))] + ); + } + + /// INVARIANTS 1–5 of shares and set_entry_parties 1: refusals change + /// nothing. + #[test] + fn bad_shares_are_refused_changing_nothing() { + let mut b = books(); + let e = post( + &mut b.l, + &[ + (b.exp, Side::Debit, "10.00"), + (b.bank, Side::Credit, "10.00"), + ], + ); + let (s, k) = (b.stephen, b.kat); + refused(&mut b.l, "duplicate", |l| { + l.set_entry_parties(e, &derived(&[s, s])) + }); + refused(&mut b.l, "unknown party", |l| { + l.set_entry_parties(e, &derived(&[s, 999])) + }); + refused(&mut b.l, "unknown entry", |l| { + l.set_entry_parties(999, &derived(&[s])) + }); + refused(&mut b.l, "mixed kinds", |l| { + l.set_entry_parties( + e, + &[ + PartyShare { + party: s, + share: Share::Amount(d("5.00")), + }, + PartyShare::whole(k), + ], + ) + }); + refused(&mut b.l, "percents sum to 99", |l| { + l.set_entry_parties(e, &percents(&[(s, "50"), (k, "49")])) + }); + refused(&mut b.l, "percent over 100", |l| { + l.set_entry_parties(e, &percents(&[(s, "101"), (k, "-1")])) + }); + refused(&mut b.l, "finer than a cent", |l| { + l.set_entry_parties(e, &amounts(&[(s, "5.005"), (k, "4.995")])) + }); + refused(&mut b.l, "zero amount", |l| { + l.set_entry_parties(e, &amounts(&[(s, "10.00"), (k, "0")])) + }); + let v1 = b.l.add_vendor("Vendor One").unwrap(); + let v2 = b.l.add_vendor("Vendor Two").unwrap(); + let (p1, p2) = ( + b.l.party_of_vendor(v1).unwrap(), + b.l.party_of_vendor(v2).unwrap(), + ); + refused(&mut b.l, "two vendors", |l| { + l.set_entry_parties(e, &amounts(&[(p1, "5.00"), (p2, "5.00")])) + }); + // The checker agrees on its own. + assert!(check_shares(&percents(&[(s, "100")]), |_| true).is_ok()); + assert!(check_shares(&percents(&[(s, "60"), (k, "40.0")]), |_| true).is_ok()); + } + + /// set_entry_parties INVARIANT 2: the vendor tag follows the one party + /// with a vendor record; clearing clears it. + #[test] + fn vendor_tag_follows_the_one_vendor_party() { + let mut b = books(); + let e = post( + &mut b.l, + &[ + (b.exp, Side::Debit, "10.00"), + (b.bank, Side::Credit, "10.00"), + ], + ); + let v = b.l.add_vendor("Vendor One").unwrap(); + let pv = b.l.party_of_vendor(v).unwrap(); + b.l.set_entry_parties(e, &amounts(&[(pv, "6.00"), (b.stephen, "4.00")])) + .unwrap(); + assert_eq!(b.l.entry_vendor(e), Some(v)); + b.l.set_entry_parties(e, &derived(&[b.stephen, b.kat])) + .unwrap(); + assert_eq!(b.l.entry_vendor(e), None); + b.l.set_entry_parties(e, &[]).unwrap(); + assert!(b.l.entry_parties(e).is_empty()); + } + + /// Single-party tagging is one derived share; INVARIANT 3: naming a + /// party clears the no-party mark, and the mark is refused while + /// parties are named. + #[test] + fn single_party_and_no_party_mark() { + let mut b = books(); + let e = post( + &mut b.l, + &[ + (b.exp, Side::Debit, "10.00"), + (b.bank, Side::Credit, "10.00"), + ], + ); + b.l.set_entry_party(e, Some(b.acme)).unwrap(); + assert_eq!(b.l.entry_parties(e), &[PartyShare::whole(b.acme)]); + assert_eq!(b.l.entry_party(e), Some(b.acme)); + b.l.set_entry_party(e, None).unwrap(); + b.l.set_entry_no_party(e, "own transfer").unwrap(); + b.l.set_entry_parties(e, &derived(&[b.stephen, b.kat])) + .unwrap(); + assert_eq!(b.l.entry_no_party(e), None); + refused(&mut b.l, "no-party while named", |l| { + l.set_entry_no_party(e, "none") + }); + } + + /// Reversals carry every share; the pair nets to zero for each party. + #[test] + fn reversal_carries_all_shares() { + let mut b = books(); + let e = post( + &mut b.l, + &[ + (b.exp, Side::Debit, "90.00"), + (b.bank, Side::Credit, "90.00"), + ], + ); + let shares = amounts(&[(b.stephen, "60.00"), (b.kat, "30.00")]); + b.l.set_entry_parties(e, &shares).unwrap(); + let r = b.l.reverse_entry(e, date(2026, 5, 2), "undo").unwrap(); + assert_eq!(b.l.entry_parties(r), shares.as_slice()); + assert_eq!( + resolved(&b, r).unwrap(), + vec![(b.stephen, d("-60.00")), (b.kat, d("-30.00"))] + ); + assert_eq!(paid_received(&b, b.stephen), (Decimal::ZERO, Decimal::ZERO)); + assert_eq!(paid_received(&b, b.kat), (Decimal::ZERO, Decimal::ZERO)); + } + + /// Plain `activity` is activity_with no members: a sole party still + /// counts the whole net. + #[test] + fn activity_counts_a_sole_party_in_full() { + let mut b = books(); + let e = post( + &mut b.l, + &[ + (b.exp, Side::Debit, "12.34"), + (b.bank, Side::Credit, "12.34"), + ], + ); + b.l.set_entry_party(e, Some(b.acme)).unwrap(); + let a = ledger_core::parties::activity(&b.l, b.acme, year(), &[b.bank]); + assert_eq!(a.paid, d("12.34")); + assert!(a.entries.contains(&e)); + } + + fn cents() -> impl Strategy { + (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::(), + 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 = 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 = 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::(), target); + prop_assert!(r.iter().all(|(_, v)| v.is_zero() || v.is_sign_negative() == refund)); + prop_assert!(r.iter().all(|(_, v)| v.normalize().scale() <= 2)); + // Member-derived with uneven income shares. + let s = Decimal::new(i64::from(member_share), 3); + b.members[0].share = s; + b.members[1].share = Decimal::ONE - s; + b.l.set_entry_parties(e, &derived(&[b.stephen, b.kat])).unwrap(); + let r = resolved(&b, e).unwrap(); + prop_assert_eq!(r.iter().map(|(_, v)| *v).sum::(), target); + // Amounts that sum: split at the first party's resolved part. + let first = r[0].1.abs(); + let rest = total - first; + if !first.is_zero() && !rest.is_zero() { + b.l.set_entry_parties(e, &[ + PartyShare { party: b.stephen, share: Share::Amount(first) }, + PartyShare { party: b.kat, share: Share::Amount(rest) }, + ]).unwrap(); + let r = resolved(&b, e).unwrap(); + prop_assert_eq!(r.iter().map(|(_, v)| *v).sum::(), target); + } + } + + /// Capital-line derivation: two members contribute any amounts in + /// one entry; each gets their own line, summing to the flow. + #[test] + fn capital_line_shares_sum_exactly(a in cents(), c in cents()) { + let mut b = books(); + let total = a + c; + let e = post(&mut b.l, &[ + (b.bank, Side::Debit, &total.to_string()), + (b.cap_s, Side::Credit, &a.to_string()), + (b.cap_k, Side::Credit, &c.to_string()), + ]); + b.l.set_entry_parties(e, &derived(&[b.stephen, b.kat])).unwrap(); + let r = resolved(&b, e).unwrap(); + prop_assert_eq!(r, vec![(b.stephen, -a), (b.kat, -c)]); + let sa = activity_with(&b.l, b.stephen, year(), &[b.bank], &b.members); + let sk = activity_with(&b.l, b.kat, year(), &[b.bank], &b.members); + prop_assert_eq!(sa.received + sk.received, total); + } + } +} diff --git a/crates/ledger-gui/src/attention.rs b/crates/ledger-gui/src/attention.rs index ce2bb07..9ee8339 100644 --- a/crates/ledger-gui/src/attention.rs +++ b/crates/ledger-gui/src/attention.rs @@ -399,7 +399,7 @@ pub fn find(b: &Books) -> Vec { 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") }) diff --git a/crates/ledger-gui/src/books.rs b/crates/ledger-gui/src/books.rs index ae6427b..df77fff 100644 --- a/crates/ledger-gui/src/books.rs +++ b/crates/ledger-gui/src/books.rs @@ -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, - /// 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, + /// Every party the entry names, in order, each with its share. + pub parties: Vec, /// 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, +} + /// 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, pub entry_index: HashMap, pub cash_accounts: Vec, + /// 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, /// `[books] financing_accounts`, resolved (empty when not set). pub financing_accounts: Vec, + /// 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, /// `[books] investing_accounts`, resolved (empty when not set). pub investing_accounts: Vec, pub card_accounts: Vec, @@ -249,16 +290,20 @@ pub enum PartyPeriod { /// ```text /// GOAL: answer "how much did we spend with them, and how much did we /// get back?" (Stephen, 2026-10-15) with the books' own numbers. -/// SOURCE: ledger_core::parties::activity (paid, received) and +/// SOURCE: ledger_core::parties::activity_with (paid, received) and /// ledger_core::parties::entry_flow (what for), with -/// Books::cash_accounts, as PartyInfo uses them. +/// Books::cash_accounts and Books::members, as PartyInfo uses +/// them. /// INVARIANTS: -/// 1. paid, received: activity(ledger, party, span, cash accounts)'s, -/// exactly. +/// 1. paid, received: Books::activity(party, span)'s, exactly. /// 2. net() = paid − received. /// 3. what_for: over the party's posted entries dated in the span, the /// sum by account of each entry_flow's what_for (a liability paid /// down, like a reimbursement, appears as what the money paid for), +/// for an entry naming several parties this party's part of it: a +/// member's own capital and distributions lines when they come to +/// its resolved share exactly, else the what_for apportioned to the +/// share (share_of_what_for; nothing when the shares don't resolve), /// the accounts whose sum is zero left out, largest magnitude first /// (then by account id). Positive: what money paid for; negative: /// where money received came from (or, on payables and @@ -283,6 +328,102 @@ impl PartyFlow { } } +/// What moved a member's capital account in a span, by kind +/// ([`Books::capital_statement`]). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum CapitalKind { + /// Cash put in, or paid for the company (ledger_core::k1's cash part). + CashContribution, + /// Property put in, at the value booked: fair market value + /// (ledger_core::k1's property part). + PropertyContribution, + /// A period close allocating their share of net income (or loss). + IncomeShare, + /// Taken out. + Distribution, +} + +/// One entry's effect on a member's capital, split by kind. +#[derive(Clone, Debug, PartialEq)] +pub struct CapitalLine { + pub entry: EntryId, + pub date: NaiveDate, + pub description: String, + pub kind: CapitalKind, + /// Signed: positive raises their capital, negative lowers it. + pub amount: Decimal, +} + +/// A member's capital account statement over a span: the partner capital +/// statement, read from ledger_core::k1 (the CPA package's K-1 worksheet). +/// +/// ```text +/// GOAL: a member's party page answers "what is my stake?" as a partner +/// capital statement does (Stephen, 2026-10-15), with the same +/// numbers the CPA's K-1 worksheet carries: one source of truth. +/// SOURCE: ledger_core::k1::k1_detail (members_equity's roll-forward, +/// split into cash and property); ledger_config::Company::members +/// ([[company.members]], all of them, in order, as the CPA +/// package builds its MemberCapital list); Books::k1_cash_accounts; +/// Books::financing_accounts; DECISION (the due-to accounts, and +/// the cash fallback, below). +/// DEFINITIONS: +/// row: k1_detail(span, members, k1_cash_accounts).rows[i], i this +/// member's position in [[company.members]] (matched by capital +/// account). +/// INVARIANTS: +/// 1. opening = row.beginning; contributions_cash = +/// row.contributions_cash; contributions_property = +/// row.contributions_property; income_share = row.net_income; +/// distributions_taken = row.distributions; other = row.other; +/// closing = row.ending. Opening and closing include the member's +/// share of earnings not yet closed into capital (members_equity +/// INVARIANT 3), and income_share is their share of the span's net +/// income whether or not a period close has moved it yet. +/// 2. closing = opening + contributions_cash + contributions_property + +/// income_share − distributions_taken + other, exactly. +/// 3. lines: k1_detail's lines for this member. Σ CashContribution = +/// contributions_cash, Σ PropertyContribution = +/// contributions_property, −Σ Distribution = distributions_taken; +/// IncomeShare lines are the closes in the span (they need not sum to +/// income_share: income not yet closed has no entry on capital). +/// 4. share: the member's [[company.members]] share (a fraction). +/// 5. due_to_members: the balance at span.1 of the liabilities the +/// members advance: [books] financing_accounts (Books:: +/// financing_accounts, its liability accounts) when set; only when it +/// is empty, the liability accounts whose name says "due to" or +/// "owed to" (DECISION: a name is a guess; the setting is the books' +/// word). owed_to_member: this member's part of it by the members' +/// shares (ledger_core::periods::allocate over every member, so the +/// parts sum to the balance). +/// 6. None when the party isn't a member, a [[company.members]] +/// account isn't in the chart, or k1_detail refuses (shares not +/// summing to 1, accounts not distinct equity accounts). +/// ``` +#[derive(Clone, Debug, PartialEq)] +pub struct CapitalStatement { + pub party: PartyId, + pub capital: AccountId, + pub distributions: Option, + 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, + 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, +} + /// 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 { 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 = 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 = 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 = 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::>() + .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 = 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::() == 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 { + 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 = 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 = 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 = 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> { + 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::(); + 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 { diff --git a/crates/ledger-gui/src/pages/capital.rs b/crates/ledger-gui/src/pages/capital.rs new file mode 100644 index 0000000..93672be --- /dev/null +++ b/crates/ledger-gui/src/pages/capital.rs @@ -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, + total: Option, + 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, +) { + 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)); + } +} diff --git a/crates/ledger-gui/src/pages/entry.rs b/crates/ledger-gui/src/pages/entry.rs index 866ad96..c01e6c4 100644 --- a/crates/ledger-gui/src/pages/entry.rs +++ b/crates/ledger-gui/src/pages/entry.rs @@ -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(), + }, + } +} diff --git a/crates/ledger-gui/src/pages/journal.rs b/crates/ledger-gui/src/pages/journal.rs index 12df6cc..4993387 100644 --- a/crates/ledger-gui/src/pages/journal.rs +++ b/crates/ledger-gui/src/pages/journal.rs @@ -473,6 +473,30 @@ pub fn describe(b: &Books, line: &JournalLine) -> Option { }; 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 { }; 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::>() + .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); diff --git a/crates/ledger-gui/src/pages/mod.rs b/crates/ledger-gui/src/pages/mod.rs index 3ed83f1..39dd76e 100644 --- a/crates/ledger-gui/src/pages/mod.rs +++ b/crates/ledger-gui/src/pages/mod.rs @@ -2,6 +2,7 @@ pub mod accounts; pub mod assets; +pub mod capital; pub mod cash; pub mod company; pub mod documents; diff --git a/crates/ledger-gui/src/pages/parties.rs b/crates/ledger-gui/src/pages/parties.rs index 513cf3f..e8d68ca 100644 --- a/crates/ledger-gui/src/pages/parties.rs +++ b/crates/ledger-gui/src/pages/parties.rs @@ -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) { &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 let text = if draft { p.muted } else { p.text }; let accounts: Vec = 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 (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, diff --git a/crates/ledger-gui/tests/parties.rs b/crates/ledger-gui/tests/parties.rs index 7e02c7a..269ffef 100644 --- a/crates/ledger-gui/tests/parties.rs +++ b/crates/ledger-gui/tests/parties.rs @@ -8,7 +8,7 @@ use egui_kittest::Harness; use ledger_core::arap::DocumentLine; use ledger_core::{ AccountType, Currency, EntryId, Form1099, Money, PartyField, PartyId, PayrollLine, Role, Side, - W4Facts, W9Facts, vendor_1099_report, + TinFacts, TinType, W4Facts, W9Facts, vendor_1099_report, }; use ledger_gui::books::Books; use ledger_gui::nav::{Route, Section}; @@ -577,6 +577,56 @@ fn w9_text_never_shows_more_than_the_last_four() { ); } +/// pages/journal.rs `party.tin_set`: recorded, purged, and cleared, each +/// in plain language with the TIN's type and last four, never more of it. +#[test] +fn tin_text_never_shows_more_than_the_last_four() { + let f = fixture("tintext"); + let mut s = Store::open(&f.dir.0.join("books-data")).unwrap(); + let facts = |tin_type, purged: Option<&str>| TinFacts { + tin_type, + last4: "4915".into(), + vault_record: purged.is_none().then(|| "ab".repeat(32)), + purged: purged.map(str::to_string), + }; + s.set_party_tin(f.pat, Some(facts(TinType::Ssn, None))) + .unwrap(); + s.set_party_tin( + f.pat, + Some(facts( + TinType::Ssn, + Some("purged from the wanderbooks vault on 2026-10-15: retention over"), + )), + ) + .unwrap(); + s.set_party_tin(f.pat, None).unwrap(); + s.set_party_tin(f.pat, Some(facts(TinType::Itin, None))) + .unwrap(); + drop(s); + let b = load(&f.dir.0); + let events: Vec<_> = b + .journal + .iter() + .filter(|l| l.line_type == "party.tin_set") + .filter_map(|l| describe(&b, l)) + .collect(); + let texts: Vec<&str> = events.iter().map(|e| e.text.as_str()).collect(); + assert_eq!( + texts, + vec![ + "Taxpayer ID for Pat Lee recorded: SSN ending 4915, kept in the vault", + "Purged Pat Lee's SSN (••••4915) from the vault", + "Taxpayer ID facts for Pat Lee cleared", + "Taxpayer ID for Pat Lee recorded: ITIN ending 4915, kept in the vault", + ] + ); + assert!(events.iter().all(|e| e.route == Some(Route::Party(f.pat)))); + // No more than four digits in any of them. + for t in &texts { + assert!(t.chars().filter(|c| c.is_ascii_digit()).count() <= 4, "{t}"); + } +} + #[test] fn attention_asks_for_a_w9_until_its_facts_are_recorded() { let f = fixture("attention"); @@ -914,3 +964,510 @@ fn entries_without_a_counterparty_need_attention_unless_off() { let (dir, _s, ..) = counterparty_books("np-off", Some("off")); assert!(item(&load_with_toml(&dir.0)).is_none()); } + +// ------------------------------------------------------- party shares and the capital statement +// +// Oracles: EntryParty (resolved shares), PartyFlow (each party's share), +// CapitalStatement (INVARIANTS 1–6), ledger_core::k1, docs/parties-and-attachments.md +// "Shares" and "A member's capital statement". Values worked by hand. + +struct MemberBooks { + dir: TempDir, + stephen: PartyId, + kat: PartyId, + reimburse: EntryId, + property: EntryId, + distribution: EntryId, +} + +/// Two 50/50 members. 2025: a joint cash contribution of $1,000.00 each. +/// 2026: equipment contributed ($600.00 each, Feb), an expense paid from +/// joint funds ($300.00 on 2100, Mar), revenue $1,000.00 (Apr), $100.00 +/// of 2100 reimbursed to both (May), $200.00 distributed to each (Jun), +/// and Jan–Jun closed to the members ($700.00 net: $350.00 each). +fn member_books(tag: &str) -> MemberBooks { + let dir = TempDir::new(tag); + let mut s = Store::create(&dir.0.join("books-data"), Currency::USD).unwrap(); + let acct = |s: &mut Store, c: &str, n: &str, t| s.add_account(c, n, t, None, false).unwrap(); + let bank = acct(&mut s, "1000", "Bluevine", AccountType::Asset); + let equip = acct(&mut s, "1500", "Equipment", AccountType::Asset); + let due = acct( + &mut s, + "2100", + "Due to Shareholders", + AccountType::Liability, + ); + let cap_s = acct( + &mut s, + "3000", + "Owner Capital — Stephen Waits", + AccountType::Equity, + ); + let cap_k = acct( + &mut s, + "3001", + "Owner Capital — Katherine Pendergast", + AccountType::Equity, + ); + let rev = acct(&mut s, "4000", "App Revenue", AccountType::Revenue); + let exp = acct(&mut s, "6050", "Domain & Hosting", AccountType::Expense); + let stephen = s.add_party("Stephen Waits", &[Role::Member]).unwrap(); + let kat = s + .add_party("Katherine Pendergast", &[Role::Member]) + .unwrap(); + let both = [ + ledger_core::PartyShare::whole(stephen), + ledger_core::PartyShare::whole(kat), + ]; + let post = |s: &mut Store, date, what: &str, lines: &[(u64, Side, i64)]| { + let e = s.create_entry(date, what).unwrap(); + for &(a, side, c) in lines { + s.add_line(e, a, side, usd(c)).unwrap(); + } + s.post_entry(e).unwrap(); + e + }; + let open = post( + &mut s, + d(2025, 6, 1), + "Opening contribution", + &[ + (bank, Side::Debit, 200_000), + (cap_s, Side::Credit, 100_000), + (cap_k, Side::Credit, 100_000), + ], + ); + s.set_entry_parties(open, &both).unwrap(); + let property = post( + &mut s, + d(2026, 2, 1), + "Laptop contributed", + &[ + (equip, Side::Debit, 120_000), + (cap_s, Side::Credit, 60_000), + (cap_k, Side::Credit, 60_000), + ], + ); + s.set_entry_parties(property, &both).unwrap(); + post( + &mut s, + d(2026, 3, 1), + "Hosting from joint funds", + &[(exp, Side::Debit, 30_000), (due, Side::Credit, 30_000)], + ); + post( + &mut s, + d(2026, 4, 1), + "Sales", + &[(bank, Side::Debit, 100_000), (rev, Side::Credit, 100_000)], + ); + let reimburse = post( + &mut s, + d(2026, 5, 1), + "Reimburse the household", + &[(due, Side::Debit, 10_000), (bank, Side::Credit, 10_000)], + ); + s.set_entry_parties(reimburse, &both).unwrap(); + let distribution = post( + &mut s, + d(2026, 6, 1), + "Distribution", + &[ + (cap_s, Side::Debit, 20_000), + (cap_k, Side::Debit, 20_000), + (bank, Side::Credit, 40_000), + ], + ); + s.set_entry_parties(distribution, &both).unwrap(); + let period = s + .create_period("H1 2026", d(2026, 1, 1), d(2026, 6, 30)) + .unwrap(); + let half = Decimal::new(5, 1); + s.close_period_to( + period, + &ledger_core::periods::CloseTo::Members(vec![ + ledger_core::MemberClose { + capital: cap_s, + share: half, + distributions: None, + }, + ledger_core::MemberClose { + capital: cap_k, + share: half, + distributions: None, + }, + ]), + ) + .unwrap(); + write_member_toml(&dir.0, "cash_accounts = [\"1000\"]\n"); + MemberBooks { + dir, + stephen, + kat, + reimburse, + property, + distribution, + } +} + +/// The fixture's wanderbooks.toml, with `books` as its `[books]` table. +fn write_member_toml(dir: &Path, books: &str) { + std::fs::write( + dir.join("wanderbooks.toml"), + format!( + "[books]\n{books}[company]\nname = \"Wanderland Labs LLC\"\n\ + [[company.members]]\nname = \"Stephen Waits\"\nshare = \"50%\"\ncapital_account = \"3000\"\n\ + [[company.members]]\nname = \"Katherine Pendergast\"\nshare = \"50%\"\ncapital_account = \"3001\"\n" + ), + ) + .unwrap(); +} + +/// Post one entry to the fixture's books after the fact. +fn post_more(m: &MemberBooks, date: NaiveDate, lines: &[(&str, Side, i64)]) -> EntryId { + let mut s = Store::open(&m.dir.0.join("books-data")).unwrap(); + let e = s.create_entry(date, "Later").unwrap(); + for &(code, side, c) in lines { + let a = s.ledger().chart().find_by_code(code).unwrap().id; + s.add_line(e, a, side, usd(c)).unwrap(); + } + s.post_entry(e).unwrap(); + e +} + +#[test] +fn entries_list_every_party_with_its_resolved_share() { + let m = member_books("shares-entries"); + let b = load_with_toml(&m.dir.0); + let e = b.entry(m.reimburse).unwrap(); + assert_eq!(e.party_id, None, "two parties: no sole party"); + assert_eq!( + e.parties + .iter() + .map(|p| (p.id, p.resolved)) + .collect::>(), + vec![(m.stephen, Some(cents(5_000))), (m.kat, Some(cents(5_000)))] + ); + assert_eq!( + e.party.as_deref(), + Some("Stephen Waits & Katherine Pendergast") + ); + // Property: net zero, shares of the total. + let e = b.entry(m.property).unwrap(); + assert_eq!(e.parties[1].resolved, Some(cents(60_000))); + // The journal says who, for parties_set. + let line = b + .journal + .iter() + .find(|l| l.line_type == "entry.parties_set") + .unwrap(); + let ev = describe(&b, line).unwrap(); + assert!( + ev.text.contains("Stephen Waits and Katherine Pendergast"), + "{}", + ev.text + ); +} + +#[test] +fn a_members_money_is_their_share() { + let m = member_books("shares-flow"); + let b = load_with_toml(&m.dir.0); + let span = (d(2026, 1, 1), today()); + for who in [m.stephen, m.kat] { + let f = b.party_flow(who, span, true); + // Half the reimbursement plus their own distribution; the + // property moved no money. + assert_eq!(f.paid, cents(25_000)); + assert_eq!(f.received, Decimal::ZERO); + let what: Decimal = f.what_for.iter().map(|(_, v)| *v).sum(); + assert_eq!(what, f.net(), "what for sums to the net"); + assert!(b.party(who).unwrap().entries.contains(&m.distribution)); + } + assert_eq!(b.party(m.kat).unwrap().paid_ytd, cents(25_000)); +} + +#[test] +fn a_members_page_is_their_capital_statement() { + let m = member_books("capital"); + let b = load_with_toml(&m.dir.0); + let st = b + .capital_statement(m.stephen, (d(2026, 1, 1), today())) + .expect("a member"); + assert_eq!(st.share, Decimal::new(5, 1)); + assert_eq!(st.opening, cents(100_000)); + assert_eq!(st.contributions_cash, Decimal::ZERO); + assert_eq!(st.contributions_property, cents(60_000)); + assert_eq!(st.income_share, cents(35_000)); + assert_eq!(st.distributions_taken, cents(20_000)); + assert_eq!(st.closing, cents(175_000)); + assert_eq!( + st.closing, + st.opening + st.contributions_cash + st.contributions_property + st.income_share + - st.distributions_taken + ); + assert_eq!(st.other, Decimal::ZERO); + // No [books] financing_accounts: 2100's name says "due to"; $300.00 + // advanced less $100.00 reimbursed, half each. + assert_eq!(st.due_to_members, cents(20_000)); + assert_eq!(st.owed_to_member, cents(10_000)); + // The lines behind it (k1_detail INVARIANT 5), in journal order. + use ledger_gui::books::CapitalKind as K; + let kinds: Vec<(EntryId, K, Decimal)> = st + .lines + .iter() + .map(|l| (l.entry, l.kind, l.amount)) + .collect(); + assert_eq!(kinds.len(), 3); + assert_eq!( + kinds[0], + (m.property, K::PropertyContribution, cents(60_000)) + ); + assert_eq!(kinds[1], (m.distribution, K::Distribution, cents(-20_000))); + assert_eq!((kinds[2].1, kinds[2].2), (K::IncomeShare, cents(35_000))); + // Over all time: the 2025 contribution is cash (1000 is + // [books] cash_accounts), the laptop property. + let all = b + .capital_statement(m.kat, (d(2025, 1, 1), today())) + .unwrap(); + assert_eq!(all.opening, Decimal::ZERO); + assert_eq!(all.contributions_cash, cents(100_000)); + assert_eq!(all.contributions_property, cents(60_000)); + assert_eq!(all.income_share, cents(35_000)); + assert_eq!(all.distributions_taken, cents(20_000)); + assert_eq!(all.closing, cents(175_000)); + // Not a member: no statement. + let mut s = Store::open(&m.dir.0.join("books-data")).unwrap(); + let other = s.add_party("Riley Chen", &[Role::Contractor]).unwrap(); + drop(s); + let b = load_with_toml(&m.dir.0); + assert!( + b.capital_statement(other, (d(2026, 1, 1), today())) + .is_none() + ); + // The page shows it. + let mut h = Harness::builder() + .with_size(egui::vec2(1440.0, 1400.0)) + .build_eframe(|cc| App::with_books(&cc.egui_ctx, b, false, Route::Party(m.stephen))); + h.run_steps(3); + use egui_kittest::kittest::Queryable; + h.get_by_label("Capital account"); + h.get_by_label("Contributions of property (at fair market value)"); +} + +/// The viewer's statement is the K-1 worksheet's row, field for field, +/// over any span (CapitalStatement INVARIANT 1): ledger_core::k1 called +/// here with the members and cash accounts as the CPA package builds them. +#[test] +fn the_capital_statement_is_the_k1_worksheet_row() { + let m = member_books("capital-k1"); + // Income not yet closed, so beginnings carry undistributed earnings. + post_more( + &m, + d(2026, 7, 1), + &[ + ("1000", Side::Debit, 10_000), + ("4000", Side::Credit, 10_000), + ], + ); + for (books, fallback) in [("cash_accounts = [\"1000\"]\n", false), ("", true)] { + write_member_toml(&m.dir.0, books); + let b = load_with_toml(&m.dir.0); + let chart = b.ledger.chart(); + let id = |c: &str| chart.find_by_code(c).unwrap().id; + let members = + [("Stephen Waits", "3000"), ("Katherine Pendergast", "3001")].map(|(name, code)| { + ledger_core::MemberCapital { + name: name.into(), + capital_account: id(code), + distribution_account: None, + share: Decimal::new(5, 1), + } + }); + let cash: Vec<_> = if fallback { + let mut all: Vec<_> = ledger_core::parties::money_accounts(&b.ledger, &[]) + .cash + .into_iter() + .collect(); + all.sort_unstable(); + all + } else { + vec![id("1000")] + }; + for span in [ + (d(2025, 1, 1), today()), + (d(2026, 1, 1), today()), + (d(2026, 2, 1), d(2026, 5, 31)), + (d(2026, 7, 1), today()), + (d(2026, 8, 1), today()), + ] { + let rows = + ledger_core::k1::k1_worksheet(&b.ledger, span.0, span.1, &members, &cash).unwrap(); + for (who, row) in [m.stephen, m.kat].into_iter().zip(&rows) { + let st = b.capital_statement(who, span).unwrap(); + assert_eq!( + ( + st.share, + st.opening, + st.contributions_cash, + st.contributions_property, + st.income_share, + st.distributions_taken, + st.other, + st.closing, + ), + ( + row.share, + row.beginning, + row.contributions_cash, + row.contributions_property, + row.net_income, + row.distributions, + row.other, + row.ending, + ), + "{} over {span:?} (fallback {fallback})", + row.name + ); + assert_eq!( + st.closing, + st.opening + + st.contributions_cash + + st.contributions_property + + st.income_share + - st.distributions_taken + + st.other + ); + } + } + } +} + +/// Income not yet closed is the member's (members_equity INVARIANT 3): +/// $100.00 of July revenue, unclosed, is $50.00 each of income in the +/// span and of capital after it. Stephen, 2026: 1,000 + 600 + (350 + 50) +/// − 200 = 1,800. July on: 1,750 + 50 = 1,800. August on: opening 1,800 +/// (the July income carried), nothing moved. +#[test] +fn the_capital_statement_counts_income_not_yet_closed() { + let m = member_books("capital-unclosed"); + post_more( + &m, + d(2026, 7, 1), + &[ + ("1000", Side::Debit, 10_000), + ("4000", Side::Credit, 10_000), + ], + ); + let b = load_with_toml(&m.dir.0); + let st = b + .capital_statement(m.stephen, (d(2026, 1, 1), today())) + .unwrap(); + assert_eq!(st.opening, cents(100_000)); + assert_eq!(st.income_share, cents(40_000)); + assert_eq!(st.closing, cents(180_000)); + // The close is still the only income line: July's has no entry on + // capital (INVARIANT 3). + let closes: Decimal = st + .lines + .iter() + .filter(|l| l.kind == ledger_gui::books::CapitalKind::IncomeShare) + .map(|l| l.amount) + .sum(); + assert_eq!(closes, cents(35_000)); + let july = b + .capital_statement(m.stephen, (d(2026, 7, 1), today())) + .unwrap(); + assert_eq!( + (july.opening, july.income_share, july.closing), + (cents(175_000), cents(5_000), cents(180_000)) + ); + assert!(july.lines.is_empty()); + let aug = b + .capital_statement(m.kat, (d(2026, 8, 1), today())) + .unwrap(); + assert_eq!( + (aug.opening, aug.income_share, aug.closing), + (cents(180_000), Decimal::ZERO, cents(180_000)) + ); +} + +/// "Owed to them" reads [books] financing_accounts first (INVARIANT 5): +/// with 2200 "Member advances" ($40.00 advanced in July) as the financing +/// account, 2100's "due to" name no longer counts: $40.00, $20.00 each. +/// Without the setting, the name rule: 2100's $200.00 (2200's name says +/// neither "due to" nor "owed to"). +#[test] +fn owed_to_members_reads_the_financing_accounts_first() { + let m = member_books("capital-financing"); + let mut s = Store::open(&m.dir.0.join("books-data")).unwrap(); + s.add_account( + "2200", + "Member advances", + AccountType::Liability, + None, + false, + ) + .unwrap(); + drop(s); + post_more( + &m, + d(2026, 7, 2), + &[("6050", Side::Debit, 4_000), ("2200", Side::Credit, 4_000)], + ); + let span = (d(2026, 1, 1), today()); + write_member_toml( + &m.dir.0, + "cash_accounts = [\"1000\"]\nfinancing_accounts = [\"2200\"]\n", + ); + let b = load_with_toml(&m.dir.0); + let st = b.capital_statement(m.kat, span).unwrap(); + assert_eq!( + (st.due_to_members, st.owed_to_member), + (cents(4_000), cents(2_000)) + ); + write_member_toml(&m.dir.0, "cash_accounts = [\"1000\"]\n"); + let b = load_with_toml(&m.dir.0); + let st = b.capital_statement(m.kat, span).unwrap(); + assert_eq!( + (st.due_to_members, st.owed_to_member), + (cents(20_000), cents(10_000)) + ); +} + +/// Without [books] cash_accounts the capital statement reads money as +/// core does (every asset account but receivables: Books:: +/// k1_cash_accounts), so the laptop on 1500 reads as cash: Stephen's 2026 +/// contributions are $600.00 cash, none property. The Cash page keeps its +/// name-based guess ("Bluevine" names no cash word: no cash accounts). +#[test] +fn without_cash_accounts_the_capital_statement_reads_money_as_core_does() { + let m = member_books("capital-no-cash"); + write_member_toml(&m.dir.0, ""); + let b = load_with_toml(&m.dir.0); + let id = |c: &str| b.ledger.chart().find_by_code(c).unwrap().id; + assert_eq!(b.k1_cash_accounts, vec![id("1000"), id("1500")]); + assert!( + b.cash_accounts.is_empty(), + "the Cash page's guess is unchanged" + ); + let st = b + .capital_statement(m.stephen, (d(2026, 1, 1), today())) + .unwrap(); + assert_eq!( + (st.contributions_cash, st.contributions_property), + (cents(60_000), Decimal::ZERO) + ); + assert_eq!( + st.lines[0].kind, + ledger_gui::books::CapitalKind::CashContribution + ); + assert_eq!(st.closing, cents(175_000)); + let all = b + .capital_statement(m.kat, (d(2025, 1, 1), today())) + .unwrap(); + assert_eq!( + (all.contributions_cash, all.contributions_property), + (cents(160_000), Decimal::ZERO) + ); +} diff --git a/crates/ledger-mcp/src/server.rs b/crates/ledger-mcp/src/server.rs index 53e0a02..630d48c 100644 --- a/crates/ledger-mcp/src/server.rs +++ b/crates/ledger-mcp/src/server.rs @@ -54,7 +54,7 @@ use rust_decimal::Decimal; use std::sync::{Arc, Mutex}; /// The agent operating contract, delivered via initialize. -const INSTRUCTIONS: &str = "You are the bookkeeping agent for these books. The engine enforces the accounting; you enforce the workflow.\n\nRULES\n- Money is exact decimal. Always pass amounts as decimal strings (\"123.45\"), never floats.\n- Never post directly. The flow is draft (books.draft_entry) -> add lines (books.add_line) -> preview (books.post_entry without confirmed) -> review the preview -> post (books.post_entry with confirmed=true).\n- Every entry must balance: debits equal credits, exactly. Every line needs a real account from books.chart. The engine rejects anything else.\n- Receipts: any entry moving more than $25.00 in debits needs at least one receipt attached (books.attach_receipt) before posting. The server rejects the post otherwise. This is enforced, not a suggestion.\n- Never invent accounts. If the account you need does not exist, add it with books.add_account first.\n- Corrections go through books.reverse_entry. Posted entries are immutable; there is no edit.\n- Receipt corrections: receipts can change, but nothing is erased. To replace one, attach the new receipt, then books.unlink_receipt the old (with a reason); retire unwanted receipts or documents with books.withdraw_attachment (with a reason). The journal keeps every step.\n- Attachments: file a file once and link it wherever it is evidence (books.link_attachment, or link_to on books.attach_receipt and books.file_document): an entry (a file linked to an entry counts as its receipt), an account, an asset, a party, an invoice, a bill, a period, a payroll run, a schedule, a reconciliation, or the company. books.attachments lists a subject's files; books.unlink_attachment takes a link off with a reason. File documents under their kind (statement, contract, photo, warranty, import_source, formation, legal, tax, accounting, other). File a bank export before importing it and pass source_attachment_id to books.import_bank_csv.\n- Reconciliations: every month, for each bank, cash, and card account, file the statement (books.file_document category statement), record what it says (books.record_statement), mark the lines it shows (books.mark_cleared), preview books.reconcile with statement_attachment, and confirm with confirmed=true once the difference is exactly zero: that records the reconciliation with its statement. Never confirm by forcing the numbers; find the difference. books.void_reconciliation (with a reason) undoes a mistake. books.digest lists unreconciled_accounts: accounts not reconciled through the end of last month.\n- After posting, check books.digest to review what changed, so a human can review too.\n- Amounts in every response are decimal strings. Treat them as exact.\n- Fixed assets: register with books.register_asset, inspect with books.asset_schedule and books.list_assets, post monthly depreciation with books.run_depreciation (exempt from the receipt policy; the schedule is the evidence), and dispose with books.dispose_asset (preview first; the bill of sale is the receipt). Depreciation is straight-line, book basis only - tax depreciation stays with the CPA. Every asset gets a tag for the label on the thing itself (books.register_asset generates the next one, like WL-0001, unless the human has their own); record the manufacturer's serial number too, change either with books.label_asset, and print label sheets with books.asset_labels (save the PDF; print at 100% on Avery 5160/8160). Asset tools take the tag in place of the id. Record every asset's federal tax basis beside its book value (books.set_asset_tax_basis, or tax_basis_rule on books.register_asset): cost for a purchase; for a member's contribution, the member's adjusted basis (what they paid, less depreciation they took) with rule personal_use_conversion (their personal property: tax basis is the lesser of value and their basis) or carryover (their business property). Book value stays fair market value. books.export fixed_asset_register is the register for the CPA; books.show asset opens one in the viewer.\n- Prepaid expenses and deferred revenue: record the original payment (with its receipt), then books.create_schedule; books.run_schedules posts the monthly amortization (preview first; exempt from the receipt policy like depreciation). Recurring entries (books.create_recurring, books.run_recurring) only ever create drafts: attach each actual receipt and post through the normal preview.\n- Platform payouts (app stores, payment processors, marketplaces): never book the net deposit as revenue. books.record_payout grosses it up (gross revenue, fees, adjustments) into a draft and refuses unless it reconciles to the cent; attach the payout statement, then preview and post.\n- The viewer: the human usually has the read-only viewer open beside you (`just gui`). When they ask to see something, and after you post, call books.show so it's on their screen.\n- Parties: keep the directory current. Every counterparty (vendor, contractor, landlord, customer, payout platform, tax agency, bank or card issuer, member, employee) gets a party: books.add_party with its roles (books.add_vendor and books.add_customer link their record to the party of the same name, or make one). Tag every entry with its party (books.draft_entry party, or books.set_entry_party; a party with a vendor record tags the vendor for 1099s too). Every post_entry preview says where the entry stands (party_policy, party_missing, party_suggestions): under [books] parties = \"suggest\" (the default), when party_missing, name the likely party to the human and tag it (or add it) before confirming; under \"require\", posting is refused (party_required) until the entry names a party or is marked as having none (books.set_entry_party with party null and no_party_reason, or draft_entry no_party_reason: only for an entry with truly no counterparty, like a transfer between the company's own accounts); under \"off\", don't raise it. Record W-9 facts (books.set_w9) for contractors and landlords before paying them, and W-4 facts (books.set_w4) for employees before their first payroll; name the employee party on every books.run_payroll line. Never put a full TIN or SSN anywhere in the books, only the last four digits: the engine refuses any nine-digit number in a party field. The full TIN goes only to the encrypted TIN vault: books.set_w9 with tin and tin_type (ssn, ein, itin), and the signed form with w9_file_path (never books.file_document or books.attach_receipt: the form shows the TIN). Never repeat a TIN back to the human, in a description, note, file name, commit, or anywhere else; tools show the last four only. books.get_w9 saves the form to a path outside the books for the human; books.purge_tin (preview, then confirmed, with a reason) deletes a TIN no longer needed. books.parties lists the directory, books.party shows one party with the year's totals, and books.show party opens it in the viewer. Archive (books.update_party archived) parties no longer dealt with; never delete history.\n- 1099s: mark contractors and landlords with books.set_vendor_1099 (and whether their W-9 is on file), and tag every payment to them with its vendor or party (books.draft_entry vendor or party, books.set_entry_vendor, or books.set_entry_party; books.pay_bill tags automatically). books.vendor_1099 is the year-end worksheet for the CPA; with include_tins: true (only when the human asks for the CPA's copy) it carries the full TINs from the vault, the one output that does: hand it over (save_csv_to, a path outside the books) and never repeat those numbers anywhere else.\n- Rules that change with the law: wanderbooks knows some legal amounts (like the 1099 threshold) only through a given year and never guesses past it. When a tool refuses with \"wanderbooks knows the ... only through\", or books.digest lists stale_rules: check today's date, find the current figure from the official source (the refusal names the vendored source file) or ask the human (owner, CPA), confirm it with them, and pass it explicitly. Then update the rule for everyone: open a pull request at https://github.com/swaits/wanderbooks with the new value and its official text (best), or at least an issue; if you can't reach GitHub, give the human the issue text to file.\n- Control roles: invoices, bills, payroll, and closes post to the account filling each role (receivables, revenue, payables, general expense, retained earnings, payroll expense and withholdings); books.roles lists them. A role uses its default code (1200, 4000, 2000, 6999, 3999, 6100, 6110, 2110-2150) until it is assigned one of the chart's own accounts with books.assign_role (journaled). If the chart uses a default code for a different kind of account, the engine refuses and says which. It can't tell an account of the right type but another purpose (a hosting expense at payroll's 6100): check books.roles before running payroll, bills, or invoices. Never assign roles or renumber the human's accounts on your own: explain the conflict, and when they decide, assign the role to their account (best: their numbering stays) or books.renumber_account the account (its history stays).\n- Settings: wanderbooks.toml beside the books holds the company's facts and settings (name, fiscal year, tax form, members and their capital accounts, basis, receipt threshold, cash flow roles, close target); books.settings shows what is in force and lists problems, settings that disagree with the chart. Edit the file when the human tells you a company fact, when a tool refuses naming a wanderbooks.toml setting, when books.settings or books.digest lists a settings problem, and after books.renumber_account lists config_mentions. The server reloads the file whenever it changes, with no restart: after each edit, call books.settings and check reload_error is null (a broken edit leaves the last good settings in force) and problems is empty. Never put a member's SSN or any TIN in it."; +const INSTRUCTIONS: &str = "You are the bookkeeping agent for these books. The engine enforces the accounting; you enforce the workflow.\n\nRULES\n- Money is exact decimal. Always pass amounts as decimal strings (\"123.45\"), never floats.\n- Never post directly. The flow is draft (books.draft_entry) -> add lines (books.add_line) -> preview (books.post_entry without confirmed) -> review the preview -> post (books.post_entry with confirmed=true).\n- Every entry must balance: debits equal credits, exactly. Every line needs a real account from books.chart. The engine rejects anything else.\n- Receipts: any entry moving more than $25.00 in debits needs at least one receipt attached (books.attach_receipt) before posting. The server rejects the post otherwise. This is enforced, not a suggestion.\n- Never invent accounts. If the account you need does not exist, add it with books.add_account first.\n- Corrections go through books.reverse_entry. Posted entries are immutable; there is no edit.\n- Receipt corrections: receipts can change, but nothing is erased. To replace one, attach the new receipt, then books.unlink_receipt the old (with a reason); retire unwanted receipts or documents with books.withdraw_attachment (with a reason). The journal keeps every step.\n- Attachments: file a file once and link it wherever it is evidence (books.link_attachment, or link_to on books.attach_receipt and books.file_document): an entry (a file linked to an entry counts as its receipt), an account, an asset, a party, an invoice, a bill, a period, a payroll run, a schedule, a reconciliation, or the company. books.attachments lists a subject's files; books.unlink_attachment takes a link off with a reason. File documents under their kind (statement, contract, photo, warranty, import_source, formation, legal, tax, accounting, other). File a bank export before importing it and pass source_attachment_id to books.import_bank_csv.\n- Reconciliations: every month, for each bank, cash, and card account, file the statement (books.file_document category statement), record what it says (books.record_statement), mark the lines it shows (books.mark_cleared), preview books.reconcile with statement_attachment, and confirm with confirmed=true once the difference is exactly zero: that records the reconciliation with its statement. Never confirm by forcing the numbers; find the difference. books.void_reconciliation (with a reason) undoes a mistake. books.digest lists unreconciled_accounts: accounts not reconciled through the end of last month.\n- After posting, check books.digest to review what changed, so a human can review too.\n- Amounts in every response are decimal strings. Treat them as exact.\n- Fixed assets: register with books.register_asset, inspect with books.asset_schedule and books.list_assets, post monthly depreciation with books.run_depreciation (exempt from the receipt policy; the schedule is the evidence), and dispose with books.dispose_asset (preview first; the bill of sale is the receipt). Depreciation is straight-line, book basis only - tax depreciation stays with the CPA. Every asset gets a tag for the label on the thing itself (books.register_asset generates the next one, like WL-0001, unless the human has their own); record the manufacturer's serial number too, change either with books.label_asset, and print label sheets with books.asset_labels (save the PDF; print at 100% on Avery 5160/8160). Asset tools take the tag in place of the id. Record every asset's federal tax basis beside its book value (books.set_asset_tax_basis, or tax_basis_rule on books.register_asset): cost for a purchase; for a member's contribution, the member's adjusted basis (what they paid, less depreciation they took) with rule personal_use_conversion (their personal property: tax basis is the lesser of value and their basis) or carryover (their business property). Book value stays fair market value. books.export fixed_asset_register is the register for the CPA; books.show asset opens one in the viewer.\n- Prepaid expenses and deferred revenue: record the original payment (with its receipt), then books.create_schedule; books.run_schedules posts the monthly amortization (preview first; exempt from the receipt policy like depreciation). Recurring entries (books.create_recurring, books.run_recurring) only ever create drafts: attach each actual receipt and post through the normal preview.\n- Platform payouts (app stores, payment processors, marketplaces): never book the net deposit as revenue. books.record_payout grosses it up (gross revenue, fees, adjustments) into a draft and refuses unless it reconciles to the cent; attach the payout statement, then preview and post.\n- The viewer: the human usually has the read-only viewer open beside you (`just gui`). When they ask to see something, and after you post, call books.show so it's on their screen.\n- Parties: keep the directory current. Every counterparty (vendor, contractor, landlord, customer, payout platform, tax agency, bank or card issuer, member, employee) gets a party: books.add_party with its roles (books.add_vendor and books.add_customer link their record to the party of the same name, or make one). Tag every entry with its party (books.draft_entry party, or books.set_entry_party; a party with a vendor record tags the vendor for 1099s too). An entry with several counterparties names them all with parties ([{party, amount?|percent?}] on books.draft_entry or books.set_entry_party): there is no \"joint\" party. A joint member transaction (paid from or reimbursed to joint funds on Due to Shareholders, a contribution from joint funds, a distribution to both) names both members with no shares, and their shares derive (from their capital or distribution lines on the entry, else their [[company.members]] shares); when the split is uneven, or a party isn't a member, give each party an exact amount (or a percent). The post_entry preview lists each party's resolved share (parties) and refuses to post (party_shares, parties_error) until the shares add up to what the entry moved; show the split to the human before confirming. Every post_entry preview says where the entry stands (party_policy, party_missing, party_suggestions): under [books] parties = \"suggest\" (the default), when party_missing, name the likely party to the human and tag it (or add it) before confirming; under \"require\", posting is refused (party_required) until the entry names a party or is marked as having none (books.set_entry_party with party null and no_party_reason, or draft_entry no_party_reason: only for an entry with truly no counterparty, like a transfer between the company's own accounts); under \"off\", don't raise it. Record W-9 facts (books.set_w9) for contractors and landlords before paying them, and W-4 facts (books.set_w4) for employees before their first payroll; name the employee party on every books.run_payroll line. Never put a full TIN or SSN anywhere in the books, only the last four digits: the engine refuses any nine-digit number in a party field. The full TIN goes only to the encrypted TIN vault, and every member's and every 1099 payee's belongs there: books.set_tin (party, tin, tin_type: ssn, ein, itin; any role, so each member's SSN for their K-1) and books.set_w9 with tin and tin_type for a payee's W-9, and the signed form with w9_file_path (never books.file_document or books.attach_receipt: the form shows the TIN). Never repeat a TIN back to the human, in chat, a description, note, file name, commit message, or anywhere else; tools show the last four only. books.get_w9 saves the form to a path outside the books for the human; books.purge_tin (preview, then confirmed, with a reason) deletes a TIN no longer needed. books.parties lists the directory, books.party shows one party with the year's totals, and books.show party opens it in the viewer. Archive (books.update_party archived) parties no longer dealt with; never delete history.\n- 1099s: mark contractors and landlords with books.set_vendor_1099 (and whether their W-9 is on file), and tag every payment to them with its vendor or party (books.draft_entry vendor or party, books.set_entry_vendor, or books.set_entry_party; books.pay_bill tags automatically). books.vendor_1099 is the year-end worksheet for the CPA; with include_tins: true (only when the human asks for the CPA's copy) it carries the full TINs from the vault, the one output that does: hand it over (save_csv_to, a path outside the books) and never repeat those numbers anywhere else.\n- CPA package: books.export_cpa_package carries every member's and 1099 payee's full TIN from the vault (the K-1 worksheet, k1-members.csv, and the 1099 worksheet), so it needs save_to, a new .zip or a folder outside the books dir (never the books or its exports/: git keeps them forever). Tell the human the path it was saved to; never open, read, attach, or copy the file, and never echo its TINs. tin_gaps lists each member or payee whose TIN it lacks and why: collect them (books.set_tin, books.set_w9) and export again.\n- Rules that change with the law: wanderbooks knows some legal amounts (like the 1099 threshold) only through a given year and never guesses past it. When a tool refuses with \"wanderbooks knows the ... only through\", or books.digest lists stale_rules: check today's date, find the current figure from the official source (the refusal names the vendored source file) or ask the human (owner, CPA), confirm it with them, and pass it explicitly. Then update the rule for everyone: open a pull request at https://github.com/swaits/wanderbooks with the new value and its official text (best), or at least an issue; if you can't reach GitHub, give the human the issue text to file.\n- Control roles: invoices, bills, payroll, and closes post to the account filling each role (receivables, revenue, payables, general expense, retained earnings, payroll expense and withholdings); books.roles lists them. A role uses its default code (1200, 4000, 2000, 6999, 3999, 6100, 6110, 2110-2150) until it is assigned one of the chart's own accounts with books.assign_role (journaled). If the chart uses a default code for a different kind of account, the engine refuses and says which. It can't tell an account of the right type but another purpose (a hosting expense at payroll's 6100): check books.roles before running payroll, bills, or invoices. Never assign roles or renumber the human's accounts on your own: explain the conflict, and when they decide, assign the role to their account (best: their numbering stays) or books.renumber_account the account (its history stays).\n- Settings: wanderbooks.toml beside the books holds the company's facts and settings (name, fiscal year, tax form, members and their capital accounts, basis, receipt threshold, cash flow roles, close target); books.settings shows what is in force and lists problems, settings that disagree with the chart. Edit the file when the human tells you a company fact, when a tool refuses naming a wanderbooks.toml setting, when books.settings or books.digest lists a settings problem, and after books.renumber_account lists config_mentions. The server reloads the file whenever it changes, with no restart: after each edit, call books.settings and check reload_error is null (a broken edit leaves the last good settings in force) and problems is empty. Never put a member's SSN or any TIN in it."; /// Max receipt/document upload: 25 MB. const MAX_UPLOAD_BYTES: usize = 25 * 1024 * 1024; @@ -140,7 +140,10 @@ fn suggest_parties(store: &Store, entry: &JournalEntry) -> Result, +) -> Result { + ledger_core::vendor_1099_report_with_members( + ledger, + year, + threshold, + &payment_accounts(ledger, &settings.roles), + &settings.company.party_members(ledger), + ) +} + impl BooksServer { /// The wanderbooks.toml settings that name account `code`. fn config_mentions(&self, code: &str) -> Vec { @@ -858,6 +879,52 @@ fn resolve_party(store: &Store, party: &str) -> Result, +) -> Result { + use ledger_core::parties::{has_full_tin, redact_full_tins}; + use serde_json::Value; + fn redact_value(v: &mut Value) { + match v { + Value::String(t) if has_full_tin(t) => *t = redact_full_tins(t).into_owned(), + Value::Array(a) => a.iter_mut().for_each(redact_value), + Value::Object(o) => o.values_mut().for_each(redact_value), + _ => {} + } + } + match out { + Err(mut e) => { + if has_full_tin(&e.message) { + e.message = redact_full_tins(&e.message).into_owned().into(); + } + if let Some(d) = e.data.as_mut() { + redact_value(d); + } + Err(e) + } + Ok(rmcp::model::CallToolResponse::Complete(mut r)) if r.is_error == Some(true) => { + for c in &mut r.content { + if let rmcp::model::ContentBlock::Text(t) = c + && has_full_tin(&t.text) + { + t.text = redact_full_tins(&t.text).into_owned(); + } + } + if let Some(v) = r.structured_content.as_mut() { + redact_value(v); + } + Ok(rmcp::model::CallToolResponse::Complete(r)) + } + other => other, + } +} + /// The subject kinds, for messages. const SUBJECT_KINDS: &str = "entry, account, asset, party, invoice, bill, period, payroll_run, schedule, reconciliation, company"; @@ -1168,6 +1235,51 @@ fn outside_books(store: &Store, path: &str) -> Result Result { + let raw = std::path::PathBuf::from(to.trim()); + if to.trim().is_empty() { + return Err("give save_to: a new .zip path or an existing folder".into()); + } + let dest = if raw.is_dir() { + raw.join(file_name) + } else if raw + .extension() + .is_some_and(|e| e.eq_ignore_ascii_case("zip")) + { + raw + } else { + return Err(format!( + "save_to {} is neither an existing folder nor a .zip file", + raw.display() + )); + }; + if tins { + return outside_books(store, &dest.to_string_lossy()).map_err(|e| { + format!("{e} (the CPA package holds full TINs; save it outside the books, e.g. in ~/Documents)") + }); + } + match dest.parent() { + Some(d) if d.as_os_str().is_empty() || d.is_dir() => {} + _ => return Err(format!("the folder for {} doesn't exist", dest.display())), + } + if dest.exists() { + return Err(format!( + "{} exists already: name a new file", + dest.display() + )); + } + Ok(dest) +} + /// Write a new file readable by its owner only (0600 on Unix). fn write_private_new(path: &std::path::Path, bytes: &[u8]) -> Result<(), String> { use std::io::Write; @@ -1205,6 +1317,84 @@ fn tin_type_key(t: ledger_vault::TinType) -> &'static str { } } +/// The books' name for a vault TIN's type. +fn tin_kind(t: ledger_vault::TinType) -> ledger_core::TinType { + match t { + ledger_vault::TinType::Ssn => ledger_core::TinType::Ssn, + ledger_vault::TinType::Ein => ledger_core::TinType::Ein, + ledger_vault::TinType::Itin => ledger_core::TinType::Itin, + } +} + +/// The full TIN the vault holds for a party, checked against what the +/// books record of it; else why not, in words for a worksheet's note (the +/// note never holds more than a last four). +/// +/// ```text +/// GOAL: a worksheet for the CPA carries a TIN only when the vault and the +/// books agree on it, and says why when it can't. +/// INVARIANTS: +/// 1. The party's TIN facts, when on file, have the vault TIN's type and +/// last four, and the record's hash when they name one. +/// 2. Its W-9 facts, when on file, have the vault TIN's last four; with +/// no TIN facts, the W-9 facts' vault record is the record's hash. +/// 3. A vault that can't be opened or read, or holds no TIN for the +/// party, is a note, never an error (a package never fails on it). +/// ``` +fn vault_tin( + ledger: &Ledger, + vault: &Result, + party: ledger_core::PartyId, +) -> Result { + let vault = vault.as_ref().map_err(String::clone)?; + let t = vault + .tin(party) + .map_err(|e| e.to_string())? + .ok_or_else(|| { + "TIN not in the vault: record it with books.set_tin (or books.set_w9 tin, from the W-9)" + .to_string() + })?; + let hash = vault.record_hash(party).ok().flatten(); + let p = ledger.get_party(party); + let (facts, w9) = ( + p.and_then(|p| p.tin.as_ref()), + p.and_then(|p| p.w9.as_ref()), + ); + if facts.is_none() && w9.is_none() { + return Err( + "the vault holds a TIN but the books have no record of it: record it again with books.set_tin" + .into(), + ); + } + if let Some(f) = facts { + if f.last4 != t.last4() || f.tin_type != tin_kind(t.kind) { + return Err(format!( + "the vault's TIN ({}, ending {}) isn't the one the books record ({}, ending {}): record the right one with books.set_tin", + tin_type_key(t.kind), + t.last4(), + f.tin_type.key(), + f.last4 + )); + } + if f.vault_record.is_some() && f.vault_record != hash { + return Err("the vault record isn't the one the books recorded: record the TIN again with books.set_tin".into()); + } + } + if let Some(f) = w9 { + if f.tin_last4 != t.last4() { + return Err(format!( + "the vault's TIN ends in {}, the W-9 facts say {}: check the W-9 and fix with books.set_w9", + t.last4(), + f.tin_last4 + )); + } + if facts.is_none() && f.vault_record.is_some() && f.vault_record != hash { + return Err("the vault record isn't the one the books recorded: check the W-9 and record it again with books.set_w9 tin".into()); + } + } + Ok(t) +} + /// Read a W-9 file (PDF, PNG, or JPEG, 25 MB at most) for the vault. fn read_w9_file(path: &str) -> Result { let p = std::path::Path::new(path.trim()); @@ -1294,6 +1484,12 @@ fn party_out(p: &ledger_core::Party) -> PartyOut { state: w.state.clone(), kept_at: w.kept_at.clone(), }), + tin: p.tin.as_ref().map(|t| TinOut { + tin_type: t.tin_type.key().to_string(), + last4: t.last4.clone(), + in_vault: t.vault_record.is_some(), + purged: t.purged.clone(), + }), } } @@ -1345,24 +1541,23 @@ struct PartyActivity { } /// The party's entries and the year's money paid and received -/// (`PartyTotalsOut`): ledger_core::parties::activity over the calendar -/// year, with the `[books] cash_accounts`. +/// (`PartyTotalsOut`): ledger_core::parties::activity_with over the +/// calendar year, with the `[books] cash_accounts` and the members (so an +/// entry naming several parties counts this one's share). fn party_activity( store: &Store, party: &ledger_core::Party, - cash_codes: &[String], + settings: &Settings, year: i32, ) -> Result { let ledger = store.ledger(); - let cash: Vec = cash_codes - .iter() - .filter_map(|c| ledger.chart().find_by_code(c).map(|a| a.id)) - .collect(); + let cash = cash_ids(ledger, &settings.roles.cash); let span = ( NaiveDate::from_ymd_opt(year, 1, 1).ok_or("year out of range")?, NaiveDate::from_ymd_opt(year, 12, 31).ok_or("year out of range")?, ); - let a = ledger_core::parties::activity(ledger, party.id, span, &cash); + let members = settings.company.party_members(ledger); + let a = ledger_core::parties::activity_with(ledger, party.id, span, &cash, &members); Ok(PartyActivity { entries: a.entries, paid: a.paid, @@ -1370,6 +1565,132 @@ fn party_activity( }) } +/// The chart's ids for these account codes (codes it lacks are skipped). +fn cash_ids(ledger: &Ledger, codes: &[String]) -> Vec { + codes + .iter() + .filter_map(|c| ledger.chart().find_by_code(c).map(|a| a.id)) + .collect() +} + +/// What party shares resolve with ([`ledger_core::shares::resolve`]): the +/// money accounts over the `[books] cash_accounts`, and the members. +fn share_context( + ledger: &Ledger, + settings: &Settings, +) -> ( + ledger_core::parties::MoneyAccounts, + Vec, +) { + ( + ledger_core::parties::money_accounts(ledger, &cash_ids(ledger, &settings.roles.cash)), + settings.company.party_members(ledger), + ) +} + +/// `parties` params as shares: each party resolved, `amount` or `percent` +/// parsed (not both), neither meaning derived. +fn parse_shares( + store: &Store, + params: &[PartyShareParam], +) -> Result, String> { + params + .iter() + .map(|p| { + let party = resolve_party(store, &p.party)?; + let share = match (&p.amount, &p.percent) { + (Some(_), Some(_)) => { + return Err(format!( + "party {}: give an amount or a percent, not both", + p.party + )); + } + (Some(a), None) => ledger_core::Share::Amount(parse_amount(a)?), + (None, Some(pc)) => ledger_core::Share::Percent(parse_amount(pc)?), + (None, None) => ledger_core::Share::Derived, + }; + Ok(ledger_core::PartyShare { party, share }) + }) + .collect() +} + +/// Whether an entry's lines are final enough to check shares against: +/// posted, or a draft with lines whose debits equal its credits. +fn lines_settled(entry: &JournalEntry) -> bool { + if entry.status() == ledger_core::EntryStatus::Posted { + return true; + } + let side = |s: Side| -> Decimal { + entry + .lines() + .iter() + .filter(|l| l.side == s) + .map(|l| l.amount.amount) + .sum() + }; + !entry.lines().is_empty() && side(Side::Debit) == side(Side::Credit) +} + +/// Refuse shares that don't resolve against the entry's lines, once the +/// lines are settled ([`lines_settled`]). +fn check_shares_resolve( + store: &Store, + settings: &Settings, + entry_id: u64, + shares: &[ledger_core::PartyShare], +) -> Result<(), String> { + let ledger = store.ledger(); + let entry = ledger + .get_entry(entry_id) + .ok_or_else(|| format!("unknown entry {entry_id}"))?; + if !lines_settled(entry) { + return Ok(()); + } + let (money, members) = share_context(ledger, settings); + ledger_core::shares::resolve(entry, shares, &money, &members).map(|_| ()) +} + +/// An entry's parties for output, each share resolved, and why they don't +/// resolve when they don't. +fn entry_parties_out( + store: &Store, + settings: &Settings, + entry: &JournalEntry, +) -> (Vec, Option) { + let ledger = store.ledger(); + let shares = ledger.entry_parties(entry.id()); + let (accounts, members) = share_context(ledger, settings); + let (resolved, error) = match ledger_core::shares::resolve(entry, shares, &accounts, &members) { + Ok(r) => (Some(r), None), + Err(e) => (None, Some(e)), + }; + let out = shares + .iter() + .enumerate() + .map(|(i, s)| { + let (kind, amount, percent) = match s.share { + ledger_core::Share::Amount(a) => ("amount", Some(money(a)), None), + ledger_core::Share::Percent(p) => { + ("percent", None, Some(p.normalize().to_string())) + } + ledger_core::Share::Derived => ("derived", None, None), + }; + EntryPartyOut { + party_id: s.party, + party: ledger + .get_party(s.party) + .map(|p| p.label().to_string()) + .unwrap_or_else(|| format!("party {}", s.party)), + share: kind.to_string(), + amount, + percent, + resolved: resolved.as_ref().map(|r| money(r[i].1)), + } + }) + .collect(); + (out, error) +} + /// The party's customer record's invoices, by id. fn party_invoices<'a>( ledger: &'a Ledger, @@ -1874,7 +2195,7 @@ impl BooksServer { #[tool( name = "books.draft_entry", - description = "Start a draft journal entry. Drafts are journaled and survive restarts, but don't affect reports until posted; add lines with books.add_line. Pass party (id or name) for the counterparty: every entry with a vendor, platform, agency, bank, member, or contractor names its party (a party with a vendor record tags the vendor too, for 1099s)." + description = "Start a draft journal entry. Drafts are journaled and survive restarts, but don't affect reports until posted; add lines with books.add_line. Pass party (id or name) for the counterparty: every entry with a vendor, platform, agency, bank, member, or contractor names its party (a party with a vendor record tags the vendor too, for 1099s). An entry with several counterparties passes parties instead, each with an amount, a percent, or neither (derived): name both members on a joint member transaction and let their shares derive." )] async fn draft_entry( &self, @@ -1899,11 +2220,27 @@ impl BooksServer { .as_deref() .map(str::trim) .filter(|r| !r.is_empty()); - if no_party.is_some() && (party.is_some() || vendor.is_some()) { + let shares = p + .parties + .as_deref() + .map(|ps| parse_shares(&store, ps)) + .transpose()?; + if no_party.is_some() && (party.is_some() || vendor.is_some() || shares.is_some()) { return Err( - "no_party_reason is for an entry with no counterparty: pass it without party or vendor".into(), + "no_party_reason is for an entry with no counterparty: pass it without party, parties, or vendor".into(), ); } + if shares.is_some() && (party.is_some() || vendor.is_some()) { + return Err( + "pass party (one) or parties (several, with shares), not both; a vendor party in parties tags its vendor record".into(), + ); + } + if let Some(sh) = &shares { + store + .ledger() + .check_party_shares(sh) + .map_err(|e| e.to_string())?; + } if let (Some(v), Some(pid)) = (vendor, party) { let party_vendor = store.ledger().get_party(pid).and_then(|p| p.vendor); if party_vendor != Some(v) { @@ -1915,7 +2252,11 @@ impl BooksServer { let id = store .create_entry(date, &p.description) .map_err(|e| e.to_string())?; - if party.is_some() { + if let Some(sh) = shares.filter(|sh| !sh.is_empty()) { + store + .set_entry_parties(id, &sh) + .map_err(|e| e.to_string())?; + } else if party.is_some() { store .set_entry_party(id, party) .map_err(|e| e.to_string())?; @@ -2039,12 +2380,14 @@ impl BooksServer { let receipt_missing = receipt_required && receipts.is_empty(); let party_policy = self.settings().parties; let party_id = store.ledger().entry_party(p.entry_id); + let (parties, parties_error) = entry_parties_out(&store, &self.settings(), &entry); let no_party_reason = store .ledger() .entry_no_party(p.entry_id) .map(str::to_string); + // Any party, or a no-party mark, satisfies the policy. let party_missing = party_policy != ledger_config::PartyPolicy::Off - && party_id.is_none() + && parties.is_empty() && no_party_reason.is_none(); let party_suggestions = if party_missing { suggest_parties(&store, &entry)? @@ -2067,6 +2410,8 @@ impl BooksServer { party: party_id .and_then(|i| store.ledger().get_party(i)) .map(|x| x.name.clone()), + parties, + parties_error, no_party_reason, party_missing, party_suggestions, @@ -2074,6 +2419,18 @@ impl BooksServer { if !p.confirmed.unwrap_or(false) { return Ok(Json(out)); } + if let Some(why) = &out.parties_error { + return Err(serde_json::json!({ + "code": "party_shares", + "message": format!( + "entry {}'s party shares don't add up: {why}. Fix them with books.set_entry_party parties (amounts that sum to what it moved, or percents summing to 100).", + p.entry_id + ), + "entry_id": p.entry_id, + "remedy_tool": "books.set_entry_party", + }) + .to_string()); + } if party_missing && party_policy == ledger_config::PartyPolicy::Require { let names: Vec = out .party_suggestions @@ -2574,6 +2931,7 @@ impl BooksServer { } }) .collect(); + let (parties, parties_error) = entry_parties_out(&store, &self.settings(), &entry); Ok(Json(GetEntryOut { entry_id: p.entry_id, date: entry.date().to_string(), @@ -2597,6 +2955,8 @@ impl BooksServer { .entry_party(p.entry_id) .and_then(|id| store.ledger().get_party(id)) .map(|p| p.label().to_string()), + parties, + parties_error, })) } @@ -3455,7 +3815,7 @@ impl BooksServer { #[tool( name = "books.set_entry_vendor", - description = "Tag an entry (draft or posted) with the vendor it paid, for 1099 tracking, or omit vendor to clear the tag. Reporting metadata only: amounts never change. books.pay_bill tags its payments automatically; books.draft_entry takes a vendor too." + description = "Tag an entry (draft or posted) with the vendor it paid, for 1099 tracking, or omit vendor to clear the tag. Reporting metadata only: amounts never change. On an entry naming several parties (books.set_entry_party parties) the parties stay as they are: the vendor of a party already named is tagged with every share kept; any other vendor, or clearing the tag while its party is named, is refused (change the parties instead). An entry and its reversal are tagged together. books.pay_bill tags its payments automatically; books.draft_entry takes a vendor too." )] async fn set_entry_vendor( &self, @@ -3481,7 +3841,7 @@ impl BooksServer { #[tool( name = "books.vendor_1099", - description = "1099 worksheet for a calendar year: cash paid to each vendor (credits minus debits to asset accounts on entries tagged with the vendor, so card payments, which the card processor reports on 1099-K, are excluded), which vendors are reportable (marked NEC/MISC and paid at least the threshold: $600 through 2025, $2,000 in 2026, indexed for inflation after that), which are missing a W-9, and each payee's TIN last four. include_tins: true adds each payee's full TIN from the TIN vault (or why it can't): only when the human asks for the CPA's worksheet, and never repeat those TINs anywhere else; save_csv_to also writes the worksheet as CSV to a new file outside the books dir. For a year whose threshold wanderbooks doesn't know yet it refuses rather than guess: get the figure from the IRS or the human, pass it as `threshold`, and update the rule for everyone (pull request or issue at github.com/swaits/wanderbooks). For the CPA to confirm.", + description = "1099 worksheet for a calendar year: cash paid to each vendor (credits minus debits to the [books] cash and financing accounts on entries tagged with the vendor, so card payments, which the card processor reports on 1099-K, are excluded; on an entry naming several parties, only the vendor's resolved share of that payment), which vendors are reportable (marked NEC/MISC and paid at least the threshold: $600 through 2025, $2,000 in 2026, indexed for inflation after that), which are missing a W-9, and each payee's TIN last four. include_tins: true adds each payee's full TIN from the TIN vault (or why it can't): only when the human asks for the CPA's worksheet, and never repeat those TINs anywhere else; save_csv_to also writes the worksheet as CSV to a new file outside the books dir. For a year whose threshold wanderbooks doesn't know yet it refuses rather than guess: get the figure from the IRS or the human, pass it as `threshold`, and update the rule for everyone (pull request or issue at github.com/swaits/wanderbooks). For the CPA to confirm.", annotations(read_only_hint = true) )] async fn vendor_1099( @@ -3493,13 +3853,8 @@ impl BooksServer { let store = self.lock()?; // A year past what wanderbooks knows is refused with guidance // (research or ask, pass `threshold`, then update the rule). - let r = ledger_core::vendor_1099_report_with( - store.ledger(), - p.year, - threshold, - &payment_accounts(store.ledger(), &self.settings().roles), - ) - .map_err(|e| e.to_string())?; + let r = vendor_1099_report(store.ledger(), &self.settings(), p.year, threshold) + .map_err(|e| e.to_string())?; let missing_w9 = r .vendors .iter() @@ -3530,6 +3885,16 @@ impl BooksServer { p.year )); } + if !r.unresolved.is_empty() { + notes.push(format!( + "Entries {} name several parties whose shares don't resolve, so each counts in full toward its vendor: fix the shares with books.set_entry_party.", + r.unresolved + .iter() + .map(|id| id.to_string()) + .collect::>() + .join(", ") + )); + } let ledger = store.ledger(); // With include_tins, the vault is opened once (never creating a // key: a locked vault becomes a note on each line). @@ -3537,9 +3902,11 @@ impl BooksServer { let mut vendors = Vec::new(); for v in r.vendors { let party_id = ledger.party_of_vendor(v.vendor_id); - let facts = party_id - .and_then(|id| ledger.get_party(id)) - .and_then(|p| p.w9.clone()); + let tin_last4 = party_id.and_then(|id| ledger.get_party(id)).and_then(|p| { + p.w9.as_ref() + .map(|f| f.tin_last4.clone()) + .or_else(|| p.tin.as_ref().map(|t| t.last4.clone())) + }); let mut line = Vendor1099LineOut { vendor_id: v.vendor_id, name: v.name, @@ -3548,45 +3915,20 @@ impl BooksServer { paid: money(v.paid), reportable: v.reportable, party_id, - tin_last4: facts.as_ref().map(|f| f.tin_last4.clone()), + tin_last4, tin: None, tin_type: None, tin_note: None, }; if let Some(vault) = &vault { - line.tin_note = match (vault, party_id) { - (_, None) => Some("no party for this vendor".into()), - (Err(e), _) => Some(e.clone()), - (Ok(vault), Some(pid)) => match vault.tin(pid) { - Err(e) => Some(e.to_string()), - Ok(None) => Some( - "not in the vault: record it with books.set_w9 tin (from the W-9)" - .into(), - ), - Ok(Some(t)) => { - let hash = vault.record_hash(pid).ok().flatten(); - match &facts { - None => Some( - "the vault holds a TIN but the books have no W-9 facts: record them with books.set_w9" - .into(), - ), - Some(f) if f.tin_last4 != t.last4() => Some(format!( - "the vault's TIN ends in {}, the W-9 facts say {}: check the W-9 and fix with books.set_w9", - t.last4(), - f.tin_last4 - )), - Some(f) if f.vault_record.is_some() && f.vault_record != hash => { - Some("the vault record isn't the one the books recorded: check the W-9 and record it again with books.set_w9 tin".into()) - } - Some(_) => { - line.tin = Some(t.formatted()); - line.tin_type = Some(tin_type_key(t.kind).to_string()); - None - } - } - } - }, - }; + match party_id.map(|pid| vault_tin(ledger, vault, pid)) { + None => line.tin_note = Some("no party for this vendor".into()), + Some(Err(why)) => line.tin_note = Some(why), + Some(Ok(t)) => { + line.tin = Some(t.formatted()); + line.tin_type = Some(tin_type_key(t.kind).to_string()); + } + } } vendors.push(line); } @@ -3946,6 +4288,7 @@ impl BooksServer { share: m.share.normalize().to_string(), capital_account: m.capital_account.clone(), distribution_account: m.distribution_account.clone(), + files_jointly_with: m.files_jointly_with.clone(), }) .collect(), cash_accounts: cfg.roles.cash.clone(), @@ -5373,7 +5716,7 @@ impl BooksServer { /// ``` #[tool( name = "books.set_w9", - description = "Record what a contractor's or landlord's W-9 says, or clear it (clear: true alone): the date received (received_on), line 3 entity type, line 4 exempt payee and FATCA codes, the TIN's last four digits, and where the form is kept. Give the full TIN as tin with tin_type (ssn, ein, itin) and it goes only to the encrypted TIN vault (vault/ beside the books, git-ignored; its key outside the books, created on first use): the books keep its last four (tin_last4 is then optional, and refused if it disagrees) and a hash of the vault record, and kept_at defaults to \"wanderbooks vault\". Give w9_file_path (a local PDF, PNG, or JPEG of the signed form) and the form goes to the vault too, never to the receipts and documents; read it back with books.get_w9. Never put a TIN in any other field, note, description, or file: the engine refuses any nine-digit number, and no tool output echoes one. The party needs a 1099 role (contractor or landlord: books.set_party_roles). Record it before paying them. A party with a vendor record also gets its W-9 marked on file there (books.vendor_1099 missing_w9). Clearing W-9 facts whose TIN is in the vault needs books.purge_tin first. Journaled as party.w9_set. Returns the party (the TIN's last four only)." + description = "Record what a contractor's or landlord's W-9 says, or clear it (clear: true alone): the date received (received_on), line 3 entity type, line 4 exempt payee and FATCA codes, the TIN's last four digits, and where the form is kept. Give the full TIN as tin with tin_type (ssn, ein, itin) and it goes only to the encrypted TIN vault (vault/ beside the books, git-ignored; its key outside the books, created on first use): the books keep its last four (tin_last4 is then optional, and refused if it disagrees) and a hash of the vault record, in the W-9 facts and the party's TIN facts alike (party.tin_set, as books.set_tin), and kept_at defaults to \"wanderbooks vault\". The CPA package carries the full TIN to the CPA. Give w9_file_path (a local PDF, PNG, or JPEG of the signed form) and the form goes to the vault too, never to the receipts and documents; read it back with books.get_w9. Never put a TIN in any other field, note, description, or file: the engine refuses any nine-digit number, and no tool output echoes one. The party needs a 1099 role (contractor or landlord: books.set_party_roles). Record it before paying them. A party with a vendor record also gets its W-9 marked on file there (books.vendor_1099 missing_w9). Clearing W-9 facts whose TIN is in the vault needs books.purge_tin first. Journaled as party.w9_set. Returns the party (the TIN's last four only)." )] async fn set_w9(&self, params: Parameters) -> Result, String> { let p = params.0; @@ -5434,6 +5777,13 @@ impl BooksServer { let mut store = self.lock()?; let id = resolve_party(&store, &p.party)?; let existing = store.ledger().get_party(id).and_then(|p| p.w9.clone()); + let party_tin = store.ledger().get_party(id).and_then(|p| p.tin.clone()); + // A new TIN with another last four replaces the party's TIN facts: + // cleared first, so the two never disagree on the record (B-26). + let replace_tin = tin + .as_ref() + .is_some_and(|t| party_tin.as_ref().is_some_and(|pt| pt.last4 != t.last4())); + let mut tin_facts: Option = None; let vault_dir = Self::vault_dir(&store); let (held_tin, held_w9) = ledger_vault::records_held(&vault_dir, id).map_err(|e| e.to_string())?; @@ -5470,9 +5820,25 @@ impl BooksServer { // (INVARIANT 3). let mut rehearsal = store.ledger().clone(); f.vault_record = Some("0".repeat(64)); + let stand_in = tin.as_ref().map(|t| ledger_core::TinFacts { + tin_type: tin_kind(t.kind), + last4: t.last4().to_string(), + vault_record: Some("0".repeat(64)), + purged: None, + }); + if replace_tin { + rehearsal + .set_party_tin(id, None) + .map_err(|e| e.to_string())?; + } rehearsal .set_party_w9(id, Some(f.clone())) .map_err(|e| e.to_string())?; + if stand_in.is_some() { + rehearsal + .set_party_tin(id, stand_in.clone()) + .map_err(|e| e.to_string())?; + } f.vault_record = if tin.is_some() || w9_file.is_some() { let vault = self.open_vault(&store, true)?; let tin_hash = match &tin { @@ -5498,11 +5864,24 @@ impl BooksServer { } else { None }; + // The TIN's facts carry the same record as the W-9's. + tin_facts = stand_in.map(|t| ledger_core::TinFacts { + vault_record: f.vault_record.clone(), + ..t + }); Some(f) } }; let on_file = facts.is_some(); + if replace_tin { + store.set_party_tin(id, None).map_err(|e| e.to_string())?; + } store.set_party_w9(id, facts).map_err(|e| e.to_string())?; + if tin_facts.is_some() { + store + .set_party_tin(id, tin_facts) + .map_err(|e| e.to_string())?; + } // Keep the vendor record's W-9 flag (the 1099 worksheet's) in step. let vendor = store .ledger() @@ -5521,6 +5900,87 @@ impl BooksServer { Ok(Json(party_out(party))) } + /// ```text + /// GOAL: any party's full TIN (a member's SSN for their K-1, a payee's + /// from their W-9) kept where the CPA package can carry it, and + /// nowhere else. + /// SOURCE: docs/parties-and-attachments.md "The TIN vault"; B-26; + /// DECISION (Stephen, 2026-10-15): "In the vault, that's the + /// WHOLE reason we built it. And it needs to be in the damn CPA + /// package." + /// INVARIANTS: + /// 1. The full TIN goes only to the vault (set_w9's path: key outside + /// the books, created on first use; books in git must ignore + /// vault/); `party.tin_set` journals its type, last four, and the + /// record's hash. The output never holds more than the last four. + /// 2. Any role. + /// 3. The facts are checked (on a copy of the ledger) before the vault + /// is written; a refusal journals nothing and writes no record. + /// 4. W-9 facts on file must have the same last four (else refused: + /// a payee's new TIN comes with its W-9, books.set_w9); when they + /// do, their vault record follows the new record (`party.w9_set`). + /// ``` + #[tool( + name = "books.set_tin", + description = "Put a party's full taxpayer identification number in the encrypted TIN vault: tin (nine digits, hyphens allowed) and tin_type (ssn, ein, itin). Any role: collect every member's (their K-1 needs it) and every 1099 payee's (books.set_w9 tin records a W-9's TIN the same way). The books keep only its type, last four, and a hash of the vault record (journaled as party.tin_set); books.export_cpa_package carries the full number to the CPA. A party whose W-9 facts name another last four is refused: a payee's new TIN comes with its new W-9 (books.set_w9). Never repeat a TIN in chat, descriptions, notes, commit messages, or file names. Returns the party (the last four only)." + )] + async fn set_tin(&self, params: Parameters) -> Result, String> { + let p = params.0; + let tin = ledger_vault::Tin::new(p.tin.0.trim(), parse_tin_type(&p.tin_type)?) + .map_err(|e| e.to_string())?; + let mut store = self.lock()?; + let id = resolve_party(&store, &p.party)?; + let w9 = store.ledger().get_party(id).and_then(|p| p.w9.clone()); + let stand_in = ledger_core::TinFacts { + tin_type: tin_kind(tin.kind), + last4: tin.last4().to_string(), + vault_record: Some("0".repeat(64)), + purged: None, + }; + // Rehearse on a copy before the vault is written (INVARIANT 3). + let mut rehearsal = store.ledger().clone(); + rehearsal + .set_party_tin(id, Some(stand_in.clone())) + .map_err(|e| e.to_string())?; + if let Some(f) = &w9 { + rehearsal + .set_party_w9( + id, + Some(ledger_core::W9Facts { + vault_record: Some("0".repeat(64)), + ..f.clone() + }), + ) + .map_err(|e| e.to_string())?; + } + let vault = self.open_vault(&store, true)?; + let hash = vault.put_tin(id, &tin).map_err(|e| e.to_string())?; + store + .set_party_tin( + id, + Some(ledger_core::TinFacts { + vault_record: Some(hash.clone()), + ..stand_in + }), + ) + .map_err(|e| e.to_string())?; + if let Some(f) = w9 + && f.vault_record.as_deref() != Some(hash.as_str()) + { + store + .set_party_w9( + id, + Some(ledger_core::W9Facts { + vault_record: Some(hash), + ..f + }), + ) + .map_err(|e| e.to_string())?; + } + let party = store.ledger().get_party(id).expect("resolved"); + Ok(Json(party_out(party))) + } + /// ```text /// GOAL: hand the human (for the CPA) the signed W-9 the vault keeps, /// without it passing through the books or the conversation. @@ -5575,9 +6035,10 @@ impl BooksServer { /// INVARIANTS: /// 1. Without confirmed=true: a preview, changing nothing. /// 2. Confirmed: the party's vault records (TIN and W-9 file) are - /// deleted, needing no key; and when the party has W-9 facts, - /// `party.w9_set` journals them with the vault record cleared and - /// kept_at saying when and why (the last four stay). + /// deleted, needing no key; when the party has TIN facts, + /// `party.tin_set` journals them with the vault record cleared + /// and `purged` saying when and why, and when it has W-9 facts, + /// `party.w9_set` likewise in kept_at (the last four stay). /// 3. The new facts are checked before anything is deleted; a refusal /// changes nothing. /// SCENARIOS: @@ -5587,7 +6048,7 @@ impl BooksServer { /// ``` #[tool( name = "books.purge_tin", - description = "Delete a party's full TIN and W-9 file from the TIN vault (after the retention period, at the payee's request, or to replace a wrong one), with the reason. Without confirmed=true it previews what the vault holds and changes nothing. Confirmed, the records are deleted for good and the books journal it: the party's W-9 facts (party.w9_set) keep the last four, drop the vault record, and say when and why it was purged in kept_at. Works without the vault key." + description = "Delete a party's full TIN and W-9 file from the TIN vault (after the retention period, at the payee's request, or to replace a wrong one), with the reason. Without confirmed=true it previews what the vault holds and changes nothing. Confirmed, the records are deleted for good and the books journal it: the party's TIN facts (party.tin_set: purged says when and why) and W-9 facts (party.w9_set: kept_at says so) keep the last four and drop the vault record. Works without the vault key. The next CPA package then lacks that TIN (and says so)." )] async fn purge_tin( &self, @@ -5604,35 +6065,48 @@ impl BooksServer { let vault_dir = Self::vault_dir(&store); let (held_tin, held_w9) = ledger_vault::records_held(&vault_dir, id).map_err(|e| e.to_string())?; - let recorded = party.w9.as_ref().is_some_and(|f| f.vault_record.is_some()); + let recorded = party.w9.as_ref().is_some_and(|f| f.vault_record.is_some()) + || party.tin.as_ref().is_some_and(|t| t.vault_record.is_some()); if !held_tin && !held_w9 && !recorded { return Err(format!( - "the TIN vault holds nothing for party {id} ({}) and its W-9 facts name no vault record", + "the TIN vault holds nothing for party {id} ({}) and its W-9 and TIN facts name no vault record", party.name )); } + let why = format!("purged from the wanderbooks vault on {}: {reason}", today()); let new_facts = party.w9.clone().map(|f| ledger_core::W9Facts { vault_record: None, - kept_at: format!( - "not kept: purged from the wanderbooks vault on {}: {reason}", - today() - ), + kept_at: format!("not kept: {why}"), ..f }); + let new_tin = party.tin.clone().map(|t| ledger_core::TinFacts { + vault_record: None, + purged: Some(why.clone()), + ..t + }); + // Both checked before anything is deleted (INVARIANT 3). + let mut rehearsal = store.ledger().clone(); if let Some(f) = &new_facts { - store - .ledger() - .clone() + rehearsal .set_party_w9(id, Some(f.clone())) .map_err(|e| e.to_string())?; } + if let Some(t) = &new_tin { + rehearsal + .set_party_tin(id, Some(t.clone())) + .map_err(|e| e.to_string())?; + } let mut out = PurgeTinOut { status: "preview".into(), party_id: id, name: party.name.clone(), tin_in_vault: held_tin, w9_file_in_vault: held_w9, - tin_last4: party.w9.as_ref().map(|f| f.tin_last4.clone()), + tin_last4: party + .w9 + .as_ref() + .map(|f| f.tin_last4.clone()) + .or_else(|| party.tin.as_ref().map(|t| t.last4.clone())), journaled: false, note: format!( "Confirming deletes {} for good{}. Make sure the CPA has what they need first.", @@ -5642,10 +6116,14 @@ impl BooksServer { (false, true) => "the W-9 file", (false, false) => "nothing in the vault (the books' record of it is cleared)", }, - if new_facts.is_some() { - " and journals the purge in the W-9 facts (party.w9_set; the last four stay)" - } else { - "" + match (new_facts.is_some(), new_tin.is_some()) { + (true, true) => + " and journals the purge in the TIN and W-9 facts (party.tin_set, party.w9_set; the last four stay)", + (false, true) => + " and journals the purge in the TIN facts (party.tin_set; the last four stay)", + (true, false) => + " and journals the purge in the W-9 facts (party.w9_set; the last four stay)", + (false, false) => "", } ), }; @@ -5657,11 +6135,17 @@ impl BooksServer { store.set_party_w9(id, Some(f)).map_err(|e| e.to_string())?; out.journaled = true; } + if let Some(t) = new_tin { + store + .set_party_tin(id, Some(t)) + .map_err(|e| e.to_string())?; + out.journaled = true; + } out.status = "purged".into(); out.note = format!( "Purged from the vault{}.", if out.journaled { - "; the W-9 facts record when and why" + "; the books record when and why" } else { "" } @@ -5715,7 +6199,7 @@ impl BooksServer { .map(|r| parse_roles(&[r.to_string()]).map(|v| v[0])) .transpose()?; let year = p.year.unwrap_or_else(this_year); - let cash = self.settings().roles.cash.clone(); + let settings = self.settings(); let store = self.lock()?; let mut parties = Vec::new(); for party in store.ledger().parties() { @@ -5725,7 +6209,7 @@ impl BooksServer { if role.is_some_and(|r| !party.all_roles().contains(&r)) { continue; } - let act = party_activity(&store, party, &cash, year)?; + let act = party_activity(&store, party, &settings, year)?; parties.push(PartySummaryOut { party_id: party.id, name: party.name.clone(), @@ -5752,12 +6236,12 @@ impl BooksServer { async fn party(&self, params: Parameters) -> Result, String> { let p = params.0; let year = p.year.unwrap_or_else(this_year); - let cash = self.settings().roles.cash.clone(); + let settings = self.settings(); let store = self.lock()?; let ledger = store.ledger(); let id = resolve_party(&store, &p.party)?; let party = ledger.get_party(id).expect("resolved"); - let act = party_activity(&store, party, &cash, year)?; + let act = party_activity(&store, party, &settings, year)?; let in_year = |d: NaiveDate| chrono::Datelike::year(&d) == year; let mut entries: Vec = act .entries @@ -5781,13 +6265,8 @@ impl BooksServer { let bills = party_bills(ledger, party); let amount_1099 = match party.vendor { Some(v) => { - let r = ledger_core::vendor_1099_report_with( - ledger, - year, - Some(Decimal::ZERO), - &payment_accounts(ledger, &self.settings().roles), - ) - .map_err(|e| e.to_string())?; + let r = vendor_1099_report(ledger, &self.settings(), year, Some(Decimal::ZERO)) + .map_err(|e| e.to_string())?; Some(money( r.vendors .iter() @@ -5877,7 +6356,7 @@ impl BooksServer { #[tool( name = "books.set_entry_party", - description = "Tag an entry (draft or posted) with the party it was with (id or name), or pass party null (or omit it) to clear the tag. A party with a vendor record also tags the entry's vendor, so a payment to a contractor or landlord counts in books.vendor_1099; a party without one, or clearing, clears the vendor tag. Metadata only: amounts never change. Journaled as entry.party_set. Returns the entry (as books.get_entry)." + description = "Tag an entry (draft or posted) with the party it was with (id or name), or pass party null (or omit it) to clear the tag. A party with a vendor record also tags the entry's vendor, so a payment to a contractor or landlord counts in books.vendor_1099; a party without one, or clearing, clears the vendor tag. For an entry with several counterparties pass parties instead: [{party, amount?|percent?}], each share an exact amount (cents), a percent (summing to 100; leftover cents to the first party), or neither (derived: a sole party gets all; members named together get what their capital or distributions lines say, else their [[company.members]] share). Name both members on a joint member transaction (a reimbursement on 2100, a joint contribution) and let the shares derive; give amounts when a split is uneven. Refused: a party twice, mixed kinds of share, two parties with vendor records, and, once the entry has balanced lines, shares that don't add up to what it moved. Metadata only: amounts never change. An entry and its reversal carry the same parties: tagging either tags both, so each party's part nets to zero. Journaled as entry.party_set (one party) or entry.parties_set (parties). Returns the entry (as books.get_entry), each party's share resolved." )] async fn set_entry_party( &self, @@ -5885,23 +6364,43 @@ impl BooksServer { ) -> Result, String> { let p = params.0; { + let settings = self.settings(); let mut store = self.lock()?; let party = p .party .as_deref() .map(|r| resolve_party(&store, r)) .transpose()?; + let shares = p + .parties + .as_deref() + .map(|ps| parse_shares(&store, ps)) + .transpose()?; let reason = p .no_party_reason .as_deref() .map(str::trim) .filter(|r| !r.is_empty()); - match (party, reason) { - (Some(_), Some(_)) => { + match (party, reason, shares) { + (Some(_), _, Some(_)) => { + return Err( + "pass party (one) or parties (several, with shares), not both".into(), + ); + } + (_, Some(_), Some(_)) => { + return Err("pass parties or a no_party_reason, not both".into()); + } + (Some(_), Some(_), None) => { return Err("pass a party or a no_party_reason, not both".into()); } - (None, Some(r)) => { - if store.ledger().entry_party(p.entry_id).is_some() { + (None, Some(r), _) => { + if !store.ledger().entry_parties(p.entry_id).is_empty() { + // Check the mark before clearing, so a refusal + // journals nothing. + if r.trim().is_empty() { + return Err("say why the entry has no counterparty".into()); + } + ledger_core::parties::check_text("reason", r)?; store .set_entry_party(p.entry_id, None) .map_err(|e| e.to_string())?; @@ -5910,7 +6409,17 @@ impl BooksServer { .set_entry_no_party(p.entry_id, r) .map_err(|e| e.to_string())?; } - (party, None) => { + (None, None, Some(sh)) => { + store + .ledger() + .check_set_entry_parties(p.entry_id, &sh) + .map_err(|e| e.to_string())?; + check_shares_resolve(&store, &settings, p.entry_id, &sh)?; + store + .set_entry_parties(p.entry_id, &sh) + .map_err(|e| e.to_string())?; + } + (party, None, None) => { store .set_entry_party(p.entry_id, party) .map_err(|e| e.to_string())?; @@ -6405,9 +6914,30 @@ impl BooksServer { // CPA handoff package // ------------------------------------------------------------------ + /// ```text + /// GOAL: everything the CPA needs for the return in one zip, the K-1s' + /// and 1099s' TINs included, without a full TIN ever landing in + /// the books (a git repository) or the conversation. + /// SOURCE: docs/mcp-interface.md; B-26; DECISION (Stephen, + /// 2026-10-15): "it needs to be in the damn CPA package." + /// INVARIANTS: + /// 1. With include_tins (the default) the zip is only ever written to + /// save_to, a new file outside the books dir (0600), and the output + /// is its path, never its bytes; without save_to it is refused. + /// include_tins false: last fours only, base64 unless save_to. + /// 2. With members, k1-members.csv has one row per [[company.members]] + /// member: name, party, share, capital account, TIN and type, and + /// the K-1 worksheet's capital figures (ledger_core::k1) for the + /// fiscal year to date. The 1099 worksheet adds each payee's party, + /// last four, TIN, and type. + /// 3. A locked vault or a missing TIN never fails the package: the + /// row says why (vault_tin), the README lists it, and so does + /// tin_gaps (members, and 1099 payees that are reportable). + /// 4. Read-only: nothing is journaled. + /// ``` #[tool( name = "books.export_cpa_package", - description = "Build the CPA handoff package as a zip (base64): the profit and loss, balance sheet, cash flow, statement of changes in members' equity (multi-member LLCs), tax summary, and trial balance, each as PDF and XLSX; the fixed asset register as PDF and XLSX when there are fixed assets; the general ledger (by account) and general journal (entry by entry) as XLSX; the journal as IIF (imports into QuickBooks Desktop); the 1099 worksheet as CSV; every receipt behind the year's entries (receipts/, with an index.csv tying each file to its entry) and the company's documents (documents/), unless include_receipts is false; and a README with the journal's verified hash. cash_accounts (e.g. [\"1000\"]) is required: the cash flow statement reconciles to them. as_of defaults to today; period reports cover the fiscal year to date.", + description = "Build the CPA handoff package as a zip: the profit and loss, balance sheet, cash flow, statement of changes in members' equity (multi-member LLCs), tax summary, and trial balance, each as PDF and XLSX; the fixed asset register as PDF and XLSX when there are fixed assets; the general ledger (by account) and general journal (entry by entry) as XLSX; the journal as IIF (imports into QuickBooks Desktop); the 1099 worksheet as CSV; with members, the K-1 worksheet (k1-members.csv: per member their party, share, capital account, TIN, and beginning capital, contributions in cash and in property, share of income, distributions, and ending capital for the fiscal year to date); every receipt behind the year's entries (receipts/, with an index.csv tying each file to its entry) and the company's documents (documents/), unless include_receipts is false; and a README with the journal's verified hash. By default (include_tins) the K-1 and 1099 worksheets carry every member's and payee's full TIN from the TIN vault, so the package is saved, never returned: save_to (a new .zip path or an existing folder, outside the books dir) is required, and the output gives only its path; tell the human where it is and never open, attach, or copy it. A locked vault or a missing TIN doesn't stop the package: tin_gaps lists each one (collect them with books.set_tin or books.set_w9 and export again). include_tins: false builds it with last fours only, returned as base64 unless save_to is given. cash_accounts (e.g. [\"1000\"]) is required: the cash flow statement reconciles to them. as_of defaults to today; period reports cover the fiscal year to date.", annotations(read_only_hint = true) )] async fn export_cpa_package( @@ -6416,13 +6946,31 @@ impl BooksServer { ) -> Result, String> { use ledger_import::{XlsxReport, report, report_to_xlsx, statement_pdf}; let p = params.0; - let as_of = match p.as_of { - Some(s) => parse_date(&s)?, + let as_of = match &p.as_of { + Some(s) => parse_date(s)?, None => today(), }; + // A package with full TINs is only ever a file outside the books, + // never tool output (INVARIANT 1). + if p.include_tins && p.save_to.is_none() { + return Err( + "the CPA package carries every member's and 1099 payee's full TIN (include_tins, the default), so it is saved, never returned: pass save_to, a new .zip path or an existing folder outside the books dir, then tell the human where it is. (include_tins: false builds one with last fours only, returned as base64.)" + .into(), + ); + } + let file_name = format!("cpa-package-{as_of}.zip"); let store = self.lock()?; + let dest = p + .save_to + .as_deref() + .map(|to| package_dest(&store, to, &file_name, p.include_tins)) + .transpose()?; let meta = self.report_meta(&store); let ledger = store.ledger(); + // The vault, opened once and never creating a key: locked, each + // TIN row says so and the package is built anyway (INVARIANT 3). + let vault = p.include_tins.then(|| self.open_vault(&store, false)); + let mut tin_gaps: Vec = Vec::new(); // Fiscal year to date, per wanderbooks.toml's fiscal_year_start. let (fy_start, _) = self.settings().company.fiscal_year_containing(as_of); @@ -6467,10 +7015,143 @@ impl BooksServer { ), ), ]; + let mut k1_csv: Option<(String, Vec)> = None; if !self.settings().company.members.is_empty() { - let me = ledger_core::members_equity(ledger, fy, as_of, &self.member_capitals(ledger)?) + let capitals = self.member_capitals(ledger)?; + let me = ledger_core::members_equity(ledger, fy, as_of, &capitals) .map_err(|e| e.to_string())?; statements.push(("members-equity", report::members_equity(&me))); + // The K-1 worksheet: each member's capital account for the + // year (Schedule K-1 Item L) and their TIN (INVARIANT 2). + let cash_codes = if p.cash_accounts.is_empty() { + &self.settings().roles.cash + } else { + &p.cash_accounts + }; + let cash: Vec = cash_codes + .iter() + .filter_map(|c| ledger.chart().find_by_code(c).map(|a| a.id)) + .collect(); + k1_csv = Some( + match ledger_core::k1::k1_worksheet(ledger, fy, as_of, &capitals, &cash) { + Ok(rows) => { + let mut w = String::from("member,party_id,share,capital_account,"); + w.push_str(if p.include_tins { + "tin,tin_type,tin_note," + } else { + "tin_last4,tin_type," + }); + w.push_str("beginning_capital,contributions_cash,contributions_property,net_income,distributions,other,ending_capital\n"); + for (row, m) in rows.iter().zip(&self.settings().company.members) { + let party = ledger_core::k1::member_party(ledger, &m.name); + let facts = party + .and_then(|id| ledger.get_party(id)) + .and_then(|p| p.tin.clone()); + let mut cells = vec![ + csv_escape(&m.name), + party.map(|id| id.to_string()).unwrap_or_default(), + m.share.to_string(), + m.capital_account.clone(), + ]; + match &vault { + Some(vault) => { + let got = match party { + None => Err(format!( + "TIN not in the vault: no party named {:?} with the member role (books.add_party with role member, or books.set_party_roles, then books.set_tin)", + m.name + )), + Some(id) => vault_tin(ledger, vault, id), + }; + match got { + Ok(t) => { + cells.push(t.formatted()); + cells.push(tin_type_key(t.kind).into()); + cells.push(String::new()); + } + Err(why) => { + tin_gaps.push(format!("member {}: {why}", m.name)); + cells.push(String::new()); + cells.push( + facts + .map(|f| f.tin_type.key().to_string()) + .unwrap_or_default(), + ); + cells.push(csv_escape(&why)); + } + } + } + None => { + cells.push( + facts.as_ref().map(|f| f.last4.clone()).unwrap_or_default(), + ); + cells.push( + facts + .map(|f| f.tin_type.key().to_string()) + .unwrap_or_default(), + ); + } + } + for v in [ + row.beginning, + row.contributions_cash, + row.contributions_property, + row.net_income, + row.distributions, + row.other, + row.ending, + ] { + cells.push(money(v)); + } + w.push_str(&cells.join(",")); + w.push('\n'); + } + // The members together: ties to the 1065's Schedule L + // capital and the statement of members' equity. Not + // a K-1 (each member gets their own). + if rows.len() > 1 { + let sum = |f: fn(&ledger_core::k1::K1Row) -> Decimal| { + rows.iter().map(f).sum::() + }; + let share: Decimal = self + .settings() + .company + .members + .iter() + .map(|m| m.share) + .sum(); + let mut cells = vec![ + csv_escape("Total, all members (not a K-1)"), + String::new(), + share.to_string(), + String::new(), + String::new(), + String::new(), + ]; + if p.include_tins && vault.is_some() { + cells.push(String::new()); + } + for v in [ + sum(|r| r.beginning), + sum(|r| r.contributions_cash), + sum(|r| r.contributions_property), + sum(|r| r.net_income), + sum(|r| r.distributions), + sum(|r| r.other), + sum(|r| r.ending), + ] { + cells.push(money(v)); + } + w.push_str(&cells.join(",")); + w.push('\n'); + } + ("k1-members.csv".into(), w.into_bytes()) + } + Err(e) => ( + "k1-members-NOT-INCLUDED.txt".into(), + format!("{e}\n").into_bytes(), + ), + }, + ); } statements.push(( "tax-summary", @@ -6512,23 +7193,55 @@ impl BooksServer { )); // 1099 worksheet for the calendar year of as_of. let year = chrono::Datelike::year(&as_of); - match ledger_core::vendor_1099_report_with( - ledger, - year, - None, - &payment_accounts(ledger, &self.settings().roles), - ) { + match vendor_1099_report(ledger, &self.settings(), year, None) { Ok(r1099) => { - let mut w = String::from("vendor,form,w9_on_file,paid,reportable\n"); + let mut w = String::from("vendor,form,w9_on_file,paid,reportable"); + if vault.is_some() { + w.push_str(",party_id,tin_last4,tin,tin_type,tin_note"); + } + w.push('\n'); for v in &r1099.vendors { w.push_str(&format!( - "\"{}\",{},{},{},{}\n", + "\"{}\",{},{},{},{}", v.name.replace('"', "\"\""), v.form.map(|f| f.label()).unwrap_or(""), v.tin_on_file, money(v.paid), v.reportable )); + // The payee's full TIN from the vault (INVARIANT 2). + if let Some(vault) = &vault { + let party = ledger.party_of_vendor(v.vendor_id); + let last4 = party.and_then(|id| ledger.get_party(id)).and_then(|p| { + p.w9.as_ref() + .map(|f| f.tin_last4.clone()) + .or_else(|| p.tin.as_ref().map(|t| t.last4.clone())) + }); + let got = match party { + None => Err("TIN not in the vault: no party for this vendor".into()), + Some(id) => vault_tin(ledger, vault, id), + }; + let (tin, kind, note) = match got { + Ok(t) => ( + t.formatted(), + tin_type_key(t.kind).to_string(), + String::new(), + ), + Err(why) => { + if v.reportable { + tin_gaps.push(format!("1099 payee {}: {why}", v.name)); + } + (String::new(), String::new(), why) + } + }; + w.push_str(&format!( + ",{},{},{tin},{kind},{}", + party.map(|id| id.to_string()).unwrap_or_default(), + last4.unwrap_or_default(), + csv_escape(¬e) + )); + } + w.push('\n'); } files.push((format!("1099-worksheet-{year}.csv"), w.into_bytes())); } @@ -6601,6 +7314,9 @@ impl BooksServer { } files.push(("receipts/index.csv".into(), index.into_bytes())); } + if let Some(k1) = k1_csv { + files.push(k1); + } let seq = store.journal_seq(); drop(store); files.push(( @@ -6608,7 +7324,7 @@ impl BooksServer { self.export_iif()?.into_bytes(), )); let readme = format!( - "{name} — books package for the CPA\n\nFiscal year to date: {fy_start} through {as_of_s}. {basis} basis.\nKept in wanderbooks: double-entry books from an append-only, hash-chained journal ({seq} events, verified).\n\nStatements (PDF and XLSX): profit and loss, balance sheet, cash flow, {members}tax summary, trial balance.\nLedgers (XLSX): general ledger by account; general journal entry by entry.\ngeneral-journal.iif: every posted entry, for import into QuickBooks Desktop.\n1099-worksheet-{year}.csv: cash paid per vendor marked for a 1099, against the year's threshold.\n{receipts}\nThe tax summary maps accounts to return categories; it's a worksheet, not a filing. Questions go to the owners, who can ask their bookkeeping agent to trace any number to its entries and receipts.\n", + "{name} — books package for the CPA\n\nFiscal year to date: {fy_start} through {as_of_s}. {basis} basis.\nKept in wanderbooks: double-entry books from an append-only, hash-chained journal ({seq} events, verified).\n\nStatements (PDF and XLSX): profit and loss, balance sheet, cash flow, {members}tax summary, trial balance.\nLedgers (XLSX): general ledger by account; general journal entry by entry.\ngeneral-journal.iif: every posted entry, for import into QuickBooks Desktop.\n1099-worksheet-{year}.csv: cash paid per vendor marked for a 1099, against the year's threshold{payee_tins}.\n{k1}{receipts}{tins}\nThe tax summary maps accounts to return categories; it's a worksheet, not a filing. Questions go to the owners, who can ask their bookkeeping agent to trace any number to its entries and receipts.\n", name = self.settings().company.name, basis = self.settings().company.basis, members = if self.settings().company.members.is_empty() { @@ -6616,6 +7332,51 @@ impl BooksServer { } else { "members' equity, " }, + payee_tins = if p.include_tins { + ", with each payee's TIN" + } else { + "" + }, + k1 = if self.settings().company.members.is_empty() { + String::new() + } else { + { + let mut k = format!( + "k1-members.csv: each member's capital account for the year (Schedule K-1 Item L: beginning, contributions in cash and in property, share of income, distributions, ending), with their {}; the last row totals the members (not a K-1) to tie to Schedule L and the statement of members' equity.\n", + if p.include_tins { + "taxpayer ID" + } else { + "taxpayer ID's last four" + } + ); + let members = &self.settings().company.members; + for (i, m) in members.iter().enumerate() { + if let Some(j) = &m.files_jointly_with + && members[..i].iter().all(|o| o.name != *j) + { + k.push_str(&format!( + "{} and {j} file a joint individual return: each receives their own Schedule K-1; both are reported on the one Form 1040 (Schedule E, Part II).\n", + m.name + )); + } + } + k + } + }, + tins = if p.include_tins { + let mut t = String::from( + "\nThis package holds full taxpayer identification numbers: keep it out of email and shared folders, and delete copies once filed.\n", + ); + if !tin_gaps.is_empty() { + t.push_str("TINs not included:\n"); + for g in &tin_gaps { + t.push_str(&format!("- {g}\n")); + } + } + t + } else { + String::new() + }, receipts = if p.include_receipts { format!( "receipts/: the {receipt_count} receipts behind the period's entries; index.csv ties each to its entry, date, amount, and SHA-256.\ndocuments/: the company's formation, legal, and tax documents.\n" @@ -6640,12 +7401,47 @@ impl BooksServer { zip.finish().map_err(|e| e.to_string())?; } - Ok(Json(ExportCpaPackageOut { - file_name: format!("cpa-package-{as_of}.zip"), - content_base64: base64::Engine::encode( - &base64::engine::general_purpose::STANDARD, - &zip_buf, + let shown = |path: &std::path::Path| path.display().to_string(); + let (content_base64, saved_to, note) = match &dest { + Some(path) => { + write_private_new(path, &zip_buf)?; + let note = if p.include_tins { + format!( + "Saved the CPA package to {} (readable by you only). It holds full TINs: tell the human where it is, never open, attach, or copy it into the books, and have them delete it once the CPA has it.{}", + shown(path), + if tin_gaps.is_empty() { + String::new() + } else { + format!( + " {} TIN(s) are missing (tin_gaps): collect them with books.set_tin or books.set_w9 and export again.", + tin_gaps.len() + ) + } + ) + } else { + format!( + "Saved the CPA package to {} (last fours only).", + shown(path) + ) + }; + (None, Some(shown(path)), note) + } + None => ( + Some(base64::Engine::encode( + &base64::engine::general_purpose::STANDARD, + &zip_buf, + )), + None, + "The package, without full TINs (include_tins false).".to_string(), ), + }; + Ok(Json(ExportCpaPackageOut { + file_name, + content_base64, + saved_to, + tins_included: p.include_tins, + tin_gaps, + note, })) } } @@ -7222,7 +8018,7 @@ impl ServerHandler for BooksServer { // Pick up an edited wanderbooks.toml before the tool runs. self.live().refresh(); let tcc = rmcp::handler::server::tool::ToolCallContext::new(self, request, context); - Self::tool_router().call(tcc).await + redact_refusal(Self::tool_router().call(tcc).await) } fn get_info(&self) -> ServerConfig { @@ -7348,12 +8144,14 @@ mod tests { share: Decimal::new(5, 1), capital_account: "3000".into(), distribution_account: Some("3100".into()), + files_jointly_with: None, }, Member { name: "Bo".into(), share: Decimal::new(5, 1), capital_account: "3001".into(), distribution_account: None, + files_jointly_with: None, }, ], ..Company::default() diff --git a/crates/ledger-mcp/src/types.rs b/crates/ledger-mcp/src/types.rs index ed2cf43..8c9a440 100644 --- a/crates/ledger-mcp/src/types.rs +++ b/crates/ledger-mcp/src/types.rs @@ -550,6 +550,8 @@ pub struct MemberSettingOut { pub capital_account: String, /// Distributions account code, if any. pub distribution_account: Option, + /// The member they file a joint individual return with, if any. + pub files_jointly_with: Option, } /// Result of `books.settings`. @@ -990,8 +992,53 @@ pub struct DraftEntryParams { pub party: Option, /// 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, + /// 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>, +} + +/// 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, + /// 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, +} + +/// 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, + /// The percent given, for a percent share, decimal string. + pub percent: Option, + /// 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, } /// Result of `books.draft_entry`. @@ -1109,10 +1156,17 @@ pub struct PostEntryOut { pub split_detected: Vec, /// `[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, /// Its name. pub party: Option, + /// 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, + /// 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, /// Why it has none, when marked so. pub no_party_reason: Option, /// True when the entry names no party and isn't marked as having none @@ -1399,10 +1453,16 @@ pub struct GetEntryOut { pub receipts: Vec, /// The vendor the entry is tagged with, for 1099 tracking. pub vendor: Option, - /// 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, /// That party's label (display name, else name). pub party: Option, + /// Every party the entry names, with their shares resolved (as in the + /// `books.post_entry` preview). + pub parties: Vec, + /// Why the shares don't resolve, when they don't. + pub parties_error: Option, } // --------------------------------------------------------------------------- @@ -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-.zip`). Written + /// readable by its owner only. With TINs, never inside the books dir + /// (git keeps it forever, `exports/` included). + pub save_to: Option, } /// 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, + /// 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, + /// 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, + /// 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, +} + /// 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, - /// 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, + /// 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>, /// 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, /// W-4 facts, if on file. pub w4: Option, + /// Taxpayer-ID facts (any role), if on file: type and last four. + pub tin: Option, } /// 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. diff --git a/crates/ledger-mcp/tests/mcp_stdio.rs b/crates/ledger-mcp/tests/mcp_stdio.rs index 73eb35d..b2bd9af 100644 --- a/crates/ledger-mcp/tests/mcp_stdio.rs +++ b/crates/ledger-mcp/tests/mcp_stdio.rs @@ -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()) diff --git a/crates/ledger-mcp/tests/oracle_mcp.rs b/crates/ledger-mcp/tests/oracle_mcp.rs index db6a4bb..3ba9eea 100644 --- a/crates/ledger-mcp/tests/oracle_mcp.rs +++ b/crates/ledger-mcp/tests/oracle_mcp.rs @@ -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!( diff --git a/crates/ledger-mcp/tests/spec_mcp.rs b/crates/ledger-mcp/tests/spec_mcp.rs index 28cf1ea..4c660ae 100644 --- a/crates/ledger-mcp/tests/spec_mcp.rs +++ b/crates/ledger-mcp/tests/spec_mcp.rs @@ -681,6 +681,10 @@ fn happy_path(name: &str) -> Mcp { "books.set_w4", json!({"party": "Pat", "received_on": "2026-01-02", "state": "UT", "kept_at": "binder"}), ); + m.ok( + "books.set_tin", + json!({"party": "Pat", "tin": "571-93-2846", "tin_type": "ssn"}), + ); m.ok( "books.set_party_roles", json!({"party": "Landlord", "add": ["landlord"]}), @@ -1069,7 +1073,12 @@ fn happy_path(name: &str) -> Mcp { } m.ok( "books.export_cpa_package", - json!({"as_of": "2026-12-31", "include_receipts": false}), + json!({"as_of": "2026-12-31", "include_receipts": false, "include_tins": false}), + ); + m.ok( + "books.export_cpa_package", + json!({"as_of": "2026-12-31", "include_receipts": false, + "save_to": outside(&dir).join("cpa.zip").to_str().unwrap()}), ); m diff --git a/crates/ledger-mcp/tests/spec_parties.rs b/crates/ledger-mcp/tests/spec_parties.rs index 6145bad..604eda7 100644 --- a/crates/ledger-mcp/tests/spec_parties.rs +++ b/crates/ledger-mcp/tests/spec_parties.rs @@ -1313,3 +1313,253 @@ fn off_never_raises_it() { json!({"entry_id": id, "confirmed": true}), ); } + +// ------------------------------------------------- party shares +// +// Oracles: SetEntryPartyParams.parties and PartyShareParam (the declared +// share rules), EntryPartyOut, PostEntryOut.parties / parties_error (code +// party_shares), PartyTotalsOut (each party's share), and +// docs/mcp-interface.md "Several parties". Values worked by hand. + +/// Books with both members (50/50, capital 3000 and 3001) in the settings +/// and the directory; `extra` goes under `[books]`. +fn member_books(name: &str, extra: &str) -> Mcp { + let dir = test_dir(name); + let toml = books_toml( + &dir, + "Spec Co", + &format!( + "{extra}\n[[company.members]]\nname = \"Stephen Waits\"\nshare = \"50%\"\ncapital_account = \"3000\"\n\ + [[company.members]]\nname = \"Katherine Pendergast\"\nshare = \"50%\"\ncapital_account = \"3001\"\n" + ), + ); + let mut m = Mcp::start(&dir, &toml); + m.accounts(CHART); + m.accounts(&[("3001", "Capital K", "equity")]); + m.add_party("Stephen Waits", &["member"]); + m.add_party("Katherine Pendergast", &["member"]); + m +} + +fn both() -> Value { + json!({"parties": [{"party": "Stephen Waits"}, {"party": "Katherine Pendergast"}]}) +} + +fn resolved(v: &Value) -> Vec<(String, String)> { + v["parties"] + .as_array() + .unwrap() + .iter() + .map(|p| { + ( + p["party"].as_str().unwrap().to_string(), + p["resolved"].as_str().unwrap_or("null").to_string(), + ) + }) + .collect() +} + +#[test] +fn a_joint_reimbursement_names_both_members_and_splits_by_share() { + let mut m = member_books("shares-joint", ""); + let id = m.draft( + "2026-04-01", + both(), + &[("2100", "debit", "100.00"), ("1000", "credit", "100.00")], + ); + let preview = m.ok("books.post_entry", json!({"entry_id": id})); + assert_eq!( + resolved(&preview), + [ + ("Stephen Waits".to_string(), "50.00".to_string()), + ("Katherine Pendergast".to_string(), "50.00".to_string()) + ] + ); + assert_eq!(preview["parties"][0]["share"], "derived"); + assert!(preview["party_id"].is_null(), "no sole party"); + assert!(preview["parties_error"].is_null()); + assert_eq!(preview["party_missing"], false); + m.ok( + "books.post_entry", + json!({"entry_id": id, "confirmed": true}), + ); + assert_eq!(m.count("entry.parties_set"), 1); + for who in ["Stephen Waits", "Katherine Pendergast"] { + let t = m.party(who, 2026)["totals"].clone(); + assert_eq!(t["paid"], "50.00", "{who}'s half"); + } + let list = m.read_only("books.parties", json!({"year": 2026})); + let paid: Vec<&str> = list["parties"] + .as_array() + .unwrap() + .iter() + .map(|p| p["paid"].as_str().unwrap()) + .collect(); + assert_eq!(paid, ["50.00", "50.00"]); +} + +#[test] +fn a_joint_contribution_derives_from_the_capital_lines() { + let mut m = member_books("shares-capital", ""); + let id = m.post( + "2026-01-02", + both(), + &[ + ("1000", "debit", "300.00"), + ("3000", "credit", "200.00"), + ("3001", "credit", "100.00"), + ], + ); + let e = m.read_only("books.get_entry", json!({"entry_id": id})); + assert_eq!( + resolved(&e), + [ + ("Stephen Waits".to_string(), "-200.00".to_string()), + ("Katherine Pendergast".to_string(), "-100.00".to_string()) + ] + ); + assert_eq!( + m.party("Stephen Waits", 2026)["totals"]["received"], + "200.00" + ); + assert_eq!( + m.party("Katherine Pendergast", 2026)["totals"]["received"], + "100.00" + ); +} + +#[test] +fn explicit_amounts_and_percents_apportion_exactly() { + let mut m = member_books("shares-explicit", ""); + m.add_party("Riley", &["contractor"]); + let id = m.post( + "2026-05-01", + json!({"party": "Riley"}), + &[("6000", "debit", "100.00"), ("1000", "credit", "100.00")], + ); + let e = m.ok( + "books.set_entry_party", + json!({"entry_id": id, "parties": [ + {"party": "Riley", "amount": "70.00"}, + {"party": "Stephen Waits", "amount": "30.00"}, + ]}), + ); + assert_eq!(e["parties"][0]["share"], "amount"); + assert_eq!(e["parties"][0]["amount"], "70.00"); + assert_eq!(e["parties"][1]["resolved"], "30.00"); + assert_eq!(m.party("Riley", 2026)["totals"]["paid"], "70.00"); + let e = m.ok( + "books.set_entry_party", + json!({"entry_id": id, "parties": [ + {"party": "Riley", "percent": "33.333"}, + {"party": "Stephen Waits", "percent": "66.667"}, + ]}), + ); + // 33.333 → 33.33, 66.667 → 66.66; the leftover cent to the first. + assert_eq!(e["parties"][0]["resolved"], "33.34"); + assert_eq!(e["parties"][1]["resolved"], "66.66"); + assert_eq!(e["parties"][0]["percent"], "33.333"); + assert_eq!(m.count("entry.parties_set"), 2); +} + +#[test] +fn shares_that_dont_add_up_are_refused_changing_nothing() { + let mut m = member_books("shares-refused", ""); + m.add_party("Riley", &["contractor"]); + let id = m.post( + "2026-05-01", + json!({}), + &[("6000", "debit", "100.00"), ("1000", "credit", "100.00")], + ); + let set = |parties: Value| json!({"entry_id": id, "parties": parties}); + let why = m.refuse( + "books.set_entry_party", + set(json!([{"party": "Riley", "amount": "60.00"}, {"party": "Stephen Waits", "amount": "30.00"}])), + ); + assert!(why.contains("sum"), "{why}"); + m.refuse( + "books.set_entry_party", + set(json!([{"party": "Riley", "percent": "50"}, {"party": "Stephen Waits", "percent": "40"}])), + ); + m.refuse( + "books.set_entry_party", + set(json!([{"party": "Riley"}, {"party": "Riley"}])), + ); + m.refuse( + "books.set_entry_party", + set(json!([{"party": "Riley", "amount": "50.00", "percent": "50"}])), + ); + m.refuse( + "books.set_entry_party", + set(json!([{"party": "Riley", "amount": "50.00"}, {"party": "Stephen Waits"}])), + ); + // Derived for a non-member among several. + m.refuse( + "books.set_entry_party", + set(json!([{"party": "Riley"}, {"party": "Stephen Waits"}])), + ); + m.refuse( + "books.set_entry_party", + json!({"entry_id": id, "party": "Riley", "parties": [{"party": "Riley"}]}), + ); + assert_eq!(m.count("entry.parties_set"), 0); +} + +#[test] +fn a_draft_whose_shares_dont_add_up_previews_the_error_and_wont_post() { + let mut m = member_books("shares-draft", ""); + let id = m.draft( + "2026-06-01", + json!({"parties": [ + {"party": "Stephen Waits", "amount": "10.00"}, + {"party": "Katherine Pendergast", "amount": "10.00"}, + ]}), + &[("2100", "debit", "25.00"), ("1000", "credit", "25.00")], + ); + let preview = m.ok("books.post_entry", json!({"entry_id": id})); + assert!(preview["parties_error"].is_string(), "{preview}"); + assert!(preview["parties"][0]["resolved"].is_null()); + let why = m.refuse( + "books.post_entry", + json!({"entry_id": id, "confirmed": true}), + ); + assert!(why.contains("party_shares"), "{why}"); + m.ok( + "books.set_entry_party", + json!({"entry_id": id, "parties": [ + {"party": "Stephen Waits", "amount": "15.00"}, + {"party": "Katherine Pendergast", "amount": "10.00"}, + ]}), + ); + m.ok( + "books.post_entry", + json!({"entry_id": id, "confirmed": true}), + ); + assert_eq!(m.party("Stephen Waits", 2026)["totals"]["paid"], "15.00"); +} + +#[test] +fn under_require_several_parties_satisfy_the_policy() { + let mut m = member_books("shares-require", "parties = \"require\""); + let id = m.draft( + "2026-07-01", + both(), + &[("2100", "debit", "40.00"), ("1000", "credit", "40.00")], + ); + let preview = m.ok("books.post_entry", json!({"entry_id": id})); + assert_eq!(preview["party_missing"], false); + m.ok( + "books.post_entry", + json!({"entry_id": id, "confirmed": true}), + ); + // The reversal carries both shares. + m.ok( + "books.reverse_entry", + json!({"entry_id": id, "date": "2026-07-02", "description": "undo", "confirmed": true}), + ); + assert_eq!( + m.party("Katherine Pendergast", 2026)["totals"]["paid"], + "0.00" + ); + m.read_only("books.verify", json!({})); +} diff --git a/crates/ledger-mcp/tests/spec_vault.rs b/crates/ledger-mcp/tests/spec_vault.rs index 600f3db..64d3777 100644 --- a/crates/ledger-mcp/tests/spec_vault.rs +++ b/crates/ledger-mcp/tests/spec_vault.rs @@ -8,7 +8,11 @@ //! (DECISION, Stephen, 2026-10-15): a full TIN is never in the journal, the //! SQLite projection, the viewer, or any tool output, except the 1099 //! worksheet for the CPA when asked (`books.vendor_1099` with -//! `include_tins`). The harness is `tests/spec_parties.rs`'s, with a +//! `include_tins`); and the vault holds everyone's, members included, for +//! the CPA package, which carries them only in a zip saved outside the +//! books dir (DECISION, Stephen, 2026-10-15: "In the vault, that's the +//! WHOLE reason we built it. And it needs to be in the damn CPA +//! package."). The harness is `tests/spec_parties.rs`'s, with a //! transcript of every response and the server's stderr kept, and the vault //! key in a temp file outside the books dir (never `~/.config`). @@ -355,8 +359,13 @@ const CHART: &[(&str, &str, &str)] = &[ /// Books with the chart (no receipts needed), a contractor "Acme Design" /// with a vendor record marked NEC, and a landlord "Rent Co". fn books(name: &str) -> Mcp { + books_with(name, "") +} + +/// `books`, with `extra` TOML after `[books]` (members, say). +fn books_with(name: &str, extra: &str) -> Mcp { let dir = test_dir(name); - let toml = books_toml(&dir, "1000000.00", ""); + let toml = books_toml(&dir, "1000000.00", extra); let mut m = Mcp::start(&dir, &toml); m.accounts(CHART); m.add_party("Acme Design", &["contractor"]); @@ -374,6 +383,54 @@ const SSN: &str = "571-93-2846"; const SSN_DIGITS: &str = "571932846"; const EIN: &str = "47-3829105"; const EIN_DIGITS: &str = "473829105"; +/// A member's SSN, unlike the others. +const MEMBER_SSN: &str = "638-27-4915"; +const MEMBER_SSN_DIGITS: &str = "638274915"; + +/// Every file in a zip, by name. +fn unzip(path: &Path) -> HashMap> { + 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> { + let split = |l: &str| { + let (mut cells, mut cur, mut quoted) = (Vec::new(), String::new(), false); + let mut chars = l.chars().peekable(); + while let Some(c) = chars.next() { + match c { + '"' if quoted && chars.peek() == Some(&'"') => { + cur.push('"'); + chars.next(); + } + '"' => quoted = !quoted, + ',' if !quoted => cells.push(std::mem::take(&mut cur)), + c => cur.push(c), + } + } + cells.push(cur); + cells + }; + let text = String::from_utf8(bytes.to_vec()).unwrap(); + let mut lines = text.lines(); + let head = split(lines.next().expect("a header")); + lines + .map(|l| { + let cells = split(l); + assert_eq!(cells.len(), head.len(), "{l}"); + head.iter().cloned().zip(cells).collect() + }) + .collect() +} impl Mcp { fn books_dir(&self) -> PathBuf { @@ -921,7 +978,10 @@ fn purge_deletes_the_vault_records_and_journals_why() { /// encrypted. #[test] fn no_full_tin_leaves_the_vault_except_the_worksheet_when_asked() { - let mut m = books("no-leak"); + let mut m = books_with( + "no-leak", + "[[company.members]]\nname = \"Kat\"\nshare = \"100%\"\ncapital_account = \"3000\"\n", + ); let src = m.w9_file("acme-w9.pdf", SSN); m.w9( "Acme Design", @@ -958,6 +1018,21 @@ fn no_full_tin_leaves_the_vault_except_the_worksheet_when_asked() { json!({"party": "Acme Design", "notes": format!("ssn {SSN}")}), ); m.refuse("books.add_party", json!({"name": format!("Acme {EIN}")})); + // A member's SSN, by books.set_tin, and refusals of it. + m.add_party("Kat", &["member"]); + m.ok( + "books.set_tin", + json!({"party": "Kat", "tin": MEMBER_SSN, "tin_type": "ssn"}), + ); + for args in [ + json!({"party": "Kat", "tin": MEMBER_SSN, "tin_type": "itin"}), + json!({"party": "Kat", "tin": MEMBER_SSN_DIGITS.parse::().unwrap() as f64, + "tin_type": "ssn"}), + json!({"party": "Nobody", "tin": MEMBER_SSN, "tin_type": "ssn"}), + json!({"party": "Rent Co", "tin": MEMBER_SSN, "tin_type": "ssn"}), + ] { + m.refuse("books.set_tin", args); + } // Everything else, read and write. for (tool, args) in [ ("books.parties", json!({})), @@ -988,8 +1063,15 @@ fn no_full_tin_leaves_the_vault_except_the_worksheet_when_asked() { ), ( "books.export_cpa_package", - json!({"as_of": "2026-12-31", "cash_accounts": ["1000"]}), + json!({"as_of": "2026-12-31", "cash_accounts": ["1000"], "include_tins": false}), ), + ( + "books.export_cpa_package", + json!({"as_of": "2026-12-31", "cash_accounts": ["1000"], + "save_to": m.outside().join("cpa.zip").to_str().unwrap()}), + ), + ("books.party", json!({"party": "Kat"})), + ("books.parties", json!({"role": "member"})), ( "books.purge_tin", json!({"party": "Rent Co", "reason": "preview only"}), @@ -1029,7 +1111,8 @@ fn no_full_tin_leaves_the_vault_except_the_worksheet_when_asked() { tool == "books.vendor_1099" && resp.contains("\"warning\":\"This worksheet"); !worksheet && (holds_tin(resp.as_bytes(), SSN_DIGITS) - || holds_tin(resp.as_bytes(), EIN_DIGITS)) + || holds_tin(resp.as_bytes(), EIN_DIGITS) + || holds_tin(resp.as_bytes(), MEMBER_SSN_DIGITS)) }) .collect(); assert!(leaks.is_empty(), "full TINs in tool output: {leaks:?}"); @@ -1037,6 +1120,23 @@ fn no_full_tin_leaves_the_vault_except_the_worksheet_when_asked() { // The server's log. let log = std::fs::read(m.outside().join("stderr.log")).unwrap(); assert!(!holds_tin(&log, SSN_DIGITS) && !holds_tin(&log, EIN_DIGITS)); + assert!(!holds_tin(&log, MEMBER_SSN_DIGITS)); + // The package saved outside the books carries them all: the member's + // in the K-1 worksheet, the payees' in the 1099 worksheet. + let pkg = unzip(&m.outside().join("cpa.zip")); + assert!(holds_tin(&pkg["k1-members.csv"], MEMBER_SSN_DIGITS)); + let w1099 = &pkg["1099-worksheet-2026.csv"]; + assert!(holds_tin(w1099, SSN_DIGITS) && holds_tin(w1099, EIN_DIGITS)); + for (name, bytes) in &pkg { + if name != "k1-members.csv" && name != "1099-worksheet-2026.csv" { + assert!( + !holds_tin(bytes, SSN_DIGITS) + && !holds_tin(bytes, EIN_DIGITS) + && !holds_tin(bytes, MEMBER_SSN_DIGITS), + "{name} in the package holds a TIN" + ); + } + } // Every byte in the books dir outside the vault: the journal, the // database (and its WAL), the stored files, the settings. let books = m.books_dir(); @@ -1047,7 +1147,9 @@ fn no_full_tin_leaves_the_vault_except_the_worksheet_when_asked() { } let bytes = std::fs::read(&f).unwrap(); assert!( - !holds_tin(&bytes, SSN_DIGITS) && !holds_tin(&bytes, EIN_DIGITS), + !holds_tin(&bytes, SSN_DIGITS) + && !holds_tin(&bytes, EIN_DIGITS) + && !holds_tin(&bytes, MEMBER_SSN_DIGITS), "{} holds a full TIN", f.display() ); @@ -1058,11 +1160,13 @@ fn no_full_tin_leaves_the_vault_except_the_worksheet_when_asked() { } // The vault holds them encrypted only. let vault_files = files_under(&m.vault()); - assert!(vault_files.len() >= 3, "{vault_files:?}"); + assert!(vault_files.len() >= 4, "{vault_files:?}"); for f in vault_files { let bytes = std::fs::read(&f).unwrap(); assert!( - !holds_tin(&bytes, SSN_DIGITS) && !holds_tin(&bytes, EIN_DIGITS), + !holds_tin(&bytes, SSN_DIGITS) + && !holds_tin(&bytes, EIN_DIGITS) + && !holds_tin(&bytes, MEMBER_SSN_DIGITS), "{} holds a TIN in the clear", f.display() ); @@ -1094,3 +1198,638 @@ fn books_in_git_must_ignore_the_vault() { .refused() ); } + +// ------------------------------------------------- books.set_tin, any role + +/// The TIN facts a party's last `party.tin_set` journaled. +impl Mcp { + fn journaled_tin(&self, party: u64) -> Value { + String::from_utf8(self.journal()) + .unwrap() + .lines() + .filter_map(|l| serde_json::from_str::(l).ok()) + .filter(|v| v["type"] == "party.tin_set" || v["line_type"] == "party.tin_set") + .rfind(|v| v["payload"]["id"] == json!(party)) + .map(|v| v["payload"]["facts"].clone()) + .expect("a party.tin_set") + } +} + +/// Any role may have its TIN in the vault: a member (for the K-1), an +/// employee, a customer, a party with no role at all. The books journal +/// its type, last four, and record hash; the output shows the last four. +#[test] +fn set_tin_takes_any_party_members_included() { + let mut m = books("set-tin"); + for (name, roles) in [ + ("Kat", vec!["member"]), + ("Pat", vec!["employee"]), + ("Nobody In Particular", vec![]), + ] { + let id = m.add_party(name, &roles); + let p = m.ok( + "books.set_tin", + json!({"party": name, "tin": MEMBER_SSN, "tin_type": "ssn"}), + ); + assert_eq!(p["tin"]["last4"], "4915", "{p}"); + assert_eq!(p["tin"]["tin_type"], "ssn"); + assert_eq!(p["tin"]["in_vault"], true); + assert!(!holds_tin(p.to_string().as_bytes(), MEMBER_SSN_DIGITS)); + assert!(m.vault().join(format!("tin-{id}.bin")).exists()); + let facts = m.journaled_tin(id); + assert_eq!(facts["last4"], "4915"); + assert_eq!(facts["tin_type"], "ssn"); + assert_eq!(facts["vault_record"].as_str().unwrap().len(), 64); + assert_eq!(m.party(name, 2026)["party"]["tin"]["last4"], "4915"); + } + // A customer (its role comes with its record). + m.ok("books.add_customer", json!({"name": "Client"})); + let p = m.ok( + "books.set_tin", + json!({"party": "Client", "tin": EIN, "tin_type": "ein"}), + ); + assert_eq!(p["tin"]["last4"], "9105"); + assert!(p["roles"].as_array().unwrap().contains(&json!("customer"))); + // An EIN for a member that is a company; a replacement TIN. + let kat = m.party_id("Kat"); + let first = m.journaled_tin(kat)["vault_record"].clone(); + m.ok( + "books.set_tin", + json!({"party": "Kat", "tin": EIN, "tin_type": "ein"}), + ); + let facts = m.journaled_tin(kat); + assert_eq!( + (facts["last4"].clone(), facts["tin_type"].clone()), + (json!("9105"), json!("ein")) + ); + assert_ne!(facts["vault_record"], first); + assert!(!holds_tin(&m.journal(), EIN_DIGITS)); + assert_eq!(m.ok("books.verify", json!({}))["status"], "ok"); +} + +#[test] +fn set_tin_refusals_write_nothing() { + let mut m = books("set-tin-refusals"); + m.add_party("Kat", &["member"]); + // A W-9 payee whose W-9 facts name another last four. + m.w9( + "Acme Design", + json!({"tin_last4": "1111", "kept_at": "binder"}), + ); + let acme = m.party_id("Acme Design"); + for (party, tin, kind) in [ + ("Kat", json!("57193284"), "ssn"), // eight digits + ("Kat", json!("971-93-2846"), "ssn"), // SSN starting 9 + ("Kat", json!(SSN), "itin"), // not an ITIN + ("Kat", json!(SSN), "passport"), // no such type + ("Kat", json!(571932846.0), "ssn"), // a float + ("Nobody", json!(SSN), "ssn"), // no such party + ("Acme Design", json!(SSN), "ssn"), // W-9 says 1111 + ] { + let why = m.refuse( + "books.set_tin", + json!({"party": party, "tin": tin, "tin_type": kind}), + ); + assert!(!holds_tin(why.as_bytes(), SSN_DIGITS), "{why}"); + } + m.refuse("books.set_tin", json!({"party": "Kat", "tin_type": "ssn"})); + assert!(!m.vault().join(format!("tin-{acme}.bin")).exists()); + assert!(!m.key().exists(), "no key made for a refusal"); + assert_eq!(m.count("party.tin_set"), 0); +} + +/// A W-9 with a TIN records the party's TIN facts too, and the two never +/// disagree: set_tin agreeing with the W-9 moves its vault record along; a +/// W-9 with a new last four replaces the TIN facts. +#[test] +fn w9_and_tin_facts_agree() { + let mut m = books("w9-tin"); + let acme = m.party_id("Acme Design"); + m.w9("Acme Design", json!({"tin": SSN, "tin_type": "ssn"})); + let p = m.party("Acme Design", 2026); + assert_eq!(p["party"]["tin"]["last4"], "2846"); + assert_eq!(p["party"]["tin"]["in_vault"], true); + let (w9, tin) = (m.journaled_w9(acme), m.journaled_tin(acme)); + assert_eq!(w9["vault_record"], tin["vault_record"]); + // books.set_tin with the same number: both facts follow the new record. + m.ok( + "books.set_tin", + json!({"party": "Acme Design", "tin": SSN, "tin_type": "ssn"}), + ); + let (w9b, tinb) = (m.journaled_w9(acme), m.journaled_tin(acme)); + assert_ne!(tinb["vault_record"], tin["vault_record"], "a fresh nonce"); + assert_eq!(w9b["vault_record"], tinb["vault_record"]); + // W-9 facts without a TIN that disagree with the TIN facts: refused. + m.refuse( + "books.set_w9", + json!({"party": "Acme Design", "received_on": "2026-01-05", + "entity_type": "x", "tin_last4": "1111", "kept_at": "binder"}), + ); + // A new W-9 with a new TIN: the TIN facts are replaced (cleared first). + let before = m.count("party.tin_set"); + m.w9( + "Acme Design", + json!({"tin": "571-93-1111", "tin_type": "ssn"}), + ); + assert_eq!(m.count("party.tin_set"), before + 2, "cleared, then set"); + assert_eq!(m.journaled_tin(acme)["last4"], "1111"); + assert_eq!(m.journaled_w9(acme)["tin_last4"], "1111"); + m.pay("Acme Design", "2500.00"); + let v = m.read_only( + "books.vendor_1099", + json!({"year": 2026, "include_tins": true}), + ); + assert_eq!(v["vendors"][0]["tin"], "571-93-1111"); + assert_eq!(m.ok("books.verify", json!({}))["status"], "ok"); +} + +/// books.purge_tin covers TIN facts: a member's record goes, the books +/// keep the last four and say when and why. +#[test] +fn purge_covers_tin_facts() { + let mut m = books("purge-tin-facts"); + let kat = m.add_party("Kat", &["member"]); + m.ok( + "books.set_tin", + json!({"party": "Kat", "tin": MEMBER_SSN, "tin_type": "ssn"}), + ); + let p = m.ok( + "books.purge_tin", + json!({"party": "Kat", "reason": "left the company"}), + ); + assert_eq!(p["status"], "preview"); + assert_eq!(p["tin_in_vault"], true); + assert_eq!(p["tin_last4"], "4915"); + let p = m.ok( + "books.purge_tin", + json!({"party": "Kat", "reason": "left the company", "confirmed": true}), + ); + assert_eq!(p["journaled"], true); + assert!(!m.vault().join(format!("tin-{kat}.bin")).exists()); + let facts = m.journaled_tin(kat); + assert!(facts["vault_record"].is_null(), "{facts}"); + assert_eq!(facts["last4"], "4915"); + assert!( + facts["purged"] + .as_str() + .unwrap() + .contains("left the company") + ); + let party = m.party("Kat", 2026); + assert_eq!(party["party"]["tin"]["in_vault"], false); + m.refuse( + "books.purge_tin", + json!({"party": "Kat", "reason": "again", "confirmed": true}), + ); + assert_eq!(m.ok("books.verify", json!({}))["status"], "ok"); +} + +// ------------------------------------------------- the CPA package + +const MEMBERS: &str = "[[company.members]]\nname = \"Stephen Example\"\nshare = \"50%\"\ncapital_account = \"3000\"\n\ + [[company.members]]\nname = \"Kat Example\"\nshare = \"50%\"\ncapital_account = \"3001\"\n"; + +/// Books for the K-1 worksheet, hand-worked: +/// +/// ```text +/// 2025-06-01 Dr 1000 2000 / Cr 3000 1000, Cr 3001 1000 both contribute cash +/// 2025-07-01 Dr 1000 400 / Cr 4000 400 2025 income, 200 each +/// 2026-02-01 Dr 1500 600 / Cr 3000 600 Stephen: equipment +/// 2026-03-01 Dr 1000 500 / Cr 3001 500 Kat: cash +/// 2026-03-15 Dr 6999 100 / Cr 3000 100 Stephen paid an expense +/// 2026-04-01 Dr 1000 1000 / Cr 4000 1000 revenue +/// 2026-05-01 Dr 3000 150, Dr 3001 150 / Cr 1000 300 distributions +/// 2026-03-01 Dr 6000 2500 / Cr 1000 2500 Acme Design (m.pay) +/// 2026: net income 1000 - 100 - 2500 = -1600, -800 each. +/// Stephen: beginning 1200, cash 100, property 600, income -800, +/// distributions 150, ending 950. +/// Kat: beginning 1200, cash 500, property 0, income -800, +/// distributions 150, ending 750. +/// Check: bank 1100 + equipment 600 = 1700 = 950 + 750. +/// ``` +/// +/// Stephen's party has his SSN in the vault; Kat's has none. Acme Design +/// (NEC, an EIN in the vault) is paid 2,500.00 in 2026. +fn k1_books(name: &str) -> Mcp { + let mut m = books_with(name, MEMBERS); + m.accounts(&[ + ("1500", "Equipment", "asset"), + ("3001", "Capital Kat", "equity"), + ("4000", "App Revenue", "revenue"), + ]); + m.add_party("Stephen Example", &["member"]); + m.add_party("Kat Example", &["member"]); + m.ok( + "books.set_tin", + json!({"party": "Stephen Example", "tin": MEMBER_SSN, "tin_type": "ssn"}), + ); + m.w9("Acme Design", json!({"tin": EIN, "tin_type": "ein"})); + let none = json!({}); + m.post( + "2025-06-01", + none.clone(), + &[ + ("1000", "debit", "2000.00"), + ("3000", "credit", "1000.00"), + ("3001", "credit", "1000.00"), + ], + ); + m.post( + "2025-07-01", + none.clone(), + &[("1000", "debit", "400.00"), ("4000", "credit", "400.00")], + ); + m.post( + "2026-02-01", + none.clone(), + &[("1500", "debit", "600.00"), ("3000", "credit", "600.00")], + ); + m.post( + "2026-03-01", + none.clone(), + &[("1000", "debit", "500.00"), ("3001", "credit", "500.00")], + ); + m.post( + "2026-03-15", + none.clone(), + &[("6999", "debit", "100.00"), ("3000", "credit", "100.00")], + ); + m.post( + "2026-04-01", + none.clone(), + &[("1000", "debit", "1000.00"), ("4000", "credit", "1000.00")], + ); + m.post( + "2026-05-01", + none, + &[ + ("3000", "debit", "150.00"), + ("3001", "debit", "150.00"), + ("1000", "credit", "300.00"), + ], + ); + m.pay("Acme Design", "2500.00"); + m +} + +/// The package carries the K-1 worksheet (one row per member: party, +/// share, capital account, TIN, and the hand-worked capital figures) and +/// the 1099 worksheet with full TINs, saved outside the books only; a +/// missing TIN is a gap, never a failure. +#[test] +fn the_cpa_package_carries_the_k1_and_1099_worksheets_with_tins() { + let mut m = k1_books("k1"); + let to = m.outside().join("cpa-2026.zip"); + let out = m.read_only( + "books.export_cpa_package", + json!({"as_of": "2026-12-31", "cash_accounts": ["1000"], + "save_to": to.to_str().unwrap()}), + ); + assert_eq!(out["saved_to"], json!(to.display().to_string())); + assert!(out["content_base64"].is_null(), "never the bytes: {out}"); + assert_eq!(out["tins_included"], true); + assert!( + out["note"] + .as_str() + .unwrap() + .contains(&to.display().to_string()) + ); + let gaps: Vec<&str> = out["tin_gaps"] + .as_array() + .unwrap() + .iter() + .map(|g| g.as_str().unwrap()) + .collect(); + assert_eq!(gaps.len(), 1, "{gaps:?}"); + assert!(gaps[0].contains("Kat Example") && gaps[0].contains("TIN not in the vault")); + assert!(!holds_tin(out.to_string().as_bytes(), MEMBER_SSN_DIGITS)); + assert!(!holds_tin(out.to_string().as_bytes(), EIN_DIGITS)); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = std::fs::metadata(&to).unwrap().permissions().mode(); + assert_eq!(mode & 0o777, 0o600, "the package is the owner's only"); + } + let pkg = unzip(&to); + let k1 = csv_rows(&pkg["k1-members.csv"]); + assert_eq!(k1.len(), 3, "two members and their total"); + let (stephen, kat) = (&k1[0], &k1[1]); + // The total row: not a K-1; ties to the balance sheet (1,700.00). + let total = &k1[2]; + assert_eq!(total["member"], "Total, all members (not a K-1)"); + assert_eq!(total["tin"], ""); + for (col, v) in [ + ("beginning_capital", "2400.00"), + ("contributions_cash", "600.00"), + ("contributions_property", "600.00"), + ("net_income", "-1600.00"), + ("distributions", "300.00"), + ("other", "0.00"), + ("ending_capital", "1700.00"), + ] { + assert_eq!(total[col], v, "{col}"); + } + let sid = m.party_id("Stephen Example"); + let kid = m.party_id("Kat Example"); + for (row, want) in [ + ( + stephen, + [ + ("member", "Stephen Example"), + ("party_id", sid.to_string().as_str()), + ("share", "0.50"), + ("capital_account", "3000"), + ("tin", MEMBER_SSN), + ("tin_type", "ssn"), + ("tin_note", ""), + ("beginning_capital", "1200.00"), + ("contributions_cash", "100.00"), + ("contributions_property", "600.00"), + ("net_income", "-800.00"), + ("distributions", "150.00"), + ("other", "0.00"), + ("ending_capital", "950.00"), + ], + ), + ( + kat, + [ + ("member", "Kat Example"), + ("party_id", kid.to_string().as_str()), + ("share", "0.50"), + ("capital_account", "3001"), + ("tin", ""), + ("tin_type", ""), + ("tin_note", "TIN not in the vault"), + ("beginning_capital", "1200.00"), + ("contributions_cash", "500.00"), + ("contributions_property", "0.00"), + ("net_income", "-800.00"), + ("distributions", "150.00"), + ("other", "0.00"), + ("ending_capital", "750.00"), + ], + ), + ] { + for (col, v) in want { + let got = row + .get(col) + .unwrap_or_else(|| panic!("no column {col}: {row:?}")); + if col == "tin_note" && !v.is_empty() { + assert!(got.starts_with(v), "{col}: {got}"); + } else if col == "share" { + assert_eq!( + got.trim_end_matches('0').trim_end_matches('.'), + "0.5", + "{col}" + ); + } else { + assert_eq!(got, v, "{col} of {row:?}"); + } + } + } + // The 1099 worksheet: the payee's full TIN. + let w = csv_rows(&pkg["1099-worksheet-2026.csv"]); + let acme = w.iter().find(|r| r["vendor"] == "Acme Design").unwrap(); + assert_eq!(acme["tin"], EIN); + assert_eq!(acme["tin_type"], "ein"); + assert_eq!(acme["tin_last4"], "9105"); + assert_eq!(acme["reportable"], "true"); + let readme = String::from_utf8(pkg["README.txt"].clone()).unwrap(); + assert!( + readme.contains("k1-members.csv") && readme.contains("Kat Example"), + "{readme}" + ); + assert!(!holds_tin(readme.as_bytes(), MEMBER_SSN_DIGITS)); + // Nothing of it in the books dir. + for f in files_under(&m.books_dir()) { + if f.starts_with(m.vault()) { + continue; + } + let b = std::fs::read(&f).unwrap(); + assert!( + !holds_tin(&b, MEMBER_SSN_DIGITS) && !holds_tin(&b, EIN_DIGITS), + "{}", + f.display() + ); + assert!(!f.to_string_lossy().ends_with(".zip"), "{}", f.display()); + } + + // A folder: the zip goes in it, by its name. + let folder = m.outside().join("for-krista"); + std::fs::create_dir_all(&folder).unwrap(); + let out = m.read_only( + "books.export_cpa_package", + json!({"as_of": "2026-12-31", "cash_accounts": ["1000"], + "save_to": folder.to_str().unwrap()}), + ); + assert_eq!( + out["saved_to"], + json!( + folder + .join("cpa-package-2026-12-31.zip") + .display() + .to_string() + ) + ); + + // Without TINs: last fours only, returned as base64. + let out = m.read_only( + "books.export_cpa_package", + json!({"as_of": "2026-12-31", "cash_accounts": ["1000"], "include_tins": false}), + ); + assert_eq!(out["tins_included"], false); + let zip = base64::Engine::decode( + &base64::engine::general_purpose::STANDARD, + out["content_base64"].as_str().unwrap(), + ) + .unwrap(); + let plain_path = m.outside().join("plain.zip"); + std::fs::write(&plain_path, &zip).unwrap(); + let plain = unzip(&plain_path); + let k1 = csv_rows(&plain["k1-members.csv"]); + assert_eq!(k1[0]["tin_last4"], "4915"); + assert!(!k1[0].contains_key("tin")); + assert_eq!(k1[0]["ending_capital"], "950.00"); + for bytes in plain.values() { + assert!(!holds_tin(bytes, MEMBER_SSN_DIGITS) && !holds_tin(bytes, EIN_DIGITS)); + } +} + +/// With TINs the package is never returned and never lands in the books: +/// no save_to, a path in the books dir or its exports/, an existing file, +/// or a path that is neither a folder nor a .zip is refused. +#[test] +fn a_package_with_tins_goes_only_outside_the_books() { + let mut m = k1_books("k1-where"); + let why = m.refuse( + "books.export_cpa_package", + json!({"as_of": "2026-12-31", "cash_accounts": ["1000"]}), + ); + assert!(why.contains("save_to"), "{why}"); + let books = m.books_dir(); + std::fs::create_dir_all(books.join("exports")).unwrap(); + let existing = m.outside().join("taken.zip"); + std::fs::write(&existing, b"x").unwrap(); + for to in [ + books.join("cpa.zip"), + books.join("exports"), + books.join("exports/cpa.zip"), + m.data.join("cpa.zip"), + books.join("vault/../cpa.zip"), + existing.clone(), + m.outside().join("cpa.txt"), + m.outside().join("no/such/dir/cpa.zip"), + ] { + m.refuse( + "books.export_cpa_package", + json!({"as_of": "2026-12-31", "cash_accounts": ["1000"], + "save_to": to.to_str().unwrap()}), + ); + } + assert_eq!(std::fs::read(&existing).unwrap(), b"x"); + assert!( + std::fs::read_dir(books.join("exports")) + .unwrap() + .next() + .is_none() + ); + assert!(!books.join("cpa.zip").exists()); +} + +/// A locked vault never fails the package: every TIN row says why, and +/// tin_gaps lists each member and payee. +#[test] +fn a_locked_vault_still_builds_the_package() { + let mut m = k1_books("k1-locked"); + std::fs::remove_file(m.key()).unwrap(); + let to = m.outside().join("cpa.zip"); + let out = m.read_only( + "books.export_cpa_package", + json!({"as_of": "2026-12-31", "cash_accounts": ["1000"], + "save_to": to.to_str().unwrap()}), + ); + let gaps = out["tin_gaps"].as_array().unwrap(); + assert_eq!(gaps.len(), 3, "two members and Acme: {gaps:?}"); + assert!( + gaps.iter().all(|g| g.as_str().unwrap().contains("locked")), + "{gaps:?}" + ); + assert!(!m.key().exists(), "reading never makes a key"); + let pkg = unzip(&to); + let k1 = csv_rows(&pkg["k1-members.csv"]); + let members: Vec<_> = k1 + .iter() + .filter(|r| !r["member"].starts_with("Total")) + .collect(); + assert_eq!(members.len(), 2); + assert!( + members + .iter() + .all(|r| r["tin"].is_empty() && r["tin_note"].contains("locked")) + ); + assert_eq!(k1[0]["ending_capital"], "950.00", "the numbers are there"); + let w = csv_rows(&pkg["1099-worksheet-2026.csv"]); + assert!(w.iter().all(|r| r["tin"].is_empty())); +} + +/// B-26 ("No tool output, error, or log holds a full TIN"): a TIN put in +/// the wrong field is refused, and the refusal never repeats it, however +/// it was written: as a party name, a bare number where a string goes, or +/// a share's percent. +#[test] +fn refusals_never_echo_a_tin() { + let mut m = books("echo"); + let id = m.post( + "2026-02-01", + json!({}), + &[("6999", "debit", "100.00"), ("1000", "credit", "100.00")], + ); + for (tool, args) in [ + ( + "books.set_tin", + json!({"party": MEMBER_SSN, "tin": MEMBER_SSN, "tin_type": "ssn"}), + ), + ( + "books.set_tin", + json!({"party": MEMBER_SSN_DIGITS.parse::().unwrap(), "tin": MEMBER_SSN, + "tin_type": "ssn"}), + ), + ( + "books.purge_tin", + json!({"party": "638.27.4915", "reason": "x", "confirmed": true}), + ), + ( + "books.set_entry_party", + json!({"entry_id": id, "parties": [ + {"party": "Acme Design", "percent": MEMBER_SSN}, + {"party": MEMBER_SSN, "amount": "1.00"}, + ]}), + ), + ] { + let why = m.refuse(tool, args.clone()); + assert!( + !holds_tin(why.as_bytes(), MEMBER_SSN_DIGITS), + "{tool} {args} echoed the TIN: {why}" + ); + } +} + +/// k1::member_party: a vendor whose display name is a member's name, with +/// a W-9 EIN in the vault, never puts its TIN on the member's K-1 row: +/// the member's TIN is a gap. +#[test] +fn a_k1_row_never_carries_another_partys_tin() { + let mut m = k1_books("k1-lookalike"); + m.ok( + "books.update_party", + json!({"party": "Kat Example", "archived": true}), + ); + m.add_party("KE Consulting LLC", &["contractor"]); + m.ok( + "books.update_party", + json!({"party": "KE Consulting LLC", "display_name": "Kat Example"}), + ); + m.w9( + "KE Consulting LLC", + json!({"tin": "12-3456789", "tin_type": "ein"}), + ); + let to = m.outside().join("cpa-lookalike.zip"); + let out = m.read_only( + "books.export_cpa_package", + json!({"as_of": "2026-12-31", "cash_accounts": ["1000"], + "save_to": to.to_str().unwrap()}), + ); + let gaps = out["tin_gaps"].to_string(); + assert!(gaps.contains("member Kat Example"), "{gaps}"); + let pkg = unzip(&to); + let k1 = csv_rows(&pkg["k1-members.csv"]); + let kat = k1.iter().find(|r| r["member"] == "Kat Example").unwrap(); + assert_eq!(kat["tin"], "", "{kat:?}"); + assert_eq!(kat["party_id"], "", "{kat:?}"); + assert!(!holds_tin(&pkg["k1-members.csv"], "123456789")); +} + +/// Members who file a joint individual return (files_jointly_with on +/// both): the README says each gets their own K-1 and both go on one Form +/// 1040; books.settings reports it; a one-sided setting is a settings error. +#[test] +fn joint_filers_are_noted_in_the_package() { + let joint = "[[company.members]]\nname = \"Stephen Example\"\nshare = \"50%\"\ncapital_account = \"3000\"\nfiles_jointly_with = \"Kat Example\"\n\ + [[company.members]]\nname = \"Kat Example\"\nshare = \"50%\"\ncapital_account = \"3001\"\nfiles_jointly_with = \"Stephen Example\"\n"; + let mut m = books_with("joint", joint); + m.accounts(&[("3001", "Capital Kat", "equity")]); + let s = m.read_only("books.settings", json!({})); + assert_eq!(s["members"][0]["files_jointly_with"], "Kat Example"); + assert_eq!(s["members"][1]["files_jointly_with"], "Stephen Example"); + let to = m.outside().join("joint.zip"); + m.read_only( + "books.export_cpa_package", + json!({"as_of": "2026-12-31", "cash_accounts": ["1000"], + "save_to": to.to_str().unwrap()}), + ); + let pkg = unzip(&to); + let readme = String::from_utf8(pkg["README.txt"].clone()).unwrap(); + let note = "Stephen Example and Kat Example file a joint individual return: each receives their own Schedule K-1; both are reported on the one Form 1040 (Schedule E, Part II)."; + assert_eq!(readme.matches(note).count(), 1, "once per pair: {readme}"); +} diff --git a/crates/ledger-store/src/store.rs b/crates/ledger-store/src/store.rs index 680db43..7e32978 100644 --- a/crates/ledger-store/src/store.rs +++ b/crates/ledger-store/src/store.rs @@ -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, + ) -> 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 = + 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 = + 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. diff --git a/crates/ledger-store/tests/parties.rs b/crates/ledger-store/tests/parties.rs index b127e3e..211e0e8 100644 --- a/crates/ledger-store/tests/parties.rs +++ b/crates/ledger-store/tests/parties.rs @@ -752,3 +752,190 @@ fn books_from_before_parties_open_and_get_parties_by_replay() { let s = Store::open(&dst).unwrap(); assert_eq!(view(s.ledger()), before); } + +// ------------------------------------------------------- party shares + +/// Oracle: Store::set_entry_parties doc (`entry.parties_set {entry_id, +/// parties: [{party_id, share}]}`, a share `{"amount": "30.00"}`, +/// `{"percent": "60"}`, or `"derived"`; refusals journal nothing) and +/// Store::verify (a fresh replay equals the live ledger). +mod shares { + use super::*; + use ledger_core::{PartyShare, Share}; + + fn share(party: PartyId, share: Share) -> PartyShare { + PartyShare { party, share } + } + + #[test] + fn set_entry_parties_journals_one_event_with_each_share() { + let dir = test_dir("shares-event"); + let (mut s, fx) = fixture(&dir); + let a = s.add_party("Stephen Waits", &[Role::Member]).unwrap(); + let b = s + .add_party("Katherine Pendergast", &[Role::Member]) + .unwrap(); + let ((), pl) = one_event(&mut s, &dir, "entry.parties_set", |s| { + s.set_entry_parties(fx.entry, &[PartyShare::whole(a), PartyShare::whole(b)]) + .unwrap() + }); + assert_eq!( + pl, + json!({"entry_id": fx.entry, "parties": [ + {"party_id": a, "share": "derived"}, + {"party_id": b, "share": "derived"}, + ]}) + ); + let ((), pl) = one_event(&mut s, &dir, "entry.parties_set", |s| { + s.set_entry_parties( + fx.entry, + &[ + share(a, Share::Amount(dec("300.00"))), + share(b, Share::Amount(dec("200.00"))), + ], + ) + .unwrap() + }); + assert_eq!( + pl, + json!({"entry_id": fx.entry, "parties": [ + {"party_id": a, "share": {"amount": "300.00"}}, + {"party_id": b, "share": {"amount": "200.00"}}, + ]}) + ); + let ((), pl) = one_event(&mut s, &dir, "entry.parties_set", |s| { + s.set_entry_parties( + fx.entry, + &[ + share(a, Share::Percent(dec("60"))), + share(b, Share::Percent(dec("40"))), + ], + ) + .unwrap() + }); + assert_eq!( + pl["parties"][0]["share"], + json!({"percent": "60"}), + "percent shares journal as strings" + ); + assert_eq!(s.ledger().entry_parties(fx.entry).len(), 2); + assert_eq!(s.ledger().entry_party(fx.entry), None); + } + + #[test] + fn refusals_journal_nothing() { + let dir = test_dir("shares-refused"); + let (mut s, fx) = fixture(&dir); + let a = s.add_party("A", &[Role::Member]).unwrap(); + let b = s.add_party("B", &[Role::Member]).unwrap(); + let v1 = s.add_vendor("V1").unwrap(); + let v2 = s.add_vendor("V2").unwrap(); + let (p1, p2) = ( + s.ledger().party_of_vendor(v1).unwrap(), + s.ledger().party_of_vendor(v2).unwrap(), + ); + let cases: Vec<(&str, EntryId, Vec)> = vec![ + ("unknown entry", fx.entry + 99, vec![PartyShare::whole(a)]), + ("unknown party", fx.entry, vec![PartyShare::whole(999)]), + ( + "duplicate", + fx.entry, + vec![PartyShare::whole(a), PartyShare::whole(a)], + ), + ( + "mixed", + fx.entry, + vec![share(a, Share::Percent(dec("50"))), PartyShare::whole(b)], + ), + ( + "percent sum", + fx.entry, + vec![ + share(a, Share::Percent(dec("50"))), + share(b, Share::Percent(dec("40"))), + ], + ), + ( + "sub-cent", + fx.entry, + vec![ + share(a, Share::Amount(dec("250.001"))), + share(b, Share::Amount(dec("249.999"))), + ], + ), + ( + "two vendors", + fx.entry, + vec![ + share(p1, Share::Amount(dec("250.00"))), + share(p2, Share::Amount(dec("250.00"))), + ], + ), + ]; + for (what, e, shares) in cases { + refused_nothing_journaled(&mut s, &dir, what, |s| s.set_entry_parties(e, &shares)); + } + } + + #[test] + fn replay_gives_equal_state() { + let dir = test_dir("shares-replay"); + let (mut s, fx) = fixture(&dir); + let a = s.add_party("Stephen Waits", &[Role::Member]).unwrap(); + let b = s + .add_party("Katherine Pendergast", &[Role::Member]) + .unwrap(); + let v = s.add_vendor("Contractor").unwrap(); + let pv = s.ledger().party_of_vendor(v).unwrap(); + s.set_entry_parties( + fx.entry, + &[ + share(a, Share::Amount(dec("300.00"))), + share(b, Share::Amount(dec("200.00"))), + ], + ) + .unwrap(); + // A second entry shared with a vendor party, then reversed. + let other = s.create_entry(date(2026, 2, 1), "Split work").unwrap(); + let exp = s + .add_account("6000", "Work", AccountType::Expense, None, false) + .unwrap(); + s.add_line(other, exp, Side::Debit, usd("90.00")).unwrap(); + s.add_line(other, fx.cash, Side::Credit, usd("90.00")) + .unwrap(); + s.post_entry(other).unwrap(); + s.set_entry_parties( + other, + &[ + share(pv, Share::Percent(dec("66.67"))), + share(a, Share::Percent(dec("33.33"))), + ], + ) + .unwrap(); + assert_eq!(s.ledger().entry_vendor(other), Some(v)); + let r = s.reverse_entry(other, date(2026, 2, 2), "undo").unwrap(); + assert_eq!(s.ledger().entry_parties(r), s.ledger().entry_parties(other)); + // An old-style single tag after a multi-party one. + let third = s.create_entry(date(2026, 3, 1), "Draft").unwrap(); + s.set_entry_parties(third, &[PartyShare::whole(a), PartyShare::whole(b)]) + .unwrap(); + s.set_entry_party(third, Some(b)).unwrap(); + s.verify().unwrap(); + let live = s.ledger().clone(); + drop(s); + for db in [true, false] { + if !db { + delete_db(&dir); + } + let s = Store::open(&dir).unwrap(); + assert!( + *s.ledger() == live, + "replay (db {db}): {:?}", + s.ledger().differs_from(&live) + ); + assert_eq!(s.ledger().entry_parties(r).len(), 2); + assert_eq!(s.ledger().entry_party(third), Some(b)); + s.verify().unwrap(); + } + } +} diff --git a/crates/ledger-store/tests/party_tin.rs b/crates/ledger-store/tests/party_tin.rs new file mode 100644 index 0000000..4018f77 --- /dev/null +++ b/crates/ledger-store/tests/party_tin.rs @@ -0,0 +1,144 @@ +//! Spec-derived tests for a party's taxpayer-ID facts in the store. +//! +//! Oracles: `Store::set_party_tin` ("journaled as `party.tin_set`" {id, +//! facts}), the `ledger_core::parties` spec (INVARIANTS 1, 2, 2a: any role, +//! never a full TIN, the TIN and W-9 facts agree), and `Store::verify` (a +//! fresh replay equals the live ledger). Values worked by hand. + +use chrono::NaiveDate; +use ledger_core::{Currency, Role, TinFacts, TinType, W9Facts}; +use ledger_store::journal::read_and_verify_journal; +use ledger_store::{DB_FILE, JOURNAL_FILE, JournalWriter, Store}; +use serde_json::{Value, json}; +use std::path::{Path, PathBuf}; + +fn test_dir(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("wb-party-tin-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir +} + +fn journal(dir: &Path) -> Vec<(String, Value)> { + read_and_verify_journal(&dir.join(JOURNAL_FILE)) + .unwrap() + .into_iter() + .map(|l| (l.line_type, l.payload)) + .collect() +} + +fn tin(last4: &str) -> TinFacts { + TinFacts { + tin_type: TinType::Ssn, + last4: last4.into(), + vault_record: Some("ab".repeat(32)), + purged: None, + } +} + +fn w9(last4: &str) -> W9Facts { + W9Facts { + received: NaiveDate::from_ymd_opt(2026, 1, 5).unwrap(), + entity_type: "individual/sole proprietor".into(), + exempt_payee_code: None, + fatca_code: None, + tin_last4: last4.into(), + kept_at: "wanderbooks vault".into(), + vault_record: None, + } +} + +fn delete_db(dir: &Path) { + for suffix in ["", "-wal", "-shm", "-journal"] { + let _ = std::fs::remove_file(dir.join(format!("{DB_FILE}{suffix}"))); + } +} + +/// Any role, journaled as one `party.tin_set {id, facts}` line, replayed +/// on open (with and without the database), and cleared with null. +#[test] +fn tin_facts_journal_and_replay_for_any_role() { + let dir = test_dir("replay"); + let mut s = Store::create(&dir, Currency::USD).unwrap(); + let member = s.add_party("Kat", &[Role::Member]).unwrap(); + let nobody = s.add_party("Plain", &[]).unwrap(); + s.set_party_tin(member, Some(tin("4915"))).unwrap(); + s.set_party_tin(nobody, Some(tin("1234"))).unwrap(); + s.set_party_tin(nobody, None).unwrap(); + let j = journal(&dir); + let tins: Vec<&(String, Value)> = j.iter().filter(|(t, _)| t == "party.tin_set").collect(); + assert_eq!(tins.len(), 3); + assert_eq!(tins[0].1["id"], json!(member)); + assert_eq!(tins[0].1["facts"]["last4"], "4915"); + assert_eq!(tins[0].1["facts"]["tin_type"], "ssn"); + assert!(tins[2].1["facts"].is_null()); + s.verify().unwrap(); + drop(s); + for without_db in [false, true] { + if without_db { + delete_db(&dir); + } + let s = Store::open(&dir).unwrap(); + assert_eq!(s.ledger().get_party(member).unwrap().tin, Some(tin("4915"))); + assert_eq!(s.ledger().get_party(nobody).unwrap().tin, None); + } +} + +/// The TIN and W-9 facts never disagree (INVARIANT 2a), and a refusal +/// journals nothing. +#[test] +fn tin_and_w9_facts_agree() { + let dir = test_dir("agree"); + let mut s = Store::create(&dir, Currency::USD).unwrap(); + let c = s.add_party("Acme", &[Role::Contractor]).unwrap(); + s.set_party_w9(c, Some(w9("2846"))).unwrap(); + let before = journal(&dir).len(); + assert!(s.set_party_tin(c, Some(tin("1111"))).is_err()); + assert!(s.set_party_tin(c, Some(tin("12"))).is_err(), "four digits"); + assert!( + s.set_party_tin( + c, + Some(TinFacts { + purged: Some("571932846".into()), + ..tin("2846") + }) + ) + .is_err(), + "never a full TIN" + ); + assert!(s.set_party_tin(99, Some(tin("2846"))).is_err()); + assert_eq!(journal(&dir).len(), before); + s.set_party_tin(c, Some(tin("2846"))).unwrap(); + assert!(s.set_party_w9(c, Some(w9("1111"))).is_err()); + // Cleared first, the W-9 may change, then the TIN facts follow. + s.set_party_tin(c, None).unwrap(); + s.set_party_w9(c, Some(w9("1111"))).unwrap(); + s.set_party_tin(c, Some(tin("1111"))).unwrap(); + s.verify().unwrap(); +} + +/// A `party.tin_set` line that can't replay is refused on open. +#[test] +fn corrupt_tin_payloads_are_refused_on_open() { + for (i, facts) in [ + json!({"tin_type": "passport", "last4": "1234"}), + json!({"tin_type": "ssn", "last4": "123456789"}), + json!({"tin_type": "ssn", "last4": "1234", "vault_record": "nothex"}), + json!({"last4": "1234"}), + ] + .into_iter() + .enumerate() + { + let dir = test_dir(&format!("corrupt-{i}")); + let mut s = Store::create(&dir, Currency::USD).unwrap(); + let id = s.add_party("A", &[Role::Member]).unwrap(); + drop(s); + let mut w = JournalWriter::open(&dir.join(JOURNAL_FILE)).unwrap(); + w.append("party.tin_set", json!({"id": id, "facts": facts})) + .unwrap(); + drop(w); + assert!(Store::open(&dir).is_err(), "{facts} opened"); + delete_db(&dir); + assert!(Store::open(&dir).is_err(), "{facts} opened without the db"); + } +} diff --git a/docs/mcp-interface.md b/docs/mcp-interface.md index 3d89882..963fdb6 100644 --- a/docs/mcp-interface.md +++ b/docs/mcp-interface.md @@ -33,7 +33,10 @@ to 100%) drives the statement of members' equity, `[books] accounting_basis` ("accrual" or "cash", default accrual) labels report footers, and `[books] receipt_threshold` sets the receipt policy unless `config.json` sets `receipt_policy`. Members may name a -`distribution_account`; single-owner businesses may set `[books] close_into` +`distribution_account`, and `files_jointly_with` (spouses filing one joint +return; the CPA package's README notes their two K-1s go on one Form 1040, +and k1-members.csv ends with a members' total, not a K-1, that ties to +Schedule L); single-owner businesses may set `[books] close_into` (the equity account a close puts net income in; default Retained Earnings) and `close_sweep` (draw, distribution, or dividend accounts closed into it). The cash flow statement's account roles are `[books] cash_accounts` (what @@ -112,21 +115,31 @@ below. - `books.chart` → `{ accounts: [{ id, code, name, type, parent, placeholder, balance }] }`. Balances included: one call answers "what do we have." - `books.draft_entry { date: "2026-10-10", description: "...", vendor?, - party? }` → `{ entry_id, status: "draft" }`. `party` tags the - counterparty (see "Tools — parties"); given with `vendor`, both must name - the same vendor record, else nothing is drafted. + party?, parties?, no_party_reason? }` → `{ entry_id, status: "draft" }`. + `party` tags the counterparty (see "Tools — parties"); given with + `vendor`, both must name the same vendor record, else nothing is + drafted. `parties` names several, each with a share (as + `books.set_entry_party`), in place of `party` and `vendor`. - `books.add_line { entry_id, account: "1000" | , side: "debit" | "credit", amount: "123.45" }` → `{ ok: true }`. A refused line (unknown account or class, bad amount) adds nothing. - `books.post_entry { entry_id, confirmed }` → `{ status, entry_id, date, description, total, lines (with resulting balances), receipts, - receipt_required, receipt_missing, split_detected }`. Without - `confirmed: true`, `status` is `"preview"` and nothing changes; with it, - the receipt policy is enforced and `status` is `"posted"`. + receipt_required, receipt_missing, split_detected, party_policy, + party_id?, party?, parties, parties_error?, no_party_reason?, + party_missing, party_suggestions }`. Without `confirmed: true`, `status` + is `"preview"` and nothing changes; with it, the receipt policy is + enforced and `status` is `"posted"`. `parties` lists every party the + entry names with its share resolved to the cent (`{ party_id, party, + share: "amount" | "percent" | "derived", amount?, percent?, resolved }`); + when the shares don't add up to what the entry moved, `parties_error` + says why and confirming fails with code `party_shares`. - `books.reverse_entry { entry_id, date, description, confirmed }` → same preview/confirm pattern as post. - `books.get_entry { entry_id }` → full entry with lines, receipts, and - its `vendor`, `party_id`, and `party` (the party's label) tags. + its `vendor`, `party_id`, and `party` (the party's label, when it names + exactly one) tags, and `parties` / `parties_error` as in the + `post_entry` preview. - `books.search_entries { account?, date_from?, date_to?, text?, limit?, offset? }` → entries with lines, newest first. `limit` caps at 100. - `books.trial_balance { as_of? }` → `as_of`, lines, totals, `balanced` @@ -290,14 +303,22 @@ is on file and the TIN's last four; full TINs live only in the TIN vault the tag. Metadata only: posted entries may be tagged. `books.draft_entry` takes `vendor` too; bills and their payments are tagged automatically. `books.set_entry_party` tags the vendor too when the party has a vendor - record, so tagging by party counts for 1099s the same way. + record, so tagging by party counts for 1099s the same way. On an entry + naming several parties the parties are `books.set_entry_party`'s: + tagging the vendor of a party already named keeps every share as it + is; tagging a vendor whose party isn't named, or clearing the tag while + its party is named, is refused (drop or add the party with `parties` + instead). An entry and its reversal always carry the same tags. - `books.vendor_1099 { year, threshold?, include_tins?, save_csv_to? }` → `{ year, threshold, vendors: [{ vendor_id, name, form_1099?, tin_on_file, paid, reportable, party_id?, tin_last4?, tin?, tin_type?, tin_note? }], missing_w9, notes, warning?, saved_csv? }`. For a year whose threshold wanderbooks doesn't know (after 2026 until the indexed amount is added) it refuses with what to do; pass `threshold` explicitly once the figure - is confirmed. `tin_last4` comes from the party's W-9 facts. With + is confirmed. On an entry naming several parties, `paid` counts only + the vendor's resolved share of the payment (rules.md B-19); an entry + whose shares don't resolve counts in full and `notes` names it. + `tin_last4` comes from the party's W-9 facts. With `include_tins: true` (default false) each line carries `tin`, the full TIN formatted as the IRS writes it (123-45-6789; 12-3456789 for an EIN), and `tin_type`, read from the vault, or `tin_note` saying why not @@ -359,11 +380,44 @@ texts are at most 500 characters, no control characters. `"wanderbooks vault"`. - `books.set_w4 { party, received_on, state, kept_at }` or `{ party, clear: true }` → the party. Journaled as `party.w4_set`. -- `books.set_entry_party { entry_id, party? }` → the entry (as - `books.get_entry`). `party` null or omitted clears the tag. A party with - a vendor record also tags the entry's vendor; one without, or clearing, - clears the vendor tag. Metadata only; posted entries may be tagged. - Journaled as `entry.party_set`. +- `books.set_entry_party { entry_id, party?, parties?, no_party_reason? }` + → the entry (as `books.get_entry`). `party` null or omitted clears the + tag. A party with a vendor record also tags the entry's vendor; one + without, or clearing, clears the vendor tag. Metadata only; posted + entries may be tagged. Tagging an entry that was reversed, or a + reversal, tags the other too (the whole reversal chain), so each + party's part of the pair still nets to zero. Journaled as + `entry.party_set`. + `no_party_reason` (with no party) marks an entry as having no + counterparty (`entry.no_party`). +- **Several parties:** `parties: [{ party, amount? | percent? }]`, in + place of `party`, names every counterparty with its share; `[]` clears. + Journaled as `entry.parties_set { entry_id, parties: [{ party_id, share + }] }`, `share` being `{"amount": "30.00"}`, `{"percent": "60"}`, or + `"derived"`. Each share, of what the entry moved (its `entry_flow` net; + for an entry moving no money, like property contributed, its total): + - `amount`: exactly that, positive, in cents; the amounts must sum to + what it moved. + - `percent`: that percent; the percents must sum to exactly 100. Each + part is rounded down to the cent and the leftover cents go to the + first party listed. + - neither (derived): a sole party gets all of it. Members named + together (parties with the member role named in `[[company.members]]`) + get what their own capital and distributions lines on the entry say, + when it has any (these must account for all it moved); else their + `[[company.members]]` shares, rescaled to the members named, rounded as + percents are. A derived share for a non-member among several is + refused. + Every party gives the same kind of share. Refused, journaling nothing: a + party named twice, an unknown party, mixed kinds, percents not summing + to 100, an amount finer than a cent, two parties with vendor records (a + 1099 counts a payment toward one vendor; the one vendor party's record + is tagged and counts the whole payment), and, once the entry has + balanced lines, shares that don't resolve. A draft's shares are checked + against its lines again at `post_entry`. Under `[books] parties = + "require"`, any party (or the no-party mark) satisfies the check. + Reversals carry every share. There is no "joint" party: a joint member + transaction names both members and lets the shares derive. - `books.parties { role?, include_archived?, year? }` → `{ year, parties: [{ party_id, name, label, roles, vendor_id?, customer_id?, w9_on_file, tin_last4?, w4_on_file, archived, paid, received }] }`, by id. @@ -397,7 +451,8 @@ include vendor and customer when it has those records. positive net was paid to the party, a negative one received from it; a reversal counts against its original's side. A bill isn't paid until its payment is; an entry only moving money between money accounts nets to - zero. + zero. An entry naming several parties counts this party's resolved + share only (an entry whose shares don't resolve counts for none). - `invoiced` / `billed`: its invoices' and bills' totals dated in the year. `owed` / `receivable`: its open bills' and invoices' balances now (every year). @@ -408,9 +463,12 @@ include vendor and customer when it has those records. ## Tools — the TIN vault -Full TINs (SSN, EIN, ITIN) and signed W-9 forms, kept so the CPA's 1099 -worksheet is complete, never in the books (`ledger-vault`; design in -`docs/parties-and-attachments.md` "The TIN vault"; B-26). +Full TINs (SSN, EIN, ITIN) and signed W-9 forms, kept so what the CPA +gets is complete (every member's TIN for their K-1, every payee's for +their 1099), never in the books (`ledger-vault`; design in +`docs/parties-and-attachments.md` "The TIN vault"; B-26). DECISION +(Stephen, 2026-10-15): the vault holds everyone's TIN, members included, +and the CPA package carries them. - **Where:** `vault/` in the books dir (the parent of the data dir, beside `wanderbooks.toml`; git-ignored), one file per record, each encrypted @@ -424,14 +482,31 @@ worksheet is complete, never in the books (`ledger-vault`; design in locked: the books open and verify; the worksheet says the TINs are missing. - **Never out:** no tool output, error, or log holds more than a TIN's - last four, except `books.vendor_1099 { include_tins: true }`. The tool - arguments that take a TIN never echo it, even when refused. + last four, except `books.vendor_1099 { include_tins: true }`; the CPA + package carries them only in its zip, written outside the books dir. + The tool arguments that take a TIN never echo it, even when refused. +- **TIN facts:** any party (any role) may have `tin` facts: + `{ tin_type: "ssn" | "ein" | "itin", last4, vault_record?, purged? }`, + journaled as `party.tin_set {id, facts|null}` and shown in every party + output as `tin: { tin_type, last4, in_vault, purged? }`. They never + disagree with the party's W-9 facts: both on file means the same last + four (the engine refuses otherwise). +- `books.set_tin { party, tin, tin_type }` → the party. Validates the TIN + as `books.set_w9` does, checks the facts on a copy of the ledger, writes + the TIN to the vault (the same key and git-ignore checks), and journals + `party.tin_set` with the record's hash. Any role. A party whose W-9 + facts name another last four is refused (a payee's new TIN comes with + its W-9: `books.set_w9`); one whose W-9 facts agree gets their + `vault_record` updated too (`party.w9_set`). - `books.set_w9 { ..., tin, tin_type, w9_file_path? }` (above): validates the TIN against its type (nine digits, hyphens allowed; an ITIN starts with 9 and has an IRS group in digits 4-5; an SSN or EIN doesn't start with 9), checks the facts on a copy of the ledger, then writes the vault and journals the facts with `vault_record`, the SHA-256 of the TIN - record (of the form's record when only a form is given). The form (PDF, + record (of the form's record when only a form is given). With a TIN it + also journals the party's TIN facts (`party.tin_set`, as + `books.set_tin`); a TIN with a new last four clears the old TIN facts + first (`party.tin_set` null), so the two never disagree. The form (PDF, PNG, JPEG; 25 MB at most) goes to the vault only, never the attachment store. Facts given later without a TIN keep the vault record when their last four agree with the vault's TIN, and are refused when they don't @@ -447,8 +522,10 @@ worksheet is complete, never in the books (`ledger-vault`; design in journaled, note }`. Without `confirmed`, a preview that changes nothing. Confirmed, deletes the party's vault records (needs no key) and, when the party has W-9 facts, journals `party.w9_set` with `vault_record` cleared, - the last four kept, and `kept_at` saying when and why it was purged. - Refused when the reason is blank or there is nothing to purge. + the last four kept, and `kept_at` saying when and why it was purged; + when it has TIN facts, `party.tin_set` likewise, with `purged` saying + when and why. Refused when the reason is blank or there is nothing to + purge. ## Tools — attachments @@ -794,8 +871,38 @@ entries. ## Tools — CPA handoff -- `books.export_cpa_package { as_of?, cash_accounts?, financing_accounts?, investing_accounts? }` - → `{ file_name, content_base64 }`. A zip with the full set, each +- `books.export_cpa_package { as_of?, cash_accounts?, financing_accounts?, + investing_accounts?, include_receipts?, include_tins?, save_to? }` → + `{ file_name, content_base64?, saved_to?, tins_included, tin_gaps, note }`. + **TINs** (`include_tins`, default true; DECISION, Stephen, 2026-10-15): + the K-1 worksheet and the 1099 worksheet carry every member's and + payee's full TIN from the vault, so the zip is written to `save_to` (a + new `.zip` path, or an existing folder it goes in as + `cpa-package-.zip`; outside the books dir, `exports/` included; + mode 0600) and the output is its path, never its bytes; without + `save_to` it is refused. A locked vault or a missing TIN never fails the + package: the row says why ("TIN not in the vault", "the TIN vault is + locked", a record that disagrees with the books), the README lists it, + and `tin_gaps` names each member and each reportable payee without one. + `include_tins: false`: last fours only, returned as `content_base64` + unless `save_to` is given. **K-1 worksheet** (`k1-members.csv`, with + `[[company.members]]`): one row per member, in order: `member, + party_id, share, capital_account, tin, tin_type, tin_note` (without + TINs: `tin_last4, tin_type`), then `beginning_capital, + contributions_cash, contributions_property, net_income, distributions, + other, ending_capital` for the fiscal year to date: the statement of + members' equity's figures (`ledger_core::k1`), contributions split into + property (what a contribution debits to asset accounts other than cash, + receivables, and prepaid accounts: a prepaid schedule's account, or one + named "prepaid") and cash (the rest, including a service the members + paid ahead). Property in an entry crediting several members is shared + in proportion to their credits. An entry and its reversal both in the + year cancel: neither is a contribution or a distribution. A member's + party is the party with the member's name (not a display name) and the + member role, as the viewer's capital statement finds it; with none, the + member's TIN is a gap. + **1099 worksheet** with TINs adds `party_id, tin_last4, tin, tin_type, + tin_note`. A zip with the full set, each statement as PDF and XLSX: `profit-and-loss`, `balance-sheet`, `cash-flow` (reconciled to `cash_accounts`), `members-equity` (multi-member LLCs), `trial-balance`; plus `general-ledger.xlsx` and diff --git a/docs/parties-and-attachments.md b/docs/parties-and-attachments.md index c98486a..f0fa3b9 100644 --- a/docs/parties-and-attachments.md +++ b/docs/parties-and-attachments.md @@ -62,7 +62,8 @@ Rules (to vendor as B-26 with sources): ### Links - Entries: `entry.party_set {entry_id, party_id|null}` generalizes - `entry.vendor_set`; an entry has one counterparty. + `entry.vendor_set` (one counterparty); `entry.parties_set` names + several, each with a share (below). - Invoices and bills already name a customer or vendor id: those are party ids after migration. - Payroll lines name an employee party id (old runs keep their name @@ -74,9 +75,94 @@ Rules (to vendor as B-26 with sources): `PartyField` per event, `{field, value}`: name, display_name, website, address, notes, archived), `party.role_set {id, role, on}`, `party.w9_set {id, facts|null}`, `party.w4_set {id, facts|null}`, -`entry.party_set {entry_id, party_id|null}`. Replayed and projected like +`party.tin_set {id, facts|null}` (TIN facts: type, last four, vault +record, when purged), `entry.party_set {entry_id, party_id|null}`, +`entry.parties_set {entry_id, parties: [{party_id, share}]}` (a share is +`{"amount": "30.00"}`, `{"percent": "60"}`, or `"derived"`; `[]` +clears). Replayed and projected like the rest; every event gets viewer journal text (the GUI test enforces it). +### Shares: an entry with several parties + +DECISION (Stephen, 2026-10-15): there is no "joint" party. A joint +transaction is with both members and shows on both members' pages, each +with their share; apportioning is explicit and exact when it isn't +derived. Spec: `ledger_core::shares`. + +- An entry names a list of `PartyShare {party, share}`; `Share` is + `Amount(d)`, `Percent(p)`, or `Derived`. Tagging one party + (`set_entry_party`) is one derived share: all of it. + `Ledger::entry_party` means "the sole party"; `entry_parties` lists + them all. +- **What a share is of:** the entry's `entry_flow` net (what it moved + with its counterparties), or its total debits when the net is zero (a + member contributing property moves no money but is still theirs). +- **Amount:** exactly that, positive, in cents; the amounts must sum to + what the entry moved. +- **Percent:** more than 0, at most 100, summing to exactly 100. Each part + is rounded toward zero to the cent and the leftover cents (fewer than + one per party) go to the first party in order. +- **Derived:** a sole party gets all of it. Several parties must all be + members (`[[company.members]]` matched by name to a party with the + member role; core is handed them as `PartyMember`s). If the entry has + lines on a named member's capital or distributions account, each + member gets what their own lines say (and those must account for all + it moved); otherwise each gets their income share, rescaled to the + members named, rounded as percents are. +- **Refused** (journaling nothing): a party named twice, an unknown + party, mixed kinds of share, percents not summing to 100, an amount + finer than a cent, and two parties with vendor records. A 1099 counts + a payment toward a single vendor: the vendor tag follows the one party + with a vendor record, and the 1099 worksheet counts the whole payment + toward it, so name a vendor among others only when the whole payment + was its (otherwise record its part as its own entry). Whether shares + add up depends on the settings (cash accounts, members), so core + checks only their form; the MCP server checks they resolve once the + entry has balanced lines, and `post_entry` refuses (`party_shares`) + until they do. Replay never consults settings. +- **Money:** `parties::activity_with` counts each party's resolved share; + an entry moving no money counts nothing; an entry whose shares don't + resolve counts for no one (listed as unresolved). Reversals carry + every share, so a reversal subtracts each party's part; re-tagging + either one (parties, shares, or vendor) re-tags the other, so the pair + always nets to zero. +- **For the agent:** a joint member transaction (a reimbursement on Due + to Shareholders, a contribution or distribution from joint funds) + names both members with no shares and lets them derive; an uneven + split gives amounts. + +### A member's capital statement + +A member's party page (role member, named in `[[company.members]]`, +whose capital account is in the chart) is their capital account +statement over the page's period (`Books::capital_statement`). Every +figure is the CPA package's K-1 worksheet row for that member and span +(`ledger_core::k1::k1_detail`, itself the statement of changes in +members' equity split into cash and property), so the page and the +K-1 never disagree: + +- capital at the start of the period (including their share of earnings + not yet closed into capital); +- add contributions of cash (deposits, and what they paid for the + company) and of property (debits to asset accounts that are neither + cash nor receivables, at the value booked: fair market value); +- add their share of the period's net income, closed or not; +- less distributions; +- any other change in their capital, when there is one; +- capital at the end of the period (always opening plus the movements, + exactly); +- their share of profit, loss, and capital; and their part of what the + company owes the members jointly, split by the members' shares: the + balance of the `[books] financing_accounts` liabilities (the + liabilities members advance), or, only when that is not set, of the + liability accounts named "due to" / "owed to" (such as 2100 Due to + Shareholders). + +Cash and property are told apart by `[books] cash_accounts`. Without it +the statement reads money as the engine does (every asset account but +receivables), so every contribution reads as cash; set `cash_accounts` +to see property apart. (The Cash page keeps its own name-based guess.) + ### Migration Old journals replay unchanged. `vendor.added` and `customer.added` keep @@ -87,7 +173,13 @@ same name share one party; a second vendor of a taken name gets a new party named "Name (2)" (names stay unique). Vendor and customer names obey the party text rules (no full TIN). `entry.vendor_set` also sets the entry's party, and `entry.party_set` to a party with a vendor record sets -the vendor tag: one counterparty. +the vendor tag: one counterparty. On an entry naming several parties the +vendor tag follows the parties, never the other way: `entry.vendor_set` +for the vendor of a party already named keeps every share; for any other +vendor, or clearing the tag while its party is named, it is refused. A +vendor record can't link to a party named beside another party with a +vendor record (refused: the entry would name two vendors); linking one +tags the entries naming the party that have no vendor tag yet. ### Tools @@ -115,13 +207,22 @@ year. The directory shows the same columns, sortable. ## The TIN vault -Decision (Stephen, 2026-10-15): wanderbooks holds full TINs, so the 1099 -worksheet it hands the CPA is complete, but never in the journal. +Decision (Stephen, 2026-10-15): wanderbooks holds full TINs, so what it +hands the CPA is complete, but never in the journal. The vault holds +everyone's, members included, and the CPA package carries them: "In the +vault, that's the WHOLE reason we built it. And it needs to be in the +damn CPA package." - **Where:** `vault/` in the books dir, listed in `.gitignore`. Never in the hash-chained journal, the SQLite projection, git, an export other - than the 1099 worksheet, or the viewer. Nothing outside the vault holds - more than the last four digits. + than the 1099 worksheet and the CPA package (saved outside the books + dir), or the viewer. Nothing outside the vault holds more than the last + four digits. +- **Who:** any party, whatever its roles: every member (their K-1 needs + it) and every 1099 payee. The books keep the party's TIN facts (type, + last four, the vault record's hash; `party.tin_set`), which never + disagree with its W-9 facts' last four. The viewer shows "TIN on file + ••••1234 (SSN)" on the party's page, never more. - **What:** per party, the full TIN, its type (SSN, EIN, ITIN), and the W-9 file itself, each encrypted at rest (XChaCha20-Poly1305, one random nonce per record; the file format carries a version and the party id as @@ -139,24 +240,35 @@ worksheet it hands the CPA is complete, but never in the journal. - **Purge:** `books.purge_tin {party, reason}` deletes the vault record (after the retention period, or on request) and journals that it was purged; the journal's facts stay. -- **Use:** only `books.vendor_1099` with `include_tins: true` (and the CPA - package, when asked) decrypts them. The MCP server never returns a TIN - in any other tool, log, or error. Tests check that no other output - contains a nine-digit run. +- **Use:** `books.vendor_1099` with `include_tins: true` and + `books.export_cpa_package` (its K-1 worksheet, `k1-members.csv`, and its + 1099 worksheet; `include_tins` defaults to true there) decrypt them. + The package is written only to `save_to`, outside the books dir, and + the tool returns its path; it is refused without one. The MCP server + never returns a TIN in any other tool, log, or error. Tests check that + no other output contains a nine-digit run. - **Books without a vault** (another machine, a restored backup) still - open and verify; the 1099 worksheet then says which TINs are missing. + open and verify; the 1099 worksheet and the CPA package then say which + TINs are missing (`tin_gaps`), and the package is still built. - **Tools** (built; `docs/mcp-interface.md` "Tools — the TIN vault"): `books.set_w9` takes `tin` + `tin_type` and `w9_file_path` (the form goes to the vault, never the attachment store) and journals the last four with `vault_record`; facts that disagree with the vault's TIN are refused, and clearing facts with a vault record needs a purge first. + With a TIN it also records the party's TIN facts. `books.set_tin + {party, tin, tin_type}` stores any party's TIN (a member's SSN, say) the + same way and journals `party.tin_set`; a party whose W-9 facts name + another last four is refused (a payee's new TIN comes with its W-9). `books.get_w9 {party, save_to}` writes the form to a new file outside the books dir and returns only the path. `books.purge_tin {party, reason, confirmed?}` previews, then deletes the records (no key needed) and journals the purge as `party.w9_set` with `vault_record` cleared and - `kept_at` saying when and why. `books.vendor_1099 {include_tins: true}` - is the one output with full TINs (with a warning; `save_csv_to` writes - the CSV outside the books dir). A key file inside the books dir is + `kept_at` saying when and why, and `party.tin_set` with `vault_record` + cleared and `purged` saying when and why. `books.vendor_1099 {include_tins: true}` + is the one tool output with full TINs (with a warning; `save_csv_to` + writes the CSV outside the books dir); `books.export_cpa_package + {save_to}` writes them in its zip, outside the books dir, and returns + only the path. A key file inside the books dir is refused. `tests/spec_vault.rs` scans every tool response, the server's log, and every file in the books dir outside `vault/` for the TINs in any formatting. diff --git a/docs/spec/rules.md b/docs/spec/rules.md index faab054..df3ccae 100644 --- a/docs/spec/rules.md +++ b/docs/spec/rules.md @@ -234,6 +234,14 @@ closed period). period at a time: a member's ending on one statement is their beginning on the next, to the cent, and a member's period income is the roll-forward's remainder (their share within a rounding cent). +- A correction undoes what it corrects (B-9): an entry and its reversal + both in a statement's span are neither contributions nor + distributions (`reports::cancelled_in_span`; the K-1 worksheet reads + the same). The K-1 worksheet's contributed property (Item L) is what + the member owned and handed over, with its own basis (§723): never a + prepaid service they paid for the company (cash), and shared among + members credited in one entry in proportion to their credits + (`k1` INVARIANTS 3, 4, 6). **Enforced:** `CloseTo::Members`, `periods::allocate`, `kernel::apportion`, `members_equity`. **Checked:** Kani @@ -387,6 +395,38 @@ bill is not paid until it is (`form1099::vendor_1099_report_with`, checked by `payments_come_from_cash_and_financing_only`). A W-9 is on file by the vendor's flag or its party's W-9 facts. +An entry naming several parties (a payment shared between a contractor +and a member; `ledger_core::shares`) counts only the vendor's share +toward its 1099: what was paid to the payee (§6041(a)), not what the +entry moved for others. The share is resolved (`shares::resolve`) +against the entry's flow, the same target the MCP server checks shares +against, then scaled to the 1099 payment: the share unchanged when the +payment equals the target (the usual case, an expense paid from cash or +by a member), else payment × share ÷ target, rounded to the nearest cent, +half away from zero (so a part charged to a card scales out, 1099-K). A +percent split's leftover cent goes to the party named first (shares +INVARIANT 8). A reversal carries the shares and subtracts the same part, +and re-apportioning either one re-apportions the other +(`Ledger::set_entry_parties` INVARIANT 5), so the pair nets to zero. The +vendor tag follows the parties: on an entry naming several, +`set_entry_vendor` never replaces their shares (refused unless the +vendor's party is named), and a vendor record never links to a party +named beside another vendor (`add_vendor`), checked by +`tagging_the_vendor_keeps_a_shared_entrys_parties`, +`clearing_the_vendor_on_a_shared_entry_is_refused`, +`re_apportioning_a_reversed_entry_re_apportions_its_reversal`, +`a_named_party_gaining_a_vendor_record_keeps_one_vendor_per_entry`. +Shares that don't resolve count the whole payment toward the vendor (the +conservative answer) and the entry is listed (`Vendor1099Report:: +unresolved`; a `books.vendor_1099` note). An entry naming one party +counts as before (`form1099::vendor_1099_report_with_members`, checked +by `a_shared_payment_counts_the_vendors_share`, +`a_percent_split_gives_the_leftover_cent_to_the_first`, +`a_reversed_shared_payment_nets_to_zero`, +`a_shared_payment_partly_on_a_card_scales_to_the_payment`, +`unresolved_shares_count_in_full_and_are_listed`). DECISION (Stephen, +2026-10-15: shares). + **Enforced:** `form1099::default_threshold`, a dated rule (`ledger_core::rules::THRESHOLD_1099`, known through 2026, review from 2026-11-01). For a later year the engine refuses with `RuleNotKnown` @@ -525,20 +565,35 @@ The full TIN, when the books need it for the 1099 worksheet, lives in the TIN vault (`vault/` in the books dir, git-ignored; encrypted under a key file outside the books dir): `books.set_w9` `tin` writes it there and journals only the last four and the vault record's hash; the signed W-9 -goes there too, never to the attachment store. No tool output, error, or -log holds a full TIN except `books.vendor_1099` with `include_tins` -(DECISION, Stephen, 2026-10-15: for the CPA, when asked); files holding -one (`books.get_w9`, `save_csv_to`) are written only outside the books -dir. `books.purge_tin` deletes a party's vault records and journals that -it did. +goes there too, never to the attachment store. Any party may have its +TIN in the vault, whatever its roles: every member's (for their K-1) and +every 1099 payee's belongs there (DECISION, Stephen, 2026-10-15: "In the +vault, that's the WHOLE reason we built it. And it needs to be in the +damn CPA package."). `books.set_tin` stores one for any party; the books +journal its TIN facts (type, last four, the record's hash: +`party.tin_set`), which never disagree with its W-9 facts' last four. No +tool output, error, or log holds a full TIN except `books.vendor_1099` +with `include_tins` (DECISION, Stephen, 2026-10-15: for the CPA, when +asked): a refusal that quotes its input masks any TIN in it +(`parties::redact_full_tins`, checked by `refusals_never_echo_a_tin`), +and a party text field refuses nine digits however they are separated +(space, hyphen, dot, slash, underscore; `has_full_tin`). Files holding one (`books.get_w9`, `save_csv_to`, and the CPA +package, whose K-1 and 1099 worksheets carry every member's and payee's +TIN by default) are written only outside the books dir, and the package +comes back only as its path. A locked vault or a missing TIN never fails +the package: it says which TINs are missing. `books.purge_tin` deletes a +party's vault records and journals that it did. **Enforced:** `crate::parties` (has_full_tin, check_text, W9Facts::check, W4Facts::check), `Ledger::add_party`, `update_party`, `set_party_role`, -`set_party_w9`, `set_party_w4`, `set_entry_party`, `validate_payroll`; -`ledger-vault` (Tin::new, the encrypted records, records_held, -purge_records); `ledger-mcp` `books.set_w9`, `books.get_w9`, -`books.purge_tin`, `books.vendor_1099` (open_vault refuses a key file in -the books dir; outside_books for files with a TIN). +`set_party_w9`, `set_party_w4`, `set_party_tin` (TinFacts::check; TIN and +W-9 facts agree), `set_entry_party`, `validate_payroll`; `ledger-core` +`k1` (the K-1 worksheet); `ledger-vault` (Tin::new, the encrypted +records, records_held, purge_records); `ledger-mcp` `books.set_w9`, +`books.set_tin`, `books.get_w9`, `books.purge_tin`, `books.vendor_1099`, +`books.export_cpa_package` (open_vault refuses a key file in the books +dir; outside_books for files with a TIN; vault_tin checks the vault +against the books). **Checked:** `ledger-core` `tests/spec_parties.rs`; `ledger-store` `tests/parties.rs`; `ledger-vault` `tests/vault.rs`; `ledger-mcp` `tests/spec_parties.rs`, `tests/spec_vault.rs` (every response, the log,