diff --git a/crates/ledger-core/src/cashflow.rs b/crates/ledger-core/src/cashflow.rs index a9f5551..ba355a5 100644 --- a/crates/ledger-core/src/cashflow.rs +++ b/crates/ledger-core/src/cashflow.rs @@ -717,6 +717,10 @@ enum Bucket { Equity(String), /// A `financing_accounts` liability, by code. Borrowing(String), + /// A liability a distribution was declared to (rule 2: distributions + /// payable), by code: its payment is a distribution paid (ASC + /// 230-10-45-15(a)), never a supplier's. + Declared(String), } /// One line of an entry, or one part of a line split between sections @@ -751,6 +755,10 @@ struct Roles<'a> { receivables: Option, member_of: HashMap, members: Vec, + /// Liabilities a distribution was declared to (rule 2). + declared: BTreeSet, + /// The registered fixed assets' depreciation expense accounts (rule 8). + depreciation: BTreeSet, } impl<'a> Roles<'a> { @@ -791,9 +799,11 @@ impl<'a> Roles<'a> { } let mut cost = BTreeSet::new(); let mut accum = BTreeSet::new(); + let mut depreciation = BTreeSet::new(); for asset in ledger.registered_assets() { cost.insert(asset.cost_account); accum.insert(asset.accum_depr_account); + depreciation.insert(asset.expense_account); } let mut member_of = HashMap::new(); for (i, m) in params.members.iter().enumerate() { @@ -802,11 +812,19 @@ impl<'a> Roles<'a> { member_of.insert(d, i); } } + let cash: BTreeSet = params.cash_accounts.iter().copied().collect(); + let financing: BTreeSet = params.financing_accounts.iter().copied().collect(); + let investing: BTreeSet = params.investing_accounts.iter().copied().collect(); + let declared = declared_payables(ledger, params.end, |id| { + !cash.contains(&id) && !financing.contains(&id) && !investing.contains(&id) + }); Ok(Roles { ledger, - cash: params.cash_accounts.iter().copied().collect(), - financing: params.financing_accounts.iter().copied().collect(), - investing: params.investing_accounts.iter().copied().collect(), + declared, + depreciation, + cash, + financing, + investing, cost, accum, receivables: ledger.role_account(&crate::RECEIVABLES), @@ -843,6 +861,8 @@ impl<'a> Roles<'a> { } } else if self.financing.contains(&a.id) { (Flow::Financing, Bucket::Borrowing(a.code.clone())) + } else if self.declared.contains(&a.id) { + (Flow::Financing, Bucket::Declared(a.code.clone())) } else if Some(a.id) == self.receivables { (Flow::Operating, Bucket::Receivables) } else { @@ -929,6 +949,14 @@ impl<'a> Roles<'a> { (1, 1, c.clone()), format!("Distributions to {owners} — {}", named(c)), ), + (Bucket::Declared(c), true) => ( + (0, 2, c.clone()), + format!("Contributions from {owners} — {}", named(c)), + ), + (Bucket::Declared(c), false) => ( + (1, 2, c.clone()), + format!("Distributions to {owners} — {}", named(c)), + ), (Bucket::Borrowing(c), true) => ( (2, 0, c.clone()), format!("Proceeds from borrowing — {}", named(c)), @@ -941,6 +969,50 @@ impl<'a> Roles<'a> { } } +/// The liabilities that hold distributions declared by `end` (rules.md +/// B-14 rule 2), among the accounts `open` leaves to be placed: each +/// credited, in the posted entries dated on or before `end` (closes and +/// reversals aside), only by entries whose debits are all to equity (Dr +/// Distributions / Cr Distributions Payable; a corporation's Dr Retained +/// Earnings / Cr Dividends Payable), at least once. A liability credited +/// by any other entry as well (what the members advanced, an accrued +/// expense, money borrowed) is not one: its payments are what they paid +/// for. Paying one is a distribution paid, a financing +/// outflow (ASC 230-10-45-15(a)), and the declaration an exchange inside +/// financing, disclosed (50-3, 50-4), never a payment to a supplier and a +/// noncash operating item: before the P3 seam review a distribution +/// declared then paid read as cash paid to suppliers. +fn declared_payables( + ledger: &Ledger, + end: NaiveDate, + open: impl Fn(AccountId) -> bool, +) -> BTreeSet { + let chart = ledger.chart(); + let of = |id: AccountId, t: AccountType| chart.get(id).is_some_and(|a| a.account_type == t); + let closings = ledger.closing_entry_ids(); + let (mut declared, mut other) = (BTreeSet::new(), BTreeSet::new()); + for e in ledger + .posted_entries() + .filter(|e| e.date() <= end && !closings.contains(&e.id()) && e.reversal_of().is_none()) + { + let lines = e.lines(); + let declares = lines + .iter() + .filter(|l| l.side == Side::Debit) + .all(|l| of(l.account, AccountType::Equity)); + for l in lines { + if l.side == Side::Credit && of(l.account, AccountType::Liability) && open(l.account) { + if declares { + declared.insert(l.account); + } else { + other.insert(l.account); + } + } + } + } + declared.difference(&other).copied().collect() +} + /// Where a kind sits in its section: its group (contributions before /// distributions, purchases before sales, ...), then its member or /// account. @@ -1842,18 +1914,27 @@ fn reconcile( const WHAT: &str = "the operating reconciliation"; let mut net_income = Decimal::ZERO; let mut gains = (Decimal::ZERO, Vec::new()); + // A disposal's lines on a fixed asset's depreciation expense account + // (the depreciation still due through the disposal day, or what the + // month's run posted past it, credited back): depreciation, not gain + // or loss, so the line equals the P&L's depreciation expense. + let mut disposed = (Decimal::ZERO, Vec::new()); // X by key: 0 depreciation, 1 through an account (by code), 2 other. let mut noncash: BTreeMap<(u8, String), (Decimal, Vec)> = BTreeMap::new(); // W by code: the operating balance-sheet accounts' movements. let mut balances: BTreeMap = BTreeMap::new(); for a in kept { let shown = !paired.contains(&a.entry.id()); - let mut g = Decimal::ZERO; + let (mut g, mut dep) = (Decimal::ZERO, Decimal::ZERO); for p in &a.parts { if p.pl { net_income = add(net_income, -p.signed(), WHAT)?; if p.flow == Flow::Investing { - g = add(g, p.signed(), WHAT)?; + if roles.depreciation.contains(&p.account) { + dep = add(dep, p.signed(), WHAT)?; + } else { + g = add(g, p.signed(), WHAT)?; + } } } else if p.flow == Flow::Operating { let code = roles.account(p.account)?.code.clone(); @@ -1867,6 +1948,12 @@ fn reconcile( gains.1.push(a.entry.id()); } } + if !dep.is_zero() { + disposed.0 = add(disposed.0, dep, WHAT)?; + if shown { + disposed.1.push(a.entry.id()); + } + } let x = a.flows.noncash[0]; if x.is_zero() { continue; @@ -1904,6 +1991,15 @@ fn reconcile( }; let mut depreciation = None; let mut rest = Vec::new(); + if !disposed.0.is_zero() { + let slot = noncash + .entry((0, String::new())) + .or_insert((Decimal::ZERO, Vec::new())); + slot.0 = add(slot.0, disposed.0, WHAT)?; + slot.1.extend(disposed.1); + slot.1.sort_unstable(); + slot.1.dedup(); + } for ((key, code), (amount, entries)) in noncash { if amount.is_zero() { continue; diff --git a/crates/ledger-core/src/tax_capital.rs b/crates/ledger-core/src/tax_capital.rs index 4fca8f7..1c0235a 100644 --- a/crates/ledger-core/src/tax_capital.rs +++ b/crates/ledger-core/src/tax_capital.rs @@ -63,6 +63,53 @@ use crate::{AccountId, AssetId, AssetStatus, EntryId, Ledger, LedgerError, Side} use chrono::NaiveDate; use rust_decimal::Decimal; +/// The depreciation an asset's disposal entry posts, if it was disposed in +/// `[start, end]`: its lines on the asset's depreciation expense account, +/// debit-positive (the depreciation still due through the disposal day, +/// or, negative, what the month's run posted past it, credited back: +/// rules.md B-15 rule 5). With the runs +/// (`Ledger::depreciation_posted_between`) it is the asset's depreciation +/// expense in the span, the P&L's and the cash flow's (B-14 rule 8). +/// The disposal entry is the posted entry dated the disposal day that +/// credits the asset's cost account by its cost (`Store::dispose_asset` +/// posts it so). +fn disposal_depreciation( + ledger: &Ledger, + a: &crate::Asset, + start: NaiveDate, + end: NaiveDate, +) -> Result { + let AssetStatus::Disposed { date, .. } = a.status else { + return Ok(Decimal::ZERO); + }; + if date < start || date > end { + return Ok(Decimal::ZERO); + } + let closings = ledger.closing_entry_ids(); + let Some(e) = ledger.posted_entries().find(|e| { + e.date() == date + && !closings.contains(&e.id()) + && e.reversal_of().is_none() + && e.lines().iter().any(|l| { + l.side == Side::Credit + && l.account == a.cost_account + && l.amount.amount == a.cost.amount + }) + }) else { + return Ok(Decimal::ZERO); + }; + e.lines() + .iter() + .filter(|l| l.account == a.expense_account) + .try_fold(Decimal::ZERO, |t, l| { + t.checked_add(match l.side { + Side::Debit => l.amount.amount, + Side::Credit => -l.amount.amount, + }) + }) + .ok_or_else(|| LedgerError::TooLargeToCompute(format!("asset {}'s depreciation", a.id))) +} + /// A figure the books can't know, with what they do have. #[derive(Clone, Debug, PartialEq, Eq)] pub enum Open { @@ -104,7 +151,8 @@ pub enum Open { /// Treas. Reg. §1.704-3): the CPA's. The book depreciation posted in /// the span, all members, and the part on contributed property. TaxDepreciation { - /// Book depreciation posted in the span from runs. + /// Book depreciation posted in the span: the runs, and what + /// disposals posted to depreciation expense (the P&L's). book: Decimal, /// The part of it on contributed property. contributed_book: Decimal, @@ -424,7 +472,10 @@ fn span( if a.in_service_date <= end && !gone_before { in_service = true; } - let d = ledger.depreciation_posted_between(a.id, start, end)?; + let d = ledger + .depreciation_posted_between(a.id, start, end)? + .checked_add(disposal_depreciation(ledger, a, start, end)?) + .ok_or_else(too_large)?; book_depr = book_depr.checked_add(d).ok_or_else(too_large)?; if a.basis.as_ref().is_some_and(|b| b.rule.is_contribution()) { contributed_depr = contributed_depr.checked_add(d).ok_or_else(too_large)?; diff --git a/crates/ledger-core/tests/spec_cash_flow.rs b/crates/ledger-core/tests/spec_cash_flow.rs index c582d4e..c79654d 100644 --- a/crates/ledger-core/tests/spec_cash_flow.rs +++ b/crates/ledger-core/tests/spec_cash_flow.rs @@ -3124,3 +3124,108 @@ proptest! { prop_assert!(cf.noncash_disclosures.is_empty()); } } + +/// Rule 2 (P3 seam review): a distribution declared to a liability that +/// holds only distributions (Dr capital / Cr 2400 Distributions Payable) +/// and paid later is a distribution paid, financing (ASC +/// 230-10-45-15(a)), under the payable's kind; the declaration is an +/// exchange inside financing, disclosed (50-3, 50-4). Before the fix the +/// payment was "Cash paid to suppliers and employees" and the declaration +/// an "Other noncash items" line beside an increase in the payable. +#[test] +fn a_distribution_declared_then_paid_is_a_distribution_paid() { + let mut b = funded(); + b.acct("2400", "Distributions payable", AccountType::Liability); + let declared = b.post( + d(2026, 1, 31), + "Distribution declared", + &[ + ("3000", DR, "600.00"), + ("3001", DR, "400.00"), + ("2400", CR, "1000.00"), + ], + ); + let paid = b.post( + d(2026, 2, 10), + "Distribution paid", + &[("2400", DR, "1000.00"), ("1000", CR, "1000.00")], + ); + let cf = feb(&b, &[], &[]); + assert!(cf.operating.is_empty(), "{:?}", cf.operating); + assert_eq!( + kinds(&cf, CashFlowSection::Financing), + [( + s("Distributions to members — 2400 Distributions payable"), + x("-1000.00") + )] + ); + assert_eq!(em(&cf.financing), [(paid, x("-1000.00"))]); + assert!(recon(&cf).is_empty(), "{:?}", recon(&cf)); + // The declaration's month: no cash, the exchange disclosed, and no + // operating item. + let jan = b.cf(d(2026, 1, 1), d(2026, 1, 31), &["1000", "1010"], &[], &[]); + foots(&jan); + assert_eq!(disclosed(&jan), [(declared, x("1000.00"))]); + assert!( + recon(&jan).is_empty(), + "{:?}", + jan.operating_reconciliation.lines + ); + // A liability credited for anything else as well is no distributions + // payable: an expense accrued to it keeps it operating. + b.post( + d(2026, 2, 20), + "Accrued supplies", + &[("6000", DR, "50.00"), ("2400", CR, "50.00")], + ); + let cf = feb(&b, &[], &[]); + assert_eq!( + cf.operating.iter().map(|l| l.entry).collect::>(), + [paid] + ); +} + +/// Rule 8 (P3 seam review): a disposal's lines on the asset's +/// depreciation expense account (the days a month's run posted past the +/// disposal, credited back) are depreciation on the reconciliation, not +/// gain or loss, so its depreciation line is the P&L's depreciation +/// expense. +#[test] +fn a_disposals_depreciation_credit_back_is_depreciation() { + let mut b = funded(); + b.post( + d(2026, 1, 10), + "Buy equipment", + &[("1500", DR, "1000.00"), ("1000", CR, "1000.00")], + ); + b.post( + d(2026, 2, 28), + "Depreciation", + &[("6100", DR, "30.00"), ("1510", CR, "30.00")], + ); + // Sold Feb 14: 15 of the month's 30.00 taken back, a 10.00 loss on + // the 985.00 book value. + b.post( + d(2026, 2, 14), + "Sell equipment", + &[ + ("1000", DR, "975.00"), + ("1510", DR, "30.00"), + ("6900", DR, "10.00"), + ("1500", CR, "1000.00"), + ("6100", CR, "15.00"), + ], + ); + let cf = feb(&b, &[], &[]); + assert_eq!( + recon(&cf), + [ + (s("Depreciation"), x("15.00")), + ( + s("(Gain) loss and other noncash changes in investing assets"), + x("10.00") + ), + ] + ); + assert_eq!(cf.cash_from_investing, x("975.00")); +} diff --git a/crates/ledger-core/tests/spec_reports.proptest-regressions b/crates/ledger-core/tests/spec_reports.proptest-regressions index c245572..0321990 100644 --- a/crates/ledger-core/tests/spec_reports.proptest-regressions +++ b/crates/ledger-core/tests/spec_reports.proptest-regressions @@ -9,3 +9,4 @@ cc 9f5e63191433240d6df895c196fdd89b56ce1a2a3d8d5225740c2f10dc720756 # shrinks to cc 5397d840688ed53dc033aceaf98b8db5df3a7a2616c4afe7f3f5fc2e0c8775f2 # shrinks to gens = [Gen { day: 140, mode: 2, picks: [0, 0, 2], x: 1, y: 1, classes: [0, 0, 0] }], (a, _m, e) = (0, 0, 140), designate = false cc abd84709578e7889c275a0019ae0e71366d77526ae93072777be614d7ffb0dd5 # shrinks to gens = [Gen { day: 108, mode: 1, picks: [1, 5, 3], x: 1, y: 1, classes: [0, 0, 0] }], (a, _m, e) = (0, 0, 108), k = 2 cc 7481a0fb806d08439357ce4be66224389a7f76a7d91bf93ac320bb2b3c4d4891 # shrinks to gens = [Gen { day: 3, mode: 1, picks: [4, 5, 1], x: 1, y: 1, classes: [0, 0, 0] }], drafts = [Gen { day: 0, mode: 0, picks: [0, 0, 0], x: 1, y: 1, classes: [0, 0, 0] }], (a, _m, e) = (0, 0, 3) +cc b594a44c6ad193ee922103d535d0a8e7b41ff808b37166c296491f27cd73d5a4 # shrinks to gens = [Gen { day: 0, mode: 2, picks: [3, 0, 2], x: 1, y: 1, classes: [0, 0, 0] }, Gen { day: 135, mode: 2, picks: [0, 3, 2], x: 1, y: 1, classes: [0, 0, 0] }], (a, _m, e) = (0, 0, 135), designate = false diff --git a/crates/ledger-core/tests/spec_reports.rs b/crates/ledger-core/tests/spec_reports.rs index 881701a..a1d0417 100644 --- a/crates/ledger-core/tests/spec_reports.rs +++ b/crates/ledger-core/tests/spec_reports.rs @@ -2517,12 +2517,33 @@ proptest! { let credits = |qs: &[i64]| qs.iter().map(|q| (-q).max(0)).sum::(); let debits = |qs: &[i64]| qs.iter().map(|q| (*q).max(0)).sum::(); let mut fin = 0i64; + // Rule 2: a liability left operating that only entries debiting + // nothing but equity credit, through the end, holds distributions + // declared: financing, after the loan. + let declared = |code: &str| { + let mut by_declaration = false; + for g in gens.iter().filter(|g| g.date() <= day(e)) { + let legs = g.legs(1); + if !legs.iter().any(|&(i, s, _)| s == CR && RCHART[i].0 == code) { + continue; + } + if legs.iter().filter(|l| l.1 == DR).all(|&(i, _, _)| RCHART[i].2 == AccountType::Equity) { + by_declaration = true; + } else { + return false; + } + } + by_declaration + }; + let (payables_declared, loan_declared) = (declared("2000"), !designate && declared("2500")); for g in gens.iter().filter(|g| g.date() >= day(a) && g.date() <= day(e)) { // Kinds: cash; operating balance sheet, revenue and expense; - // capital A, capital B, the loan (financing when designated). + // capital A, capital B, the loan (financing when designated), + // then the liabilities holding distributions declared. let mut cash = 0i64; let mut op = [0i64; 2]; let mut fi = [0i64; 3]; + let mut declared_fi = [0i64; 2]; for (i, side, cents) in g.legs(1) { let m = if side == DR { cents } else { -cents }; match RCHART[i].0 { @@ -2530,10 +2551,13 @@ proptest! { "3000" => fi[0] += m, "3001" => fi[1] += m, "2500" if designate => fi[2] += m, + "2000" if payables_declared => declared_fi[0] += m, + "2500" if loan_declared => declared_fi[1] += m, "4000" | "4100" | "6000" | "6100" => op[1] += m, _ => op[0] += m, } } + let fi_all = fi.iter().sum::() + declared_fi.iter().sum::(); // Rule 3: operating, then financing (no investing here). let take = |left: i64, m: i64| { if left > 0 && m < 0 { @@ -2545,10 +2569,12 @@ proptest! { } }; let to_op = take(cash, op[0] + op[1]); - let to_fin = take(cash - to_op, fi.iter().sum()); + let to_fin = take(cash - to_op, fi_all); fin += to_fin; if to_op > 0 { settle(&mut op, to_op, false) } else { settle(&mut op, -to_op, true) } - if to_fin > 0 { settle(&mut fi, to_fin, false) } else { settle(&mut fi, -to_fin, true) } + let mut kinds: Vec = fi.iter().chain(&declared_fi).copied().collect(); + if to_fin > 0 { settle(&mut kinds, to_fin, false) } else { settle(&mut kinds, -to_fin, true) } + fi.copy_from_slice(&kinds[..3]); // Rule 11: the loan pays, then receives; then capital. if designate { let paid = credits(&fi[2..]).min(debits(&op)); diff --git a/crates/ledger-gui/tests/p3_seams.rs b/crates/ledger-gui/tests/p3_seams.rs new file mode 100644 index 0000000..ba99f63 --- /dev/null +++ b/crates/ledger-gui/tests/p3_seams.rs @@ -0,0 +1,130 @@ +//! P3's seams in the viewer: on the seam review's books (a year of a +//! two-member LLC, `ledger-package`'s `tests/seams/books.rs`), the +//! viewer's figures, read through its own [`Books`] from the books' folder +//! and settings file as it opens them, are the engine's (the server's +//! tools make theirs with the same calls and settings): each member's +//! capital statement, party money, the cash flow statement, the fixed +//! asset register and the 1099 worksheet, on several dates. + +#[path = "../../ledger-package/tests/seams/books.rs"] +mod books; + +use books::{books, d}; +use chrono::NaiveDate; +use ledger_gui::books::Books as Viewer; +use ledger_gui::{Source, data}; + +const START: &str = "2026-01-01"; + +fn viewer(dir: &std::path::Path, today: NaiveDate) -> Viewer { + data::load_once(&Source { + data_dir: dir.join("books-data"), + today: Some(today), + }) + .unwrap() +} + +#[test] +fn the_viewers_figures_are_the_engines_on_every_date() { + let dir = tempfile::tempdir().unwrap(); + let b = books(dir.path()); + let l = b.store.ledger(); + let cash: Vec<_> = b + .cash + .iter() + .map(|c| l.chart().find_by_code(c).unwrap().id) + .collect(); + let financing: Vec<_> = b + .financing + .iter() + .map(|c| l.chart().find_by_code(c).unwrap().id) + .collect(); + let settings = ledger_package::Settings { + company: &b.company, + cash_accounts: &b.cash, + financing_accounts: &b.financing, + investing_accounts: &[], + }; + let capitals = ledger_package::member_capitals(l, &b.company.members).unwrap(); + for end in ["2026-06-30", "2026-09-15", "2026-12-31"].map(d) { + let v = viewer(dir.path(), end); + let span = (d(START), end); + // The settings the viewer read are the books'. + assert_eq!(v.money_cash_accounts, cash); + assert_eq!(v.financing_accounts, financing); + // Each member's capital statement is the K-1 worksheet's row. + let k1 = ledger_core::k1::k1_worksheet(l, span.0, span.1, &capitals, &cash).unwrap(); + for (i, party) in [b.ana, b.ben].into_iter().enumerate() { + let c = v.capital_statement(party, span).unwrap(); + let k = &k1[i]; + assert_eq!( + ( + c.opening, + c.contributions_cash, + c.contributions_property, + c.income_share, + c.distributions_taken, + c.other, + c.closing + ), + ( + k.beginning, + k.contributions_cash, + k.contributions_property, + k.net_income, + k.distributions, + k.other, + k.ending + ), + "{end}" + ); + } + // Party money is the engine's for every party. + let money = ledger_core::parties::money_accounts(l, &cash, &financing); + let members = b.company.party_members(l); + for p in l.parties() { + assert_eq!( + v.activity(p.id, span), + ledger_core::parties::activity_with(l, p.id, span, &money, &members), + "{} {end}", + p.name + ); + } + // The cash flow statement the viewer lays out is the tools'. + let tools = ledger_package::cash_flow_for( + l, + &settings, + Some(START), + Some(&end.to_string()), + &[], + &[], + &[], + end, + ) + .unwrap(); + let viewers = ledger_package::cash_flow_for( + &v.ledger, + &ledger_package::Settings { + company: &v.settings.company, + cash_accounts: &v.settings.cash_accounts, + financing_accounts: &v.settings.financing_accounts, + investing_accounts: &v.settings.investing_accounts, + }, + Some(START), + Some(&end.to_string()), + &[], + &[], + &[], + v.today, + ) + .unwrap(); + assert_eq!(viewers, tools, "{end}"); + // The register, and the 1099 worksheet as of the day. + assert_eq!( + v.asset_register(end).unwrap(), + ledger_core::fixed_asset_register(l, end).unwrap() + ); + let year = ledger_package::vendor_1099_report(l, &settings, 2026, None, None).unwrap(); + assert_eq!(v.vendor_1099(2026).unwrap(), year, "{end}"); + } +} diff --git a/crates/ledger-mcp/src/server.rs b/crates/ledger-mcp/src/server.rs index cc782bc..9e0854d 100644 --- a/crates/ledger-mcp/src/server.rs +++ b/crates/ledger-mcp/src/server.rs @@ -6466,7 +6466,7 @@ impl BooksServer { #[tool( name = "books.list_assets", title = "List fixed assets", - description = "List every registered fixed asset as of a date (as_of, default today): in-service and acquired dates, cost, and per asset accumulated_posted (what the books carry: runs posted, less reversals), accumulated_scheduled_through (what the schedule says is due by as_of), unposted_depreciation (the difference: run books.run_depreciation for it) and book_value (cost less posted: what the books carry); held says whether the books hold it on as_of. With its basis facts and tax figures. An asset whose figures can't be computed (one an earlier version took) lists with null figures and schedule_error.", + description = "List every registered fixed asset as of a date (as_of, default today): in-service and acquired dates, cost, and per asset accumulated_posted (what the books carry: runs posted, less reversals), accumulated_scheduled_through (what the schedule says is due by as_of), unposted_depreciation (the difference: run books.run_depreciation for it) and book_value (cost less posted: what the books carry); status (active or disposed) and held (acquired and not yet disposed) are as of as_of too, so a later disposal never shows. With its basis facts and tax figures. An asset whose figures can't be computed (one an earlier version took) lists with null figures and schedule_error.", annotations(read_only_hint = true, open_world_hint = false) )] async fn list_assets( @@ -6513,10 +6513,9 @@ impl BooksServer { acquired_date: acquired.to_string(), cost: money(a.cost.amount), useful_life_months: a.useful_life_months, - status: match a.status { - AssetStatus::Active => "active".to_string(), - AssetStatus::Disposed { .. } => "disposed".to_string(), - }, + // As of as_of, like every figure here: a disposal + // after it hasn't happened yet (rules.md B-33). + status: if disposed { "disposed" } else { "active" }.to_string(), held: acquired <= as_of && !disposed, accumulated_posted: figure(|r| r.accumulated_posted), accumulated_scheduled_through: figure(|r| r.accumulated_scheduled), diff --git a/crates/ledger-mcp/src/types.rs b/crates/ledger-mcp/src/types.rs index cd0d641..7f4b69d 100644 --- a/crates/ledger-mcp/src/types.rs +++ b/crates/ledger-mcp/src/types.rs @@ -2607,7 +2607,8 @@ pub struct AssetSummary { pub cost: String, /// Useful life in months. pub useful_life_months: u32, - /// "active" or "disposed". + /// "active" or "disposed", on as_of: "disposed" once a disposal dated + /// on or before it is recorded (a later one doesn't show). pub status: String, /// Whether the books hold it on as_of: acquired by then and not yet /// disposed. diff --git a/crates/ledger-mcp/tests/p3_seams.rs b/crates/ledger-mcp/tests/p3_seams.rs new file mode 100644 index 0000000..6dd59e4 --- /dev/null +++ b/crates/ledger-mcp/tests/p3_seams.rs @@ -0,0 +1,350 @@ +//! P3's seams through the tools: on the seam review's books (a year of a +//! two-member LLC, `ledger-package`'s `tests/seams/books.rs`), built +//! in-process with the real `Store`, closed, then served by the real +//! binary over stdio from the same folder and its settings file, every +//! figure a tool reports is the engine's for the same accounts and dates, +//! and the tools agree with each other: the cash flow statement, party +//! totals on both bases, the 1099 worksheet as of a day, the fixed +//! assets, the members' equity statement and the CPA package. + +#[path = "../../ledger-package/tests/seams/books.rs"] +mod books; + +use books::{books, d, dec}; +use serde_json::{Value, json}; +use std::io::{BufRead, BufReader, Write}; +use std::path::Path; +use std::process::{Child, ChildStdin, Command, Stdio}; +use std::sync::mpsc::{Receiver, channel}; +use std::time::Duration; + +const TIMEOUT: Duration = Duration::from_secs(180); +const START: &str = "2026-01-01"; + +struct Mcp { + stdin: ChildStdin, + rx: Receiver, + child: Child, + next_id: u64, +} + +impl Drop for Mcp { + fn drop(&mut self) { + let _ = self.child.kill(); + let _ = self.child.wait(); + } +} + +impl Mcp { + fn start(dir: &Path) -> Self { + let run = dir.join("run"); + std::fs::create_dir_all(&run).unwrap(); + let mut child = Command::new(env!("CARGO_BIN_EXE_ledger-mcp")) + .arg("--data-dir") + .arg(dir.join("books-data")) + .arg("--books-toml") + .arg(dir.join("wanderbooks.toml")) + .env("XDG_RUNTIME_DIR", &run) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .expect("spawn ledger-mcp"); + let stdin = child.stdin.take().unwrap(); + let stdout = BufReader::new(child.stdout.take().unwrap()); + let (tx, rx) = channel(); + std::thread::spawn(move || { + for line in stdout.lines() { + let Ok(line) = line else { break }; + if tx.send(line).is_err() { + break; + } + } + }); + let mut mcp = Mcp { + stdin, + rx, + child, + next_id: 1, + }; + let init = mcp.rpc( + "initialize", + json!({ + "protocolVersion": "2025-06-18", + "capabilities": {}, + "clientInfo": {"name": "p3-seams", "version": "0.1.0"}, + }), + ); + assert!(init.get("result").is_some(), "initialize failed: {init}"); + writeln!( + mcp.stdin, + "{}", + json!({"jsonrpc": "2.0", "method": "notifications/initialized"}) + ) + .unwrap(); + mcp + } + + fn rpc(&mut self, method: &str, params: Value) -> Value { + let id = self.next_id; + self.next_id += 1; + let msg = json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params}); + writeln!(self.stdin, "{msg}").unwrap(); + self.stdin.flush().unwrap(); + loop { + let line = self + .rx + .recv_timeout(TIMEOUT) + .unwrap_or_else(|_| panic!("no response to {method}")); + let v: Value = serde_json::from_str(&line).unwrap(); + if v.get("id") == Some(&json!(id)) { + return v; + } + } + } + + /// A tool's structured result; a refusal fails the test. + fn ok(&mut self, name: &str, args: Value) -> Value { + let resp = self.rpc("tools/call", json!({"name": name, "arguments": args})); + let r = &resp["result"]; + assert!( + resp.get("error").is_none() && r.get("isError") != Some(&json!(true)), + "{name} {args}: {resp}" + ); + r["structuredContent"].clone() + } +} + +fn m(v: &Value) -> rust_decimal::Decimal { + dec(v + .as_str() + .unwrap_or_else(|| panic!("a decimal string: {v}"))) +} + +/// What the engine says, worked out before the server opens the books. +struct Expected { + end: String, + cash_flow: ledger_core::CashFlowStatement, + members_equity: ledger_core::MembersEquityStatement, + register: ledger_core::AssetRegister, + vendor_1099: ledger_core::Vendor1099Report, + depreciation: rust_decimal::Decimal, + iif: String, +} + +/// Every figure the tools report on the seam review's books is the +/// engine's for the same accounts and dates, on every date (B-33's "the +/// tool's for the same accounts and through"), and party totals for the +/// year are the engine's on both bases. +#[test] +fn the_tools_figures_are_the_engines_on_every_date() { + let dir = tempfile::tempdir().unwrap(); + let b = books(dir.path()); + let l = b.store.ledger(); + let settings = ledger_package::Settings { + company: &b.company, + cash_accounts: &b.cash, + financing_accounts: &b.financing, + investing_accounts: &[], + }; + let capitals = ledger_package::member_capitals(l, &b.company.members).unwrap(); + let mut expected = Vec::new(); + for end in ["2026-06-30", "2026-09-15", "2026-12-31"] { + let e = d(end); + expected.push(Expected { + end: end.to_string(), + cash_flow: ledger_package::cash_flow_for( + l, + &settings, + Some(START), + Some(end), + &[], + &[], + &[], + e, + ) + .unwrap(), + members_equity: ledger_core::members_equity(l, d(START), e, &capitals).unwrap(), + register: ledger_core::fixed_asset_register(l, e).unwrap(), + vendor_1099: ledger_package::vendor_1099_report(l, &settings, 2026, None, Some(e)) + .unwrap(), + depreciation: ledger_core::profit_and_loss_for_period(l, d(START), e, None) + .unwrap() + .expenses + .iter() + .filter(|x| x.code == "6500") + .map(|x| x.amount) + .sum(), + iif: ledger_package::export_iif(l, &b.cash, e).unwrap().text, + }); + } + let money = ledger_core::parties::money_accounts( + l, + &[l.chart().find_by_code("1000").unwrap().id], + &b.financing + .iter() + .map(|c| l.chart().find_by_code(c).unwrap().id) + .collect::>(), + ); + let members = b.company.party_members(l); + let year = (d(START), d("2026-12-31")); + let parties: Vec<(String, ledger_core::parties::Activity)> = l + .parties() + .map(|p| { + ( + p.name.clone(), + ledger_core::parties::activity_with(l, p.id, year, &money, &members), + ) + }) + .collect(); + drop(b); + + let mut mcp = Mcp::start(dir.path()); + for x in &expected { + let end = x.end.as_str(); + // The cash flow statement: every line, kind and reconciling item. + let cf = mcp.ok("books.cash_flow", json!({"start": START, "end": end})); + for (key, total) in [ + ("cash_from_operations", x.cash_flow.cash_from_operations), + ("cash_from_investing", x.cash_flow.cash_from_investing), + ("cash_from_financing", x.cash_flow.cash_from_financing), + ("net_change_in_cash", x.cash_flow.net_change_in_cash), + ] { + assert_eq!(m(&cf[key]), total, "{end} {key}"); + } + for (key, section) in [ + ("operating", ledger_core::CashFlowSection::Operating), + ("investing", ledger_core::CashFlowSection::Investing), + ("financing", ledger_core::CashFlowSection::Financing), + ] { + let lines: Vec<(String, rust_decimal::Decimal)> = cf[key] + .as_array() + .unwrap() + .iter() + .map(|l| (l["kind"].as_str().unwrap().to_string(), m(&l["amount"]))) + .collect(); + let engine: Vec<(String, rust_decimal::Decimal)> = x + .cash_flow + .lines(section) + .iter() + .map(|l| (l.kind.clone(), l.amount)) + .collect(); + assert_eq!(lines, engine, "{end} {key}"); + } + let recon: Vec<(String, rust_decimal::Decimal)> = cf["operating_reconciliation"]["lines"] + .as_array() + .unwrap() + .iter() + .map(|l| (l["label"].as_str().unwrap().to_string(), m(&l["amount"]))) + .collect(); + let engine: Vec<(String, rust_decimal::Decimal)> = x + .cash_flow + .operating_reconciliation + .lines + .iter() + .map(|l| (l.label.clone(), l.amount)) + .collect(); + assert_eq!(recon, engine, "{end}"); + let depreciation: rust_decimal::Decimal = recon + .iter() + .filter(|(label, _)| label == "Depreciation") + .map(|(_, a)| *a) + .sum(); + assert_eq!( + depreciation, x.depreciation, + "{end}: the P&L's depreciation" + ); + // The P&L's depreciation. + let pnl = mcp.ok("books.profit_and_loss", json!({"start": START, "end": end})); + let dep: rust_decimal::Decimal = pnl["expense_accounts"] + .as_array() + .unwrap() + .iter() + .filter(|a| a["code"] == "6500") + .map(|a| m(&a["amount"])) + .sum(); + assert_eq!(dep, x.depreciation, "{end}"); + // The members' equity statement. + let me = mcp.ok( + "books.export", + json!({"report": "members_equity", "format": "json", "start": START, "end": end}), + ); + let me: Value = serde_json::from_str(me["content"].as_str().unwrap()).unwrap(); + for (col, engine) in me["members"] + .as_array() + .unwrap() + .iter() + .zip(&x.members_equity.members) + { + for (key, v) in [ + ("beginning", engine.beginning), + ("contributions", engine.contributions), + ("net_income", engine.net_income), + ("distributions", engine.distributions), + ("other", engine.other), + ("ending", engine.ending), + ] { + assert_eq!(m(&col[key]), v, "{end} {} {key}", engine.name); + } + } + // The fixed assets, as of the day: list_assets is the register. + let assets = mcp.ok("books.list_assets", json!({"as_of": end})); + for (a, row) in assets["assets"] + .as_array() + .unwrap() + .iter() + .zip(&x.register.rows) + { + assert_eq!(m(&a["accumulated_posted"]), row.accumulated_posted, "{end}"); + assert_eq!( + m(&a["accumulated_scheduled_through"]), + row.accumulated_scheduled, + "{end}" + ); + assert_eq!(m(&a["book_value"]), row.book_value, "{end}"); + let disposed = row.disposed.is_some(); + assert_eq!(a["held"], json!(!disposed), "{end}"); + assert_eq!( + a["status"], + json!(if disposed { "disposed" } else { "active" }), + "{end}: a disposal after as_of doesn't show" + ); + } + // The 1099 worksheet as of the day. + let w = mcp.ok("books.vendor_1099", json!({"year": 2026, "through": end})); + let vendors = w["vendors"].as_array().unwrap(); + assert_eq!(vendors.len(), x.vendor_1099.vendors.len(), "{end}"); + for (v, engine) in vendors.iter().zip(&x.vendor_1099.vendors) { + assert_eq!(v["vendor_id"], json!(engine.vendor_id)); + for (key, amount) in [ + ("paid", engine.paid), + ("excluded_card", engine.excluded_card), + ("undetermined", engine.undetermined), + ("not_reportable", engine.not_reportable), + ] { + assert_eq!(m(&v[key]), amount, "{end} {} {key}", engine.name); + } + } + // The IIF as of the day. + let iif = mcp.ok( + "books.export", + json!({"report": "general_journal", "format": "iif", "as_of": end}), + ); + assert_eq!(iif["content"].as_str().unwrap(), x.iif, "{end}"); + } + // Party totals for the year, on both bases. + for (name, a) in &parties { + for (basis, key) in [ + (ledger_core::parties::Basis::Cash, "cash"), + (ledger_core::parties::Basis::All, "all"), + ] { + let p = mcp.ok( + "books.party", + json!({"party": name, "year": 2026, "basis": key}), + ); + let (paid, received) = a.totals(basis); + assert_eq!(m(&p["totals"]["paid"]), paid, "{name} {key}"); + assert_eq!(m(&p["totals"]["received"]), received, "{name} {key}"); + } + } +} diff --git a/crates/ledger-package/tests/p3_seams.rs b/crates/ledger-package/tests/p3_seams.rs new file mode 100644 index 0000000..63f88ad --- /dev/null +++ b/crates/ledger-package/tests/p3_seams.rs @@ -0,0 +1,677 @@ +//! P3's seams: the same economic fact, computed by two pieces of the +//! engine, agrees (or rules.md says exactly why not). +//! +//! One year of fictional books for a two-member LLC (Ana Example and Ben +//! Example, 50/50), built through the real [`Store`]: cash and property +//! contributions, member-paid expenses through Due to Members and their +//! reimbursement, card purchases and card payments, a prepaid on a daily +//! schedule, a 1099 contractor paid by check, by card and by a member's +//! check, whose status changes mid-year, a vendor refund, a loan, +//! depreciation runs with one reversed and caught up, a disposal, a +//! distribution declared then paid, and monthly closes to the members with +//! odd cents. Every name and amount is made up. +//! +//! Then, on several dates, the pieces are compared with each other: +//! the members' equity statement, the K-1 worksheet (book and tax basis), +//! party totals on both bases, the cash flow statement, the 1099 +//! worksheet, the P&L, the balance sheet, the fixed asset register, the +//! IIF and the CPA package as of each date. + +#[path = "seams/books.rs"] +mod books; + +use books::{Books, books, d, dec}; +use chrono::NaiveDate; +use ledger_core::parties::Basis; +use ledger_core::tax_capital::Open; +use ledger_core::{AccountId, CashFlowSection, CashFlowStatement, Ledger, MemberCapital, PartyId}; +use ledger_package::{PackageRequest, Settings, build_package}; +use rust_decimal::Decimal; + +fn capitals(b: &Books) -> Vec { + ledger_package::member_capitals(b.store.ledger(), &b.company.members).unwrap() +} + +fn settings(b: &Books) -> Settings<'_> { + Settings { + company: &b.company, + cash_accounts: &b.cash, + financing_accounts: &b.financing, + investing_accounts: &[], + } +} + +fn ids(l: &Ledger, codes: &[String]) -> Vec { + codes + .iter() + .map(|c| l.chart().find_by_code(c).unwrap().id) + .collect() +} + +/// The dates every check runs on: mid-year after a close with a +/// distribution declared and unpaid, mid-month with nothing closed for +/// the month, and the year's end. +const DATES: [&str; 3] = ["2026-06-30", "2026-09-15", "2026-12-31"]; +const START: &str = "2026-01-01"; + +fn cash_flow(b: &Books, end: NaiveDate) -> CashFlowStatement { + ledger_package::cash_flow_for( + b.store.ledger(), + &settings(b), + Some(START), + Some(&end.to_string()), + &[], + &[], + &[], + end, + ) + .unwrap() +} + +/// The total of the kinds in `section` whose caption starts with `prefix`. +fn kinds(cf: &CashFlowStatement, section: CashFlowSection, prefix: &str) -> Decimal { + cf.kinds(section) + .iter() + .filter(|k| k.kind.starts_with(prefix)) + .map(|k| k.amount) + .sum() +} + +fn kind(cf: &CashFlowStatement, section: CashFlowSection, caption: &str) -> Decimal { + cf.kinds(section) + .iter() + .filter(|k| k.kind == caption) + .map(|k| k.amount) + .sum() +} + +fn activity(b: &Books, party: PartyId, end: NaiveDate) -> ledger_core::parties::Activity { + let l = b.store.ledger(); + let money = ledger_core::parties::money_accounts(l, &ids(l, &b.cash), &ids(l, &b.financing)); + ledger_core::parties::activity_with( + l, + party, + (d(START), end), + &money, + &b.company.party_members(l), + ) +} + +/// An account's balance through `end`, credit-positive. +fn credit_balance(l: &Ledger, code: &str, end: NaiveDate) -> Decimal { + let id = l.chart().find_by_code(code).unwrap().id; + l.posted_entries() + .filter(|e| e.date() <= end) + .flat_map(|e| e.lines().iter()) + .filter(|x| x.account == id) + .map(|x| match x.side { + ledger_core::Side::Credit => x.amount.amount, + ledger_core::Side::Debit => -x.amount.amount, + }) + .sum() +} + +/// What the P&L says an expense account was from START to `end`. +fn pnl_expense(l: &Ledger, code: &str, end: NaiveDate) -> Decimal { + ledger_core::profit_and_loss_for_period(l, d(START), end, None) + .unwrap() + .expenses + .iter() + .filter(|x| x.code == code) + .map(|x| x.amount) + .sum() +} + +/// The P&L's depreciation expense (6500) from START to `end`. +fn pnl_depreciation(l: &Ledger, end: NaiveDate) -> Decimal { + pnl_expense(l, "6500", end) +} + +/// The members' figures agree on every date as B-13, B-35 items 4 and 8 +/// and B-14 rule 11 say: the statement of members' equity and the K-1 +/// worksheet are one roll-forward; party totals on basis all are the +/// statement's contributions, on basis cash the K-1's cash +/// contributions; the cash flow statement's contributions are the K-1's +/// cash, the property is disclosed, and its distributions are the +/// statement's less what is declared and unpaid, which party totals +/// count when paid too; what the members advanced is borrowed and repaid +/// on the statement and the repayment is paid to them in party totals. +#[test] +fn the_members_figures_agree_on_every_date() { + let dir = tempfile::tempdir().unwrap(); + let b = books(dir.path()); + let l = b.store.ledger(); + for end in DATES.map(d) { + let me = ledger_core::members_equity(l, d(START), end, &capitals(&b)).unwrap(); + let k1 = ledger_core::k1::k1_worksheet(l, d(START), end, &capitals(&b), &ids(l, &b.cash)) + .unwrap(); + let cf = cash_flow(&b, end); + let bs = ledger_core::balance_sheet_as_of(l, end).unwrap(); + assert_eq!(me.total.ending, bs.total_equity, "{end}"); + let unpaid = credit_balance(l, "2200", end); + let mut distributions_paid = Decimal::ZERO; + let mut paid_to_members = Decimal::ZERO; + for (i, (party, name)) in [(b.ana, "Ana Example"), (b.ben, "Ben Example")] + .into_iter() + .enumerate() + { + let (m, k) = (&me.members[i], &k1[i]); + // One roll-forward (k1 INVARIANT 1). + assert_eq!( + ( + m.beginning, + m.net_income, + m.distributions, + m.other, + m.ending + ), + ( + k.beginning, + k.net_income, + k.distributions, + k.other, + k.ending + ), + "{name} {end}" + ); + assert_eq!( + m.contributions, + k.contributions_cash + k.contributions_property + ); + // Party totals (B-35 item 4). + let a = activity(&b, party, end); + assert!(a.unresolved.is_empty(), "{name} {end}: {:?}", a.unresolved); + assert_eq!(a.totals(Basis::All).1, m.contributions, "{name} {end}"); + assert_eq!( + a.totals(Basis::Cash).1, + k.contributions_cash, + "{name} {end}" + ); + assert_eq!(a.received_in_kind, k.contributions_property, "{name} {end}"); + // The cash flow (B-14 rule 11, B-35 item 8). + assert_eq!( + kind( + &cf, + CashFlowSection::Financing, + &format!("Contributions from members — {name}") + ), + k.contributions_cash, + "{name} {end}" + ); + distributions_paid -= kind( + &cf, + CashFlowSection::Financing, + &format!("Distributions to members — {name}"), + ); + paid_to_members += a.totals(Basis::Cash).0; + } + // A distribution declared to Distributions Payable is a + // distribution on the statement of members' equity when declared + // and on the cash flow statement and in party totals when paid + // (B-14 rule 2, B-35 item 4). + distributions_paid -= kind( + &cf, + CashFlowSection::Financing, + "Distributions to members — 2200 Distributions Payable", + ); + assert_eq!( + distributions_paid, + me.total.distributions - unpaid, + "{end}: distributions paid" + ); + assert!( + cf.noncash_disclosures + .iter() + .any(|n| n.label.starts_with("Distribution declared") && n.amount == dec("2000.00")), + "{end}: the declaration is disclosed" + ); + assert!( + cf.noncash_disclosures + .iter() + .any(|n| n.label.starts_with("Ana contributes her laptop") + && n.amount == dec("1800.00")), + "{end}: the property contributed is disclosed" + ); + // No distribution is ever cash paid to suppliers, and no noncash + // operating item hides the declaration. + assert!( + !cf.operating_reconciliation + .lines + .iter() + .any(|r| r.label.contains("2200") || r.label == "Other noncash items"), + "{end}: {:?}", + cf.operating_reconciliation.lines + ); + // What the members advanced: borrowed when they paid, repaid when + // reimbursed; the reimbursements are what party totals paid the + // members beyond their distributions. + let advanced = kind( + &cf, + CashFlowSection::Financing, + "Proceeds from borrowing — 2100 Due to Members", + ); + let repaid = -kind( + &cf, + CashFlowSection::Financing, + "Repayments of borrowing — 2100 Due to Members", + ); + let expect = if end < d("2026-05-20") { + dec("120.00") + } else { + dec("1020.00") + }; + assert_eq!(advanced, expect, "{end}"); + assert_eq!(repaid, expect, "{end}"); + assert_eq!(paid_to_members, distributions_paid + repaid, "{end}"); + assert_eq!(credit_balance(l, "2100", end), Decimal::ZERO, "{end}"); + } + // Hand figures at the year's end: Ana 7,500 cash and the 1,800 laptop, + // Ben 7,500; each took 1,000 declared and 500 directly. + let end = d("2026-12-31"); + let k1 = + ledger_core::k1::k1_worksheet(l, d(START), end, &capitals(&b), &ids(l, &b.cash)).unwrap(); + assert_eq!(k1[0].contributions_property, dec("1800.00")); + assert_eq!(k1[0].distributions, dec("1500.00")); + assert_eq!( + activity(&b, b.ana, end).totals(Basis::Cash).0, + dec("1620.00") + ); + assert_eq!( + activity(&b, b.ben, end).totals(Basis::Cash).0, + dec("2400.00") + ); + // Mid-year, the declaration is unpaid: the statement counts it, party + // totals and the cash flow don't yet (B-35 item 4's second condition). + let mid = d("2026-06-30"); + assert_eq!( + activity(&b, b.ana, mid).totals(Basis::Cash).0, + dec("120.00") + ); + assert_eq!( + ledger_core::members_equity(l, d(START), mid, &capitals(&b)) + .unwrap() + .members[0] + .distributions, + dec("1000.00") + ); +} + +/// B-14 rule 2, from the seam review: a distribution declared to a +/// liability and paid later is a financing outflow when paid (ASC +/// 230-10-45-15(a)), its declaration a noncash financing activity; before +/// the fix it was "Cash paid to suppliers and employees". +#[test] +fn a_distribution_declared_then_paid_is_financing_when_paid() { + let dir = tempfile::tempdir().unwrap(); + let b = books(dir.path()); + let cf = cash_flow(&b, d("2026-07-31")); + assert_eq!( + kind( + &cf, + CashFlowSection::Financing, + "Distributions to members — 2200 Distributions Payable" + ), + dec("-2000.00") + ); + let paid = cf + .lines(CashFlowSection::Operating) + .iter() + .filter(|x| x.label.starts_with("Distribution paid")) + .count(); + assert_eq!(paid, 0, "{:?}", cf.operating); + // A statement of July alone, after the declaration's period, reads + // the payment the same way. + let july = ledger_package::cash_flow_for( + b.store.ledger(), + &settings(&b), + Some("2026-07-01"), + Some("2026-07-31"), + &[], + &[], + &[], + d("2026-07-31"), + ) + .unwrap(); + assert_eq!( + kind( + &july, + CashFlowSection::Financing, + "Distributions to members — 2200 Distributions Payable" + ), + dec("-2000.00") + ); + // Through June the payment hasn't happened: nothing in the body, the + // declaration disclosed. + let june = cash_flow(&b, d("2026-06-30")); + assert_eq!( + kinds(&june, CashFlowSection::Financing, "Distributions"), + Decimal::ZERO + ); +} + +/// B-35 item 9 and B-19: for a payee every entry of which names it alone, +/// party totals' paid less received (basis cash) is the 1099 worksheet's +/// paid + excluded card + undetermined + not reportable, less what the +/// worksheet lists as received for something else; and the worksheet +/// ties to the ledger by hand. +#[test] +fn the_1099_worksheet_ties_to_party_totals_and_the_ledger() { + let dir = tempfile::tempdir().unwrap(); + let b = books(dir.path()); + let l = b.store.ledger(); + for end in DATES.map(d) { + let w = + ledger_package::vendor_1099_report(l, &settings(&b), 2026, None, Some(end)).unwrap(); + assert!(w.unresolved.is_empty()); + for v in &w.vendors { + let party = l.party_of_vendor(v.vendor_id).unwrap(); + let a = activity(&b, party, end); + let (paid, received) = a.totals(Basis::Cash); + let other: Decimal = w + .other_receipts + .iter() + .filter(|p| p.vendor_id == v.vendor_id) + .map(|p| p.amount) + .sum(); + assert_eq!( + paid - received, + v.paid + v.excluded_card + v.undetermined + v.not_reportable - other, + "{} {end}", + v.name + ); + } + let delta = w.vendors.iter().find(|v| v.vendor_id == b.delta).unwrap(); + // By hand: 1,500 by check and 900 by Ben's check count; 700 on + // the card is excluded (Form 1099-K); 1,200 paid after the + // election is not reportable (D-40). + let expect = if end < d("2026-09-10") { + ("2400.00", "700.00", "0") + } else { + ("2400.00", "700.00", "1200.00") + }; + assert_eq!( + (delta.paid, delta.excluded_card, delta.not_reportable), + (dec(expect.0), dec(expect.1), dec(expect.2)), + "{end}" + ); + assert_eq!(delta.status, ledger_core::form1099::Status1099::Reportable); + // The contract labor expense (6400) is exactly Delta's payments: + // the four buckets are its debits in the year. + let labor = pnl_expense(l, "6400", end); + assert_eq!( + delta.paid + delta.excluded_card + delta.undetermined + delta.not_reportable, + labor, + "{end}" + ); + // The supplies vendor: 300.25 on the card, a 40.00 refund netted + // on the worksheet and received in party totals. + let paper = w + .vendors + .iter() + .find(|v| l.party_of_vendor(v.vendor_id) == Some(b.paper_party)) + .unwrap(); + assert_eq!( + (paper.paid, paper.excluded_card), + (dec("-40.00"), dec("300.25")) + ); + assert_eq!( + activity(&b, b.paper_party, end).totals(Basis::Cash), + (dec("300.25"), dec("40.00")) + ); + } +} + +/// The IIF's amounts on one account, through as_of (B-34: every posted +/// entry, closes left out), debit-positive. +fn iif_total(iif: &str, account: &str) -> Decimal { + let mut total = Decimal::ZERO; + for line in iif.split("\r\n") { + let f: Vec<&str> = line.split('\t').collect(); + if (f[0] == "TRNS" || f[0] == "SPL") && f.get(4) == Some(&account) { + total += dec(f[6]); + } + } + total +} + +/// Every date in the IIF, as written (MM/DD/YYYY). +fn iif_dates(iif: &str) -> Vec { + iif.split("\r\n") + .filter_map(|line| { + let f: Vec<&str> = line.split('\t').collect(); + (f[0] == "TRNS" || f[0] == "SPL") + .then(|| NaiveDate::parse_from_str(f[3], "%m/%d/%Y").unwrap()) + }) + .collect() +} + +/// Depreciation is one figure everywhere (B-15 rule 8, B-14 rule 8, +/// B-36): the P&L's expense, the cash flow reconciliation's add-back, the +/// tax-basis K-1's open item, the IIF's, and the runs plus what the +/// disposal posted; the balance sheet's accumulated depreciation is the +/// register's posted figure, and posted differs from scheduled by exactly +/// what is still to be caught up. +#[test] +fn depreciation_is_one_figure_everywhere() { + let dir = tempfile::tempdir().unwrap(); + let b = books(dir.path()); + let l = b.store.ledger(); + // (date, scheduled, posted): May's camera run, reversed on June 3, is + // caught up by July's run; mid-September the month isn't run yet. + let expect = [ + ("2026-06-30", "440.00", "365.00"), + ("2026-09-15", "752.50", "690.00"), + ("2026-12-31", "550.00", "550.00"), + ]; + for (end, scheduled, posted) in expect { + let end = d(end); + let pnl = pnl_depreciation(l, end); + let cf = cash_flow(&b, end); + let addback: Decimal = cf + .operating_reconciliation + .lines + .iter() + .filter(|r| r.kind == ledger_core::ReconcilingKind::Depreciation) + .map(|r| r.amount) + .sum(); + assert_eq!( + addback, pnl, + "{end}: the cash flow's depreciation is the P&L's" + ); + let tc = ledger_core::tax_capital::tax_basis_capital( + l, + d(START), + end, + &capitals(&b), + &ids(l, &b.cash), + ) + .unwrap(); + let open: Vec = tc + .open + .iter() + .filter_map(|o| match o { + Open::TaxDepreciation { book, .. } => Some(*book), + _ => None, + }) + .collect(); + assert_eq!( + open, + vec![pnl], + "{end}: the tax-basis K-1's book depreciation" + ); + let iif = ledger_package::export_iif(l, &b.cash, end).unwrap(); + assert_eq!( + iif_total(&iif.text, "Depreciation"), + pnl, + "{end}: the IIF's" + ); + assert!(iif_dates(&iif.text).iter().all(|x| *x <= end)); + let reg = ledger_core::fixed_asset_register(l, end).unwrap(); + assert_eq!(reg.total_accumulated_scheduled, dec(scheduled), "{end}"); + assert_eq!(reg.total_accumulated_posted, dec(posted), "{end}"); + assert_eq!( + reg.ledger_accumulated, reg.total_accumulated_posted, + "{end}" + ); + let bs_accum = -(credit_balance(l, "1510", end) + credit_balance(l, "1530", end)); + assert_eq!( + -bs_accum, reg.ledger_accumulated, + "{end}: the balance sheet's" + ); + for r in ®.rows { + if r.disposed.is_none() { + assert_eq!(r.book_value, r.cost - r.accumulated_posted); + assert_eq!( + r.accumulated_posted, + l.accumulated_posted(r.asset_id, end).unwrap(), + "list_assets and the viewer read Ledger::accumulated_posted" + ); + } + } + // The runs plus the disposal's own depreciation lines are the + // expense. + let runs: Decimal = [b.laptop, b.camera] + .iter() + .map(|&a| l.depreciation_posted_between(a, d(START), end).unwrap()) + .sum(); + let disposal = if end >= d("2026-11-20") { + dec("-25.00") + } else { + Decimal::ZERO + }; + assert_eq!(runs + disposal, pnl, "{end}"); + } + // By hand: the laptop 50.00 a month, February through December; the + // camera 75.00 a month from April 15 through November 20, by days: + // 40.00 + 6 × 75.00 + 50.00 = 540.00. + assert_eq!(pnl_depreciation(l, d("2026-12-31")), dec("1090.00")); + let cf = cash_flow(&b, d("2026-12-31")); + let loss: Decimal = cf + .operating_reconciliation + .lines + .iter() + .filter(|r| r.kind == ledger_core::ReconcilingKind::InvestingGainLoss) + .map(|r| r.amount) + .sum(); + // Book value 3,600.00 − 540.00 = 3,060.00, sold for 2,900.00. + assert_eq!(loss, dec("160.00")); +} + +/// Footing on every date: the trial balance, the balance sheet, the cash +/// flow (sections, change in cash, the reconciliation to operating). +#[test] +fn every_statement_foots_on_every_date() { + let dir = tempfile::tempdir().unwrap(); + let b = books(dir.path()); + let l = b.store.ledger(); + for end in DATES.map(d) { + assert!( + ledger_core::reports::trial_balance_as_of(l, end).balanced(), + "{end}" + ); + let bs = ledger_core::balance_sheet_as_of(l, end).unwrap(); + assert!(bs.balanced(), "{end}"); + let cf = cash_flow(&b, end); + assert!(cf.reconciles.difference.is_zero()); + assert_eq!( + cf.cash_from_operations + cf.cash_from_investing + cf.cash_from_financing, + cf.net_change_in_cash + ); + assert_eq!(cf.cash_ending - cf.cash_beginning, cf.net_change_in_cash); + assert_eq!(cf.cash_ending, -credit_balance(l, "1000", end)); + assert_eq!( + cf.operating_reconciliation.net_cash_from_operating, + cf.cash_from_operations + ); + let pnl = ledger_core::profit_and_loss_for_period(l, d(START), end, None).unwrap(); + assert_eq!(cf.operating_reconciliation.net_income, pnl.net_income()); + for section in CashFlowSection::ALL { + let lines: Decimal = cf.lines(section).iter().map(|x| x.amount).sum(); + assert_eq!(lines, cf.total(section)); + } + } +} + +fn file<'p>(p: &'p ledger_package::Package, name: &str) -> &'p str { + let (_, bytes) = p + .files + .iter() + .find(|(n, _)| n == name) + .unwrap_or_else(|| panic!("{name}")); + std::str::from_utf8(bytes).unwrap() +} + +/// The CPA package as of each date: every file stops at as_of, and its +/// figures are the tools' for the same accounts and dates (B-33). +#[test] +fn the_cpa_package_as_of_each_date_is_the_tools_figures() { + let dir = tempfile::tempdir().unwrap(); + let b = books(dir.path()); + let l = b.store.ledger(); + for end in DATES.map(d) { + let p = build_package( + &b.store, + &settings(&b), + &PackageRequest { + as_of: end, + prepared_on: d("2027-01-15"), + cash_accounts: &[], + financing_accounts: &[], + investing_accounts: &[], + include_receipts: false, + tins: None, + }, + ) + .unwrap(); + // The K-1 worksheet's rows are k1_worksheet's for the year to date. + let k1 = ledger_core::k1::k1_worksheet(l, d(START), end, &capitals(&b), &ids(l, &b.cash)) + .unwrap(); + let csv = file(&p, "k1-members.csv"); + for (row, k) in csv.lines().skip(1).zip(&k1) { + let cells: Vec<&str> = row.split(',').collect(); + let figures: Vec = cells[cells.len() - 7..].iter().map(|c| dec(c)).collect(); + assert_eq!( + figures, + vec![ + k.beginning, + k.contributions_cash, + k.contributions_property, + k.net_income, + k.distributions, + k.other, + k.ending + ], + "{end}" + ); + } + // The tax-basis worksheet's depreciation is the P&L's. + let tax = file(&p, "k1-tax-basis-capital.csv"); + let pnl = pnl_depreciation(l, end); + assert!( + tax.contains(&format!( + "the book depreciation posted ({} in the span", + ledger_package::money(pnl) + )), + "{end}: {tax}" + ); + // The 1099 worksheet is books.vendor_1099's CSV through as_of. + let w = + ledger_package::vendor_1099_worksheet(l, &settings(&b), 2026, None, Some(end), None) + .unwrap(); + assert_eq!( + file(&p, "1099-worksheet-2026.csv"), + ledger_package::vendor_1099_csv(&w, false) + ); + // The IIF is books.export's as of the day, and holds nothing after. + let iif = ledger_package::export_iif(l, &b.cash, end).unwrap(); + assert_eq!(file(&p, "general-journal.iif"), iif.text); + assert!(iif_dates(&iif.text).iter().all(|x| *x <= end)); + // The fixed asset register is in it: both assets were bought by + // every date. + assert!( + p.files + .iter() + .any(|(n, _)| n == "fixed-asset-register.xlsx") + ); + } +} diff --git a/crates/ledger-package/tests/seams/books.rs b/crates/ledger-package/tests/seams/books.rs new file mode 100644 index 0000000..bb33835 --- /dev/null +++ b/crates/ledger-package/tests/seams/books.rs @@ -0,0 +1,699 @@ +//! The P3 seam review's books (shared by `ledger-package`'s +//! `tests/p3_seams.rs`, `ledger-gui`'s `tests/p3_seams.rs` and +//! `ledger-mcp`'s `tests/p3_seams.rs`, which include this file): one year +//! of fictional books for a two-member LLC, Ana Example and Ben Example, +//! 50/50, built through the real [`Store`], with the settings the server +//! and the viewer read written beside them (`wanderbooks.toml`). Every +//! name and amount is made up. +//! +//! What happens, by month (2026): +//! - Jan: the members contribute 7,500.00 each in cash. +//! - Feb: Ana contributes her personal laptop (value 1,800.00, her basis +//! 2,400.00: a personal-use conversion, D-41); Delta Studio LLC, a +//! 1099-NEC contractor, is paid 1,500.00 by check. +//! - Mar: Delta 700.00 on the business card; a year of insurance, +//! 1,095.00, prepaid from March 15 on a daily schedule; supplies +//! 300.25 on the card; Ana pays a 120.00 subscription on her own card +//! (through Due to Members). +//! - Apr: an 8,000.00 bank loan; a 40.00 refund from the supplies +//! vendor; the card paid, 1,000.25; a camera bought, 3,600.00, in +//! service April 15; Ana reimbursed. +//! - May: Ben pays Delta 900.00 with his own check (Due to Members). +//! - Jun: a loan payment; Ben reimbursed; May's camera run reversed on +//! June 3 (caught up by July's run); a 2,000.00 distribution declared +//! to Distributions Payable on June 30. +//! - Jul: the distribution paid; a loan payment. +//! - Aug 1: Delta elects corporate taxation (not reportable from then). +//! - Sep: Delta paid 1,200.00 by check. +//! - Nov: November's camera run made, then the camera sold on November 20 +//! for 2,900.00 (the disposal credits back the run's last ten days). +//! - Dec: a 1,000.00 distribution paid directly. +//! - Every month: rent 450.00, revenue with odd cents, depreciation, the +//! insurance schedule, and a close to the members. + +#![allow(dead_code)] + +use chrono::NaiveDate; +use ledger_core::settings::{Company, Member, Source}; +use ledger_core::{ + AccountId, AccountType, AssetBasis, BasisRule, CloseTo, Currency, Dated1099, Decision1099, + Form1099, InstrumentKind, MemberClose, Money, NewAsset, NewSchedule, PartyId, PartyShare, Role, + ScheduleConvention, ScheduleKind, Sec704cMethod, Side, +}; +use ledger_store::Store; +use rust_decimal::Decimal; +use std::str::FromStr; + +pub fn d(s: &str) -> NaiveDate { + NaiveDate::parse_from_str(s, "%Y-%m-%d").unwrap() +} + +pub fn dec(s: &str) -> Decimal { + Decimal::from_str(s).unwrap() +} + +pub fn usd(s: &str) -> Money { + Money::new(dec(s), Currency::USD) +} + +fn src(on: &str) -> Source { + Source { + decided_by: "Ana Example".into(), + decided_on: d(on), + citation: None, + note: None, + } +} + +pub const CASH: &str = "1000"; +pub const FINANCING: [&str; 3] = ["2050", "2100", "2300"]; + +/// The scenario's books and the ids the checks need. +pub struct Books { + pub store: Store, + pub company: Company, + pub cash: Vec, + pub financing: Vec, + pub ana: PartyId, + pub ben: PartyId, + pub delta: u64, + pub delta_party: PartyId, + pub paper_party: PartyId, + pub laptop: u64, + pub camera: u64, +} + +pub fn id(s: &Store, code: &str) -> AccountId { + s.ledger().chart().find_by_code(code).unwrap().id +} + +fn post(s: &mut Store, date: &str, desc: &str, lines: &[(&str, Side, &str)]) -> u64 { + let e = s.create_entry(d(date), desc).unwrap(); + for (code, side, a) in lines { + let acct = id(s, code); + s.add_line(e, acct, *side, usd(a)).unwrap(); + } + e +} + +fn done(s: &mut Store, e: u64) -> u64 { + s.post_entry(e).unwrap(); + e +} + +fn with_party(s: &mut Store, e: u64, p: PartyId) -> u64 { + s.set_entry_party(e, Some(p)).unwrap(); + done(s, e) +} + +fn with_vendor(s: &mut Store, e: u64, v: u64) -> u64 { + s.set_entry_vendor(e, Some(v)).unwrap(); + done(s, e) +} + +fn both(s: &mut Store, e: u64, ana: PartyId, ben: PartyId, each: &str) -> u64 { + let share = ledger_core::shares::Share::Amount(dec(each)); + s.set_entry_parties( + e, + &[ + PartyShare { party: ana, share }, + PartyShare { party: ben, share }, + ], + ) + .unwrap(); + done(s, e) +} + +/// The settings the server and the viewer read for these books (beside +/// `books-data`, as an older books dir keeps them): [`company`] and the +/// account roles, the receipt threshold high so no entry needs a receipt. +pub const SETTINGS_TOML: &str = "[company] +name = \"Example Trails LLC\" +basis = \"Cash\" +fiscal_year_start = \"01-01\" + +[[company.members]] +name = \"Ana Example\" +share = \"50%\" +capital_account = \"3000\" +distribution_account = \"3010\" +files_jointly_with = \"Ben Example\" + +[[company.members]] +name = \"Ben Example\" +share = \"50%\" +capital_account = \"3001\" +distribution_account = \"3011\" +files_jointly_with = \"Ana Example\" + +[books] +receipt_threshold = \"1000000.00\" +cash_accounts = [\"1000\"] +financing_accounts = [\"2050\", \"2100\", \"2300\"] +"; + +/// The company as [`SETTINGS_TOML`] gives it. +pub fn company() -> Company { + let member = |name: &str, capital: &str, draws: &str, other: &str| Member { + name: name.into(), + share: dec("0.5"), + capital_account: capital.into(), + distribution_account: Some(draws.into()), + files_jointly_with: Some(other.into()), + }; + Company { + name: "Example Trails LLC".into(), + basis: "Cash".into(), + members: vec![ + member("Ana Example", "3000", "3010", "Ben Example"), + member("Ben Example", "3001", "3011", "Ana Example"), + ], + ..Company::default() + } +} + +pub const MONTHS: [&str; 12] = [ + "2026-01", "2026-02", "2026-03", "2026-04", "2026-05", "2026-06", "2026-07", "2026-08", + "2026-09", "2026-10", "2026-11", "2026-12", +]; + +pub fn month_end(m: &str) -> NaiveDate { + let first = d(&format!("{m}-01")); + let next = first.checked_add_months(chrono::Months::new(1)).unwrap(); + next.pred_opt().unwrap() +} + +pub fn books(dir: &std::path::Path) -> Books { + let mut s = Store::create(&dir.join("books-data"), Currency::USD).unwrap(); + for (code, name, ty) in [ + ("1000", "Checking", AccountType::Asset), + ("1300", "Prepaid Insurance", AccountType::Asset), + ("1500", "Camera", AccountType::Asset), + ( + "1510", + "Accumulated Depreciation - Camera", + AccountType::Asset, + ), + ("1520", "Laptop", AccountType::Asset), + ( + "1530", + "Accumulated Depreciation - Laptop", + AccountType::Asset, + ), + ("2050", "Business Card", AccountType::Liability), + ("2100", "Due to Members", AccountType::Liability), + ("2200", "Distributions Payable", AccountType::Liability), + ("2300", "Bank Loan", AccountType::Liability), + ("3000", "Capital - Ana", AccountType::Equity), + ("3001", "Capital - Ben", AccountType::Equity), + ("3010", "Distributions - Ana", AccountType::Equity), + ("3011", "Distributions - Ben", AccountType::Equity), + ("4000", "Guiding Revenue", AccountType::Revenue), + ("6000", "Rent", AccountType::Expense), + ("6150", "Software", AccountType::Expense), + ("6200", "Supplies", AccountType::Expense), + ("6300", "Insurance", AccountType::Expense), + ("6400", "Contract Labor", AccountType::Expense), + ("6500", "Depreciation", AccountType::Expense), + ("6900", "Interest Expense", AccountType::Expense), + ("7000", "Gain or Loss on Disposal", AccountType::Revenue), + ] { + s.add_account(code, name, ty, None, false).unwrap(); + } + let ana = s.add_party("Ana Example", &[Role::Member]).unwrap(); + let ben = s.add_party("Ben Example", &[Role::Member]).unwrap(); + for m in [ana, ben] { + s.set_party_creditor(m, id(&s, "2100"), true).unwrap(); + } + let client = s.add_customer("Summit Client Co").unwrap(); + let client = s.ledger().party_of_customer(client).unwrap(); + let lender = s + .add_party("Example Savings Bank", &[Role::FinancialInstitution]) + .unwrap(); + s.set_party_creditor(lender, id(&s, "2300"), true).unwrap(); + let delta = s.add_vendor("Delta Studio LLC").unwrap(); + let delta_party = s.ledger().party_of_vendor(delta).unwrap(); + // Delta files a 1099-NEC, then elects corporate taxation effective + // August 1 (Form 8832): not reportable from that day (D-40). + s.set_vendor_1099( + delta, + &Dated1099::from_beginning(Decision1099::Files(Form1099::Nec), Some(src("2026-01-02"))), + Some(true), + ) + .unwrap(); + s.set_vendor_1099( + delta, + &Dated1099 { + effective: Some(d("2026-08-01")), + decision: Decision1099::not_reportable( + ledger_core::ExemptReason::Corporation, + "elected corporate taxation, Form 8832 effective 2026-08-01", + ) + .unwrap(), + source: Some(src("2026-08-03")), + }, + Some(true), + ) + .unwrap(); + let paper = s.add_vendor("Paper Goods Co").unwrap(); + let paper_party = s.ledger().party_of_vendor(paper).unwrap(); + let cloud = s.add_vendor("Cloud Tools Inc").unwrap(); + let lens = s.add_vendor("Lens Shop Inc").unwrap(); + let insurer = s.add_vendor("Safe Insurance Inc").unwrap(); + let landlord = s.add_vendor("Trailhead Rentals LLC").unwrap(); + + let card = s + .add_instrument( + InstrumentKind::BusinessCard, + "Business card", + Some("4242"), + None, + id(&s, "2050"), + ) + .unwrap(); + let ana_card = s + .add_instrument( + InstrumentKind::MemberCard, + "Ana's personal card", + Some("1111"), + Some(ana), + id(&s, "2100"), + ) + .unwrap(); + let ben_check = s + .add_instrument( + InstrumentKind::MemberCashOrCheck, + "Ben's personal checks", + None, + Some(ben), + id(&s, "2100"), + ) + .unwrap(); + + let members: Vec = company() + .members + .iter() + .map(|m| MemberClose { + capital: id(&s, &m.capital_account), + share: m.share, + distributions: m.distribution_account.as_deref().map(|c| id(&s, c)), + }) + .collect(); + + let mut laptop = 0; + let mut camera = 0; + // Odd-cent revenue so the closes split odd cents. + let revenue = [ + "2100.01", "1850.37", "2433.33", "2950.05", "3100.11", "3333.33", "2875.99", "2600.01", + "2999.97", "3050.03", "2200.21", "1999.99", + ]; + for (i, m) in MONTHS.iter().enumerate() { + let end = month_end(m); + let day = |n: u32| format!("{m}-{n:02}"); + match i { + 0 => { + let e = post( + &mut s, + &day(5), + "Members' opening contributions", + &[ + ("1000", Side::Debit, "15000.00"), + ("3000", Side::Credit, "7500.00"), + ("3001", Side::Credit, "7500.00"), + ], + ); + both(&mut s, e, ana, ben, "7500.00"); + } + 1 => { + // Ana contributes the laptop she used personally (D-41). + let e = post( + &mut s, + &day(1), + "Ana contributes her laptop", + &[ + ("1520", Side::Debit, "1800.00"), + ("3000", Side::Credit, "1800.00"), + ], + ); + let e = with_party(&mut s, e, ana); + laptop = s + .register_asset(NewAsset { + description: "Laptop".into(), + in_service_date: d("2026-02-01"), + cost: usd("1800.00"), + salvage_value: usd("0.00"), + useful_life_months: 36, + cost_account: id(&s, "1520"), + accum_depr_account: id(&s, "1530"), + expense_account: id(&s, "6500"), + }) + .unwrap(); + s.set_asset_acquisition(laptop, e).unwrap(); + s.set_asset_basis( + laptop, + AssetBasis { + rule: BasisRule::PersonalUseConversion, + contributor_adjusted_basis: Some(dec("2400.00")), + fmv_at_contribution: Some(dec("1800.00")), + sec704c_method: Some(Sec704cMethod::Traditional), + source: Some(src("2026-02-01")), + }, + ) + .unwrap(); + // Delta paid by check from the bank: no method recorded, + // presumed a check (D-40). + let e = post( + &mut s, + &day(10), + "Delta Studio: trail maps", + &[ + ("6400", Side::Debit, "1500.00"), + ("1000", Side::Credit, "1500.00"), + ], + ); + with_vendor(&mut s, e, delta); + } + 2 => { + let e = post( + &mut s, + &day(12), + "Delta Studio: logo work, on the card", + &[ + ("6400", Side::Debit, "700.00"), + ("2050", Side::Credit, "700.00"), + ], + ); + s.set_entry_instrument(e, Some(card)).unwrap(); + with_vendor(&mut s, e, delta); + let e = post( + &mut s, + &day(15), + "Liability insurance, a year from March 15", + &[ + ("1300", Side::Debit, "1095.00"), + ("1000", Side::Credit, "1095.00"), + ], + ); + with_vendor(&mut s, e, insurer); + s.register_schedule(NewSchedule { + description: "Liability insurance".into(), + kind: ScheduleKind::PrepaidExpense, + total: usd("1095.00"), + start: d("2026-03-15"), + months: 12, + balance_account: id(&s, "1300"), + pl_account: id(&s, "6300"), + convention: ScheduleConvention::Daily, + }) + .unwrap(); + let e = post( + &mut s, + &day(20), + "Paper Goods: maps paper, on the card", + &[ + ("6200", Side::Debit, "300.25"), + ("2050", Side::Credit, "300.25"), + ], + ); + s.set_entry_instrument(e, Some(card)).unwrap(); + with_vendor(&mut s, e, paper); + // Ana pays a software subscription on her own card. + let e = post( + &mut s, + &day(22), + "Cloud Tools: route planner, Ana's card", + &[ + ("6150", Side::Debit, "120.00"), + ("2100", Side::Credit, "120.00"), + ], + ); + s.set_entry_instrument(e, Some(ana_card)).unwrap(); + with_vendor(&mut s, e, cloud); + } + 3 => { + let e = post( + &mut s, + &day(1), + "Equipment loan", + &[ + ("1000", Side::Debit, "8000.00"), + ("2300", Side::Credit, "8000.00"), + ], + ); + with_party(&mut s, e, lender); + let e = post( + &mut s, + &day(5), + "Paper Goods refund: damaged paper", + &[ + ("1000", Side::Debit, "40.00"), + ("6200", Side::Credit, "40.00"), + ], + ); + with_vendor(&mut s, e, paper); + let e = post( + &mut s, + &day(8), + "Card payment", + &[ + ("2050", Side::Debit, "1000.25"), + ("1000", Side::Credit, "1000.25"), + ], + ); + s.set_entry_no_party(e, "paying the company's own card") + .unwrap(); + done(&mut s, e); + let e = post( + &mut s, + &day(10), + "Lens Shop: trail camera", + &[ + ("1500", Side::Debit, "3600.00"), + ("1000", Side::Credit, "3600.00"), + ], + ); + let e = with_vendor(&mut s, e, lens); + camera = s + .register_asset(NewAsset { + description: "Trail camera".into(), + in_service_date: d("2026-04-15"), + cost: usd("3600.00"), + salvage_value: usd("0.00"), + useful_life_months: 48, + cost_account: id(&s, "1500"), + accum_depr_account: id(&s, "1510"), + expense_account: id(&s, "6500"), + }) + .unwrap(); + s.set_asset_acquisition(camera, e).unwrap(); + s.set_asset_basis( + camera, + AssetBasis { + rule: BasisRule::Purchase, + contributor_adjusted_basis: None, + fmv_at_contribution: None, + sec704c_method: None, + source: Some(src("2026-04-10")), + }, + ) + .unwrap(); + let e = post( + &mut s, + &day(25), + "Ana reimbursed for the route planner", + &[ + ("2100", Side::Debit, "120.00"), + ("1000", Side::Credit, "120.00"), + ], + ); + with_party(&mut s, e, ana); + } + 4 => { + // Ben pays Delta with his own check, at the company's + // direction: the company is the payor (B-19). + let e = post( + &mut s, + &day(20), + "Delta Studio: guidebook layout, Ben's check", + &[ + ("6400", Side::Debit, "900.00"), + ("2100", Side::Credit, "900.00"), + ], + ); + s.set_entry_instrument(e, Some(ben_check)).unwrap(); + with_vendor(&mut s, e, delta); + } + 5 => { + let e = post( + &mut s, + &day(1), + "Loan payment", + &[ + ("2300", Side::Debit, "300.00"), + ("6900", Side::Debit, "40.00"), + ("1000", Side::Credit, "340.00"), + ], + ); + with_party(&mut s, e, lender); + let e = post( + &mut s, + &day(15), + "Ben reimbursed for the guidebook layout", + &[ + ("2100", Side::Debit, "900.00"), + ("1000", Side::Credit, "900.00"), + ], + ); + with_party(&mut s, e, ben); + let e = post( + &mut s, + &day(30), + "Distribution declared", + &[ + ("3010", Side::Debit, "1000.00"), + ("3011", Side::Debit, "1000.00"), + ("2200", Side::Credit, "2000.00"), + ], + ); + both(&mut s, e, ana, ben, "1000.00"); + } + 6 => { + let e = post( + &mut s, + &day(10), + "Distribution paid", + &[ + ("2200", Side::Debit, "2000.00"), + ("1000", Side::Credit, "2000.00"), + ], + ); + both(&mut s, e, ana, ben, "1000.00"); + let e = post( + &mut s, + &day(1), + "Loan payment", + &[ + ("2300", Side::Debit, "302.00"), + ("6900", Side::Debit, "38.00"), + ("1000", Side::Credit, "340.00"), + ], + ); + with_party(&mut s, e, lender); + } + 8 => { + // After Delta's election: not reportable (D-40). + let e = post( + &mut s, + &day(10), + "Delta Studio: signage", + &[ + ("6400", Side::Debit, "1200.00"), + ("1000", Side::Credit, "1200.00"), + ], + ); + with_vendor(&mut s, e, delta); + } + 10 => { + // November's run is made before the camera's sale is + // recorded: the disposal takes back the days after it. + s.run_depreciation(camera, m).unwrap(); + let e = s + .dispose_asset( + camera, + d("2026-11-20"), + usd("2900.00"), + id(&s, "1000"), + id(&s, "7000"), + ) + .unwrap(); + let buyer = lens_buyer(&mut s); + s.set_entry_party(e, Some(buyer)).unwrap(); + } + 11 => { + let e = post( + &mut s, + &day(15), + "Year-end distribution", + &[ + ("3010", Side::Debit, "500.00"), + ("3011", Side::Debit, "500.00"), + ("1000", Side::Credit, "1000.00"), + ], + ); + both(&mut s, e, ana, ben, "500.00"); + } + _ => {} + } + let e = post( + &mut s, + &day(3), + "Trail office rent", + &[ + ("6000", Side::Debit, "450.00"), + ("1000", Side::Credit, "450.00"), + ], + ); + with_vendor(&mut s, e, landlord); + let e = post( + &mut s, + &end.to_string(), + "Guided hikes", + &[ + ("1000", Side::Debit, revenue[i]), + ("4000", Side::Credit, revenue[i]), + ], + ); + with_party(&mut s, e, client); + // Month end: depreciation, the schedules, the close. + for asset in [laptop, camera] { + if asset == 0 { + continue; + } + let a = s.ledger().get_asset(asset).unwrap().clone(); + if matches!(a.status, ledger_core::AssetStatus::Disposed { .. }) { + continue; + } + if s.ledger().live_depreciation_run(asset, m).is_none() { + s.run_depreciation(asset, m).unwrap(); + } + } + if i == 5 { + let may = s + .ledger() + .live_depreciation_run(camera, "2026-05") + .unwrap() + .entry; + s.reverse_entry( + may, + d("2026-06-03"), + "May's camera run reversed: wrong asset", + ) + .unwrap(); + // June's run (made above) came first; reversing May leaves + // May's depreciation for the next run to catch up. + } + s.run_schedules(end).unwrap(); + let p = s.create_period(m, d(&format!("{m}-01")), end).unwrap(); + s.close_period_to(p, &CloseTo::Members(members.clone())) + .unwrap(); + } + std::fs::write(dir.join("wanderbooks.toml"), SETTINGS_TOML).unwrap(); + Books { + store: s, + company: company(), + cash: vec![CASH.to_string()], + financing: FINANCING.iter().map(|c| c.to_string()).collect(), + ana, + ben, + delta, + delta_party, + paper_party, + laptop, + camera, + } +} + +fn lens_buyer(s: &mut Store) -> PartyId { + let c = s.add_customer("Ridge Outfitters Inc").unwrap(); + s.ledger().party_of_customer(c).unwrap() +} diff --git a/docs/spec/rules.md b/docs/spec/rules.md index 876f01a..4c2a568 100644 --- a/docs/spec/rules.md +++ b/docs/spec/rules.md @@ -387,8 +387,24 @@ in public sources that reproduce the Codification, `sources/fasb-asc.md`): operating (45-10), so every non-cash account is in exactly one section: investing (the cost and accumulated-depreciation accounts of every registered fixed asset, and `books.investing_accounts`); financing - (equity, and `books.financing_accounts`); operating (every other - balance-sheet account, and revenue and expense, but see rule 4). The + (equity, `books.financing_accounts`, and a liability that holds + distributions declared, below); operating (every other balance-sheet + account, and revenue and expense, but see rule 4). A liability left + operating that, in the posted entries dated through the period's end + (closes and reversals aside), only entries debiting nothing but equity + credit, at least once (Dr Distributions / Cr Distributions Payable; a + corporation's Dr Retained Earnings / Cr Dividends Payable), holds + distributions declared and unpaid: paying it is a distribution + (45-15(a): "payments of dividends or other distributions to owners"), + listed as "Distributions to members — " (the statement + reads no parties, so not by member), and the declaration an exchange + inside financing, disclosed (rule 7; 50-3). A liability credited by any + other entry as well (what the members advanced, an accrued expense, + money borrowed) stays where it is (P3 seam review, 2026-10-21: before, + a distribution declared then paid read as "Cash paid to suppliers and + employees", the declaration as "Other noncash items" beside an increase + in the payable; `a_distribution_declared_then_paid_is_a_distribution_paid`, + `a_distribution_declared_then_paid_is_financing_when_paid`). The roles must agree with the chart and each other, or the statement is refused with the setting to change: cash and investing accounts are assets; financing accounts are liabilities (equity listed is accepted: @@ -483,7 +499,17 @@ in public sources that reproduce the Codification, `sources/fasb-asc.md`): or "Other noncash items"; W, minus each operating balance-sheet account's movement ("Increase in" / "Decrease in"). An identity over balanced entries (Lean - `operating_reconciliation_ties`), checked at runtime. + `operating_reconciliation_ties`), checked at runtime. G's lines on a + registered asset's depreciation expense account (a disposal's + depreciation still due through its day, or what the month's run posted + past it, credited back: B-15 rule 5) are shown in the "Depreciation" + line, not the gain or loss, so that line is the P&L's depreciation + expense for the period (45-28(a)'s depreciation is the expense + recognized; the sum NI + G + X + W is unchanged). P3 seam review, + 2026-10-21: before, a disposal that credited back 25.00 of a month's run + showed depreciation 25.00 above the P&L's and the loss 25.00 below the + entry's (`a_disposals_depreciation_credit_back_is_depreciation`, + `depreciation_is_one_figure_everywhere`). 9. **The sections foot** (45-24: the statement reconciles beginning and ending cash). `reconciles {sections_sum, net_change, difference}`; any difference, a section unequal to its lines, or a reconciliation unequal @@ -663,7 +689,13 @@ shapes), `prop_pure_entries_sections_are_movements`; regressions `converting_member_debt_to_capital_is_disclosed`, `a_bill_payment_carries_its_bills_split`, `a_reversed_bill_payment_leaves_the_purchase_unpaid`, the role refusals, -`foot_refuses_any_difference` (`crates/ledger-core/tests/spec_cash_flow.rs`). +`foot_refuses_any_difference`, +`a_distribution_declared_then_paid_is_a_distribution_paid`, +`a_disposals_depreciation_credit_back_is_depreciation` +(`crates/ledger-core/tests/spec_cash_flow.rs`); the seam review's year +of books (`ledger-package` `tests/p3_seams.rs`, its books +`tests/seams/books.rs`; the viewer's `ledger-gui` `tests/p3_seams.rs`; +the tools' `ledger-mcp` `tests/p3_seams.rs`). ## B-15 Depreciation: straight line, salvage never depreciated, partial months by days @@ -783,7 +815,15 @@ The rule, exactly (P3, defect 22; the engineer settled it from ASC zero once the asset is disposed, its disposal entry having cleared it) are two figures, never one: book value is cost less the posted figure (`assets::scheduled_book_value` is the schedule's own, for the - schedule only). + schedule only). An asset's depreciation expense over a span is its + runs' (`Ledger::depreciation_posted_between`) plus its disposal entry's + lines on its expense account (rule 5): the P&L's, the cash flow + reconciliation's "Depreciation" (B-14 rule 8), the tax-basis K-1's + open item (B-36) and the IIF's all show that one figure + (`depreciation_is_one_figure_everywhere`). `books.list_assets` as of a + day says "disposed" only for a disposal dated by then, as `held` and + the figures do (B-33; before the seam review a later disposal showed + on an earlier day). **Enforced:** `assets::depreciation_schedule`, `assets::run_amount`, `assets::catch_up_room`, `assets::depreciation_effects`, @@ -2325,7 +2365,18 @@ not the books' accounting method (`books.accounting_basis`). and spent, on that date (a financing inflow and an operating or investing outflow); paying the member back is a financing outflow; property a member contributes, and a debt to them converted to - capital, are noncash (disclosed). Party totals report what moved with + capital, are noncash (disclosed); a distribution declared to a + distributions payable is a distribution on the statement when it is + paid (B-14 rule 2), listed under the payable ("Distributions to + members — 2200 Distributions Payable"), not by member: the statement + reads accounts, not parties, so the member a payment from the payable + went to is party totals' (the payment's own parties) and the + declaration's (the statement of members' equity, at its date). So over + a span, the statement's distributions (by member and under the payable) + are the statement of members' equity's less what is declared and still + unpaid at the span's end, and party totals' paid to the members on the + entries that move their capital or pay a declared distribution, exactly + (`the_members_figures_agree_on_every_date`). Party totals report what moved with each party: the vendor is paid when the member pays it (the same date and amount as the statement's outflow); the member is paid back when the statement shows the financing outflow; and a member's capital @@ -2424,7 +2475,12 @@ book capital reconciles to tax capital: treatment of a change in book capital the books can't explain, and last year's tax ending when an earlier year left a figure open. Each such line is left blank and marked open, with what the books know so - far and the book amount the CPA's figure replaces. + far and the book amount the CPA's figure replaces. The book + depreciation the tax figure replaces is the period's depreciation + expense, the P&L's: each asset's runs and what its disposal entry posts + to its expense account (B-15 rule 8; before the seam review the runs + alone, so a disposal that credited back part of a month's run stated + more than the P&L). - The reconciliation: book ending less tax ending (as far as the books know it) = beginning (book less tax) + the built-in gain or loss share + property without a basis (at book) − property distributed (at book), @@ -2434,4 +2490,6 @@ book capital reconciles to tax capital: `ledger-package` `k1_tax` (`k1-tax-basis-capital.csv` in the CPA package). **Checked:** `ledger-core` `tests/tax_capital.rs`; `ledger-package` `tests/package.rs` -(`the_k1_worksheet_reports_tax_basis_capital`). +(`the_k1_worksheet_reports_tax_basis_capital`), `tests/p3_seams.rs` +(`depreciation_is_one_figure_everywhere`, +`the_cpa_package_as_of_each_date_is_the_tools_figures`). diff --git a/docs/testing.md b/docs/testing.md index a1d8d5b..b362e30 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -1640,6 +1640,47 @@ the projection loader's two key sets; `tax_capital::share` and conditions (`in_service`, the disposal window), and the reconciliation's signs. +## P3: the seams between the pieces (2026-10-21) + +One year of fictional books for a two-member LLC, built through the real +`Store` (`ledger-package/tests/seams/books.rs`: cash and property +contributions, member-paid expenses through Due to Members and their +reimbursement, card purchases and payments, a daily prepaid, a 1099 +contractor paid by check, card and a member's check whose status changes +mid-year, a refund, a loan, depreciation with a run reversed and caught +up, a disposal that credits back part of a month's run, a distribution +declared then paid, monthly closes with odd cents), checked on June 30, +September 15 and December 31: the same fact from two pieces agrees, or +rules.md says why not. Mutation runs wait for P12 (D-33). + +- **Engine** (`ledger-package/tests/p3_seams.rs`): + `the_members_figures_agree_on_every_date` (members' equity, the K-1 + worksheet, party totals on both bases, the cash flow's contributions, + advances and distributions, B-35 items 4 and 8), + `a_distribution_declared_then_paid_is_financing_when_paid`, + `the_1099_worksheet_ties_to_party_totals_and_the_ledger` (B-35 item 9, + B-19), `depreciation_is_one_figure_everywhere` (the P&L, the cash flow + add-back, the tax-basis K-1's open item, the IIF, the register posted + and scheduled, the balance sheet), `every_statement_foots_on_every_date`, + `the_cpa_package_as_of_each_date_is_the_tools_figures` (B-33). +- **Viewer** (`ledger-gui/tests/p3_seams.rs`): its capital statements, + party money, cash flow, register and 1099 worksheet, read from the + books' folder and settings file as it opens them, are the engine's. +- **Tools** (`ledger-mcp/tests/p3_seams.rs`, the real binary over + stdio): `books.cash_flow` line by line, `books.profit_and_loss`, + `books.export` members_equity and iif, `books.list_assets`, + `books.vendor_1099` through each date, `books.party` on both bases. +- **Found and fixed** (each test fails before its fix): a distribution + declared to a payable and paid later read as cash paid to suppliers + (B-14 rule 2; `a_distribution_declared_then_paid_is_a_distribution_paid` + in `ledger-core/tests/spec_cash_flow.rs`, with the property + `prop_cf_financing_is_equity_plus_designated_debt`'s model extended); + a disposal's depreciation credit-back was in the cash flow's gain or + loss, not its depreciation (B-14 rule 8; + `a_disposals_depreciation_credit_back_is_depreciation`), and the + tax-basis K-1's book depreciation left it out (B-36); `books.list_assets` + as of a day said "disposed" for a later disposal (B-15 rule 8). + ## Benchmarks (baseline 2026-10-13) No budget, by decision (the owner, 2026-10-13: the books do little work