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:
13 files changed
+2258
-24
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
@@ -3124,3 +3124,108 @@ proptest! {
|
||||
prop_assert!(cf.noncash_disclosures.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
/// Rule 2 (P3 seam review): a distribution declared to a liability that
|
||||
/// holds only distributions (Dr capital / Cr 2400 Distributions Payable)
|
||||
/// and paid later is a distribution paid, financing (ASC
|
||||
/// 230-10-45-15(a)), under the payable's kind; the declaration is an
|
||||
/// exchange inside financing, disclosed (50-3, 50-4). Before the fix the
|
||||
/// payment was "Cash paid to suppliers and employees" and the declaration
|
||||
/// an "Other noncash items" line beside an increase in the payable.
|
||||
#[test]
|
||||
fn a_distribution_declared_then_paid_is_a_distribution_paid() {
|
||||
let mut b = funded();
|
||||
b.acct("2400", "Distributions payable", AccountType::Liability);
|
||||
let declared = b.post(
|
||||
d(2026, 1, 31),
|
||||
"Distribution declared",
|
||||
&[
|
||||
("3000", DR, "600.00"),
|
||||
("3001", DR, "400.00"),
|
||||
("2400", CR, "1000.00"),
|
||||
],
|
||||
);
|
||||
let paid = b.post(
|
||||
d(2026, 2, 10),
|
||||
"Distribution paid",
|
||||
&[("2400", DR, "1000.00"), ("1000", CR, "1000.00")],
|
||||
);
|
||||
let cf = feb(&b, &[], &[]);
|
||||
assert!(cf.operating.is_empty(), "{:?}", cf.operating);
|
||||
assert_eq!(
|
||||
kinds(&cf, CashFlowSection::Financing),
|
||||
[(
|
||||
s("Distributions to members — 2400 Distributions payable"),
|
||||
x("-1000.00")
|
||||
)]
|
||||
);
|
||||
assert_eq!(em(&cf.financing), [(paid, x("-1000.00"))]);
|
||||
assert!(recon(&cf).is_empty(), "{:?}", recon(&cf));
|
||||
// The declaration's month: no cash, the exchange disclosed, and no
|
||||
// operating item.
|
||||
let jan = b.cf(d(2026, 1, 1), d(2026, 1, 31), &["1000", "1010"], &[], &[]);
|
||||
foots(&jan);
|
||||
assert_eq!(disclosed(&jan), [(declared, x("1000.00"))]);
|
||||
assert!(
|
||||
recon(&jan).is_empty(),
|
||||
"{:?}",
|
||||
jan.operating_reconciliation.lines
|
||||
);
|
||||
// A liability credited for anything else as well is no distributions
|
||||
// payable: an expense accrued to it keeps it operating.
|
||||
b.post(
|
||||
d(2026, 2, 20),
|
||||
"Accrued supplies",
|
||||
&[("6000", DR, "50.00"), ("2400", CR, "50.00")],
|
||||
);
|
||||
let cf = feb(&b, &[], &[]);
|
||||
assert_eq!(
|
||||
cf.operating.iter().map(|l| l.entry).collect::<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 abd84709578e7889c275a0019ae0e71366d77526ae93072777be614d7ffb0dd5 # shrinks to gens = [Gen { day: 108, mode: 1, picks: [1, 5, 3], x: 1, y: 1, classes: [0, 0, 0] }], (a, _m, e) = (0, 0, 108), k = 2
|
||||
cc 7481a0fb806d08439357ce4be66224389a7f76a7d91bf93ac320bb2b3c4d4891 # shrinks to gens = [Gen { day: 3, mode: 1, picks: [4, 5, 1], x: 1, y: 1, classes: [0, 0, 0] }], drafts = [Gen { day: 0, mode: 0, picks: [0, 0, 0], x: 1, y: 1, classes: [0, 0, 0] }], (a, _m, e) = (0, 0, 3)
|
||||
cc b594a44c6ad193ee922103d535d0a8e7b41ff808b37166c296491f27cd73d5a4 # shrinks to gens = [Gen { day: 0, mode: 2, picks: [3, 0, 2], x: 1, y: 1, classes: [0, 0, 0] }, Gen { day: 135, mode: 2, picks: [0, 3, 2], x: 1, y: 1, classes: [0, 0, 0] }], (a, _m, e) = (0, 0, 135), designate = false
|
||||
@@ -2517,12 +2517,33 @@ proptest! {
|
||||
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 mut fin = 0i64;
|
||||
// Rule 2: a liability left operating that only entries debiting
|
||||
// nothing but equity credit, through the end, holds distributions
|
||||
// declared: financing, after the loan.
|
||||
let declared = |code: &str| {
|
||||
let mut by_declaration = false;
|
||||
for g in gens.iter().filter(|g| g.date() <= day(e)) {
|
||||
let legs = g.legs(1);
|
||||
if !legs.iter().any(|&(i, s, _)| s == CR && RCHART[i].0 == code) {
|
||||
continue;
|
||||
}
|
||||
if legs.iter().filter(|l| l.1 == DR).all(|&(i, _, _)| RCHART[i].2 == AccountType::Equity) {
|
||||
by_declaration = true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
by_declaration
|
||||
};
|
||||
let (payables_declared, loan_declared) = (declared("2000"), !designate && declared("2500"));
|
||||
for g in gens.iter().filter(|g| g.date() >= day(a) && g.date() <= day(e)) {
|
||||
// Kinds: cash; operating balance sheet, revenue and expense;
|
||||
// capital A, capital B, the loan (financing when designated).
|
||||
// capital A, capital B, the loan (financing when designated),
|
||||
// then the liabilities holding distributions declared.
|
||||
let mut cash = 0i64;
|
||||
let mut op = [0i64; 2];
|
||||
let mut fi = [0i64; 3];
|
||||
let mut declared_fi = [0i64; 2];
|
||||
for (i, side, cents) in g.legs(1) {
|
||||
let m = if side == DR { cents } else { -cents };
|
||||
match RCHART[i].0 {
|
||||
@@ -2530,10 +2551,13 @@ proptest! {
|
||||
"3000" => fi[0] += m,
|
||||
"3001" => fi[1] += m,
|
||||
"2500" if designate => fi[2] += m,
|
||||
"2000" if payables_declared => declared_fi[0] += m,
|
||||
"2500" if loan_declared => declared_fi[1] += m,
|
||||
"4000" | "4100" | "6000" | "6100" => op[1] += m,
|
||||
_ => op[0] += m,
|
||||
}
|
||||
}
|
||||
let fi_all = fi.iter().sum::<i64>() + declared_fi.iter().sum::<i64>();
|
||||
// Rule 3: operating, then financing (no investing here).
|
||||
let take = |left: i64, m: i64| {
|
||||
if left > 0 && m < 0 {
|
||||
@@ -2545,10 +2569,12 @@ proptest! {
|
||||
}
|
||||
};
|
||||
let to_op = take(cash, op[0] + op[1]);
|
||||
let to_fin = take(cash - to_op, fi.iter().sum());
|
||||
let to_fin = take(cash - to_op, fi_all);
|
||||
fin += to_fin;
|
||||
if to_op > 0 { settle(&mut op, to_op, false) } else { settle(&mut op, -to_op, true) }
|
||||
if to_fin > 0 { settle(&mut fi, to_fin, false) } else { settle(&mut fi, -to_fin, true) }
|
||||
let mut kinds: Vec<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.
|
||||
if designate {
|
||||
let paid = credits(&fi[2..]).min(debits(&op));
|
||||
|
||||
Reference in new issue
Block a user