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),
/// 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<AccountId>,
member_of: HashMap<AccountId, usize>,
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> {
@@ -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<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 {
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<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
/// 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<EntryId>)> = BTreeMap::new();
// W by code: the operating balance-sheet accounts' movements.
let mut balances: BTreeMap<String, (AccountId, Decimal)> = 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;
+53 -2
View File
@@ -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<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.
#[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)?;