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),
|
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;
|
||||||
|
|||||||
@@ -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)?;
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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));
|
||||||
|
|||||||
@@ -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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 ®.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")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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`).
|
||||||
@@ -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
|
||||||
|
|||||||
Reference in new issue
Block a user