P3: the cash flow statement by ASC 230 (defect 1, D-35, D-42)
check / check (push) Canceled after 0s
check / check (push) Canceled after 0s
Direct method with the operating reconciliation and noncash schedules; it foots by construction (Lean and Kani). Every non-cash account belongs to one section; reversals net; a member or lender paying for the company is cash borrowed and spent (D-42, constructive receipt and disbursement); property a member contributes stays noncash. The bookspec seed: one loader checks every example. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
This commit is contained in:
64 files changed
+12128
-1567
No files matched your search
File diff suppressed because it is too large.
Load diff
@@ -397,6 +397,29 @@ pub enum LedgerError {
|
||||
end: String,
|
||||
},
|
||||
|
||||
/// The statement of cash flows would not foot (rules.md B-14 rule 9):
|
||||
/// an engine error, never output. It cannot happen on balanced books
|
||||
/// (Lean `cashflow_sections_sum_to_cash_change`); if it does, the
|
||||
/// engine is wrong, and nothing is shown rather than a wrong figure.
|
||||
#[error(
|
||||
"engine error: the cash flow statement does not foot ({what}: {found} against {expected}); nothing was produced, please report it"
|
||||
)]
|
||||
CashFlowDoesNotFoot {
|
||||
/// Which check failed: "sections", "investing lines", "operating
|
||||
/// reconciliation", "entry #N", ...
|
||||
what: String,
|
||||
/// The figure it should equal.
|
||||
expected: Decimal,
|
||||
/// The figure it came to.
|
||||
found: Decimal,
|
||||
},
|
||||
|
||||
/// The cash flow statement's account roles (cash, financing, investing)
|
||||
/// contradict each other or the chart (rules.md B-14 rule 2). The text
|
||||
/// names the setting and says what to change.
|
||||
#[error("cash flow account roles: {0}")]
|
||||
CashFlowRoles(String),
|
||||
|
||||
/// The referenced accounting period does not exist.
|
||||
#[error("unknown period id {0}")]
|
||||
UnknownPeriod(u64),
|
||||
|
||||
@@ -29,7 +29,14 @@
|
||||
//! remainder (B-13);
|
||||
//! * a cumulative allocation hands out exactly the new amount, and leaves
|
||||
//! each running total one allocation of everything allocated, so it never
|
||||
//! drifts a unit from its exact share (B-13).
|
||||
//! drifts a unit from its exact share (B-13);
|
||||
//! * an entry's cash is applied to the sections of the statement of cash
|
||||
//! flows moving against it, in a fixed order, each up to its own
|
||||
//! movement: the parts sum to the entry's cash, what no cash settled sums
|
||||
//! to zero, and a reversal's parts are the original's negated (B-14);
|
||||
//! * what a member or lender paid or received for the company, with no
|
||||
//! cash account moving, is split among the kinds it settled, netting to
|
||||
//! zero, and mirrored by a reversal (B-14 rule 11).
|
||||
|
||||
use crate::{AccountType, Side};
|
||||
use rust_decimal::Decimal;
|
||||
@@ -409,10 +416,350 @@ pub fn apportion_cumulative<T: Amount>(
|
||||
Some(())
|
||||
}
|
||||
|
||||
/// Where a line falls on the statement of cash flows (rules.md B-14 rule
|
||||
/// 2): a cash account, or one of the three sections.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
||||
pub enum Flow {
|
||||
/// A cash account: what the statement explains the change in.
|
||||
Cash,
|
||||
/// Operating activities.
|
||||
Operating,
|
||||
/// Investing activities.
|
||||
Investing,
|
||||
/// Financing activities.
|
||||
Financing,
|
||||
}
|
||||
|
||||
impl Flow {
|
||||
/// The three sections, in the order cash is applied to them (rules.md
|
||||
/// B-14 rule 3): operating, investing, financing.
|
||||
pub const SECTIONS: [Flow; 3] = [Flow::Operating, Flow::Investing, Flow::Financing];
|
||||
}
|
||||
|
||||
/// One entry's effect on the statement of cash flows.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct EntryFlows<T> {
|
||||
/// Net movement of the entry's cash lines, debit-positive: + is cash
|
||||
/// in.
|
||||
pub cash: T,
|
||||
/// Cash per section, `[operating, investing, financing]`, + is in.
|
||||
/// Sums to `cash`.
|
||||
pub flows: [T; 3],
|
||||
/// What no cash settled, per section, debit-positive: the section's
|
||||
/// movement plus its cash. Sums to zero.
|
||||
pub noncash: [T; 3],
|
||||
}
|
||||
|
||||
/// The cash one section takes from what is left of an entry's cash
|
||||
/// (`remaining`), given the section's net movement (debit-positive): only
|
||||
/// a section moving against the cash takes any, and never more than its
|
||||
/// own movement. Cash in (`remaining > 0`) settles credits (`movement <
|
||||
/// 0`); cash out settles debits.
|
||||
pub fn take<T: Amount>(remaining: T, movement: T) -> Option<T> {
|
||||
let against = movement.checked_neg()?;
|
||||
Some(if remaining > T::ZERO && movement < T::ZERO {
|
||||
remaining.min(against)
|
||||
} else if remaining < T::ZERO && movement > T::ZERO {
|
||||
remaining.max(against)
|
||||
} else {
|
||||
T::ZERO
|
||||
})
|
||||
}
|
||||
|
||||
/// Apply `cash` to `movements` in order, each taking [`take`] of what is
|
||||
/// left: `applied[k]` is the cash of part `k`. `None` when something is
|
||||
/// left over (the movements against the cash are smaller than it), the
|
||||
/// slices differ in length, or on overflow.
|
||||
///
|
||||
/// ```text
|
||||
/// GOAL: split an entry's cash among the parts it settled, without
|
||||
/// inventing or losing any.
|
||||
/// SOURCE: rules.md B-14 rule 3 (ASC 230-10-50-5: only the cash portion
|
||||
/// of a part-cash transaction is a cash flow; the order is a
|
||||
/// CONVENTION, the ASC is silent).
|
||||
/// INVARIANTS:
|
||||
/// 1. On success the parts sum to `cash` exactly.
|
||||
/// 2. Each part has the sign of `cash` (or is zero) and is no larger
|
||||
/// than its own movement, which runs against the cash.
|
||||
/// 3. Applying `-cash` to the negated movements gives the negated parts
|
||||
/// (a reversal mirrors its original).
|
||||
/// SCENARIOS:
|
||||
/// 1. IF the movements against the cash sum to at least the cash:
|
||||
/// - SHOULD succeed (a balanced entry always does)
|
||||
/// 2. IF they don't: SHOULD return None, never a part out of thin air.
|
||||
/// ```
|
||||
pub fn apply_cash<T: Amount>(cash: T, movements: &[T], applied: &mut [T]) -> Option<()> {
|
||||
if movements.len() != applied.len() {
|
||||
return None;
|
||||
}
|
||||
let mut remaining = cash;
|
||||
for (out, &movement) in applied.iter_mut().zip(movements) {
|
||||
let part = take(remaining, movement)?;
|
||||
*out = part;
|
||||
remaining = remaining.checked_sub(part)?;
|
||||
}
|
||||
(remaining == T::ZERO).then_some(())
|
||||
}
|
||||
|
||||
/// An entry's cash flows (rules.md B-14 rule 3): the net movement of its
|
||||
/// cash lines, applied to the sections moving against it in the order
|
||||
/// operating, investing, financing, each up to its own movement; what is
|
||||
/// left of a section's movement is the part no cash settled. `None` when
|
||||
/// the lines don't net to zero (never for a posted entry) or on overflow.
|
||||
pub fn entry_flows<T: Amount>(lines: &[(Flow, Side, T)]) -> Option<EntryFlows<T>> {
|
||||
let mut cash = T::ZERO;
|
||||
let mut moved = [T::ZERO; 3];
|
||||
for &(flow, side, amount) in lines {
|
||||
let delta = signed(side, amount)?;
|
||||
let slot = match flow {
|
||||
Flow::Cash => &mut cash,
|
||||
Flow::Operating => &mut moved[0],
|
||||
Flow::Investing => &mut moved[1],
|
||||
Flow::Financing => &mut moved[2],
|
||||
};
|
||||
*slot = slot.checked_add(delta)?;
|
||||
}
|
||||
// Balanced lines only: every part below rests on it.
|
||||
let mut total = cash;
|
||||
for &m in &moved {
|
||||
total = total.checked_add(m)?;
|
||||
}
|
||||
if total != T::ZERO {
|
||||
return None;
|
||||
}
|
||||
let mut flows = [T::ZERO; 3];
|
||||
apply_cash(cash, &moved, &mut flows)?;
|
||||
let mut noncash = [T::ZERO; 3];
|
||||
for ((n, m), f) in noncash.iter_mut().zip(moved).zip(flows) {
|
||||
*n = m.checked_add(f)?;
|
||||
}
|
||||
Some(EntryFlows {
|
||||
cash,
|
||||
flows,
|
||||
noncash,
|
||||
})
|
||||
}
|
||||
|
||||
/// The credits and the debits among some movements (debit-positive):
|
||||
/// `(Σ max(-m, 0), Σ max(m, 0))`.
|
||||
fn credits_and_debits<T: Amount>(movements: &[T]) -> Option<(T, T)> {
|
||||
let (mut credits, mut debits) = (T::ZERO, T::ZERO);
|
||||
for &m in movements {
|
||||
if m < T::ZERO {
|
||||
credits = credits.checked_sub(m)?;
|
||||
} else {
|
||||
debits = debits.checked_add(m)?;
|
||||
}
|
||||
}
|
||||
Some((credits, debits))
|
||||
}
|
||||
|
||||
/// Which way money a member or a lender handled for the company went
|
||||
/// (rules.md B-14 rule 11, D-42).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub enum OnBehalf {
|
||||
/// Paid for the company: sources credited (borrowed, contributed)
|
||||
/// against uses debited (spent).
|
||||
Paid,
|
||||
/// Received for the company: sources debited (repaid, distributed)
|
||||
/// against uses credited (collected).
|
||||
Received,
|
||||
}
|
||||
|
||||
impl OnBehalf {
|
||||
/// The other way: what an entry's reversal does.
|
||||
pub fn mirror(self) -> Self {
|
||||
match self {
|
||||
OnBehalf::Paid => OnBehalf::Received,
|
||||
OnBehalf::Received => OnBehalf::Paid,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What a member or a lender paid, or received, for the company with no
|
||||
/// cash account moving: a constructive receipt and disbursement (rules.md
|
||||
/// B-14 rule 11, D-42). `sources` are what is left unsettled of the
|
||||
/// entry's kinds that can pay or receive for the company (a financing
|
||||
/// liability, a member's equity), `uses` of the kinds they can pay or
|
||||
/// receive for, each debit-positive. [`OnBehalf::Paid`]: `amount =
|
||||
/// min(source credits, use debits)`, cash in to the sources' credits and
|
||||
/// out of the uses' debits (borrowed or contributed, then spent);
|
||||
/// [`OnBehalf::Received`]: `amount = min(source debits, use credits)`,
|
||||
/// cash in to the uses' credits and out of the sources' debits (collected,
|
||||
/// then repaid or distributed). `from[i]` is source `i`'s cash (+ in) and
|
||||
/// `to[j]` use `j`'s, each applied in order by [`apply_cash`]. `None` when
|
||||
/// the slices differ in length, or on overflow.
|
||||
///
|
||||
/// ```text
|
||||
/// GOAL: show money a member or lender handled for the company as the
|
||||
/// company's own, borrowed (or contributed) and spent, without
|
||||
/// inventing or losing a cent.
|
||||
/// SOURCE: rules.md B-14 rule 11 (D-42; ASC 230 is silent, PwC Viewpoint
|
||||
/// FSP 6.9.3.1, Deloitte DART Roadmap 7.2, BDO Blueprint 3.4).
|
||||
/// INVARIANTS:
|
||||
/// 1. The parts net to zero: Σ from + Σ to = 0, so the entry's cash
|
||||
/// flows still sum to its cash.
|
||||
/// 2. Each part settles its own movement, toward zero and never past it
|
||||
/// (zero, or the opposite sign and no larger).
|
||||
/// 3. The other way, minus the sources and minus the uses give minus
|
||||
/// the parts (a reversal mirrors its original).
|
||||
/// 4. As much is settled as can be: afterwards, paid, no source credit
|
||||
/// is left beside a use debit; received, no source debit beside a use
|
||||
/// credit.
|
||||
/// ```
|
||||
pub fn constructive<T: Amount>(
|
||||
way: OnBehalf,
|
||||
sources: &[T],
|
||||
uses: &[T],
|
||||
from: &mut [T],
|
||||
to: &mut [T],
|
||||
) -> Option<()> {
|
||||
let (source_credits, source_debits) = credits_and_debits(sources)?;
|
||||
let (use_credits, use_debits) = credits_and_debits(uses)?;
|
||||
// The cash the sources take (+ in): what was paid, or minus what was
|
||||
// received; the uses take the opposite.
|
||||
let cash = match way {
|
||||
OnBehalf::Paid => source_credits.min(use_debits),
|
||||
OnBehalf::Received => source_debits.min(use_credits).checked_neg()?,
|
||||
};
|
||||
apply_cash(cash, sources, from)?;
|
||||
apply_cash(cash.checked_neg()?, uses, to)
|
||||
}
|
||||
|
||||
/// How much of an entry is an exchange inside investing and financing
|
||||
/// that no cash settled (rules.md B-14 rule 7, ASC 230-10-50-4: debt
|
||||
/// converted to equity, one asset traded for another). `debits` and
|
||||
/// `credits` are the net debits and net credits of the entry's investing
|
||||
/// and financing balance-sheet accounts (each account netted first; both
|
||||
/// non-negative); `inflow` and `outflow` are the cash those two sections
|
||||
/// took in and paid out (both non-negative; constructive cash included,
|
||||
/// rule 11). Cash in settles credits and cash out settles debits; what's
|
||||
/// left of the smaller side was exchanged:
|
||||
/// `max(0, min(debits - outflow, credits - inflow))`.
|
||||
pub fn exchanged<T: Amount>(debits: T, credits: T, inflow: T, outflow: T) -> Option<T> {
|
||||
let d = debits.checked_sub(outflow)?;
|
||||
let c = credits.checked_sub(inflow)?;
|
||||
Some(d.min(c).max(T::ZERO))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// rules.md B-14 rule 3, by hand. Equipment 1,000 for 300 cash and a
|
||||
/// 700 note: investing takes the 300, the note's 700 and the rest of
|
||||
/// the equipment's are the noncash part.
|
||||
#[test]
|
||||
fn part_cash_part_noncash_takes_only_the_cash() {
|
||||
use Flow::*;
|
||||
let f = entry_flows(&[
|
||||
(Investing, Side::Debit, 1000i64),
|
||||
(Cash, Side::Credit, 300),
|
||||
(Financing, Side::Credit, 700),
|
||||
])
|
||||
.unwrap();
|
||||
assert_eq!(f.cash, -300);
|
||||
assert_eq!(f.flows, [0, -300, 0]);
|
||||
assert_eq!(f.noncash, [0, 700, -700]);
|
||||
// A fee withheld from a loan: Dr cash 9,900, Dr fee 100 / Cr loan
|
||||
// 10,000. Operating moves with the cash (a debit), so takes none.
|
||||
let f = entry_flows(&[
|
||||
(Cash, Side::Debit, 9900i64),
|
||||
(Operating, Side::Debit, 100),
|
||||
(Financing, Side::Credit, 10000),
|
||||
])
|
||||
.unwrap();
|
||||
assert_eq!(f.flows, [0, 0, 9900]);
|
||||
assert_eq!(f.noncash, [100, 0, -100]);
|
||||
}
|
||||
|
||||
/// Lines that don't net to zero are refused (review item 12): no part
|
||||
/// is made up for them.
|
||||
#[test]
|
||||
fn unbalanced_lines_have_no_flows() {
|
||||
use Flow::*;
|
||||
assert_eq!(
|
||||
entry_flows(&[(Operating, Side::Debit, 5i64), (Investing, Side::Debit, 5)]),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
entry_flows(&[(Cash, Side::Debit, 5i64), (Investing, Side::Credit, 4)]),
|
||||
None
|
||||
);
|
||||
// Too little against the cash: nothing is invented.
|
||||
let mut out = [0i64; 2];
|
||||
assert_eq!(apply_cash(10, &[-3, 4], &mut out), None);
|
||||
assert_eq!(apply_cash(10, &[-3], &mut out), None);
|
||||
}
|
||||
|
||||
/// The exchange measure (rules.md B-14 rule 7) on the review's
|
||||
/// hand-checked cases.
|
||||
#[test]
|
||||
fn exchanged_matches_the_hand_checked_cases() {
|
||||
// Debt to equity, 500.
|
||||
assert_eq!(exchanged(500i64, 500, 0, 0), Some(500));
|
||||
// Part-cash purchase with the seller's note: D 1,000, C 700, cash
|
||||
// out 300.
|
||||
assert_eq!(exchanged(1000i64, 700, 0, 300), Some(700));
|
||||
// Note received for equipment: D 700, C 1,000, cash in 300.
|
||||
assert_eq!(exchanged(700i64, 1000, 300, 0), Some(700));
|
||||
// Trade-in with 1,500 boot: D 2,000, C 1,000, cash out 1,500.
|
||||
assert_eq!(exchanged(2000i64, 1000, 0, 1500), Some(500));
|
||||
// A cash sale: nothing debited, cash in 500.
|
||||
assert_eq!(exchanged(0i64, 1000, 500, 0), Some(0));
|
||||
// A cash purchase: cash out covers every debit.
|
||||
assert_eq!(exchanged(1000i64, 0, 0, 1000), Some(0));
|
||||
// Equipment a lender paid for (rule 11): 1,000 borrowed in, 1,000
|
||||
// spent out, nothing exchanged.
|
||||
assert_eq!(exchanged(1000i64, 1000, 1000, 1000), Some(0));
|
||||
}
|
||||
|
||||
/// rules.md B-14 rule 11 (D-42), by hand.
|
||||
#[test]
|
||||
fn constructive_pays_and_receives_for_the_company() {
|
||||
use OnBehalf::{Paid, Received};
|
||||
// A member pays a 59 expense (Dr expense / Cr due to members): the
|
||||
// source's 59 credit pays the use's 59 debit.
|
||||
let (mut from, mut to) = ([0i64; 1], [0i64; 1]);
|
||||
constructive(Paid, &[-59], &[59], &mut from, &mut to).unwrap();
|
||||
assert_eq!((from, to), ([59], [-59]));
|
||||
// Its reversal, the other way, mirrors it.
|
||||
constructive(Received, &[59], &[-59], &mut from, &mut to).unwrap();
|
||||
assert_eq!((from, to), ([-59], [59]));
|
||||
// The reversal's shape read as a payment settles nothing.
|
||||
constructive(Paid, &[59], &[-59], &mut from, &mut to).unwrap();
|
||||
assert_eq!((from, to), ([0], [0]));
|
||||
// A 100 credit from the source against 60 of uses (the other 40
|
||||
// against something else): only 60 is paid; uses take in order.
|
||||
let (mut from, mut to) = ([0i64; 2], [0i64; 2]);
|
||||
constructive(Paid, &[-100, 0], &[25, 35], &mut from, &mut to).unwrap();
|
||||
assert_eq!((from, to), ([60, 0], [-25, -35]));
|
||||
// Two sources, one use smaller than both: the first source pays
|
||||
// first.
|
||||
constructive(Paid, &[-30, -30], &[40, 0], &mut from, &mut to).unwrap();
|
||||
assert_eq!((from, to), ([30, 10], [-40, 0]));
|
||||
// Each way settles only its own side.
|
||||
constructive(Paid, &[-50, 20], &[50, -20], &mut from, &mut to).unwrap();
|
||||
assert_eq!((from, to), ([50, 0], [-50, 0]));
|
||||
constructive(Received, &[-50, 20], &[50, -20], &mut from, &mut to).unwrap();
|
||||
assert_eq!((from, to), ([0, -20], [0, 20]));
|
||||
// Nothing against: nothing moves.
|
||||
constructive(Paid, &[-50, 0], &[-10, 0], &mut from, &mut to).unwrap();
|
||||
assert_eq!((from, to), ([0, 0], [0, 0]));
|
||||
// Mismatched lengths are refused.
|
||||
let one = [-1i64];
|
||||
assert_eq!(
|
||||
constructive(Paid, &one, &[1], &mut [0i64; 2], &mut [0]),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
constructive(Paid, &one, &[1], &mut [0i64], &mut [0; 2]),
|
||||
None
|
||||
);
|
||||
assert_eq!(Paid.mirror(), Received);
|
||||
assert_eq!(Received.mirror(), Paid);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn max_line_amount_is_one_quadrillion() {
|
||||
assert_eq!(MAX_LINE_AMOUNT, Decimal::from(1_000_000_000_000_000u64));
|
||||
|
||||
@@ -21,6 +21,7 @@ pub mod analysis;
|
||||
pub mod arap;
|
||||
pub mod assets;
|
||||
pub mod calendar;
|
||||
pub mod cashflow;
|
||||
pub mod classes;
|
||||
pub mod conventions;
|
||||
pub mod error;
|
||||
@@ -53,6 +54,12 @@ pub use assets::{
|
||||
ScheduleLine, TaxBasis, TaxBasisRule, accumulated_depreciation, book_value,
|
||||
depreciation_schedule, disposal_amounts,
|
||||
};
|
||||
pub use cashflow::{
|
||||
CashFlowFooting, CashFlowKind, CashFlowLine, CashFlowParams, CashFlowSection,
|
||||
CashFlowStatement, CashFlowWarning, CashFlowWarningKind, NoncashDisclosure, OperatingClass,
|
||||
OperatingReconciliation, ReconcilingKind, ReconcilingLine, RoleList, RoleProblem, cash_flow,
|
||||
role_problems,
|
||||
};
|
||||
pub use classes::{Class, ClassId};
|
||||
pub use error::LedgerError;
|
||||
pub use form1099::{
|
||||
@@ -60,6 +67,7 @@ pub use form1099::{
|
||||
vendor_1099_report_with, vendor_1099_report_with_members,
|
||||
};
|
||||
pub use journal::{EntryId, EntryStatus, JournalEntry, JournalLine};
|
||||
pub use kernel::{EntryFlows, Flow};
|
||||
pub use ledger::{
|
||||
ControlAccount, GENERAL_EXPENSES, Ledger, PAYABLES, PAYROLL, RECEIVABLES, RETAINED_EARNINGS,
|
||||
REVENUE, ReconciliationReport, UnclearedLine, control_account, control_accounts,
|
||||
@@ -73,12 +81,11 @@ pub use periods::{
|
||||
PeriodId, PeriodStatus, RETAINED_EARNINGS_CODE,
|
||||
};
|
||||
pub use reports::{
|
||||
AssetRegister, AssetRegisterRow, BalanceSheet, BalanceSheetStatement, CashFlowLine,
|
||||
CashFlowParams, CashFlowStatement, ClassProfitAndLoss, GeneralLedger, LedgerAccount,
|
||||
LedgerPosting, MemberCapital, MemberEquityColumn, MembersEquityStatement, ProfitAndLoss,
|
||||
ProfitAndLossStatement, StatementLine, TrialBalance, TrialBalanceLine, balance_sheet_as_of,
|
||||
cash_flow, fixed_asset_register, general_ledger, members_equity, profit_and_loss_for_period,
|
||||
trial_balance_as_of,
|
||||
AssetRegister, AssetRegisterRow, BalanceSheet, BalanceSheetStatement, ClassProfitAndLoss,
|
||||
GeneralLedger, LedgerAccount, LedgerPosting, MemberCapital, MemberEquityColumn,
|
||||
MembersEquityStatement, ProfitAndLoss, ProfitAndLossStatement, StatementLine, TrialBalance,
|
||||
TrialBalanceLine, balance_sheet_as_of, fixed_asset_register, general_ledger, members_equity,
|
||||
profit_and_loss_for_period, trial_balance_as_of,
|
||||
};
|
||||
pub use schedules::{
|
||||
AmortizationSchedule, Frequency, NewRecurring, NewSchedule, RecurringEntry, RecurringId,
|
||||
|
||||
@@ -9,8 +9,10 @@
|
||||
//! property test checks the two instantiations agree.
|
||||
//!
|
||||
//! Bounds: entries of up to [`N`] lines over up to [`K`] accounts, and
|
||||
//! amounts up to 2^40 in magnitude. The Lean model in `verify/lean`
|
||||
//! proves the same laws for any number of lines and accounts.
|
||||
//! amounts up to 2^40 in magnitude; the statement of cash flows' entry
|
||||
//! harnesses, up to [`NF`] lines, each in any of the four places. The Lean
|
||||
//! model in `verify/lean` proves the same laws for any number of lines and
|
||||
//! accounts.
|
||||
|
||||
use crate::kernel::{self, Amount, LineError};
|
||||
use crate::{AccountType, Side};
|
||||
@@ -713,6 +715,382 @@ fn cumulative_closing_entry_balances() {
|
||||
kani::cover!(true_up, "a true-up line on a member");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The statement of cash flows
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
use kernel::Flow;
|
||||
|
||||
/// Most lines per entry the cash flow harnesses explore: three is the
|
||||
/// fewest an entry needs to be mixed (cash and two sections), and each
|
||||
/// line can land in any of the four places. (Four lines ran past half an
|
||||
/// hour per harness, too long for `just prove`; the Lean model covers any
|
||||
/// number.)
|
||||
const NF: usize = 3;
|
||||
|
||||
/// Any count up to `NF`.
|
||||
fn any_flow_len() -> usize {
|
||||
let n: usize = kani::any();
|
||||
kani::assume(n <= NF);
|
||||
n
|
||||
}
|
||||
|
||||
fn any_flow() -> Flow {
|
||||
let t: u8 = kani::any();
|
||||
kani::assume(t < 4);
|
||||
match t {
|
||||
0 => Flow::Cash,
|
||||
1 => Flow::Operating,
|
||||
2 => Flow::Investing,
|
||||
_ => Flow::Financing,
|
||||
}
|
||||
}
|
||||
|
||||
/// `NF` lines, each tagged with its place on the statement (use a
|
||||
/// prefix).
|
||||
fn any_flow_lines() -> [(Flow, Side, i64); NF] {
|
||||
core::array::from_fn(|_| (any_flow(), any_side(), any_amount()))
|
||||
}
|
||||
|
||||
/// The spec's net movement of the lines tagged `f`.
|
||||
fn tagged(lines: &[(Flow, Side, i64)], f: Flow) -> i64 {
|
||||
let mut total = 0i64;
|
||||
for &(g, s, a) in lines {
|
||||
if g == f {
|
||||
total += wide(s, a);
|
||||
}
|
||||
}
|
||||
total
|
||||
}
|
||||
|
||||
/// The spec's section movements, `[operating, investing, financing]`.
|
||||
fn sections(lines: &[(Flow, Side, i64)]) -> [i64; 3] {
|
||||
[
|
||||
tagged(lines, Flow::Operating),
|
||||
tagged(lines, Flow::Investing),
|
||||
tagged(lines, Flow::Financing),
|
||||
]
|
||||
}
|
||||
|
||||
/// Whether the lines would post: their sides and amounts pass
|
||||
/// `check_lines`.
|
||||
fn accepted(lines: &[(Flow, Side, i64)]) -> bool {
|
||||
let mut s = [(Side::Debit, 0i64); N];
|
||||
for (o, &(_, side, a)) in s.iter_mut().zip(lines) {
|
||||
*o = (side, a);
|
||||
}
|
||||
kernel::check_lines(&s[..lines.len()]).is_ok()
|
||||
}
|
||||
|
||||
/// The building block of every cash flow harness below, for any two
|
||||
/// amounts: what a section takes is odd (a reversal takes minus it), has
|
||||
/// the cash's sign (or is zero), comes only from a movement against the
|
||||
/// cash, and is never more than the cash left or the movement.
|
||||
#[kani::proof]
|
||||
fn take_is_odd_and_bounded() {
|
||||
let r = any_amount();
|
||||
let n = any_amount();
|
||||
let t = kernel::take(r, n).expect("in range");
|
||||
assert_eq!(kernel::take(-r, -n), Some(-t));
|
||||
assert!(t == 0 || (t > 0) == (r > 0));
|
||||
assert!(t == 0 || (t > 0) == (n < 0));
|
||||
assert!(t.abs() <= r.abs() && t.abs() <= n.abs());
|
||||
kani::cover!(
|
||||
t != 0 && t.abs() < r.abs(),
|
||||
"a movement smaller than the cash"
|
||||
);
|
||||
}
|
||||
|
||||
/// Applying minus the cash to minus the movements gives minus the parts,
|
||||
/// part by part, and succeeds exactly when the original does.
|
||||
#[kani::proof]
|
||||
#[kani::unwind(5)]
|
||||
fn apply_cash_mirrors() {
|
||||
const M: usize = 3;
|
||||
let cash = any_amount();
|
||||
let moves: [i64; M] = core::array::from_fn(|_| any_amount());
|
||||
let neg: [i64; M] = core::array::from_fn(|k| -moves[k]);
|
||||
let mut a = [0i64; M];
|
||||
let mut b = [0i64; M];
|
||||
let ra = kernel::apply_cash(cash, &moves, &mut a);
|
||||
let rb = kernel::apply_cash(-cash, &neg, &mut b);
|
||||
assert_eq!(ra.is_some(), rb.is_some());
|
||||
if ra.is_some() {
|
||||
for k in 0..M {
|
||||
assert_eq!(b[k], -a[k]);
|
||||
}
|
||||
kani::cover!(a[0] != 0 && a[2] != 0, "two sections settled, mirrored");
|
||||
}
|
||||
}
|
||||
|
||||
/// An accepted entry always has cash flows, and they explain its cash
|
||||
/// exactly: the sections' cash sums to the entry's cash, and what no cash
|
||||
/// settled (each section's movement plus its cash) sums to zero
|
||||
/// (rules.md B-14 rules 3 and 9: the statement foots, entry by entry).
|
||||
#[kani::proof]
|
||||
#[kani::unwind(5)]
|
||||
fn entry_flows_sum_to_cash() {
|
||||
let all = any_flow_lines();
|
||||
let lines = &all[..any_flow_len()];
|
||||
if !accepted(lines) {
|
||||
return;
|
||||
}
|
||||
let f = kernel::entry_flows(lines).expect("an accepted entry has cash flows");
|
||||
let n = sections(lines);
|
||||
assert_eq!(f.cash, tagged(lines, Flow::Cash));
|
||||
assert_eq!(f.flows[0] + f.flows[1] + f.flows[2], f.cash);
|
||||
assert_eq!(f.noncash[0] + f.noncash[1] + f.noncash[2], 0);
|
||||
for k in 0..3 {
|
||||
assert_eq!(f.noncash[k], n[k] + f.flows[k]);
|
||||
}
|
||||
kani::cover!(
|
||||
f.flows[1] != 0 && f.noncash[1] != 0,
|
||||
"a part-cash, part-noncash investing entry"
|
||||
);
|
||||
}
|
||||
|
||||
/// When every section moves against the cash (or not at all), the cash
|
||||
/// settles every movement: each section's cash is minus its movement and
|
||||
/// nothing is noncash. (With no cash at all the premise would hold
|
||||
/// vacuously; review item 7: it needs cash.)
|
||||
#[kani::proof]
|
||||
#[kani::unwind(5)]
|
||||
fn entry_flows_pure_are_movements() {
|
||||
let all = any_flow_lines();
|
||||
let lines = &all[..any_flow_len()];
|
||||
if !accepted(lines) {
|
||||
return;
|
||||
}
|
||||
let c = tagged(lines, Flow::Cash);
|
||||
let n = sections(lines);
|
||||
let against = |m: i64| m == 0 || (c > 0 && m < 0) || (c < 0 && m > 0);
|
||||
if c == 0 || !(against(n[0]) && against(n[1]) && against(n[2])) {
|
||||
return;
|
||||
}
|
||||
let f = kernel::entry_flows(lines).expect("an accepted entry has cash flows");
|
||||
for k in 0..3 {
|
||||
assert_eq!(f.flows[k], -n[k]);
|
||||
assert_eq!(f.noncash[k], 0);
|
||||
}
|
||||
kani::cover!(n[0] != 0 && n[2] != 0, "cash settling two sections at once");
|
||||
}
|
||||
|
||||
/// Each section's cash has the cash's sign (or is zero), is never larger
|
||||
/// than the section's own movement, and comes only from a section moving
|
||||
/// against the cash.
|
||||
#[kani::proof]
|
||||
#[kani::unwind(5)]
|
||||
fn entry_flows_are_bounded() {
|
||||
let all = any_flow_lines();
|
||||
let lines = &all[..any_flow_len()];
|
||||
let Some(f) = kernel::entry_flows(lines) else {
|
||||
return;
|
||||
};
|
||||
let n = sections(lines);
|
||||
for k in 0..3 {
|
||||
let x = f.flows[k];
|
||||
assert!(x == 0 || (x > 0) == (f.cash > 0));
|
||||
assert!(x.abs() <= n[k].abs());
|
||||
assert!(x == 0 || (x > 0) == (n[k] < 0));
|
||||
}
|
||||
kani::cover!(
|
||||
f.flows[0] != 0 && f.flows[0].abs() < n[0].abs(),
|
||||
"a section only partly settled by cash"
|
||||
);
|
||||
}
|
||||
|
||||
/// A reversal's cash flows are its original's, negated, part by part:
|
||||
/// what lets an entry and its reversal in one period drop out of the
|
||||
/// statement together (rules.md B-14 rule 6).
|
||||
#[kani::proof]
|
||||
#[kani::unwind(5)]
|
||||
fn entry_flows_of_a_reversal_mirror() {
|
||||
let all = any_flow_lines();
|
||||
let n = any_flow_len();
|
||||
let lines = &all[..n];
|
||||
// Element by element (see `kani_symbolic_copy_canary`).
|
||||
let mut rev = [(Flow::Cash, Side::Debit, 0i64); NF];
|
||||
for i in 0..n {
|
||||
rev[i] = kernel::reverse_line(lines[i]);
|
||||
}
|
||||
let a = kernel::entry_flows(lines);
|
||||
let b = kernel::entry_flows(&rev[..n]);
|
||||
assert_eq!(a.is_some(), b.is_some());
|
||||
if let (Some(a), Some(b)) = (a, b) {
|
||||
assert_eq!(b.cash, -a.cash);
|
||||
for k in 0..3 {
|
||||
assert_eq!(b.flows[k], -a.flows[k]);
|
||||
assert_eq!(b.noncash[k], -a.noncash[k]);
|
||||
}
|
||||
kani::cover!(
|
||||
a.flows[1] != 0 && a.noncash[2] != 0,
|
||||
"a mixed entry, reversed"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// An entry with no cash line has no cash flows: every movement is
|
||||
/// noncash.
|
||||
#[kani::proof]
|
||||
#[kani::unwind(5)]
|
||||
fn no_cash_no_flow() {
|
||||
let all = any_flow_lines();
|
||||
let lines = &all[..any_flow_len()];
|
||||
for &(f, _, _) in lines {
|
||||
kani::assume(f != Flow::Cash);
|
||||
}
|
||||
if !accepted(lines) {
|
||||
return;
|
||||
}
|
||||
let f = kernel::entry_flows(lines).expect("an accepted entry has cash flows");
|
||||
let n = sections(lines);
|
||||
assert_eq!(f.cash, 0);
|
||||
for k in 0..3 {
|
||||
assert_eq!(f.flows[k], 0);
|
||||
assert_eq!(f.noncash[k], n[k]);
|
||||
}
|
||||
kani::cover!(n[1] != 0 && n[2] != 0, "property contributed for capital");
|
||||
}
|
||||
|
||||
/// How a section's cash splits among its kinds (contributions per member,
|
||||
/// borrowing per lender): `apply_cash` succeeds exactly when the
|
||||
/// movements against the cash cover it, and then the parts sum to the
|
||||
/// cash, each with its sign and within its own movement.
|
||||
#[kani::proof]
|
||||
#[kani::unwind(5)]
|
||||
fn apply_cash_spends_exactly() {
|
||||
const M: usize = 3;
|
||||
let cash = any_amount();
|
||||
let moves: [i64; M] = core::array::from_fn(|_| any_amount());
|
||||
let n: usize = kani::any();
|
||||
kani::assume(n <= M);
|
||||
let mut against = 0i64;
|
||||
for &m in &moves[..n] {
|
||||
if (cash > 0 && m < 0) || (cash < 0 && m > 0) {
|
||||
against += m.abs();
|
||||
}
|
||||
}
|
||||
let mut out = [0i64; M];
|
||||
let ok = kernel::apply_cash(cash, &moves[..n], &mut out[..n]);
|
||||
assert_eq!(ok.is_some(), cash.abs() <= against);
|
||||
if ok.is_some() {
|
||||
let mut sum = 0i64;
|
||||
for k in 0..n {
|
||||
let x = out[k];
|
||||
sum += x;
|
||||
assert!(x == 0 || (x > 0) == (cash > 0));
|
||||
assert!(x.abs() <= moves[k].abs());
|
||||
}
|
||||
assert_eq!(sum, cash);
|
||||
kani::cover!(n == M && out[2] != 0, "the third kind takes the rest");
|
||||
}
|
||||
}
|
||||
|
||||
/// Any amount a line may carry, not negative.
|
||||
fn any_magnitude() -> i64 {
|
||||
let a = any_amount();
|
||||
kani::assume(a >= 0);
|
||||
a
|
||||
}
|
||||
|
||||
/// The exchange inside investing and financing is the smaller side left
|
||||
/// once cash in has settled credits and cash out debits, never negative
|
||||
/// and never more than either side.
|
||||
#[kani::proof]
|
||||
fn exchanged_is_bounded() {
|
||||
let (d, c) = (any_magnitude(), any_magnitude());
|
||||
let (inflow, outflow) = (any_magnitude(), any_magnitude());
|
||||
let x = kernel::exchanged(d, c, inflow, outflow).expect("no overflow");
|
||||
let spec = (d - outflow).min(c - inflow).max(0);
|
||||
assert_eq!(x, spec);
|
||||
assert!(0 <= x && x <= d && x <= c);
|
||||
kani::cover!(x > 0 && outflow != 0, "a part-cash exchange");
|
||||
kani::cover!(
|
||||
x > 0 && inflow != 0 && outflow != 0,
|
||||
"an exchange beside cash in and out"
|
||||
);
|
||||
}
|
||||
|
||||
fn any_way() -> kernel::OnBehalf {
|
||||
if kani::any() {
|
||||
kernel::OnBehalf::Paid
|
||||
} else {
|
||||
kernel::OnBehalf::Received
|
||||
}
|
||||
}
|
||||
|
||||
/// What a member or a lender paid or received for the company (rules.md
|
||||
/// B-14 rule 11, D-42) invents and loses nothing: the parts net to zero,
|
||||
/// so an entry's flows still sum to its cash; each part settles its own
|
||||
/// movement toward zero, never past it; and as much is settled as can be.
|
||||
#[kani::proof]
|
||||
#[kani::unwind(4)]
|
||||
fn constructive_nets_to_zero() {
|
||||
const M: usize = 2;
|
||||
let way = any_way();
|
||||
let s: [i64; M] = core::array::from_fn(|_| any_amount());
|
||||
let u: [i64; M] = core::array::from_fn(|_| any_amount());
|
||||
let (mut from, mut to) = ([0i64; M], [0i64; M]);
|
||||
kernel::constructive(way, &s, &u, &mut from, &mut to).expect("amounts in range always settle");
|
||||
assert_eq!(from[0] + from[1] + to[0] + to[1], 0);
|
||||
let settles = |x: i64, m: i64| (x == 0 || (x > 0) == (m < 0)) && x.abs() <= m.abs();
|
||||
for k in 0..M {
|
||||
assert!(settles(from[k], s[k]));
|
||||
assert!(settles(to[k], u[k]));
|
||||
}
|
||||
let ls = [s[0] + from[0], s[1] + from[1]];
|
||||
let lu = [u[0] + to[0], u[1] + to[1]];
|
||||
let any = |xs: [i64; M], f: fn(i64) -> bool| f(xs[0]) || f(xs[1]);
|
||||
match way {
|
||||
kernel::OnBehalf::Paid => {
|
||||
assert!(!(any(ls, |x| x < 0) && any(lu, |x| x > 0)));
|
||||
// Paying never touches a source debit or a use credit.
|
||||
for k in 0..M {
|
||||
assert!(s[k] < 0 || from[k] == 0);
|
||||
assert!(u[k] > 0 || to[k] == 0);
|
||||
}
|
||||
}
|
||||
kernel::OnBehalf::Received => {
|
||||
assert!(!(any(ls, |x| x > 0) && any(lu, |x| x < 0)));
|
||||
for k in 0..M {
|
||||
assert!(s[k] > 0 || from[k] == 0);
|
||||
assert!(u[k] < 0 || to[k] == 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
kani::cover!(from[0] > 0 && from[1] > 0, "two sources pay");
|
||||
kani::cover!(from[0] < 0 && to[1] > 0, "received for the company");
|
||||
kani::cover!(
|
||||
ls[0] < 0 && to[0] < 0,
|
||||
"a source larger than what it paid for"
|
||||
);
|
||||
}
|
||||
|
||||
/// The other way, minus the sources and minus the uses give minus the
|
||||
/// parts: a reversal of a payment made on the company's behalf mirrors it
|
||||
/// (rules.md B-14 rule 6), so the two drop out together.
|
||||
#[kani::proof]
|
||||
#[kani::unwind(4)]
|
||||
fn constructive_mirrors() {
|
||||
const M: usize = 2;
|
||||
let way = any_way();
|
||||
let s: [i64; M] = core::array::from_fn(|_| any_amount());
|
||||
let u: [i64; M] = core::array::from_fn(|_| any_amount());
|
||||
let (ns, nu) = ([-s[0], -s[1]], [-u[0], -u[1]]);
|
||||
let (mut fa, mut ta) = ([0i64; M], [0i64; M]);
|
||||
let (mut fb, mut tb) = ([0i64; M], [0i64; M]);
|
||||
let a = kernel::constructive(way, &s, &u, &mut fa, &mut ta);
|
||||
let b = kernel::constructive(way.mirror(), &ns, &nu, &mut fb, &mut tb);
|
||||
assert_eq!(a.is_some(), b.is_some());
|
||||
if a.is_some() {
|
||||
for k in 0..M {
|
||||
assert_eq!(fb[k], -fa[k]);
|
||||
assert_eq!(tb[k], -ta[k]);
|
||||
}
|
||||
kani::cover!(fa[0] != 0 && ta[1] != 0, "a payment, mirrored");
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The checker itself
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -1146,465 +1146,14 @@ pub fn snapshot(ledger: &Ledger) -> Result<serde_json::Value, LedgerError> {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cash flow statement (indirect method, R-FUTURE-3).
|
||||
// Cash flow statement (direct method, rules.md B-14): see `crate::cashflow`.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// One labeled cash flow line. Positive is an inflow, negative an outflow.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct CashFlowLine {
|
||||
/// Human label, e.g. `"Increase in Accounts Receivable"` or account code/name.
|
||||
pub label: String,
|
||||
/// Signed amount: positive = cash in, negative = cash out.
|
||||
pub amount: Decimal,
|
||||
/// Code of the account the line is about, when it is about one (so a
|
||||
/// renderer can relabel, e.g. a member's capital account by name).
|
||||
#[serde(default)]
|
||||
pub account: Option<String>,
|
||||
}
|
||||
|
||||
/// Parameters for [`cash_flow`].
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct CashFlowParams {
|
||||
/// Period start, inclusive.
|
||||
pub start: chrono::NaiveDate,
|
||||
/// Period end, inclusive.
|
||||
pub end: chrono::NaiveDate,
|
||||
/// Accounts treated as cash. Their net change is the reconciling total.
|
||||
pub cash_accounts: Vec<crate::AccountId>,
|
||||
/// Liability accounts treated as financing (debt, including what the
|
||||
/// owners advanced: ASC 230-10-45-14(b) "other short- or long-term
|
||||
/// borrowing"). Default empty: all liabilities are operating working
|
||||
/// capital. All equity is financing.
|
||||
pub financing_accounts: Vec<crate::AccountId>,
|
||||
/// Asset accounts treated as investing: loans the entity makes and
|
||||
/// investments it buys (ASC 230-10-20; 230-10-45-13). Registered fixed
|
||||
/// assets are investing without being listed. Default empty: other
|
||||
/// assets are operating working capital.
|
||||
pub investing_accounts: Vec<crate::AccountId>,
|
||||
}
|
||||
|
||||
/// Cash flow statement, indirect method.
|
||||
///
|
||||
/// Starts from period net income, adds back non-cash depreciation, adjusts
|
||||
/// for working-capital changes, then reports investing (fixed-asset
|
||||
/// purchases at cost, disposal proceeds) and financing (equity changes,
|
||||
/// designated debt accounts) flows. The three sections always sum to the
|
||||
/// net change in the designated cash accounts.
|
||||
///
|
||||
/// Disposals are identified structurally: a posted entry that credits a
|
||||
/// registered fixed-asset cost account and debits its linked accumulated
|
||||
/// depreciation account. The gain/loss in that entry is removed from
|
||||
/// operating and the proceeds are shown in investing.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct CashFlowStatement {
|
||||
/// Period start, inclusive.
|
||||
pub start: chrono::NaiveDate,
|
||||
/// Period end, inclusive.
|
||||
pub end: chrono::NaiveDate,
|
||||
/// Period net income (revenue minus expenses).
|
||||
pub net_income: Decimal,
|
||||
/// Non-cash depreciation added back to net income.
|
||||
pub depreciation: Decimal,
|
||||
/// Working-capital adjustments (non-cash balance-sheet changes).
|
||||
pub working_capital: Vec<CashFlowLine>,
|
||||
/// Net cash from operating activities.
|
||||
pub cash_from_operations: Decimal,
|
||||
/// Investing flows: asset purchases and disposal proceeds.
|
||||
pub investing: Vec<CashFlowLine>,
|
||||
/// Net cash from investing activities.
|
||||
pub cash_from_investing: Decimal,
|
||||
/// Financing flows: equity changes and designated debt.
|
||||
pub financing: Vec<CashFlowLine>,
|
||||
/// Net cash from financing activities.
|
||||
pub cash_from_financing: Decimal,
|
||||
/// Net change in cash: the three sections summed.
|
||||
pub net_change_in_cash: Decimal,
|
||||
/// Cash balance at period start.
|
||||
pub cash_beginning: Decimal,
|
||||
/// Cash balance at period end.
|
||||
pub cash_ending: Decimal,
|
||||
}
|
||||
|
||||
/// Build the indirect-method cash flow statement for a period.
|
||||
///
|
||||
/// Pure function of the ledger: net income plus non-cash depreciation plus
|
||||
/// working-capital changes gives operating cash flow; fixed-asset purchases
|
||||
/// and disposal proceeds, and the accounts named as investing, give
|
||||
/// investing; equity and the debt named as financing give financing. The
|
||||
/// three sections sum to the net change in `cash_accounts`.
|
||||
///
|
||||
/// ```text
|
||||
/// GOAL: the statement of cash flows by the indirect method: net income
|
||||
/// adjusted to operating cash, plus investing and financing, for a
|
||||
/// period.
|
||||
/// SOURCE: docs/spec/rules.md B-14 (ASC 230-10-45-28 indirect method;
|
||||
/// 230-10-45-16 operating inflows; 230-10-45-7/8 gross over net;
|
||||
/// 230-10-45-13/14/15 investing and financing flows; 230-10-20
|
||||
/// investing activities).
|
||||
/// INVARIANTS:
|
||||
/// 1. Operating + investing + financing MUST equal the net change in the
|
||||
/// cash accounts over the period, exactly.
|
||||
/// 2. Operating MUST start from the period's net income (closing entries
|
||||
/// excluded) and adjust for changes in non-cash working-capital
|
||||
/// accounts.
|
||||
/// 3. Investing MUST hold fixed-asset purchases and disposal proceeds
|
||||
/// (a disposal's gain or loss moves out of operating into investing)
|
||||
/// and the changes in the accounts named as investing.
|
||||
/// 4. Financing MUST hold changes in equity (other than earnings) and in
|
||||
/// the liabilities named as financing.
|
||||
/// 4a. Investing and financing MUST be gross (ASC 230-10-45-7: gross
|
||||
/// receipts and payments are "more relevant" than net): per account,
|
||||
/// cash in and cash out are separate lines (contributions apart from
|
||||
/// distributions, borrowing apart from repayment), netted only within
|
||||
/// one entry.
|
||||
/// 5. NEVER count closing entries.
|
||||
/// 6. The period is `[start, end]`, inclusive: an entry dated `start` is
|
||||
/// period activity, not part of the beginning cash balance.
|
||||
/// 7. Every line's amount is its cash effect: positive is cash in. Its
|
||||
/// label names the direction ("Increase in" / "Decrease in" for
|
||||
/// working capital; "Proceeds from" / "Repayment of" for financing
|
||||
/// debt; "Owner contributions" / "Owner distributions" for equity;
|
||||
/// "Payments for" / "Collections on" for investing accounts).
|
||||
/// ```
|
||||
pub fn cash_flow(
|
||||
ledger: &Ledger,
|
||||
params: &CashFlowParams,
|
||||
) -> Result<CashFlowStatement, LedgerError> {
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
if params.end < params.start {
|
||||
return Err(LedgerError::InvalidDateRange {
|
||||
start: params.start.to_string(),
|
||||
end: params.end.to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
// Fixed-asset account roles from the register.
|
||||
let mut cost_accounts: HashSet<crate::AccountId> = HashSet::new();
|
||||
let mut accum_accounts: HashSet<crate::AccountId> = HashSet::new();
|
||||
let mut depr_expense_accounts: HashSet<crate::AccountId> = HashSet::new();
|
||||
// cost_account -> accum_account, for disposal identification.
|
||||
let mut cost_to_accum: HashMap<crate::AccountId, crate::AccountId> = HashMap::new();
|
||||
for asset in ledger.registered_assets() {
|
||||
cost_accounts.insert(asset.cost_account);
|
||||
accum_accounts.insert(asset.accum_depr_account);
|
||||
depr_expense_accounts.insert(asset.expense_account);
|
||||
cost_to_accum.insert(asset.cost_account, asset.accum_depr_account);
|
||||
}
|
||||
|
||||
let cash_set: HashSet<crate::AccountId> = params.cash_accounts.iter().copied().collect();
|
||||
let financing_set: HashSet<crate::AccountId> =
|
||||
params.financing_accounts.iter().copied().collect();
|
||||
let investing_set: HashSet<crate::AccountId> =
|
||||
params.investing_accounts.iter().copied().collect();
|
||||
|
||||
// Movement helpers: debits minus credits over a date predicate. Closing
|
||||
// entries are skipped: a close moves earnings into retained earnings,
|
||||
// which is neither net income nor an owner flow.
|
||||
let closings = ledger.closing_entry_ids();
|
||||
let movement_where = |account: crate::AccountId, pred: &dyn Fn(chrono::NaiveDate) -> bool| {
|
||||
let mut total = Decimal::ZERO;
|
||||
for entry in ledger.posted_entries() {
|
||||
if !pred(entry.date()) || closings.contains(&entry.id()) {
|
||||
continue;
|
||||
}
|
||||
for line in entry.lines() {
|
||||
if line.account == account {
|
||||
match line.side {
|
||||
crate::Side::Debit => total += line.amount.amount,
|
||||
crate::Side::Credit => total -= line.amount.amount,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
total
|
||||
};
|
||||
let begin_movement = |a: crate::AccountId| movement_where(a, &|d| d < params.start);
|
||||
let period_movement =
|
||||
|a: crate::AccountId| movement_where(a, &|d| d >= params.start && d <= params.end);
|
||||
|
||||
// Normal-side balance from a movement.
|
||||
let normal = |account: crate::AccountId, movement: Decimal| -> Decimal {
|
||||
let acct = ledger.chart().get(account).expect("chart account exists");
|
||||
match acct.account_type.normal_side() {
|
||||
crate::Side::Debit => movement,
|
||||
crate::Side::Credit => -movement,
|
||||
}
|
||||
};
|
||||
|
||||
// --- Net income and depreciation (period P&L) ---
|
||||
let mut net_income = Decimal::ZERO;
|
||||
let mut depreciation = Decimal::ZERO;
|
||||
for account in ledger.chart().all() {
|
||||
if account.placeholder {
|
||||
continue;
|
||||
}
|
||||
let pm = period_movement(account.id);
|
||||
if pm.is_zero() {
|
||||
continue;
|
||||
}
|
||||
match account.account_type {
|
||||
AccountType::Revenue => net_income += -pm, // revenue movement is credits
|
||||
AccountType::Expense => {
|
||||
net_income -= pm;
|
||||
if depr_expense_accounts.contains(&account.id) {
|
||||
depreciation += pm;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Classify balance-sheet accounts ---
|
||||
let mut working_capital: Vec<CashFlowLine> = Vec::new();
|
||||
let mut investing: Vec<CashFlowLine> = Vec::new();
|
||||
let mut financing: Vec<CashFlowLine> = Vec::new();
|
||||
|
||||
// Account display helper.
|
||||
let display = |account: crate::AccountId| -> String {
|
||||
let a = ledger.chart().get(account).expect("chart account exists");
|
||||
format!("{} {}", a.code, a.name)
|
||||
};
|
||||
|
||||
// By code: the chart iterates in hash order, and a statement's lines
|
||||
// must come out in the same order every time.
|
||||
let mut accounts: Vec<_> = ledger.chart().all().collect();
|
||||
accounts.sort_by(|a, b| a.code.cmp(&b.code));
|
||||
for account in accounts {
|
||||
if account.placeholder {
|
||||
continue;
|
||||
}
|
||||
let id = account.id;
|
||||
if cash_set.contains(&id) || cost_accounts.contains(&id) || accum_accounts.contains(&id) {
|
||||
continue; // cash reconciles; fixed assets are investing (gross, below)
|
||||
}
|
||||
if account.account_type == AccountType::Equity
|
||||
|| financing_set.contains(&id)
|
||||
|| investing_set.contains(&id)
|
||||
{
|
||||
continue; // financing and investing roles are gross, below
|
||||
}
|
||||
let begin = normal(id, begin_movement(id));
|
||||
let end = normal(id, period_movement(id) + begin_movement(id));
|
||||
let delta = end - begin;
|
||||
if delta.is_zero() {
|
||||
continue;
|
||||
}
|
||||
// Cash effect of the balance change: for both assets and
|
||||
// liabilities, it is minus the movement (debits - credits).
|
||||
let movement_delta = period_movement(id);
|
||||
let cash_effect = -movement_delta;
|
||||
let (label, line) = match account.account_type {
|
||||
AccountType::Asset => {
|
||||
let dir = if delta > Decimal::ZERO {
|
||||
"Increase in"
|
||||
} else {
|
||||
"Decrease in"
|
||||
};
|
||||
(format!("{dir} {}", display(id)), cash_effect)
|
||||
}
|
||||
AccountType::Liability => {
|
||||
let dir = if delta > Decimal::ZERO {
|
||||
"Increase in"
|
||||
} else {
|
||||
"Decrease in"
|
||||
};
|
||||
(format!("{dir} {}", display(id)), cash_effect)
|
||||
}
|
||||
_ => continue, // revenue/expense handled via net income
|
||||
};
|
||||
working_capital.push(CashFlowLine {
|
||||
label,
|
||||
amount: line,
|
||||
account: Some(account.code.clone()),
|
||||
});
|
||||
}
|
||||
|
||||
// --- Financing and investing accounts, gross (ASC 230-10-45-7) ---
|
||||
// Per account, cash in and cash out as separate lines; an entry's own
|
||||
// debits and credits to the account net first (one transaction, one
|
||||
// flow).
|
||||
let mut roles: Vec<&crate::Account> = ledger
|
||||
.chart()
|
||||
.all()
|
||||
.filter(|a| {
|
||||
!a.placeholder
|
||||
&& !cash_set.contains(&a.id)
|
||||
&& !cost_accounts.contains(&a.id)
|
||||
&& !accum_accounts.contains(&a.id)
|
||||
&& (a.account_type == AccountType::Equity
|
||||
|| financing_set.contains(&a.id)
|
||||
|| investing_set.contains(&a.id))
|
||||
})
|
||||
.collect();
|
||||
roles.sort_by(|a, b| a.code.cmp(&b.code));
|
||||
for account in roles {
|
||||
let (mut cash_in, mut cash_out) = (Decimal::ZERO, Decimal::ZERO);
|
||||
for entry in ledger.posted_entries() {
|
||||
let d = entry.date();
|
||||
if d < params.start || d > params.end || closings.contains(&entry.id()) {
|
||||
continue;
|
||||
}
|
||||
// Cash effect of this entry's change to the account: minus its
|
||||
// movement (debits - credits), for assets and claims alike.
|
||||
let effect: Decimal = entry
|
||||
.lines()
|
||||
.iter()
|
||||
.filter(|l| l.account == account.id)
|
||||
.map(|l| match l.side {
|
||||
crate::Side::Debit => -l.amount.amount,
|
||||
crate::Side::Credit => l.amount.amount,
|
||||
})
|
||||
.sum();
|
||||
if effect > Decimal::ZERO {
|
||||
cash_in += effect;
|
||||
} else {
|
||||
cash_out += effect;
|
||||
}
|
||||
}
|
||||
let name = display(account.id);
|
||||
let (in_label, out_label, target) = if investing_set.contains(&account.id) {
|
||||
(
|
||||
format!("Collections on {name}"),
|
||||
format!("Payments for {name}"),
|
||||
&mut investing,
|
||||
)
|
||||
} else if account.account_type == AccountType::Equity {
|
||||
(
|
||||
format!("Owner contributions — {name}"),
|
||||
format!("Owner distributions — {name}"),
|
||||
&mut financing,
|
||||
)
|
||||
} else {
|
||||
(
|
||||
format!("Proceeds from {name}"),
|
||||
format!("Repayment of {name}"),
|
||||
&mut financing,
|
||||
)
|
||||
};
|
||||
for (label, amount) in [(in_label, cash_in), (out_label, cash_out)] {
|
||||
if !amount.is_zero() {
|
||||
target.push(CashFlowLine {
|
||||
label,
|
||||
amount,
|
||||
account: Some(account.code.clone()),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Investing, gross: purchases and disposal proceeds ---
|
||||
// A disposal entry credits a cost account and debits its linked accum.
|
||||
let mut disposal_gain_loss = Decimal::ZERO; // +gain / -loss, to remove from operating
|
||||
for entry in ledger.posted_entries() {
|
||||
let d = entry.date();
|
||||
if d < params.start || d > params.end {
|
||||
continue;
|
||||
}
|
||||
// Is this a disposal? Credit to a cost account + debit to its accum.
|
||||
let mut disposed: Option<(crate::AccountId, Decimal, Decimal)> = None;
|
||||
for line in entry.lines() {
|
||||
if line.side == crate::Side::Credit
|
||||
&& let Some(&accum) = cost_to_accum.get(&line.account)
|
||||
{
|
||||
// Confirm the linked accum account is debited in this entry.
|
||||
let accum_debit: Decimal = entry
|
||||
.lines()
|
||||
.iter()
|
||||
.filter(|l| l.account == accum && l.side == crate::Side::Debit)
|
||||
.map(|l| l.amount.amount)
|
||||
.sum();
|
||||
if !accum_debit.is_zero() {
|
||||
disposed = Some((line.account, line.amount.amount, accum_debit));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some((cost_acct, cost_amount, accum_debit)) = disposed {
|
||||
let accum_acct = cost_to_accum[&cost_acct];
|
||||
let mut proceeds = Decimal::ZERO;
|
||||
let mut proceeds_label = String::new();
|
||||
let mut gain = Decimal::ZERO;
|
||||
let mut loss = Decimal::ZERO;
|
||||
for line in entry.lines() {
|
||||
if line.account == cost_acct || line.account == accum_acct {
|
||||
continue;
|
||||
}
|
||||
let acct = ledger
|
||||
.chart()
|
||||
.get(line.account)
|
||||
.expect("chart account exists");
|
||||
match (acct.account_type, line.side) {
|
||||
(AccountType::Asset, crate::Side::Debit) => {
|
||||
proceeds += line.amount.amount;
|
||||
proceeds_label = display(line.account);
|
||||
}
|
||||
(AccountType::Revenue, crate::Side::Credit) => gain += line.amount.amount,
|
||||
(AccountType::Expense, crate::Side::Debit) => loss += line.amount.amount,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
// Sanity: the entry balances, so proceeds + accum debit = cost + gain - loss.
|
||||
debug_assert!((proceeds + accum_debit - cost_amount - gain + loss).is_zero());
|
||||
disposal_gain_loss += gain - loss;
|
||||
investing.push(CashFlowLine {
|
||||
label: format!("Sale of fixed assets — {proceeds_label}"),
|
||||
amount: proceeds,
|
||||
account: ledger.chart().get(cost_acct).map(|a| a.code.clone()),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
// Not a disposal: debits to cost accounts are purchases.
|
||||
for line in entry.lines() {
|
||||
if cost_accounts.contains(&line.account) && line.side == crate::Side::Debit {
|
||||
investing.push(CashFlowLine {
|
||||
label: format!("Purchase of fixed assets — {}", display(line.account)),
|
||||
amount: -line.amount.amount,
|
||||
account: ledger.chart().get(line.account).map(|a| a.code.clone()),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove disposal gains/losses from operating (they live in investing).
|
||||
if !disposal_gain_loss.is_zero() {
|
||||
working_capital.push(CashFlowLine {
|
||||
label: "Less: gain on sale of fixed assets (in investing)".to_string(),
|
||||
amount: -disposal_gain_loss,
|
||||
account: None,
|
||||
});
|
||||
}
|
||||
|
||||
let cash_from_operations: Decimal =
|
||||
net_income + depreciation + working_capital.iter().map(|l| l.amount).sum::<Decimal>();
|
||||
let cash_from_investing: Decimal = investing.iter().map(|l| l.amount).sum();
|
||||
let cash_from_financing: Decimal = financing.iter().map(|l| l.amount).sum();
|
||||
|
||||
// --- Cash reconciliation ---
|
||||
let mut cash_beginning = Decimal::ZERO;
|
||||
let mut cash_ending = Decimal::ZERO;
|
||||
for &id in ¶ms.cash_accounts {
|
||||
cash_beginning += normal(id, begin_movement(id));
|
||||
cash_ending += normal(id, begin_movement(id) + period_movement(id));
|
||||
}
|
||||
let net_change_in_cash = cash_ending - cash_beginning;
|
||||
|
||||
Ok(CashFlowStatement {
|
||||
start: params.start,
|
||||
end: params.end,
|
||||
net_income,
|
||||
depreciation,
|
||||
working_capital,
|
||||
cash_from_operations,
|
||||
investing,
|
||||
cash_from_investing,
|
||||
financing,
|
||||
cash_from_financing,
|
||||
net_change_in_cash,
|
||||
cash_beginning,
|
||||
cash_ending,
|
||||
})
|
||||
}
|
||||
pub use crate::cashflow::{
|
||||
CashFlowFooting, CashFlowKind, CashFlowLine, CashFlowParams, CashFlowSection,
|
||||
CashFlowStatement, CashFlowWarning, CashFlowWarningKind, NoncashDisclosure, OperatingClass,
|
||||
OperatingReconciliation, ReconcilingKind, ReconcilingLine, cash_flow, foot,
|
||||
};
|
||||
|
||||
/// One asset on the fixed asset register.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
|
||||
@@ -294,21 +294,21 @@ pub static KEYS: &[Key] = &[
|
||||
toml: ("books", "cash_accounts"),
|
||||
kind: Kind::Codes,
|
||||
sensitivity: Sensitivity::Public,
|
||||
doc: "Account codes that hold cash (bank, savings, cash on hand): the cash flow statement reconciles to them.",
|
||||
doc: "Asset account codes that hold cash (bank, savings, cash on hand): the cash flow statement explains their change.",
|
||||
},
|
||||
Key {
|
||||
name: "books.financing_accounts",
|
||||
toml: ("books", "financing_accounts"),
|
||||
kind: Kind::Codes,
|
||||
sensitivity: Sensitivity::Public,
|
||||
doc: "Liability account codes the cash flow statement treats as financing (loans, and what owners advanced: ASC 230-10-45-14(b)).",
|
||||
doc: "Liability account codes the cash flow statement treats as financing (loans, and what owners advanced: ASC 230-10-45-14(b)); equity is always financing. What a member or lender pays for the company through one is shown as borrowed and spent (D-42).",
|
||||
},
|
||||
Key {
|
||||
name: "books.investing_accounts",
|
||||
toml: ("books", "investing_accounts"),
|
||||
kind: Kind::Codes,
|
||||
sensitivity: Sensitivity::Public,
|
||||
doc: "Asset account codes the cash flow statement treats as investing (loans the company makes, investments it buys: ASC 230-10-20).",
|
||||
doc: "Asset account codes the cash flow statement treats as investing (loans the company makes, investments it buys: ASC 230-10-20); registered fixed assets are investing unlisted.",
|
||||
},
|
||||
Key {
|
||||
name: "books.close_into",
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,398 +0,0 @@
|
||||
//! The bookspec examples about closes to the members
|
||||
//! (`bookspec/examples/members-close.*.toml`; P13, D-34; P3 defect 17),
|
||||
//! checked against the engine on every run, so they can't rot.
|
||||
//!
|
||||
//! Each example is a setup, events and expected values worked by hand from
|
||||
//! its cited source. Its shape is bookspec's (`bookspec/README.md`) with
|
||||
//! what closes need:
|
||||
//!
|
||||
//! - `[[setup.members]]` takes `share`, an exact decimal fraction (the
|
||||
//! shares sum to 1), beside `name` and `capital_account`;
|
||||
//! - an event may be a close instead of an entry: `close = { start, to,
|
||||
//! rounding? }` closes the period from `start` through the event's
|
||||
//! `date` to `"members"` (by share, in the order listed) or
|
||||
//! `"retained_earnings"`; `rounding = "per-close"` makes a members close
|
||||
//! split its own net alone (books from an engine that rounded each close
|
||||
//! by itself), and the default is the cumulative rule. A close is an
|
||||
//! event like any other: event N posts entry #N, so a close must have
|
||||
//! something to close;
|
||||
//! - an `[[expect]]` may take `after = N`: the books after event N, not
|
||||
//! after the last;
|
||||
//! - three queries: `entry` (`entry = N`: its lines, `"<code>.debit"` and
|
||||
//! `"<code>.credit"` summing each account's lines on that side, every
|
||||
//! line of the entry named), `balances` (`as_of`: each account named,
|
||||
//! `"<code>"`, its balance on its normal side, so a member's capital
|
||||
//! after a loss is negative), and `members_equity` (`from`, `to`: the
|
||||
//! statement of changes in members' equity, `"<name>.beginning"`,
|
||||
//! `.contributions`, `.net_income`, `.distributions`, `.other`,
|
||||
//! `.ending`).
|
||||
//!
|
||||
//! A key the shape doesn't have is refused, never ignored.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use chrono::NaiveDate;
|
||||
use ledger_core::{
|
||||
AccountType, CloseTo, Currency, Ledger, MemberCapital, MemberClose, Money, Side,
|
||||
members_equity, reports::trial_balance_as_of,
|
||||
};
|
||||
use rust_decimal::Decimal;
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Example {
|
||||
id: String,
|
||||
title: String,
|
||||
rule: String,
|
||||
status: String,
|
||||
sources: Vec<String>,
|
||||
tags: Vec<String>,
|
||||
setup: Setup,
|
||||
events: Vec<Event>,
|
||||
expect: Vec<Expect>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Setup {
|
||||
currency: String,
|
||||
fiscal_year_start: String,
|
||||
basis: String,
|
||||
rounding: String,
|
||||
accounts: Vec<AccountSpec>,
|
||||
#[serde(default)]
|
||||
members: Vec<MemberSpec>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct AccountSpec {
|
||||
code: String,
|
||||
name: String,
|
||||
#[serde(rename = "type")]
|
||||
kind: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct MemberSpec {
|
||||
name: String,
|
||||
capital_account: String,
|
||||
share: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Event {
|
||||
date: String,
|
||||
description: String,
|
||||
#[serde(default)]
|
||||
lines: Vec<LineSpec>,
|
||||
#[serde(default)]
|
||||
reverses: Option<u64>,
|
||||
#[serde(default)]
|
||||
close: Option<CloseSpec>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct LineSpec {
|
||||
account: String,
|
||||
side: String,
|
||||
amount: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct CloseSpec {
|
||||
start: String,
|
||||
to: String,
|
||||
#[serde(default)]
|
||||
rounding: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Expect {
|
||||
query: String,
|
||||
#[serde(default)]
|
||||
from: Option<String>,
|
||||
#[serde(default)]
|
||||
to: Option<String>,
|
||||
#[serde(default)]
|
||||
as_of: Option<String>,
|
||||
#[serde(default)]
|
||||
entry: Option<u64>,
|
||||
#[serde(default)]
|
||||
after: Option<usize>,
|
||||
values: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
fn examples() -> Vec<PathBuf> {
|
||||
let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../bookspec/examples");
|
||||
let mut paths: Vec<PathBuf> = std::fs::read_dir(&dir)
|
||||
.unwrap()
|
||||
.map(|e| e.unwrap().path())
|
||||
.filter(|p| {
|
||||
p.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.is_some_and(|n| n.starts_with("members-close.") && n.ends_with(".toml"))
|
||||
})
|
||||
.collect();
|
||||
paths.sort();
|
||||
paths
|
||||
}
|
||||
|
||||
fn date(s: &str) -> NaiveDate {
|
||||
NaiveDate::parse_from_str(s, "%Y-%m-%d").unwrap_or_else(|_| panic!("date {s:?}"))
|
||||
}
|
||||
|
||||
/// An exact decimal string, never a float.
|
||||
fn exact(s: &str) -> Decimal {
|
||||
assert!(
|
||||
!s.contains(['e', 'E']) && s.trim() == s,
|
||||
"{s:?} is not a decimal string"
|
||||
);
|
||||
s.parse()
|
||||
.unwrap_or_else(|_| panic!("{s:?} is not a decimal"))
|
||||
}
|
||||
|
||||
fn account_type(kind: &str) -> AccountType {
|
||||
match kind {
|
||||
"asset" => AccountType::Asset,
|
||||
"liability" => AccountType::Liability,
|
||||
"equity" => AccountType::Equity,
|
||||
"revenue" => AccountType::Revenue,
|
||||
"expense" => AccountType::Expense,
|
||||
other => panic!("account type {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn side(s: &str) -> Side {
|
||||
match s {
|
||||
"debit" => Side::Debit,
|
||||
"credit" => Side::Credit,
|
||||
other => panic!("side {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
struct Books<'a> {
|
||||
ex: &'a Example,
|
||||
l: Ledger,
|
||||
currency: Currency,
|
||||
}
|
||||
|
||||
impl Books<'_> {
|
||||
fn id(&self, code: &str) -> ledger_core::AccountId {
|
||||
self.l
|
||||
.chart()
|
||||
.find_by_code(code)
|
||||
.unwrap_or_else(|| panic!("{}: no account {code}", self.ex.id))
|
||||
.id
|
||||
}
|
||||
|
||||
fn members(&self) -> Vec<MemberCapital> {
|
||||
self.ex
|
||||
.setup
|
||||
.members
|
||||
.iter()
|
||||
.map(|m| MemberCapital {
|
||||
name: m.name.clone(),
|
||||
capital_account: self.id(&m.capital_account),
|
||||
distribution_account: None,
|
||||
share: exact(&m.share),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Event `n` (1-based), which must post entry #n.
|
||||
fn apply(&mut self, n: u64, e: &Event) {
|
||||
let ex = &self.ex.id;
|
||||
let day = date(&e.date);
|
||||
let posted = match (&e.close, e.reverses, e.lines.is_empty()) {
|
||||
(Some(c), None, true) => {
|
||||
let p = self
|
||||
.l
|
||||
.create_period(&format!("event {n}"), date(&c.start), day)
|
||||
.unwrap();
|
||||
let members: Vec<MemberClose> = self
|
||||
.members()
|
||||
.iter()
|
||||
.map(|m| MemberClose {
|
||||
capital: m.capital_account,
|
||||
share: m.share,
|
||||
distributions: None,
|
||||
})
|
||||
.collect();
|
||||
let to = match (c.to.as_str(), c.rounding.as_deref()) {
|
||||
("members", None) => CloseTo::Members(members),
|
||||
("members", Some("per-close")) => CloseTo::MembersPerClose(members),
|
||||
("retained_earnings", None) => CloseTo::RetainedEarnings { sweep: vec![] },
|
||||
other => panic!("{ex}: close {other:?}"),
|
||||
};
|
||||
self.l
|
||||
.close_period_to(p, &to)
|
||||
.unwrap()
|
||||
.unwrap_or_else(|| panic!("{ex}: event {n} closes nothing"))
|
||||
}
|
||||
(None, Some(k), true) => {
|
||||
assert!(k >= 1 && k < n, "{ex}: event {n} reverses {k}");
|
||||
self.l.reverse_entry(k, day, &e.description).unwrap()
|
||||
}
|
||||
(None, None, false) => {
|
||||
let id = self.l.create_entry(day, &e.description);
|
||||
for line in &e.lines {
|
||||
let account = self.id(&line.account);
|
||||
let money = Money::new(exact(&line.amount), self.currency);
|
||||
self.l
|
||||
.add_line(id, account, side(&line.side), money)
|
||||
.unwrap();
|
||||
}
|
||||
self.l.post_entry(id).unwrap();
|
||||
id
|
||||
}
|
||||
_ => panic!("{ex}: event {n} is one of lines, reverses or close"),
|
||||
};
|
||||
assert_eq!(posted, n, "{ex}: event {n} posted entry {posted}");
|
||||
}
|
||||
|
||||
/// What `q` asks, for each of its keys.
|
||||
fn answer(&self, q: &Expect) -> BTreeMap<String, Decimal> {
|
||||
let ex = &self.ex.id;
|
||||
match q.query.as_str() {
|
||||
"entry" => {
|
||||
let id = q
|
||||
.entry
|
||||
.unwrap_or_else(|| panic!("{ex}: entry query without entry"));
|
||||
let mut out = BTreeMap::new();
|
||||
for line in self.l.get_entry(id).unwrap().lines() {
|
||||
let code = &self.l.chart().get(line.account).unwrap().code;
|
||||
let side = match line.side {
|
||||
Side::Debit => "debit",
|
||||
Side::Credit => "credit",
|
||||
};
|
||||
*out.entry(format!("{code}.{side}")).or_default() += line.amount.amount;
|
||||
}
|
||||
out
|
||||
}
|
||||
"balances" => {
|
||||
let as_of = date(q.as_of.as_deref().expect("balances take as_of"));
|
||||
let tb = trial_balance_as_of(&self.l, as_of);
|
||||
q.values
|
||||
.keys()
|
||||
.map(|code| {
|
||||
let account = self.l.chart().get(self.id(code)).unwrap();
|
||||
let (dr, cr) = tb
|
||||
.lines
|
||||
.iter()
|
||||
.find(|l| &l.code == code)
|
||||
.map_or((Decimal::ZERO, Decimal::ZERO), |l| (l.debit, l.credit));
|
||||
let normal = match account.account_type.normal_side() {
|
||||
Side::Debit => dr - cr,
|
||||
Side::Credit => cr - dr,
|
||||
};
|
||||
(code.clone(), normal)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
"members_equity" => {
|
||||
let from = date(q.from.as_deref().expect("members_equity takes from"));
|
||||
let to = date(q.to.as_deref().expect("members_equity takes to"));
|
||||
let st = members_equity(&self.l, from, to, &self.members()).unwrap();
|
||||
let mut out = BTreeMap::new();
|
||||
for m in &st.members {
|
||||
for (field, v) in [
|
||||
("beginning", m.beginning),
|
||||
("contributions", m.contributions),
|
||||
("net_income", m.net_income),
|
||||
("distributions", m.distributions),
|
||||
("other", m.other),
|
||||
("ending", m.ending),
|
||||
] {
|
||||
out.insert(format!("{}.{field}", m.name), v);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
other => panic!("{ex}: query {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn check(&self, q: &Expect) {
|
||||
let ex = &self.ex.id;
|
||||
let got = self.answer(q);
|
||||
for (key, want) in &q.values {
|
||||
let got = got
|
||||
.get(key)
|
||||
.unwrap_or_else(|| panic!("{ex}: {} has no {key}", q.query));
|
||||
assert_eq!(*got, exact(want), "{ex}: {} {key}", q.query);
|
||||
}
|
||||
if q.query == "entry" {
|
||||
// Every line named: the entry is exactly what is expected.
|
||||
let named: Vec<&String> = q.values.keys().collect();
|
||||
let lines: Vec<&String> = got.keys().collect();
|
||||
assert_eq!(lines, named, "{ex}: entry {:?}'s lines", q.entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Every members-close example loads, and the engine computes every value
|
||||
/// it expects, after the event it names or the last.
|
||||
#[test]
|
||||
fn the_members_close_examples_hold() {
|
||||
let paths = examples();
|
||||
assert!(paths.len() >= 4, "{} examples", paths.len());
|
||||
for path in paths {
|
||||
let text = std::fs::read_to_string(&path).unwrap();
|
||||
let ex: Example =
|
||||
toml::from_str(&text).unwrap_or_else(|e| panic!("{}: {e}", path.display()));
|
||||
assert_eq!(
|
||||
path.file_stem().unwrap().to_str().unwrap(),
|
||||
ex.id,
|
||||
"an example's file is named for its id"
|
||||
);
|
||||
assert!(!ex.title.is_empty() && !ex.rule.is_empty() && !ex.sources.is_empty());
|
||||
assert!(!ex.tags.is_empty());
|
||||
assert!(["AUTHORITY", "CONVENTION", "CHOICE"].contains(&ex.status.as_str()));
|
||||
assert_eq!(
|
||||
(ex.setup.basis.as_str(), ex.setup.rounding.as_str()),
|
||||
("accrual", "largest-remainder"),
|
||||
"{}",
|
||||
ex.id
|
||||
);
|
||||
assert!(ex.setup.fiscal_year_start.len() == 5, "{}", ex.id);
|
||||
let shares: Decimal = ex.setup.members.iter().map(|m| exact(&m.share)).sum();
|
||||
assert_eq!(shares, Decimal::ONE, "{}: shares", ex.id);
|
||||
let currency = Currency::new(&ex.setup.currency).unwrap();
|
||||
let mut books = Books {
|
||||
ex: &ex,
|
||||
l: Ledger::new(currency),
|
||||
currency,
|
||||
};
|
||||
for a in &ex.setup.accounts {
|
||||
books
|
||||
.l
|
||||
.add_account(&a.code, &a.name, account_type(&a.kind), None, false)
|
||||
.unwrap();
|
||||
}
|
||||
for q in &ex.expect {
|
||||
assert!(
|
||||
q.after.is_none_or(|n| n >= 1 && n <= ex.events.len()),
|
||||
"{}: after {:?}",
|
||||
ex.id,
|
||||
q.after
|
||||
);
|
||||
}
|
||||
for (i, e) in ex.events.iter().enumerate() {
|
||||
books.apply(i as u64 + 1, e);
|
||||
for q in ex.expect.iter().filter(|q| q.after == Some(i + 1)) {
|
||||
books.check(q);
|
||||
}
|
||||
}
|
||||
for q in ex.expect.iter().filter(|q| q.after.is_none()) {
|
||||
books.check(q);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -744,7 +744,7 @@ fn entry_movements_sum_to_zero() {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cash flow statement (indirect method).
|
||||
// Cash flow statement (direct method, rules.md B-14).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
use ledger_core::NewAsset;
|
||||
@@ -757,6 +757,7 @@ fn cash_flow_params(cash: AccountId) -> CashFlowParams {
|
||||
cash_accounts: vec![cash],
|
||||
financing_accounts: vec![],
|
||||
investing_accounts: vec![],
|
||||
members: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -779,20 +780,36 @@ fn cash_flow_reconciles_to_change_in_cash() {
|
||||
assert_eq!(cf.net_change_in_cash, Decimal::new(820000, 2));
|
||||
|
||||
// Net income: 4,000 revenue - 800 rent = 3,200.
|
||||
assert_eq!(cf.net_income, Decimal::new(320000, 2));
|
||||
let r = &cf.operating_reconciliation;
|
||||
assert_eq!(r.net_income, Decimal::new(320000, 2));
|
||||
// No depreciation: the fixture's equipment is not a registered asset.
|
||||
assert_eq!(cf.depreciation, Decimal::ZERO);
|
||||
assert!(r.lines.iter().all(|l| l.label != "Depreciation"));
|
||||
|
||||
// Operating: 3,200 net income
|
||||
// - 2,500 increase in AR (4,000 billed - 1,500 collected)
|
||||
// - 2,500 equipment purchase (not a registered asset: working capital)
|
||||
// + 10,000 owner investment is equity -> financing, not operating.
|
||||
// Financing: +10,000 owner contribution.
|
||||
// => operating = 3,200 - 2,500 - 2,500 = -1,800
|
||||
// => investing = 0, financing = 10,000. Sum = 8,200. Reconciles.
|
||||
// Operating, by the direct method: collected 1,500 (class (a)); paid
|
||||
// 2,500 for equipment (not a registered asset: operating, rules.md
|
||||
// B-14's limit R4) and 800 rent (class (d)) = -1,800. Reconciled:
|
||||
// 3,200 net income - 2,500 increase in AR (4,000 billed - 1,500
|
||||
// collected) - 2,500 increase in equipment = -1,800.
|
||||
// Financing: +10,000 owner contribution. Sum = 8,200. Reconciles.
|
||||
assert_eq!(cf.cash_from_operations, Decimal::new(-180000, 2));
|
||||
assert_eq!(
|
||||
cf.operating_by_class(),
|
||||
[
|
||||
(
|
||||
ledger_core::OperatingClass::FromCustomers,
|
||||
Decimal::new(150000, 2)
|
||||
),
|
||||
(
|
||||
ledger_core::OperatingClass::ToSuppliersAndEmployees,
|
||||
Decimal::new(-330000, 2)
|
||||
),
|
||||
]
|
||||
);
|
||||
let total: Decimal = r.net_income + r.lines.iter().map(|l| l.amount).sum::<Decimal>();
|
||||
assert_eq!(total, cf.cash_from_operations);
|
||||
assert_eq!(cf.cash_from_investing, Decimal::ZERO);
|
||||
assert_eq!(cf.cash_from_financing, Decimal::new(1000000, 2));
|
||||
assert_eq!(cf.reconciles.difference, Decimal::ZERO);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -894,11 +911,46 @@ fn cash_flow_with_registered_asset_and_disposal() {
|
||||
// Cash: 100,000 - 60,000 + 59,600 = 99,600.
|
||||
assert_eq!(cf.cash_ending, Decimal::new(9960000, 2));
|
||||
// Net income: 148 gain - 548 depreciation = -400.
|
||||
assert_eq!(cf.net_income, Decimal::new(-40000, 2));
|
||||
// Depreciation added back: 548.
|
||||
assert_eq!(cf.depreciation, Decimal::new(54800, 2));
|
||||
// Operating: -400 + 548 - 148 (gain removed) = 0.
|
||||
let r = &cf.operating_reconciliation;
|
||||
assert_eq!(r.net_income, Decimal::new(-40000, 2));
|
||||
// Depreciation added back: 548; the gain taken out: -148 (its cash is
|
||||
// investing).
|
||||
let rec: Vec<(&str, Decimal)> = r
|
||||
.lines
|
||||
.iter()
|
||||
.map(|l| (l.label.as_str(), l.amount))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
rec,
|
||||
[
|
||||
("Depreciation", Decimal::new(54800, 2)),
|
||||
(
|
||||
"(Gain) loss and other noncash changes in investing assets",
|
||||
Decimal::new(-14800, 2)
|
||||
),
|
||||
]
|
||||
);
|
||||
// Operating: -400 + 548 - 148 = 0.
|
||||
assert_eq!(cf.cash_from_operations, Decimal::ZERO);
|
||||
// Gross (ASC 230-10-45-7): the purchase and the sale apart.
|
||||
let inv: Vec<(&str, Decimal)> = cf
|
||||
.investing
|
||||
.iter()
|
||||
.map(|l| (l.kind.as_str(), l.amount))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
inv,
|
||||
[
|
||||
(
|
||||
"Purchases of property and equipment",
|
||||
Decimal::new(-6000000, 2)
|
||||
),
|
||||
(
|
||||
"Proceeds from sales of property and equipment",
|
||||
Decimal::new(5960000, 2)
|
||||
),
|
||||
]
|
||||
);
|
||||
// Investing: -60,000 purchase + 59,600 proceeds = -400.
|
||||
assert_eq!(cf.cash_from_investing, Decimal::new(-40000, 2));
|
||||
// Financing: +100,000 owner investment.
|
||||
|
||||
@@ -5,3 +5,5 @@
|
||||
# It is recommended to check this file in to source control so that
|
||||
# everyone who runs the test benefits from these saved cases.
|
||||
cc bd44337eb42456f907441e7938e4909e0b66386ce70d26b9b54e7173c4d72fa7 # shrinks to parts = [427221022393, 672290605285], rems = [0, 0, 0, 0, 0], given = [0, 0, 0, 0, 0], short = 99, unpaired = false
|
||||
cc 763c29fdb4a32c44ec7b41b2616685739f7accb3063872c1fc9df69e7f43c0d8 # shrinks to paid = false, sources = [1], uses = [-1]
|
||||
cc badb0f9ff0f4e69ac29aa73c59c238de5afaeef1cd66f7aba7098a786a37a1fd # shrinks to paid = true, sources = [-1], uses = []
|
||||
@@ -245,4 +245,74 @@ proptest! {
|
||||
prop_assert!(drift.abs() < Decimal::from(big_w) / Decimal::from(100), "{drift}");
|
||||
}
|
||||
}
|
||||
|
||||
/// rules.md B-14 rule 3: the statement of cash flows' per-entry split
|
||||
/// (`kernel::entry_flows`, Kani `entry_flows_*`) is the same at both
|
||||
/// instantiations, on balanced and unbalanced lines alike.
|
||||
#[test]
|
||||
fn entry_flows_agree(lines in arb_lines()) {
|
||||
let tagged = |a: u8| match a {
|
||||
0 => kernel::Flow::Cash,
|
||||
1 => kernel::Flow::Operating,
|
||||
2 => kernel::Flow::Investing,
|
||||
_ => kernel::Flow::Financing,
|
||||
};
|
||||
let li: Vec<_> = lines.iter().map(|&(k, s, a)| (tagged(k), s, a)).collect();
|
||||
let ld: Vec<_> = lines.iter().map(|&(k, s, a)| (tagged(k), s, dec(a))).collect();
|
||||
let fi = kernel::entry_flows(&li).map(|f| kernel::EntryFlows {
|
||||
cash: dec(f.cash),
|
||||
flows: f.flows.map(dec),
|
||||
noncash: f.noncash.map(dec),
|
||||
});
|
||||
prop_assert_eq!(fi, kernel::entry_flows(&ld));
|
||||
}
|
||||
|
||||
/// `kernel::apply_cash` (how a section's cash splits among its kinds)
|
||||
/// and `kernel::exchanged` agree at both instantiations.
|
||||
#[test]
|
||||
fn apply_cash_and_exchanged_agree(
|
||||
cash in arb_cents(),
|
||||
moves in prop::collection::vec(arb_cents(), 0..5),
|
||||
d in 0i64..=MAX_CENTS,
|
||||
c in 0i64..=MAX_CENTS,
|
||||
inflow in 0i64..=MAX_CENTS,
|
||||
outflow in 0i64..=MAX_CENTS,
|
||||
) {
|
||||
let mut ai = vec![0i64; moves.len()];
|
||||
let mut ad = vec![Decimal::ZERO; moves.len()];
|
||||
let md: Vec<Decimal> = moves.iter().copied().map(dec).collect();
|
||||
let oi = kernel::apply_cash(cash, &moves, &mut ai);
|
||||
let od = kernel::apply_cash(dec(cash), &md, &mut ad);
|
||||
prop_assert_eq!(oi, od);
|
||||
if oi.is_some() {
|
||||
prop_assert_eq!(ai.into_iter().map(dec).collect::<Vec<_>>(), ad);
|
||||
}
|
||||
prop_assert_eq!(
|
||||
kernel::exchanged(d, c, inflow, outflow).map(dec),
|
||||
kernel::exchanged(dec(d), dec(c), dec(inflow), dec(outflow))
|
||||
);
|
||||
}
|
||||
|
||||
/// rules.md B-14 rule 11: `kernel::constructive` (what a member or
|
||||
/// lender paid or received for the company; Kani
|
||||
/// `constructive_nets_to_zero`, `constructive_mirrors`) agrees at both
|
||||
/// instantiations.
|
||||
#[test]
|
||||
fn constructive_agrees(
|
||||
paid in any::<bool>(),
|
||||
sources in prop::collection::vec(arb_cents(), 0..4),
|
||||
uses in prop::collection::vec(arb_cents(), 0..4),
|
||||
) {
|
||||
let way = if paid { kernel::OnBehalf::Paid } else { kernel::OnBehalf::Received };
|
||||
let (mut fi, mut ti) = (vec![0i64; sources.len()], vec![0i64; uses.len()]);
|
||||
let sd: Vec<Decimal> = sources.iter().copied().map(dec).collect();
|
||||
let ud: Vec<Decimal> = uses.iter().copied().map(dec).collect();
|
||||
let (mut fd, mut td) = (vec![Decimal::ZERO; sd.len()], vec![Decimal::ZERO; ud.len()]);
|
||||
let oi = kernel::constructive(way, &sources, &uses, &mut fi, &mut ti);
|
||||
let od = kernel::constructive(way, &sd, &ud, &mut fd, &mut td);
|
||||
prop_assert_eq!(oi, od);
|
||||
prop_assert!(oi.is_some());
|
||||
prop_assert_eq!(fi.into_iter().map(dec).collect::<Vec<_>>(), fd);
|
||||
prop_assert_eq!(ti.into_iter().map(dec).collect::<Vec<_>>(), td);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
# Seeds for failure cases proptest has generated in the past. It is
|
||||
# automatically read and these particular cases re-run before any
|
||||
# novel cases are generated.
|
||||
#
|
||||
# It is recommended to check this file in to source control so that
|
||||
# everyone who runs the test benefits from these saved cases.
|
||||
cc 1a4be67a53e1782122f6ca192c3525f029bef45f52351847fa24cd7fb89ce84d # shrinks to ops = [Post { day: 44, template: 9, amounts: [1, 1, 1], who: false }], extra = (0, [1, 1, 1], false), (a, m, e) = (0, 0, 44), designate = true, k = 2
|
||||
cc 69f4101495a47fc6b8c78e94de304cd3b728a1be3ada022514c5999d13048ae6 # shrinks to ops = [Post { day: 113, template: 25, amounts: [1, 1, 1], who: false }, Post { day: 0, template: 0, amounts: [1, 1, 1], who: false }, Post { day: 0, template: 0, amounts: [1, 1, 1], who: false }, Post { day: 0, template: 0, amounts: [1, 1, 1], who: false }, Post { day: 0, template: 0, amounts: [1, 1, 1], who: false }, Post { day: 0, template: 0, amounts: [1, 1, 1], who: false }, Post { day: 0, template: 0, amounts: [1, 1, 1], who: false }, Post { day: 0, template: 0, amounts: [1, 1, 1], who: false }, Post { day: 0, template: 0, amounts: [1, 1, 1], who: false }, Post { day: 0, template: 0, amounts: [1, 1, 1], who: false }, Reverse { of: 4714218928808477990, after: 54 }], (a, e) = (114, 0), designate = true
|
||||
cc 0f108f7a6a6438f5cd89f1029b529e74b202af6b7ace3603d971b726ba7523af # shrinks to ops = [Post { day: 210, template: 24, amounts: [1, 1, 1], who: false }], (a, e) = (0, 210), designate = false
|
||||
cc 3418b7695f5874cfdfde9e98e12ca276a457b5049339bfe0036bae842dc0346b # shrinks to ops = [Post { day: 0, template: 0, amounts: [1, 1, 1], who: false }], extra = (9, [1, 1, 1], false), (a, m, e) = (0, 0, 0), designate = true, k = 2
|
||||
cc 4f1c756113272b826d9234f5307df5d5839cb23ffe8a44b8eb370561679d44b3 # shrinks to ops = [Post { day: 93, template: 1, amounts: [1, 1, 1], who: false }], extra = (0, [1, 1, 1], false), (a, m, e) = (0, 0, 93), designate = false, k = 2
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1557,9 +1557,9 @@ fn entry_flow_scenarios() {
|
||||
}
|
||||
// 2. Reimbursing members; paying the card.
|
||||
for liab in [members, card] {
|
||||
let f = case(&[(liab, D, "387.64"), (bank, C, "387.64")]);
|
||||
assert_eq!(f.net, dec("387.64"));
|
||||
assert_eq!(f.what_for, vec![(liab, dec("387.64"))]);
|
||||
let f = case(&[(liab, D, "412.30"), (bank, C, "412.30")]);
|
||||
assert_eq!(f.net, dec("412.30"));
|
||||
assert_eq!(f.what_for, vec![(liab, dec("412.30"))]);
|
||||
}
|
||||
// 3. A loan.
|
||||
let f = case(&[(bank, D, "1000.00"), (loan, C, "1000.00")]);
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
# Seeds for failure cases proptest has generated in the past. It is
|
||||
# automatically read and these particular cases re-run before any
|
||||
# novel cases are generated.
|
||||
#
|
||||
# It is recommended to check this file in to source control so that
|
||||
# everyone who runs the test benefits from these saved cases.
|
||||
cc 99b387de55e0ece2480575f9cc93c688e0ad408686703012ce503dd21f73ba85 # shrinks to gens = [Gen { day: 67, mode: 1, picks: [4, 0, 4], x: 1, y: 1, classes: [0, 0, 0] }], (a, _m, e) = (0, 0, 67), designate = false
|
||||
cc 9f5e63191433240d6df895c196fdd89b56ce1a2a3d8d5225740c2f10dc720756 # shrinks to gens = [Gen { day: 100, mode: 2, picks: [3, 0, 4], x: 1, y: 1, classes: [0, 0, 0] }], (a, _m, e) = (0, 0, 100), designate = true
|
||||
cc 5397d840688ed53dc033aceaf98b8db5df3a7a2616c4afe7f3f5fc2e0c8775f2 # shrinks to gens = [Gen { day: 140, mode: 2, picks: [0, 0, 2], x: 1, y: 1, classes: [0, 0, 0] }], (a, _m, e) = (0, 0, 140), designate = false
|
||||
cc abd84709578e7889c275a0019ae0e71366d77526ae93072777be614d7ffb0dd5 # shrinks to gens = [Gen { day: 108, mode: 1, picks: [1, 5, 3], x: 1, y: 1, classes: [0, 0, 0] }], (a, _m, e) = (0, 0, 108), k = 2
|
||||
cc 7481a0fb806d08439357ce4be66224389a7f76a7d91bf93ac320bb2b3c4d4891 # shrinks to gens = [Gen { day: 3, mode: 1, picks: [4, 5, 1], x: 1, y: 1, classes: [0, 0, 0] }], drafts = [Gen { day: 0, mode: 0, picks: [0, 0, 0], x: 1, y: 1, classes: [0, 0, 0] }], (a, _m, e) = (0, 0, 3)
|
||||
@@ -1331,6 +1331,7 @@ fn cf_transfer_between_cash_accounts_is_not_a_flow() {
|
||||
cash_accounts: vec![cash, ar],
|
||||
financing_accounts: vec![],
|
||||
investing_accounts: vec![],
|
||||
members: vec![],
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
@@ -1340,6 +1341,7 @@ fn cf_transfer_between_cash_accounts_is_not_a_flow() {
|
||||
assert_eq!(cf.cash_from_financing, Decimal::ZERO);
|
||||
assert_eq!(cf.cash_beginning, c(100000));
|
||||
assert_eq!(cf.cash_ending, c(100000));
|
||||
assert!(cf.operating.is_empty() && cf.noncash_disclosures.is_empty());
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
@@ -1406,12 +1408,13 @@ fn sorted_amounts(lines: &[ledger_core::CashFlowLine]) -> Vec<Decimal> {
|
||||
v
|
||||
}
|
||||
|
||||
/// Oracle: CF INVARIANTS 1, 2, 4 — hand-computed February (indirect): NI
|
||||
/// 400; AR up 300 (-300), AP up 80 (+80); CFO 180; financing loan 300 +
|
||||
/// contribution 100 = 400; net change 580 = cash 1000 -> 1580.
|
||||
/// Oracle: CF INVARIANTS 1, 2, 4 — hand-computed February (direct):
|
||||
/// collected 200, paid 20: CFO 180; reconciled from NI 400, AR up 300
|
||||
/// (-300), AP up 80 (+80); financing loan 300 + contribution 100 = 400;
|
||||
/// net change 580 = cash 1000 -> 1580.
|
||||
///
|
||||
/// Working capital is one line per account that changed; financing is gross per
|
||||
/// account (docs/testing.md #8): here each financing account moved one way only.
|
||||
/// The reconciliation has one line per operating account that changed;
|
||||
/// financing is one line per entry (docs/testing.md #8).
|
||||
#[test]
|
||||
fn cf_hand_computed_with_designated_debt() {
|
||||
let co = cf_example_books();
|
||||
@@ -1421,16 +1424,21 @@ fn cf_hand_computed_with_designated_debt() {
|
||||
cash_accounts: vec![co.cash],
|
||||
financing_accounts: vec![co.loan],
|
||||
investing_accounts: vec![],
|
||||
members: vec![],
|
||||
};
|
||||
let cf = cash_flow(&co.l, ¶ms).unwrap();
|
||||
assert_eq!(cf.start, d(2026, 2, 1));
|
||||
assert_eq!(cf.end, d(2026, 2, 28));
|
||||
assert_eq!(cf.net_income, c(40000));
|
||||
assert_eq!(cf.depreciation, Decimal::ZERO);
|
||||
assert_eq!(
|
||||
sorted_amounts(&cf.working_capital),
|
||||
vec![c(-30000), c(8000)]
|
||||
);
|
||||
assert_eq!(cf.operating_reconciliation.net_income, c(40000));
|
||||
let mut rec: Vec<Decimal> = cf
|
||||
.operating_reconciliation
|
||||
.lines
|
||||
.iter()
|
||||
.map(|l| l.amount)
|
||||
.collect();
|
||||
rec.sort();
|
||||
assert_eq!(rec, vec![c(-30000), c(8000)]);
|
||||
assert_eq!(sorted_amounts(&cf.operating), vec![c(-2000), c(20000)]);
|
||||
assert_eq!(cf.cash_from_operations, c(18000));
|
||||
assert_eq!(cf.cash_from_investing, Decimal::ZERO);
|
||||
assert_eq!(sorted_amounts(&cf.financing), vec![c(10000), c(30000)]);
|
||||
@@ -1441,9 +1449,9 @@ fn cf_hand_computed_with_designated_debt() {
|
||||
}
|
||||
|
||||
/// Oracle: CashFlowParams doc — by default all liabilities are operating
|
||||
/// working capital (the loan moves to operating: CFO 480, financing 100).
|
||||
/// (the loan's cash moves to operating: CFO 480, financing 100).
|
||||
#[test]
|
||||
fn cf_undesignated_liability_is_working_capital() {
|
||||
fn cf_undesignated_liability_is_operating() {
|
||||
let co = cf_example_books();
|
||||
let params = CashFlowParams {
|
||||
start: d(2026, 2, 1),
|
||||
@@ -1451,6 +1459,7 @@ fn cf_undesignated_liability_is_working_capital() {
|
||||
cash_accounts: vec![co.cash],
|
||||
financing_accounts: vec![],
|
||||
investing_accounts: vec![],
|
||||
members: vec![],
|
||||
};
|
||||
let cf = cash_flow(&co.l, ¶ms).unwrap();
|
||||
assert_eq!(cf.cash_from_operations, c(48000));
|
||||
@@ -1470,6 +1479,7 @@ fn cf_never_counts_closing_entries() {
|
||||
cash_accounts: vec![co.cash],
|
||||
financing_accounts: vec![co.loan],
|
||||
investing_accounts: vec![],
|
||||
members: vec![],
|
||||
};
|
||||
let before = cash_flow(&co.l, ¶ms).unwrap();
|
||||
let p1 =
|
||||
@@ -1481,14 +1491,15 @@ fn cf_never_counts_closing_entries() {
|
||||
.unwrap();
|
||||
co.l.close_period(p2).unwrap();
|
||||
let after = cash_flow(&co.l, ¶ms).unwrap();
|
||||
assert_eq!(after.net_income, c(40000));
|
||||
assert_eq!(after.operating_reconciliation.net_income, c(40000));
|
||||
assert_eq!(cf_key(&after), cf_key(&before));
|
||||
}
|
||||
|
||||
/// Oracle: CF INVARIANTS 1-3 — fixed-asset purchase in investing,
|
||||
/// depreciation added back, disposal gain moved out of operating with the
|
||||
/// proceeds in investing (hand-computed: Feb NI -100, depr 100, CFO 0,
|
||||
/// investing -1200; Mar NI -50, depr 100, CFO 0, investing +1050).
|
||||
/// depreciation reconciling, disposal gain taken out of net income with
|
||||
/// the proceeds in investing (hand-computed: Feb NI -100, depr 100, CFO 0,
|
||||
/// investing -1200; Mar NI -50, depr 100, gain -50, CFO 0, investing
|
||||
/// +1050).
|
||||
///
|
||||
/// ASSUMED: depreciation is recognised as credits to a registered asset's
|
||||
/// accumulated-depreciation account against expense, and the disposal is
|
||||
@@ -1552,11 +1563,13 @@ fn cf_fixed_asset_purchase_depreciation_and_disposal() {
|
||||
cash_accounts: vec![cash],
|
||||
financing_accounts: vec![],
|
||||
investing_accounts: vec![],
|
||||
members: vec![],
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(feb.net_income, c(-10000));
|
||||
assert_eq!(feb.depreciation, c(10000));
|
||||
assert_eq!(feb.operating_reconciliation.net_income, c(-10000));
|
||||
assert_eq!(feb.operating_reconciliation.lines[0].label, "Depreciation");
|
||||
assert_eq!(feb.operating_reconciliation.lines[0].amount, c(10000));
|
||||
assert_eq!(feb.cash_from_operations, Decimal::ZERO);
|
||||
assert_eq!(feb.cash_from_investing, c(-120000));
|
||||
assert_eq!(feb.cash_from_financing, Decimal::ZERO);
|
||||
@@ -1570,11 +1583,18 @@ fn cf_fixed_asset_purchase_depreciation_and_disposal() {
|
||||
cash_accounts: vec![cash],
|
||||
financing_accounts: vec![],
|
||||
investing_accounts: vec![],
|
||||
members: vec![],
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(mar.net_income, c(-5000));
|
||||
assert_eq!(mar.depreciation, c(10000));
|
||||
assert_eq!(mar.operating_reconciliation.net_income, c(-5000));
|
||||
let rec: Vec<Decimal> = mar
|
||||
.operating_reconciliation
|
||||
.lines
|
||||
.iter()
|
||||
.map(|l| l.amount)
|
||||
.collect();
|
||||
assert_eq!(rec, vec![c(10000), c(-5000)]);
|
||||
assert_eq!(mar.cash_from_operations, Decimal::ZERO);
|
||||
assert_eq!(mar.cash_from_investing, c(105000));
|
||||
assert_eq!(mar.net_change_in_cash, c(105000));
|
||||
@@ -2000,7 +2020,7 @@ proptest! {
|
||||
fn prop_drafts_change_no_report(gens in arb_books(), drafts in prop::collection::vec(arb_gen(), 1..5), (a, _m, e) in three_days()) {
|
||||
let mut b = build(&gens);
|
||||
let (s0, e0) = (day(a), day(e));
|
||||
let cfp = CashFlowParams { start: s0, end: e0, cash_accounts: vec![b.ids[CASH]], financing_accounts: vec![], investing_accounts: vec![] };
|
||||
let cfp = CashFlowParams { start: s0, end: e0, cash_accounts: vec![b.ids[CASH]], financing_accounts: vec![], investing_accounts: vec![], members: vec![] };
|
||||
let tb = tb_lines(&trial_balance_as_of(&b.l, e0));
|
||||
let pl = pnl_key(&profit_and_loss_for_period(&b.l, s0, e0, None).unwrap());
|
||||
let bs = bs_key(&balance_sheet_as_of(&b.l, e0).unwrap());
|
||||
@@ -2426,12 +2446,12 @@ proptest! {
|
||||
/// Oracle: CF INVARIANTS 1-2, 4 — operating + investing + financing =
|
||||
/// net change = ending - beginning cash (model), NI is the period P&L
|
||||
/// (model, closes excluded), section totals are their line sums, CFO =
|
||||
/// NI + depreciation + working capital; with or without a close and a
|
||||
/// NI + the reconciling items; with or without a close and a
|
||||
/// designated loan.
|
||||
///
|
||||
/// With no registered fixed assets and no investing accounts (docs/testing.md #8),
|
||||
/// depreciation and investing
|
||||
/// are zero (receivables are current, so working capital).
|
||||
/// With no registered fixed assets and no investing accounts
|
||||
/// (docs/testing.md #8), investing is zero (receivables are current,
|
||||
/// so operating).
|
||||
#[test]
|
||||
fn prop_cf_sections_sum_to_change_in_cash(gens in arb_books(), (a, _m, e) in three_days(), month in opt_month(), designate in any::<bool>()) {
|
||||
let mut b = build(&gens);
|
||||
@@ -2442,6 +2462,7 @@ proptest! {
|
||||
cash_accounts: vec![b.ids[CASH]],
|
||||
financing_accounts: if designate { vec![b.ids[LOAN]] } else { vec![] },
|
||||
investing_accounts: vec![],
|
||||
members: vec![],
|
||||
};
|
||||
let cf = cash_flow(&b.l, ¶ms).unwrap();
|
||||
prop_assert_eq!(cf.cash_from_operations + cf.cash_from_investing + cf.cash_from_financing, cf.net_change_in_cash);
|
||||
@@ -2450,18 +2471,27 @@ proptest! {
|
||||
let end = model(&gens, early(), day(e), None).get("1000").copied().unwrap_or_default();
|
||||
prop_assert_eq!(cf.cash_beginning, beg);
|
||||
prop_assert_eq!(cf.cash_ending, end);
|
||||
prop_assert_eq!(cf.net_income, model_ni(&model(&gens, day(a), day(e), None)));
|
||||
prop_assert_eq!(cf.depreciation, Decimal::ZERO);
|
||||
let r = &cf.operating_reconciliation;
|
||||
prop_assert_eq!(r.net_income, model_ni(&model(&gens, day(a), day(e), None)));
|
||||
prop_assert_eq!(cf.cash_from_investing, Decimal::ZERO);
|
||||
let wc: Decimal = cf.working_capital.iter().map(|l| l.amount).sum();
|
||||
prop_assert_eq!(cf.cash_from_operations, cf.net_income + cf.depreciation + wc);
|
||||
let items: Decimal = r.lines.iter().map(|l| l.amount).sum();
|
||||
prop_assert_eq!(cf.cash_from_operations, r.net_income + items);
|
||||
prop_assert_eq!(cf.cash_from_operations, cf.operating.iter().map(|l| l.amount).sum::<Decimal>());
|
||||
prop_assert_eq!(cf.reconciles.difference, Decimal::ZERO);
|
||||
prop_assert_eq!(cf.cash_from_investing, cf.investing.iter().map(|l| l.amount).sum::<Decimal>());
|
||||
prop_assert_eq!(cf.cash_from_financing, cf.financing.iter().map(|l| l.amount).sum::<Decimal>());
|
||||
}
|
||||
|
||||
/// Oracle: CF INVARIANT 4 against the model — financing is the change in
|
||||
/// equity accounts (other than earnings) plus designated debt; the rest
|
||||
/// of the non-cash balance sheet is working capital.
|
||||
/// Oracle: CF INVARIANT 2 against an independent model — financing is
|
||||
/// the cash part of the equity and designated-debt movements: each
|
||||
/// entry's cash applied to the sections moving against it, operating,
|
||||
/// investing, financing, each up to its own movement (rules.md B-14
|
||||
/// rule 3), then to the kinds within each section in their order;
|
||||
/// plus what the loan or a member's capital paid or received for the
|
||||
/// company (rule 11, D-42): the loan pays for operating lines (the
|
||||
/// balance-sheet ones first) and receives for revenue and expense;
|
||||
/// capital pays and receives for revenue and expense only. Written
|
||||
/// here apart from the kernel.
|
||||
#[test]
|
||||
fn prop_cf_financing_is_equity_plus_designated_debt(gens in arb_books(), (a, _m, e) in three_days(), designate in any::<bool>()) {
|
||||
let b = build(&gens);
|
||||
@@ -2471,13 +2501,72 @@ proptest! {
|
||||
cash_accounts: vec![b.ids[CASH]],
|
||||
financing_accounts: if designate { vec![b.ids[LOAN]] } else { vec![] },
|
||||
investing_accounts: vec![],
|
||||
members: vec![],
|
||||
};
|
||||
let cf = cash_flow(&b.l, ¶ms).unwrap();
|
||||
let m = model(&gens, day(a), day(e), None);
|
||||
let credit = |code: &str| -m.get(code).copied().unwrap_or_default();
|
||||
let mut fin = credit("3000") + credit("3001");
|
||||
if designate { fin += credit("2500"); }
|
||||
prop_assert_eq!(cf.cash_from_financing, fin);
|
||||
// Take up to `amount` (>= 0) from the parts of `qs` with sign
|
||||
// `debits`, in order, moving them toward zero.
|
||||
fn settle(qs: &mut [i64], mut amount: i64, debits: bool) {
|
||||
for q in qs.iter_mut() {
|
||||
let room = if debits { (*q).max(0) } else { (-*q).max(0) };
|
||||
let t = amount.min(room);
|
||||
*q += if debits { -t } else { t };
|
||||
amount -= t;
|
||||
}
|
||||
}
|
||||
let credits = |qs: &[i64]| qs.iter().map(|q| (-q).max(0)).sum::<i64>();
|
||||
let debits = |qs: &[i64]| qs.iter().map(|q| (*q).max(0)).sum::<i64>();
|
||||
let mut fin = 0i64;
|
||||
for g in gens.iter().filter(|g| g.date() >= day(a) && g.date() <= day(e)) {
|
||||
// Kinds: cash; operating balance sheet, revenue and expense;
|
||||
// capital A, capital B, the loan (financing when designated).
|
||||
let mut cash = 0i64;
|
||||
let mut op = [0i64; 2];
|
||||
let mut fi = [0i64; 3];
|
||||
for (i, side, cents) in g.legs(1) {
|
||||
let m = if side == DR { cents } else { -cents };
|
||||
match RCHART[i].0 {
|
||||
"1000" => cash += m,
|
||||
"3000" => fi[0] += m,
|
||||
"3001" => fi[1] += m,
|
||||
"2500" if designate => fi[2] += m,
|
||||
"4000" | "4100" | "6000" | "6100" => op[1] += m,
|
||||
_ => op[0] += m,
|
||||
}
|
||||
}
|
||||
// Rule 3: operating, then financing (no investing here).
|
||||
let take = |left: i64, m: i64| {
|
||||
if left > 0 && m < 0 {
|
||||
left.min(-m)
|
||||
} else if left < 0 && m > 0 {
|
||||
left.max(-m)
|
||||
} else {
|
||||
0
|
||||
}
|
||||
};
|
||||
let to_op = take(cash, op[0] + op[1]);
|
||||
let to_fin = take(cash - to_op, fi.iter().sum());
|
||||
fin += to_fin;
|
||||
if to_op > 0 { settle(&mut op, to_op, false) } else { settle(&mut op, -to_op, true) }
|
||||
if to_fin > 0 { settle(&mut fi, to_fin, false) } else { settle(&mut fi, -to_fin, true) }
|
||||
// Rule 11: the loan pays, then receives; then capital.
|
||||
if designate {
|
||||
let paid = credits(&fi[2..]).min(debits(&op));
|
||||
fi[2] += paid;
|
||||
settle(&mut op, paid, true);
|
||||
let received = debits(&fi[2..]).min(credits(&op[1..]));
|
||||
fi[2] -= received;
|
||||
settle(&mut op[1..], received, false);
|
||||
fin += paid - received;
|
||||
}
|
||||
let paid = credits(&fi[..2]).min(debits(&op[1..]));
|
||||
settle(&mut fi[..2], paid, false);
|
||||
settle(&mut op[1..], paid, true);
|
||||
let received = debits(&fi[..2]).min(credits(&op[1..]));
|
||||
settle(&mut fi[..2], received, true);
|
||||
fin += paid - received;
|
||||
}
|
||||
prop_assert_eq!(cf.cash_from_financing, c(fin));
|
||||
}
|
||||
|
||||
/// Oracle: CF INVARIANT 5 — closing a period changes no cash flow
|
||||
@@ -2487,10 +2576,10 @@ proptest! {
|
||||
let open = build(&gens);
|
||||
let mut closed = build(&gens);
|
||||
close_through(&mut closed, Some(month), &re_close());
|
||||
let p = |b: &Books| CashFlowParams { start: day(a), end: day(e), cash_accounts: vec![b.ids[CASH]], financing_accounts: vec![b.ids[LOAN]], investing_accounts: vec![] };
|
||||
let p = |b: &Books| CashFlowParams { start: day(a), end: day(e), cash_accounts: vec![b.ids[CASH]], financing_accounts: vec![b.ids[LOAN]], investing_accounts: vec![], members: vec![] };
|
||||
let x = cash_flow(&open.l, &p(&open)).unwrap();
|
||||
let y = cash_flow(&closed.l, &p(&closed)).unwrap();
|
||||
prop_assert_eq!(x.net_income, y.net_income);
|
||||
prop_assert_eq!(&x.operating_reconciliation, &y.operating_reconciliation);
|
||||
prop_assert_eq!(x.cash_from_operations, y.cash_from_operations);
|
||||
prop_assert_eq!(x.cash_from_investing, y.cash_from_investing);
|
||||
prop_assert_eq!(x.cash_from_financing, y.cash_from_financing);
|
||||
@@ -2504,10 +2593,10 @@ proptest! {
|
||||
let kk = Decimal::from(k);
|
||||
let one = build(&gens);
|
||||
let many = build_scaled(&gens, k);
|
||||
let p = |b: &Books| CashFlowParams { start: day(a), end: day(e), cash_accounts: vec![b.ids[CASH]], financing_accounts: vec![], investing_accounts: vec![] };
|
||||
let p = |b: &Books| CashFlowParams { start: day(a), end: day(e), cash_accounts: vec![b.ids[CASH]], financing_accounts: vec![], investing_accounts: vec![], members: vec![] };
|
||||
let x = cash_flow(&one.l, &p(&one)).unwrap();
|
||||
let y = cash_flow(&many.l, &p(&many)).unwrap();
|
||||
prop_assert_eq!(y.net_income, x.net_income * kk);
|
||||
prop_assert_eq!(y.operating_reconciliation.net_income, x.operating_reconciliation.net_income * kk);
|
||||
prop_assert_eq!(y.cash_from_operations, x.cash_from_operations * kk);
|
||||
prop_assert_eq!(y.cash_from_financing, x.cash_from_financing * kk);
|
||||
prop_assert_eq!(y.cash_beginning, x.cash_beginning * kk);
|
||||
|
||||
@@ -215,16 +215,17 @@ fn a_closed_period_still_reports_its_earnings() {
|
||||
cash_accounts: vec![b.cash],
|
||||
financing_accounts: vec![],
|
||||
investing_accounts: vec![],
|
||||
members: vec![],
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(cf.net_income, dec(1_700));
|
||||
assert_eq!(cf.operating_reconciliation.net_income, dec(1_700));
|
||||
assert_eq!(cf.financing.len(), 1);
|
||||
assert_eq!(cf.cash_from_financing, dec(10_000));
|
||||
assert_eq!(cf.net_change_in_cash, dec(11_700));
|
||||
}
|
||||
|
||||
/// Cash flow lines come out in account-code order, every time. (The chart
|
||||
/// Cash flow kinds come out in account-code order, every time. (The chart
|
||||
/// iterates in hash order; the statement must not.)
|
||||
#[test]
|
||||
fn cash_flow_lines_are_in_code_order() {
|
||||
@@ -251,14 +252,16 @@ fn cash_flow_lines_are_in_code_order() {
|
||||
cash_accounts: vec![cash],
|
||||
financing_accounts: vec![],
|
||||
investing_accounts: vec![],
|
||||
members: vec![],
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
// Labels read "Owner contributions — 3000 3000" (code, then name).
|
||||
// Kinds read "Contributions from owners — 3000 3000" (code, then
|
||||
// name): one per account, in code order.
|
||||
let codes: Vec<&str> = cf
|
||||
.financing
|
||||
.iter()
|
||||
.map(|f| f.label.rsplit(' ').next().unwrap())
|
||||
.map(|f| f.kind.rsplit(' ').next().unwrap())
|
||||
.collect();
|
||||
assert_eq!(
|
||||
codes,
|
||||
@@ -525,9 +528,11 @@ fn members_must_be_configured_correctly() {
|
||||
));
|
||||
}
|
||||
|
||||
/// Each line names the accounts it moved, and is listed under its
|
||||
/// member's kind (D-35: "Contributions from members — <member>").
|
||||
#[test]
|
||||
fn cash_flow_lines_name_their_account() {
|
||||
let (l, _) = partnership(false);
|
||||
let (l, members) = partnership(false);
|
||||
let cf = cash_flow(
|
||||
&l,
|
||||
&CashFlowParams {
|
||||
@@ -536,15 +541,23 @@ fn cash_flow_lines_name_their_account() {
|
||||
cash_accounts: vec![1],
|
||||
financing_accounts: vec![],
|
||||
investing_accounts: vec![],
|
||||
members,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let accounts: Vec<Option<&str>> = cf.financing.iter().map(|f| f.account.as_deref()).collect();
|
||||
let accounts: Vec<&[String]> = cf.financing.iter().map(|f| &f.accounts[..]).collect();
|
||||
// Gross (ASC 230-10-45-7): 3001 contributed and took a distribution,
|
||||
// so it has a line for each.
|
||||
assert_eq!(accounts, [Some("3000"), Some("3001"), Some("3001")]);
|
||||
assert!(cf.financing[1].label.starts_with("Owner contributions"));
|
||||
assert!(cf.financing[2].label.starts_with("Owner distributions"));
|
||||
assert_eq!(accounts, [["3000"], ["3001"], ["3001"]]);
|
||||
let kinds: Vec<&str> = cf.financing.iter().map(|f| f.kind.as_str()).collect();
|
||||
assert_eq!(
|
||||
kinds,
|
||||
[
|
||||
"Contributions from members — Alice",
|
||||
"Contributions from members — Bob",
|
||||
"Distributions to members — Bob"
|
||||
]
|
||||
);
|
||||
assert!(cf.financing[1].amount > Decimal::ZERO && cf.financing[2].amount < Decimal::ZERO);
|
||||
}
|
||||
|
||||
@@ -1023,17 +1036,24 @@ fn cf_params(start: NaiveDate, end: NaiveDate, cash: AccountId) -> CashFlowParam
|
||||
cash_accounts: vec![cash],
|
||||
financing_accounts: vec![],
|
||||
investing_accounts: vec![],
|
||||
members: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
fn reconciles(cf: &ledger_core::CashFlowStatement) {
|
||||
// B-14 / cash_flow INVARIANT 1: the three sections sum to the change in
|
||||
// cash, exactly.
|
||||
// B-14 / cash_flow INVARIANT 4: the three sections sum to the change in
|
||||
// cash, exactly, and net income and its reconciling items to operating.
|
||||
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.reconciles.difference, Decimal::ZERO);
|
||||
let r = &cf.operating_reconciliation;
|
||||
assert_eq!(
|
||||
r.net_income + r.lines.iter().map(|l| l.amount).sum::<Decimal>(),
|
||||
cf.cash_from_operations
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1044,7 +1064,11 @@ fn cash_flow_starts_counting_on_the_start_date() {
|
||||
let cf = cash_flow(&b.ledger, &cf_params(d(1, 15), d(1, 15), b.cash)).unwrap();
|
||||
assert_eq!(cf.cash_beginning, dec(10_000));
|
||||
assert_eq!(cf.cash_ending, dec(12_500));
|
||||
assert_eq!(cf.net_income, dec(2_500));
|
||||
assert_eq!(cf.operating_reconciliation.net_income, dec(2_500));
|
||||
assert_eq!(
|
||||
cf.operating_by_class(),
|
||||
[(ledger_core::OperatingClass::FromCustomers, dec(2_500))]
|
||||
);
|
||||
reconciles(&cf);
|
||||
// Backwards is refused.
|
||||
assert!(matches!(
|
||||
@@ -1093,8 +1117,12 @@ fn cash_flow_labels_say_which_way_each_balance_moved() {
|
||||
post(&mut l, d(3, 5), "Repay", loan, cash, 300);
|
||||
post(&mut l, d(3, 6), "Distribution", capital, cash, 400);
|
||||
|
||||
let lines = |v: &[ledger_core::CashFlowLine]| -> Vec<(String, Decimal)> {
|
||||
v.iter().map(|l| (l.label.clone(), l.amount)).collect()
|
||||
let rec = |cf: &ledger_core::CashFlowStatement| -> Vec<(String, Decimal)> {
|
||||
let r = &cf.operating_reconciliation.lines;
|
||||
r.iter().map(|l| (l.label.clone(), l.amount)).collect()
|
||||
};
|
||||
let kinds = |v: &[ledger_core::CashFlowLine]| -> Vec<(String, Decimal)> {
|
||||
v.iter().map(|l| (l.kind.clone(), l.amount)).collect()
|
||||
};
|
||||
let s = |x: &str| x.to_string();
|
||||
let params = |start, end| CashFlowParams {
|
||||
@@ -1103,24 +1131,27 @@ fn cash_flow_labels_say_which_way_each_balance_moved() {
|
||||
cash_accounts: vec![cash],
|
||||
financing_accounts: vec![loan],
|
||||
investing_accounts: vec![],
|
||||
members: vec![],
|
||||
};
|
||||
|
||||
// February: net income 100 - 50 = 50; receivable up 100 (cash out),
|
||||
// payable up 50 (cash in): operations 0. Borrowed 300, contributed 400.
|
||||
// payable up 50 (cash in): operations 0. Borrowed 300, contributed 400
|
||||
// (the kinds in the statement's order: contributions, then
|
||||
// borrowing; ASC 230-10-45-14(a), (b)).
|
||||
let feb = cash_flow(&l, ¶ms(d(2, 1), d(2, 28))).unwrap();
|
||||
assert_eq!(feb.net_income, dec(50));
|
||||
assert_eq!(feb.operating_reconciliation.net_income, dec(50));
|
||||
assert_eq!(
|
||||
lines(&feb.working_capital),
|
||||
rec(&feb),
|
||||
[
|
||||
(s("Increase in 1100 Receivable"), dec(-100)),
|
||||
(s("Increase in 2000 Payable"), dec(50)),
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
lines(&feb.financing),
|
||||
kinds(&feb.financing),
|
||||
[
|
||||
(s("Proceeds from 2500 Loan"), dec(300)),
|
||||
(s("Owner contributions — 3000 Capital"), dec(400)),
|
||||
(s("Contributions from owners — 3000 Capital"), dec(400)),
|
||||
(s("Proceeds from borrowing — 2500 Loan"), dec(300)),
|
||||
]
|
||||
);
|
||||
assert_eq!(feb.cash_from_operations, dec(0));
|
||||
@@ -1128,21 +1159,22 @@ fn cash_flow_labels_say_which_way_each_balance_moved() {
|
||||
reconciles(&feb);
|
||||
|
||||
// March: the mirror image. Collected 100, paid 50, repaid 300,
|
||||
// distributed 400.
|
||||
// distributed 400 (45-15(a), (b)): an original's direction is its
|
||||
// nature; only a reversal never takes a label from its sign.
|
||||
let mar = cash_flow(&l, ¶ms(d(3, 1), d(3, 31))).unwrap();
|
||||
assert_eq!(mar.net_income, dec(0));
|
||||
assert_eq!(mar.operating_reconciliation.net_income, dec(0));
|
||||
assert_eq!(
|
||||
lines(&mar.working_capital),
|
||||
rec(&mar),
|
||||
[
|
||||
(s("Decrease in 1100 Receivable"), dec(100)),
|
||||
(s("Decrease in 2000 Payable"), dec(-50)),
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
lines(&mar.financing),
|
||||
kinds(&mar.financing),
|
||||
[
|
||||
(s("Repayment of 2500 Loan"), dec(-300)),
|
||||
(s("Owner distributions — 3000 Capital"), dec(-400)),
|
||||
(s("Distributions to owners — 3000 Capital"), dec(-400)),
|
||||
(s("Repayments of borrowing — 2500 Loan"), dec(-300)),
|
||||
]
|
||||
);
|
||||
assert_eq!(mar.net_change_in_cash, dec(-650));
|
||||
@@ -1198,24 +1230,33 @@ fn a_loss_on_disposal_moves_out_of_operating_into_investing() {
|
||||
l.add_line(e, equipment, Side::Credit, usd(1_000)).unwrap();
|
||||
l.post_entry(e).unwrap();
|
||||
|
||||
let lines = |v: &[ledger_core::CashFlowLine]| -> Vec<(String, Decimal)> {
|
||||
v.iter().map(|l| (l.label.clone(), l.amount)).collect()
|
||||
};
|
||||
// February: net income -300; the loss is added back (positive, it used
|
||||
// no cash), so operations is 0; the 600 proceeds are investing.
|
||||
// no cash), so operations is 0; the 600 proceeds are investing (ASC
|
||||
// 230-10-45-12(c), 45-28(b)).
|
||||
let feb = cash_flow(&l, &cf_params(d(2, 1), d(2, 28), cash)).unwrap();
|
||||
assert_eq!(feb.net_income, dec(-300));
|
||||
assert_eq!(feb.operating_reconciliation.net_income, dec(-300));
|
||||
let rec: Vec<(&str, Decimal)> = feb
|
||||
.operating_reconciliation
|
||||
.lines
|
||||
.iter()
|
||||
.map(|l| (l.label.as_str(), l.amount))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
lines(&feb.working_capital),
|
||||
rec,
|
||||
[(
|
||||
"Less: gain on sale of fixed assets (in investing)".to_string(),
|
||||
"(Gain) loss and other noncash changes in investing assets",
|
||||
dec(300)
|
||||
)]
|
||||
);
|
||||
assert_eq!(feb.cash_from_operations, dec(0));
|
||||
let inv: Vec<(&str, Decimal)> = feb
|
||||
.investing
|
||||
.iter()
|
||||
.map(|l| (l.kind.as_str(), l.amount))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
lines(&feb.investing),
|
||||
[("Sale of fixed assets — 1000 Cash".to_string(), dec(600))]
|
||||
inv,
|
||||
[("Proceeds from sales of property and equipment", dec(600))]
|
||||
);
|
||||
assert_eq!(feb.net_change_in_cash, dec(600));
|
||||
reconciles(&feb);
|
||||
@@ -1324,21 +1365,27 @@ fn roles() -> (Ledger, CashFlowParams) {
|
||||
cash_accounts: vec![cash],
|
||||
financing_accounts: vec![loan, due_owners],
|
||||
investing_accounts: vec![member_loan],
|
||||
members: vec![],
|
||||
};
|
||||
(l, params)
|
||||
}
|
||||
|
||||
fn lines(ls: &[ledger_core::reports::CashFlowLine]) -> Vec<(String, Decimal)> {
|
||||
ls.iter()
|
||||
.map(|l| (l.account.clone().unwrap_or_default(), l.amount))
|
||||
.map(|l| (l.accounts.join(","), l.amount))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Oracle: ASC 230-10-45-7 ("information about the gross amounts of cash
|
||||
/// receipts and cash payments during a period is more relevant than
|
||||
/// information about the net amounts"), 230-10-45-14(b) (proceeds from
|
||||
/// "other short- or long-term borrowing") and 45-15(2) ("Repayments of
|
||||
/// amounts borrowed"): borrowing and repayment are separate lines.
|
||||
/// "other short- or long-term borrowing") and 45-15(b) ("Repayments of
|
||||
/// amounts borrowed"): borrowing and repayment are separate lines, one
|
||||
/// per entry, borrowing first (each lender's, by code), then repayments.
|
||||
/// The software the owner paid personally is borrowed from the owner and
|
||||
/// spent on its date (rules.md B-14 rule 11, D-42: constructive receipt
|
||||
/// and disbursement). Financing: +100 + 1,000 − 100 − 400 = 600; nothing
|
||||
/// is disclosed.
|
||||
#[test]
|
||||
fn financing_is_gross_borrowing_apart_from_repayment() {
|
||||
let (l, params) = roles();
|
||||
@@ -1347,12 +1394,13 @@ fn financing_is_gross_borrowing_apart_from_repayment() {
|
||||
lines(&cf.financing),
|
||||
[
|
||||
("2100".into(), dec(100)),
|
||||
("2100".into(), dec(-100)),
|
||||
("2500".into(), dec(1000)),
|
||||
("2100".into(), dec(-100)),
|
||||
("2500".into(), dec(-400)),
|
||||
]
|
||||
);
|
||||
assert_eq!(cf.cash_from_financing, dec(600));
|
||||
assert!(cf.noncash_disclosures.is_empty());
|
||||
}
|
||||
|
||||
/// Oracle: ASC 230-10-20 ("Investing activities include making and
|
||||
@@ -1365,37 +1413,39 @@ fn a_loan_the_entity_makes_is_investing_gross() {
|
||||
let cf = cash_flow(&l, ¶ms).unwrap();
|
||||
assert_eq!(
|
||||
lines(&cf.investing),
|
||||
[("1400".into(), dec(200)), ("1400".into(), dec(-500))]
|
||||
[("1400".into(), dec(-500)), ("1400".into(), dec(200))]
|
||||
);
|
||||
assert!(cf.investing[0].label.starts_with("Collections on"));
|
||||
assert!(cf.investing[1].label.starts_with("Payments for"));
|
||||
assert!(cf.investing[0].kind.starts_with("Payments for"));
|
||||
assert!(cf.investing[1].kind.starts_with("Collections on"));
|
||||
assert!(
|
||||
cf.working_capital
|
||||
cf.operating_reconciliation
|
||||
.lines
|
||||
.iter()
|
||||
.all(|w| w.account.as_deref() != Some("1400"))
|
||||
);
|
||||
assert_eq!(cf.cash_from_investing, dec(-300));
|
||||
}
|
||||
|
||||
/// Oracle: PwC Viewpoint, Financial statement presentation 6.9.3.1
|
||||
/// (constructive receipts and disbursements: cash a third party pays on
|
||||
/// the entity's behalf is presented "as though the transaction took place
|
||||
/// through the bank accounts of the reporting entity"). The owner paying
|
||||
/// software personally is a borrowing (financing in) and an operating
|
||||
/// payment (in net income); reimbursing the owner is a repayment.
|
||||
/// Hand-computed: operating -100, investing -300, financing +600 = +200,
|
||||
/// the change in cash.
|
||||
/// Oracle: D-42 (rules.md B-14 rule 11; ASC 230 is silent on a payment
|
||||
/// made on the entity's behalf, PwC FSP 6.9.3.1 and DART Roadmap 7.2
|
||||
/// present it as constructive receipt and disbursement). The owner paying
|
||||
/// software personally is cash the company borrowed (financing +100) and
|
||||
/// spent (operating −100, 230-10-45-17(b)) on Mar 5; reimbursing the owner
|
||||
/// is a repayment (−100, 45-15(b)). Hand-computed: operating −100 (net
|
||||
/// income −100, no reconciling item), investing −300, financing +600 =
|
||||
/// +200, the change in cash. Replaces
|
||||
/// `an_expense_the_owner_paid_is_disclosed_then_repaid` (D-35).
|
||||
#[test]
|
||||
fn an_expense_the_owner_paid_is_borrowing_then_operating() {
|
||||
fn an_expense_the_owner_paid_is_borrowed_spent_then_repaid() {
|
||||
let (l, params) = roles();
|
||||
let cf = cash_flow(&l, ¶ms).unwrap();
|
||||
assert_eq!(cf.net_income, dec(-100));
|
||||
assert_eq!(cf.operating_reconciliation.net_income, dec(-100));
|
||||
assert_eq!(cf.cash_from_operations, dec(-100));
|
||||
assert_eq!(lines(&cf.operating), [("6000".into(), dec(-100))]);
|
||||
assert_eq!(cf.operating[0].on_behalf, dec(-100));
|
||||
assert!(cf.operating_reconciliation.lines.is_empty());
|
||||
assert_eq!(cf.net_change_in_cash, dec(200));
|
||||
assert_eq!(
|
||||
cf.cash_from_operations + cf.cash_from_investing + cf.cash_from_financing,
|
||||
cf.net_change_in_cash
|
||||
);
|
||||
reconciles(&cf);
|
||||
}
|
||||
|
||||
/// Gross lines net only within one entry: an entry that both debits and
|
||||
@@ -1412,6 +1462,8 @@ fn an_entry_that_nets_to_nothing_on_an_account_adds_no_line() {
|
||||
let cf = cash_flow(&l, ¶ms).unwrap();
|
||||
assert_eq!(cf.cash_from_financing, dec(600));
|
||||
assert_eq!(cf.financing.len(), 4);
|
||||
// Nothing exchanged either: the account nets to zero first.
|
||||
assert!(cf.noncash_disclosures.iter().all(|n| n.entry != 7));
|
||||
}
|
||||
|
||||
mod chaining {
|
||||
|
||||
Reference in new issue
Block a user