P3 review: the same figures agree across the pieces

A distribution declared to a payable and then paid is financing when paid,
the declaration disclosed (ASC 230-10-45-15(a)); a disposal's depreciation
lines count as depreciation in the cash flow and the tax-basis K-1; an
as-of asset list says the status of that day. Seam tests tie the members'
statement, K-1, party totals, cash flow, 1099s, depreciation and the CPA
package together on three dates.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
This commit is contained in:
swaitsandClaude Opus 5.5 committed 2026-10-21 16:37:47 -06:00
1 parent 2f999c6050
commit 2ae93fb2d6
13 files changed
+2258 -24

No files matched your search

+101 -5
View File
@@ -717,6 +717,10 @@ enum Bucket {
Equity(String), Equity(String),
/// A `financing_accounts` liability, by code. /// A `financing_accounts` liability, by code.
Borrowing(String), 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 /// One line of an entry, or one part of a line split between sections
@@ -751,6 +755,10 @@ struct Roles<'a> {
receivables: Option<AccountId>, receivables: Option<AccountId>,
member_of: HashMap<AccountId, usize>, member_of: HashMap<AccountId, usize>,
members: Vec<String>, members: Vec<String>,
/// Liabilities a distribution was declared to (rule 2).
declared: BTreeSet<AccountId>,
/// The registered fixed assets' depreciation expense accounts (rule 8).
depreciation: BTreeSet<AccountId>,
} }
impl<'a> Roles<'a> { impl<'a> Roles<'a> {
@@ -791,9 +799,11 @@ impl<'a> Roles<'a> {
} }
let mut cost = BTreeSet::new(); let mut cost = BTreeSet::new();
let mut accum = BTreeSet::new(); let mut accum = BTreeSet::new();
let mut depreciation = BTreeSet::new();
for asset in ledger.registered_assets() { for asset in ledger.registered_assets() {
cost.insert(asset.cost_account); cost.insert(asset.cost_account);
accum.insert(asset.accum_depr_account); accum.insert(asset.accum_depr_account);
depreciation.insert(asset.expense_account);
} }
let mut member_of = HashMap::new(); let mut member_of = HashMap::new();
for (i, m) in params.members.iter().enumerate() { for (i, m) in params.members.iter().enumerate() {
@@ -802,11 +812,19 @@ impl<'a> Roles<'a> {
member_of.insert(d, i); member_of.insert(d, i);
} }
} }
let cash: BTreeSet<AccountId> = params.cash_accounts.iter().copied().collect();
let financing: BTreeSet<AccountId> = params.financing_accounts.iter().copied().collect();
let investing: BTreeSet<AccountId> = 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 { Ok(Roles {
ledger, ledger,
cash: params.cash_accounts.iter().copied().collect(), declared,
financing: params.financing_accounts.iter().copied().collect(), depreciation,
investing: params.investing_accounts.iter().copied().collect(), cash,
financing,
investing,
cost, cost,
accum, accum,
receivables: ledger.role_account(&crate::RECEIVABLES), receivables: ledger.role_account(&crate::RECEIVABLES),
@@ -843,6 +861,8 @@ impl<'a> Roles<'a> {
} }
} else if self.financing.contains(&a.id) { } else if self.financing.contains(&a.id) {
(Flow::Financing, Bucket::Borrowing(a.code.clone())) (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 { } else if Some(a.id) == self.receivables {
(Flow::Operating, Bucket::Receivables) (Flow::Operating, Bucket::Receivables)
} else { } else {
@@ -929,6 +949,14 @@ impl<'a> Roles<'a> {
(1, 1, c.clone()), (1, 1, c.clone()),
format!("Distributions to {owners} — {}", named(c)), 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) => ( (Bucket::Borrowing(c), true) => (
(2, 0, c.clone()), (2, 0, c.clone()),
format!("Proceeds from borrowing — {}", named(c)), 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<AccountId> {
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 /// Where a kind sits in its section: its group (contributions before
/// distributions, purchases before sales, ...), then its member or /// distributions, purchases before sales, ...), then its member or
/// account. /// account.
@@ -1842,18 +1914,27 @@ fn reconcile(
const WHAT: &str = "the operating reconciliation"; const WHAT: &str = "the operating reconciliation";
let mut net_income = Decimal::ZERO; let mut net_income = Decimal::ZERO;
let mut gains = (Decimal::ZERO, Vec::new()); 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. // X by key: 0 depreciation, 1 through an account (by code), 2 other.
let mut noncash: BTreeMap<(u8, String), (Decimal, Vec<EntryId>)> = BTreeMap::new(); let mut noncash: BTreeMap<(u8, String), (Decimal, Vec<EntryId>)> = BTreeMap::new();
// W by code: the operating balance-sheet accounts' movements. // W by code: the operating balance-sheet accounts' movements.
let mut balances: BTreeMap<String, (AccountId, Decimal)> = BTreeMap::new(); let mut balances: BTreeMap<String, (AccountId, Decimal)> = BTreeMap::new();
for a in kept { for a in kept {
let shown = !paired.contains(&a.entry.id()); 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 { for p in &a.parts {
if p.pl { if p.pl {
net_income = add(net_income, -p.signed(), WHAT)?; net_income = add(net_income, -p.signed(), WHAT)?;
if p.flow == Flow::Investing { 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 { } else if p.flow == Flow::Operating {
let code = roles.account(p.account)?.code.clone(); let code = roles.account(p.account)?.code.clone();
@@ -1867,6 +1948,12 @@ fn reconcile(
gains.1.push(a.entry.id()); 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]; let x = a.flows.noncash[0];
if x.is_zero() { if x.is_zero() {
continue; continue;
@@ -1904,6 +1991,15 @@ fn reconcile(
}; };
let mut depreciation = None; let mut depreciation = None;
let mut rest = Vec::new(); 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 { for ((key, code), (amount, entries)) in noncash {
if amount.is_zero() { if amount.is_zero() {
continue; continue;
+53 -2
View File
@@ -63,6 +63,53 @@ use crate::{AccountId, AssetId, AssetStatus, EntryId, Ledger, LedgerError, Side}
use chrono::NaiveDate; use chrono::NaiveDate;
use rust_decimal::Decimal; 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<Decimal, LedgerError> {
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. /// A figure the books can't know, with what they do have.
#[derive(Clone, Debug, PartialEq, Eq)] #[derive(Clone, Debug, PartialEq, Eq)]
pub enum Open { pub enum Open {
@@ -104,7 +151,8 @@ pub enum Open {
/// Treas. Reg. §1.704-3): the CPA's. The book depreciation posted in /// Treas. Reg. §1.704-3): the CPA's. The book depreciation posted in
/// the span, all members, and the part on contributed property. /// the span, all members, and the part on contributed property.
TaxDepreciation { 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, book: Decimal,
/// The part of it on contributed property. /// The part of it on contributed property.
contributed_book: Decimal, contributed_book: Decimal,
@@ -424,7 +472,10 @@ fn span(
if a.in_service_date <= end && !gone_before { if a.in_service_date <= end && !gone_before {
in_service = true; 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)?; 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()) { 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)?; contributed_depr = contributed_depr.checked_add(d).ok_or_else(too_large)?;
+105
View File
@@ -3124,3 +3124,108 @@ proptest! {
prop_assert!(cf.noncash_disclosures.is_empty()); 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::<Vec<_>>(),
[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"));
}
@@ -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 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 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 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
+29 -3
View File
@@ -2517,12 +2517,33 @@ proptest! {
let credits = |qs: &[i64]| qs.iter().map(|q| (-q).max(0)).sum::<i64>(); let credits = |qs: &[i64]| qs.iter().map(|q| (-q).max(0)).sum::<i64>();
let debits = |qs: &[i64]| qs.iter().map(|q| (*q).max(0)).sum::<i64>(); let debits = |qs: &[i64]| qs.iter().map(|q| (*q).max(0)).sum::<i64>();
let mut fin = 0i64; 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)) { for g in gens.iter().filter(|g| g.date() >= day(a) && g.date() <= day(e)) {
// Kinds: cash; operating balance sheet, revenue and expense; // 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 cash = 0i64;
let mut op = [0i64; 2]; let mut op = [0i64; 2];
let mut fi = [0i64; 3]; let mut fi = [0i64; 3];
let mut declared_fi = [0i64; 2];
for (i, side, cents) in g.legs(1) { for (i, side, cents) in g.legs(1) {
let m = if side == DR { cents } else { -cents }; let m = if side == DR { cents } else { -cents };
match RCHART[i].0 { match RCHART[i].0 {
@@ -2530,10 +2551,13 @@ proptest! {
"3000" => fi[0] += m, "3000" => fi[0] += m,
"3001" => fi[1] += m, "3001" => fi[1] += m,
"2500" if designate => fi[2] += 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, "4000" | "4100" | "6000" | "6100" => op[1] += m,
_ => op[0] += m, _ => op[0] += m,
} }
} }
let fi_all = fi.iter().sum::<i64>() + declared_fi.iter().sum::<i64>();
// Rule 3: operating, then financing (no investing here). // Rule 3: operating, then financing (no investing here).
let take = |left: i64, m: i64| { let take = |left: i64, m: i64| {
if left > 0 && m < 0 { if left > 0 && m < 0 {
@@ -2545,10 +2569,12 @@ proptest! {
} }
}; };
let to_op = take(cash, op[0] + op[1]); 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; fin += to_fin;
if to_op > 0 { settle(&mut op, to_op, false) } else { settle(&mut op, -to_op, true) } 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<i64> = 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. // Rule 11: the loan pays, then receives; then capital.
if designate { if designate {
let paid = credits(&fi[2..]).min(debits(&op)); let paid = credits(&fi[2..]).min(debits(&op));
+130
View File
@@ -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}");
}
}
+4 -5
View File
@@ -6466,7 +6466,7 @@ impl BooksServer {
#[tool( #[tool(
name = "books.list_assets", name = "books.list_assets",
title = "List fixed 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) annotations(read_only_hint = true, open_world_hint = false)
)] )]
async fn list_assets( async fn list_assets(
@@ -6513,10 +6513,9 @@ impl BooksServer {
acquired_date: acquired.to_string(), acquired_date: acquired.to_string(),
cost: money(a.cost.amount), cost: money(a.cost.amount),
useful_life_months: a.useful_life_months, useful_life_months: a.useful_life_months,
status: match a.status { // As of as_of, like every figure here: a disposal
AssetStatus::Active => "active".to_string(), // after it hasn't happened yet (rules.md B-33).
AssetStatus::Disposed { .. } => "disposed".to_string(), status: if disposed { "disposed" } else { "active" }.to_string(),
},
held: acquired <= as_of && !disposed, held: acquired <= as_of && !disposed,
accumulated_posted: figure(|r| r.accumulated_posted), accumulated_posted: figure(|r| r.accumulated_posted),
accumulated_scheduled_through: figure(|r| r.accumulated_scheduled), accumulated_scheduled_through: figure(|r| r.accumulated_scheduled),
+2 -1
View File
@@ -2607,7 +2607,8 @@ pub struct AssetSummary {
pub cost: String, pub cost: String,
/// Useful life in months. /// Useful life in months.
pub useful_life_months: u32, 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, pub status: String,
/// Whether the books hold it on as_of: acquired by then and not yet /// Whether the books hold it on as_of: acquired by then and not yet
/// disposed. /// disposed.
+350
View File
@@ -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<String>,
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::<Vec<_>>(),
);
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}");
}
}
}
+677
View File
@@ -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<MemberCapital> {
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<AccountId> {
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<NaiveDate> {
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<Decimal> = 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 &reg.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<Decimal> = 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")
);
}
}
+699
View File
@@ -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<String>,
pub financing: Vec<String>,
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<MemberClose> = 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()
}
+66 -8
View File
@@ -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: operating (45-10), so every non-cash account is in exactly one section:
investing (the cost and accumulated-depreciation accounts of every investing (the cost and accumulated-depreciation accounts of every
registered fixed asset, and `books.investing_accounts`); financing registered fixed asset, and `books.investing_accounts`); financing
(equity, and `books.financing_accounts`); operating (every other (equity, `books.financing_accounts`, and a liability that holds
balance-sheet account, and revenue and expense, but see rule 4). The 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 payable>" (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 roles must agree with the chart and each other, or the statement is
refused with the setting to change: cash and investing accounts are refused with the setting to change: cash and investing accounts are
assets; financing accounts are liabilities (equity listed is accepted: 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 or "Other noncash items"; W, minus each
operating balance-sheet account's movement ("Increase in" / "Decrease operating balance-sheet account's movement ("Increase in" / "Decrease
in"). An identity over balanced entries (Lean 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 9. **The sections foot** (45-24: the statement reconciles beginning and
ending cash). `reconciles {sections_sum, net_change, difference}`; any ending cash). `reconciles {sections_sum, net_change, difference}`; any
difference, a section unequal to its lines, or a reconciliation unequal 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`, `converting_member_debt_to_capital_is_disclosed`,
`a_bill_payment_carries_its_bills_split`, `a_bill_payment_carries_its_bills_split`,
`a_reversed_bill_payment_leaves_the_purchase_unpaid`, the role refusals, `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 ## 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) 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 are two figures, never one: book value is cost less the posted figure
(`assets::scheduled_book_value` is the schedule's own, for the (`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`, **Enforced:** `assets::depreciation_schedule`, `assets::run_amount`,
`assets::catch_up_room`, `assets::depreciation_effects`, `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 and spent, on that date (a financing inflow and an operating or
investing outflow); paying the member back is a financing outflow; investing outflow); paying the member back is a financing outflow;
property a member contributes, and a debt to them converted to 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 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 and amount as the statement's outflow); the member is paid back when
the statement shows the financing outflow; and a member's capital 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 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 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 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 - 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 know it) = beginning (book less tax) + the built-in gain or loss share
+ property without a basis (at book) − property distributed (at book), + 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 `ledger-package` `k1_tax` (`k1-tax-basis-capital.csv` in the CPA
package). **Checked:** `ledger-core` `tests/tax_capital.rs`; package). **Checked:** `ledger-core` `tests/tax_capital.rs`;
`ledger-package` `tests/package.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`).
+41
View File
@@ -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 conditions (`in_service`, the disposal window), and the reconciliation's
signs. 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) ## Benchmarks (baseline 2026-10-13)
No budget, by decision (the owner, 2026-10-13: the books do little work No budget, by decision (the owner, 2026-10-13: the books do little work