test(core): every surviving mutant killed or argued equivalent
check / check (push) Canceled after 0s
check / check (push) Canceled after 0s
Max-testing pass, step 8. The final mutation run left 112 survivors (89% caught). Three agents triaged them by file, by the playbook's table: spec the boundary then test it, a law rather than one more pinned value, assert the real value, never weaken a test. Every expected value is hand-computed. - reports.rs (35): 28 killed, chiefly cash flow (inclusive start date, each line's sign and label, a loss on disposal moving to investing, financing designation), general ledger and members' equity date boundaries, and the balanced() checks (a real report nudged by a cent must stop balancing). 7 equivalent: four >/>= after an early return on zero; two in dead code (removed in this commit); one sign of a zero cleaned on output. - ledger.rs, kernel.rs, journal.rs, money.rs (52, plus one new): 49 killed, including every replay_* mutant at the core level (ids, accumulation, status, vendor tags, out-of-order refusal), FX rates, 0%/100% deductible boundaries, a dividend sweep with no income, the legacy member rounding at close, a bill paid on its own date, a liability-account reconciliation, mixed-currency entries (via serde), ISO 4217 minor units, Display/Debug. 3 equivalent: zero refused before a </<= check; a period whose end is in a closed period refused earlier; columns(0). - schedules, tax, analysis, payouts, assets, form1099 (25): 23 killed (schedule length 1-600, recurring end on its start, frequency parsing and stepping, cost of goods in tax net, a zero payout, disposal on the purchase date, quarter starts, labels); 2 equivalent (zero adds zero; days_held is provably >= 1). Two small production cleanups the triage surfaced: kernel::columns(0) returned a negative-zero credit that would print "-0" (regression a_zero_movement_has_no_negative_zero_column, shown to fail before); and cash_flow computed a book value it never used. Spec lines added where a boundary wasn't specified (cash flow's inclusive start and line signs, members' equity start date, payout with no deposit, disposal on the purchase date, bill paid on its own date). `just mutants` now runs core and store tests (the store's replay tests), not the whole workspace, which rebuilt the viewer for every mutant. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
This commit is contained in:
1 parent
8c8ba19471
commit
d0490bf106
15 files changed
+989
-5
No files matched your search
@@ -252,6 +252,8 @@ pub struct DisposalAmounts {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Compute disposal amounts. Pure math; posting is the caller's job.
|
/// Compute disposal amounts. Pure math; posting is the caller's job.
|
||||||
|
/// A disposal may fall on the purchase date (one day held, accrual being
|
||||||
|
/// inclusive, B-15 in docs/spec/rules.md); one before it is an error.
|
||||||
pub fn disposal_amounts(
|
pub fn disposal_amounts(
|
||||||
asset: &Asset,
|
asset: &Asset,
|
||||||
date: NaiveDate,
|
date: NaiveDate,
|
||||||
|
|||||||
@@ -233,6 +233,9 @@ pub fn normal_balance<T: Amount>(account_type: AccountType, movement: T) -> Opti
|
|||||||
pub fn columns<T: Amount>(movement: T) -> Option<(T, T)> {
|
pub fn columns<T: Amount>(movement: T) -> Option<(T, T)> {
|
||||||
if movement > T::ZERO {
|
if movement > T::ZERO {
|
||||||
Some((movement, T::ZERO))
|
Some((movement, T::ZERO))
|
||||||
|
} else if movement == T::ZERO {
|
||||||
|
// Never a negative zero: money never prints "-0".
|
||||||
|
Some((T::ZERO, T::ZERO))
|
||||||
} else {
|
} else {
|
||||||
Some((T::ZERO, movement.checked_neg()?))
|
Some((T::ZERO, movement.checked_neg()?))
|
||||||
}
|
}
|
||||||
@@ -381,6 +384,64 @@ mod tests {
|
|||||||
assert_eq!(parts, [1805, 0]);
|
assert_eq!(parts, [1805, 0]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The i64 model's line range: up to 2^40 either way, inclusive.
|
||||||
|
#[test]
|
||||||
|
fn i64_line_range_is_two_to_the_fortieth() {
|
||||||
|
assert!((1i64 << 40).in_line_range());
|
||||||
|
assert!((-(1i64 << 40)).in_line_range());
|
||||||
|
assert!(!((1i64 << 40) + 1).in_line_range());
|
||||||
|
assert!(!i64::MIN.in_line_range());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// B-9: a reversal flips every side and keeps accounts and amounts, so
|
||||||
|
/// with the original it nets every account to zero.
|
||||||
|
#[test]
|
||||||
|
fn a_reversal_flips_every_line_and_nets_to_zero() {
|
||||||
|
let lines = [
|
||||||
|
(1u8, Side::Debit, 70i64),
|
||||||
|
(2, Side::Credit, 50),
|
||||||
|
(3, Side::Credit, 20),
|
||||||
|
];
|
||||||
|
let rev = reversal(&lines);
|
||||||
|
assert_eq!(rev.len(), lines.len());
|
||||||
|
for (o, r) in lines.iter().zip(&rev) {
|
||||||
|
assert_eq!((r.0, r.1, r.2), (o.0, flip(o.1), o.2));
|
||||||
|
}
|
||||||
|
let both = movements(lines.iter().chain(&rev).copied()).unwrap();
|
||||||
|
assert_eq!(both, vec![(1, 0), (2, 0), (3, 0)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Spec scenario 1's preconditions: parts and remainders pair up, and
|
||||||
|
/// the unit is positive; otherwise nothing is apportioned.
|
||||||
|
#[test]
|
||||||
|
fn apportion_refuses_mismatched_inputs_or_a_nonpositive_unit() {
|
||||||
|
let mut parts = [0i64, 0];
|
||||||
|
assert_eq!(apportion(0, &mut parts, &[0], 1), None);
|
||||||
|
assert_eq!(parts, [0, 0]);
|
||||||
|
let mut none: [i64; 0] = [];
|
||||||
|
assert_eq!(apportion(0, &mut none, &[], 0), None);
|
||||||
|
assert_eq!(apportion(0, &mut none, &[], -1), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// With no parts, only a zero whole is fully apportioned.
|
||||||
|
#[test]
|
||||||
|
fn apportion_to_no_parts_accepts_only_zero() {
|
||||||
|
let mut none: [i64; 0] = [];
|
||||||
|
assert_eq!(apportion(0, &mut none, &[], 1), Some(()));
|
||||||
|
assert_eq!(apportion(5, &mut none, &[], 1), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A zero movement's columns are both a plain zero, never "-0" (found
|
||||||
|
/// by mutation triage, 2026-10-13: it returned a negative-zero credit).
|
||||||
|
#[test]
|
||||||
|
fn a_zero_movement_has_no_negative_zero_column() {
|
||||||
|
let (d, c) = columns(Decimal::ZERO).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
(d.to_string(), c.to_string()),
|
||||||
|
("0".to_string(), "0".to_string())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn credit_line_sides() {
|
fn credit_line_sides() {
|
||||||
assert_eq!(credit_line(5i64), Some((Side::Credit, 5)));
|
assert_eq!(credit_line(5i64), Some((Side::Credit, 5)));
|
||||||
|
|||||||
@@ -1892,6 +1892,8 @@ impl Ledger {
|
|||||||
/// SCENARIOS:
|
/// SCENARIOS:
|
||||||
/// 1. IF the amount isn't positive, or exceeds what's still owed: SHOULD
|
/// 1. IF the amount isn't positive, or exceeds what's still owed: SHOULD
|
||||||
/// refuse.
|
/// refuse.
|
||||||
|
/// 2. IF dated before the bill: SHOULD refuse (PaymentBeforeDocument);
|
||||||
|
/// the bill's own date is fine (DECISION, Stephen 2026-10-13).
|
||||||
/// ```
|
/// ```
|
||||||
pub fn pay_bill(
|
pub fn pay_bill(
|
||||||
&mut self,
|
&mut self,
|
||||||
|
|||||||
@@ -171,6 +171,37 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// ISO 4217 list one (docs/spec/sources/iso-4217.md): one code from
|
||||||
|
/// each exponent class, and the codes with no minor unit.
|
||||||
|
#[test]
|
||||||
|
fn minor_units_follow_iso_4217() {
|
||||||
|
let units = |c: &str| Currency::new(c).unwrap().minor_units();
|
||||||
|
assert_eq!(units("USD"), Some(2));
|
||||||
|
assert_eq!(units("JPY"), Some(0));
|
||||||
|
assert_eq!(units("BHD"), Some(3));
|
||||||
|
assert_eq!(units("KWD"), Some(3));
|
||||||
|
assert_eq!(units("CLF"), Some(4));
|
||||||
|
assert_eq!(units("UYW"), Some(4));
|
||||||
|
assert_eq!(units("XAU"), None);
|
||||||
|
assert_eq!(units("XDR"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn currency_and_money_print_their_code_and_amount() {
|
||||||
|
assert_eq!(Currency::new("usd").unwrap().to_string(), "USD");
|
||||||
|
assert_eq!(format!("{:?}", Currency::EUR), "Currency(EUR)");
|
||||||
|
assert_eq!(usd(d(1250)).to_string(), "12.50 USD");
|
||||||
|
assert_eq!(format!("{:?}", usd(d(-5))), "-0.05 USD");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn is_zero_means_exactly_zero() {
|
||||||
|
assert!(Money::zero(Currency::USD).is_zero());
|
||||||
|
assert!(usd(d(0)).is_zero());
|
||||||
|
assert!(!usd(d(1)).is_zero());
|
||||||
|
assert!(!usd(d(-1)).is_zero());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn checked_arithmetic_rejects_currency_mismatch() {
|
fn checked_arithmetic_rejects_currency_mismatch() {
|
||||||
let eur = Money::new(d(100), Currency::EUR);
|
let eur = Money::new(d(100), Currency::EUR);
|
||||||
|
|||||||
@@ -84,6 +84,8 @@ pub type PayoutPlanLine = (AccountId, Side, Decimal, Option<ClassId>);
|
|||||||
/// revenue or liability accounts, e.g. deferred revenue or sales tax
|
/// revenue or liability accounts, e.g. deferred revenue or sales tax
|
||||||
/// collected; fees debit expense accounts), and the payout reconciles to
|
/// collected; fees debit expense accounts), and the payout reconciles to
|
||||||
/// the cent.
|
/// the cent.
|
||||||
|
/// A zero deposit (fees and adjustments ate the sales exactly) has no
|
||||||
|
/// deposit line: every planned line is positive (B-17, docs/spec/rules.md).
|
||||||
pub fn payout_plan(
|
pub fn payout_plan(
|
||||||
payout: &Payout,
|
payout: &Payout,
|
||||||
chart: &ChartOfAccounts,
|
chart: &ChartOfAccounts,
|
||||||
|
|||||||
@@ -826,6 +826,7 @@ pub struct MembersEquityStatement {
|
|||||||
/// 3. Unclosed earnings and the period's income MUST be split by share,
|
/// 3. Unclosed earnings and the period's income MUST be split by share,
|
||||||
/// to the cent, by largest remainder (DECISION, Stephen 2026-10-13).
|
/// to the cent, by largest remainder (DECISION, Stephen 2026-10-13).
|
||||||
/// 4. NEVER hide an unexplained change: it goes in "other".
|
/// 4. NEVER hide an unexplained change: it goes in "other".
|
||||||
|
/// 5. An entry dated `start` is period activity, not beginning capital.
|
||||||
/// SCENARIOS:
|
/// SCENARIOS:
|
||||||
/// 1. IF shares don't sum to 1, or are not positive: SHOULD refuse.
|
/// 1. IF shares don't sum to 1, or are not positive: SHOULD refuse.
|
||||||
/// ```
|
/// ```
|
||||||
@@ -1111,6 +1112,12 @@ pub struct CashFlowStatement {
|
|||||||
/// 4. Financing MUST hold changes in equity (other than earnings) and in
|
/// 4. Financing MUST hold changes in equity (other than earnings) and in
|
||||||
/// the liabilities named as financing.
|
/// the liabilities named as financing.
|
||||||
/// 5. NEVER count closing entries.
|
/// 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 the balance moved ("Increase in" /
|
||||||
|
/// "Decrease in"; "Proceeds from" / "Repayment of" for financing
|
||||||
|
/// debt; "Owner contributions" / "Owner distributions" for equity).
|
||||||
/// ```
|
/// ```
|
||||||
pub fn cash_flow(
|
pub fn cash_flow(
|
||||||
ledger: &Ledger,
|
ledger: &Ledger,
|
||||||
@@ -1330,10 +1337,8 @@ pub fn cash_flow(
|
|||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let book_value = cost_amount - accum_debit;
|
|
||||||
// Sanity: the entry balances, so proceeds + accum debit = cost + gain - loss.
|
// Sanity: the entry balances, so proceeds + accum debit = cost + gain - loss.
|
||||||
debug_assert!((proceeds + accum_debit - cost_amount - gain + loss).is_zero());
|
debug_assert!((proceeds + accum_debit - cost_amount - gain + loss).is_zero());
|
||||||
let _ = book_value;
|
|
||||||
disposal_gain_loss += gain - loss;
|
disposal_gain_loss += gain - loss;
|
||||||
investing.push(CashFlowLine {
|
investing.push(CashFlowLine {
|
||||||
label: format!("Sale of fixed assets — {proceeds_label}"),
|
label: format!("Sale of fixed assets — {proceeds_label}"),
|
||||||
|
|||||||
@@ -6,7 +6,8 @@
|
|||||||
|
|
||||||
use chrono::NaiveDate;
|
use chrono::NaiveDate;
|
||||||
use ledger_core::analysis::{
|
use ledger_core::analysis::{
|
||||||
Interval, balance_history, balance_series, flows_by_interval, pnl_by_interval,
|
Interval, accounts_of_type, balance_history, balance_series, flows_by_interval, interval_start,
|
||||||
|
pnl_by_interval,
|
||||||
};
|
};
|
||||||
use ledger_core::{AccountType, Currency, Ledger, Money, Side};
|
use ledger_core::{AccountType, Currency, Ledger, Money, Side};
|
||||||
use rust_decimal::Decimal;
|
use rust_decimal::Decimal;
|
||||||
@@ -79,3 +80,36 @@ fn balances_pnl_and_flows_over_time() {
|
|||||||
let io: Vec<(Decimal, Decimal)> = both.iter().map(|f| (f.2, f.3)).collect();
|
let io: Vec<(Decimal, Decimal)> = both.iter().map(|f| (f.2, f.3)).collect();
|
||||||
assert_eq!(io, [(dec(5000), dec(300)), (dec(1200), dec(0))]);
|
assert_eq!(io, [(dec(5000), dec(300)), (dec(1200), dec(0))]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn intervals_start_on_calendar_boundaries() {
|
||||||
|
for (day, want) in [
|
||||||
|
(d(2, 14), d(1, 1)),
|
||||||
|
(d(5, 31), d(4, 1)),
|
||||||
|
(d(11, 30), d(10, 1)),
|
||||||
|
] {
|
||||||
|
assert_eq!(interval_start(day, Interval::Quarter), want, "{day}");
|
||||||
|
assert_eq!(interval_start(day, Interval::Year), d(1, 1), "{day}");
|
||||||
|
}
|
||||||
|
assert_eq!(interval_start(d(11, 30), Interval::Month), d(11, 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn accounts_of_type_picks_from_the_chart_in_order() {
|
||||||
|
let mut l = Ledger::new(Currency::USD);
|
||||||
|
let mut a = |c: &str, t| l.add_account(c, c, t, None, false).unwrap();
|
||||||
|
let cash = a("1000", AccountType::Asset);
|
||||||
|
let card = a("2100", AccountType::Liability);
|
||||||
|
let rent = a("6600", AccountType::Expense);
|
||||||
|
let bank = a("1010", AccountType::Asset);
|
||||||
|
let assets = accounts_of_type(&l, &[AccountType::Asset]);
|
||||||
|
assert_eq!(assets.len(), 2);
|
||||||
|
assert!(assets.contains(&cash) && assets.contains(&bank));
|
||||||
|
let mut cash_like = accounts_of_type(&l, &[AccountType::Asset, AccountType::Liability]);
|
||||||
|
cash_like.sort();
|
||||||
|
let mut want = vec![cash, card, bank];
|
||||||
|
want.sort();
|
||||||
|
assert_eq!(cash_like, want);
|
||||||
|
assert!(!cash_like.contains(&rent));
|
||||||
|
assert!(accounts_of_type(&l, &[AccountType::Revenue]).is_empty());
|
||||||
|
}
|
||||||
@@ -400,3 +400,20 @@ fn one_month_useful_life_is_valid() {
|
|||||||
let total: Decimal = sched.iter().map(|l| l.amount).sum();
|
let total: Decimal = sched.iter().map(|l| l.amount).sum();
|
||||||
assert_eq!(total, Decimal::new(30000, 2));
|
assert_eq!(total, Decimal::new(30000, 2));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn disposal_on_the_purchase_date_is_allowed() {
|
||||||
|
let (mut ledger, cost, accum, expense) = test_ledger();
|
||||||
|
let id = van_asset(&mut ledger, 60_000, cost, accum, expense);
|
||||||
|
let asset = ledger.get_asset(id).unwrap().clone();
|
||||||
|
// Held one day: 1,000/month × 1/31 = 32.26 of depreciation.
|
||||||
|
let d = disposal_amounts(&asset, date(2026, 1, 15), usd("60000")).unwrap();
|
||||||
|
assert_eq!(d.accumulated, Decimal::new(3226, 2));
|
||||||
|
assert_eq!(d.book_value, Decimal::new(5_996_774, 2));
|
||||||
|
assert_eq!(d.gain_loss, Decimal::new(3226, 2));
|
||||||
|
// The day before purchase is not a disposal.
|
||||||
|
assert!(matches!(
|
||||||
|
disposal_amounts(&asset, date(2026, 1, 14), usd("60000")),
|
||||||
|
Err(ledger_core::LedgerError::DisposalBeforePurchase(_))
|
||||||
|
));
|
||||||
|
}
|
||||||
@@ -203,3 +203,13 @@ fn dated_rules_go_stale_on_their_review_date() {
|
|||||||
);
|
);
|
||||||
assert_eq!(stale_rules(d(2030, 6, 1)).len(), 1);
|
assert_eq!(stale_rules(d(2030, 6, 1)).len(), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn forms_have_their_irs_names() {
|
||||||
|
assert_eq!(Form1099::Nec.label(), "1099-NEC");
|
||||||
|
assert_eq!(Form1099::Misc.label(), "1099-MISC");
|
||||||
|
// A label parses back to its form.
|
||||||
|
for f in [Form1099::Nec, Form1099::Misc] {
|
||||||
|
assert_eq!(Form1099::parse(f.label()), Some(f));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,7 +12,8 @@
|
|||||||
use chrono::NaiveDate;
|
use chrono::NaiveDate;
|
||||||
use ledger_core::reports::{balance_sheet, profit_and_loss, snapshot, trial_balance};
|
use ledger_core::reports::{balance_sheet, profit_and_loss, snapshot, trial_balance};
|
||||||
use ledger_core::{
|
use ledger_core::{
|
||||||
AccountId, AccountType, Currency, EntryStatus, Ledger, LedgerError, Money, Side,
|
AccountId, AccountType, ChartOfAccounts, Currency, EntryStatus, JournalEntry, Ledger,
|
||||||
|
LedgerError, Money, Side,
|
||||||
};
|
};
|
||||||
use rust_decimal::Decimal;
|
use rust_decimal::Decimal;
|
||||||
|
|
||||||
@@ -302,6 +303,39 @@ fn foreign_currency_line_is_rejected() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// R-1 / B-3: one currency throughout an entry. Even when every line
|
||||||
|
/// matches its own account's currency (a chart the ledger never builds
|
||||||
|
/// today, R-5), an entry mixing currencies is refused.
|
||||||
|
#[test]
|
||||||
|
fn an_entry_mixing_currencies_is_refused_even_on_matching_accounts() {
|
||||||
|
let c = fixture_company();
|
||||||
|
let mut ledger = c.ledger;
|
||||||
|
let id = ledger.create_entry(date(21), "two moneys");
|
||||||
|
ledger.add_line(id, c.cash, Side::Debit, usd(100)).unwrap();
|
||||||
|
ledger
|
||||||
|
.add_line(id, c.revenue, Side::Credit, usd(100))
|
||||||
|
.unwrap();
|
||||||
|
let mut json = serde_json::to_value(ledger.get_entry(id).unwrap()).unwrap();
|
||||||
|
json["lines"][1]["amount"]["currency"] = serde_json::to_value(Currency::EUR).unwrap();
|
||||||
|
let mixed: JournalEntry = serde_json::from_value(json).unwrap();
|
||||||
|
|
||||||
|
let mut chart = ChartOfAccounts::default();
|
||||||
|
chart
|
||||||
|
.add(ledger.chart().get(c.cash).unwrap().clone())
|
||||||
|
.unwrap();
|
||||||
|
let mut revenue = ledger.chart().get(c.revenue).unwrap().clone();
|
||||||
|
revenue.currency = Currency::EUR;
|
||||||
|
chart.add(revenue).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
mixed.validate(&chart),
|
||||||
|
Err(LedgerError::CurrencyMismatch {
|
||||||
|
expected: "USD".to_string(),
|
||||||
|
found: "EUR".to_string(),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn duplicate_account_code_is_rejected() {
|
fn duplicate_account_code_is_rejected() {
|
||||||
let c = fixture_company();
|
let c = fixture_company();
|
||||||
@@ -1062,3 +1096,310 @@ fn a_foreign_line_is_booked_in_whole_base_units() {
|
|||||||
// Rounds to nothing: refused.
|
// Rounds to nothing: refused.
|
||||||
assert!(book(Currency::USD, Currency::EUR, "0.001", "1").is_err());
|
assert!(book(Currency::USD, Currency::EUR, "0.001", "1").is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Ledger bookkeeping surfaces: id allocators, FX table, tax percent,
|
||||||
|
// reconciliation, and closing variants (mutation-testing gaps, 2026-10-13).
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn cents(c: i64) -> Decimal {
|
||||||
|
Decimal::new(c, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `next_*_id` predicts the id the next record receives, and each record
|
||||||
|
/// gets its own.
|
||||||
|
#[test]
|
||||||
|
fn id_allocators_predict_the_next_id_and_never_repeat() {
|
||||||
|
use ledger_core::{Frequency, NewRecurring, NewSchedule, ScheduleKind};
|
||||||
|
let c = fixture_company();
|
||||||
|
let mut l = c.ledger;
|
||||||
|
let schedule = |n: &str| NewSchedule {
|
||||||
|
description: n.into(),
|
||||||
|
kind: ScheduleKind::PrepaidExpense,
|
||||||
|
total: usd(1_200),
|
||||||
|
start: date(1),
|
||||||
|
months: 12,
|
||||||
|
balance_account: c.equipment,
|
||||||
|
pl_account: c.rent,
|
||||||
|
};
|
||||||
|
let mut seen = Vec::new();
|
||||||
|
for n in ["a", "b"] {
|
||||||
|
let predicted = l.next_schedule_id();
|
||||||
|
let id = l.register_schedule(schedule(n)).unwrap();
|
||||||
|
assert_eq!(id, predicted);
|
||||||
|
assert!(!seen.contains(&id));
|
||||||
|
seen.push(id);
|
||||||
|
}
|
||||||
|
let mut listed: Vec<_> = l.schedules().map(|s| s.id).collect();
|
||||||
|
listed.sort();
|
||||||
|
assert_eq!(listed, seen);
|
||||||
|
|
||||||
|
let recurring = || NewRecurring {
|
||||||
|
description: "rent".into(),
|
||||||
|
lines: vec![
|
||||||
|
(c.rent, Side::Debit, usd(800)),
|
||||||
|
(c.cash, Side::Credit, usd(800)),
|
||||||
|
],
|
||||||
|
frequency: Frequency::Monthly,
|
||||||
|
start: date(1),
|
||||||
|
end: None,
|
||||||
|
};
|
||||||
|
let mut seen = Vec::new();
|
||||||
|
for _ in 0..2 {
|
||||||
|
let predicted = l.next_recurring_id();
|
||||||
|
let id = l.register_recurring(recurring()).unwrap();
|
||||||
|
assert_eq!(id, predicted);
|
||||||
|
assert!(!seen.contains(&id));
|
||||||
|
seen.push(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
let v = l.add_vendor("Vend").unwrap();
|
||||||
|
for _ in 0..2 {
|
||||||
|
let predicted = l.next_bill_id();
|
||||||
|
let id = l.create_bill(v, date(5), date(30), doc(100)).unwrap();
|
||||||
|
assert_eq!(id, predicted);
|
||||||
|
assert_ne!(l.next_bill_id(), id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The FX table records positive rates for foreign currencies only, and
|
||||||
|
/// gives back exactly what was recorded.
|
||||||
|
#[test]
|
||||||
|
fn fx_rates_are_positive_foreign_and_read_back_exactly() {
|
||||||
|
let mut l = Ledger::new(Currency::USD);
|
||||||
|
let rate: Decimal = "1.0834".parse().unwrap();
|
||||||
|
assert_eq!(l.get_fx_rate(Currency::EUR, date(5)), None);
|
||||||
|
assert_eq!(
|
||||||
|
l.set_fx_rate(Currency::EUR, date(5), Decimal::ZERO),
|
||||||
|
Err(LedgerError::InvalidFxRate(Decimal::ZERO))
|
||||||
|
);
|
||||||
|
assert!(l.set_fx_rate(Currency::EUR, date(5), -rate).is_err());
|
||||||
|
assert!(l.set_fx_rate(Currency::USD, date(5), rate).is_err());
|
||||||
|
assert_eq!(l.get_fx_rate(Currency::USD, date(5)), None);
|
||||||
|
l.set_fx_rate(Currency::EUR, date(5), rate).unwrap();
|
||||||
|
assert_eq!(l.get_fx_rate(Currency::EUR, date(5)), Some(rate));
|
||||||
|
assert_eq!(l.get_fx_rate(Currency::EUR, date(6)), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A deductible percent is 0 to 100 inclusive (set_account_tax's spec);
|
||||||
|
/// 0 is real (penalties).
|
||||||
|
#[test]
|
||||||
|
fn deductible_percent_bounds_are_inclusive() {
|
||||||
|
let c = fixture_company();
|
||||||
|
let mut l = c.ledger;
|
||||||
|
let rent = c.rent;
|
||||||
|
l.set_account_tax(rent, Some("meals"), Some(Decimal::ZERO))
|
||||||
|
.unwrap();
|
||||||
|
l.set_account_tax(rent, Some("meals"), Some(Decimal::from(100)))
|
||||||
|
.unwrap();
|
||||||
|
assert!(matches!(
|
||||||
|
l.set_account_tax(rent, Some("meals"), Some(cents(-1))),
|
||||||
|
Err(LedgerError::InvalidTaxCategory(_))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Spec (pay_bill scenario 2): a payment may not predate its bill; the
|
||||||
|
/// bill's own date is fine.
|
||||||
|
#[test]
|
||||||
|
fn a_bill_can_be_paid_on_its_own_date() {
|
||||||
|
let c = fixture_company();
|
||||||
|
let mut l = c.ledger;
|
||||||
|
let v = l.add_vendor("Vend").unwrap();
|
||||||
|
let bill = l.create_bill(v, date(21), date(31), doc(100)).unwrap();
|
||||||
|
l.pay_bill(bill, date(21), cents(1_000), c.cash).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reconciliation invariants 1 and 4: on a credit-normal account the book
|
||||||
|
/// balance is the account's balance, and a line dated on the statement
|
||||||
|
/// date is outstanding until cleared.
|
||||||
|
#[test]
|
||||||
|
fn reconciling_a_card_reads_its_normal_side_and_includes_the_statement_date() {
|
||||||
|
let c = fixture_company();
|
||||||
|
let mut l = c.ledger;
|
||||||
|
let card = l
|
||||||
|
.add_account("2100", "Card", AccountType::Liability, None, false)
|
||||||
|
.unwrap();
|
||||||
|
post(
|
||||||
|
&mut l,
|
||||||
|
date(21),
|
||||||
|
"charge",
|
||||||
|
&[(c.rent, Side::Debit, 500), (card, Side::Credit, 500)],
|
||||||
|
);
|
||||||
|
post(
|
||||||
|
&mut l,
|
||||||
|
date(25),
|
||||||
|
"pay card",
|
||||||
|
&[(card, Side::Debit, 200), (c.cash, Side::Credit, 200)],
|
||||||
|
);
|
||||||
|
let r = l
|
||||||
|
.reconciliation_report(card, date(25), Decimal::from(300))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(r.book_balance, Decimal::from(300));
|
||||||
|
assert_eq!(r.book_balance, l.account_balance(card).unwrap());
|
||||||
|
let outstanding: Vec<_> = r.uncleared_lines.iter().map(|u| u.date).collect();
|
||||||
|
assert_eq!(outstanding, vec![date(21), date(25)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// B-11: closing to retained earnings with nothing to close but a swept
|
||||||
|
/// account's balance still sweeps it.
|
||||||
|
#[test]
|
||||||
|
fn a_close_sweeps_dividends_even_without_income() {
|
||||||
|
let mut l = books_with("3100", "Dividends", AccountType::Equity, false);
|
||||||
|
let dividends = 2;
|
||||||
|
post(
|
||||||
|
&mut l,
|
||||||
|
date(10),
|
||||||
|
"dividend",
|
||||||
|
&[(dividends, Side::Debit, 100), (1, Side::Credit, 100)],
|
||||||
|
);
|
||||||
|
let jan = l.create_period("2026-01", date(1), date(31)).unwrap();
|
||||||
|
let to = ledger_core::CloseTo::RetainedEarnings {
|
||||||
|
sweep: vec![dividends],
|
||||||
|
};
|
||||||
|
let predicted = l.preview_close_period_to(jan, &to).unwrap();
|
||||||
|
assert_eq!(predicted, Some(l.next_entry_id()));
|
||||||
|
assert_eq!(l.close_period_to(jan, &to).unwrap(), predicted);
|
||||||
|
assert_eq!(l.account_balance(dividends).unwrap(), Decimal::ZERO);
|
||||||
|
let re = l.chart().find_by_code("3999").unwrap().id;
|
||||||
|
assert_eq!(l.account_balance(re).unwrap(), Decimal::from(-100));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `preview_close_period` names the entry a close with income will post.
|
||||||
|
#[test]
|
||||||
|
fn preview_close_names_the_closing_entry() {
|
||||||
|
let mut c = fixture_company();
|
||||||
|
let jan = c
|
||||||
|
.ledger
|
||||||
|
.create_period("2026-01", date(1), date(31))
|
||||||
|
.unwrap();
|
||||||
|
let next = c.ledger.next_entry_id();
|
||||||
|
assert_eq!(c.ledger.preview_close_period(jan).unwrap(), Some(next));
|
||||||
|
assert_eq!(c.ledger.close_period(jan).unwrap(), Some(next));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Closes journaled under the pre-2026-10-13 rule replay under it: each
|
||||||
|
/// share rounded half away from zero, the last member absorbing the rest.
|
||||||
|
/// Net 1.05 at 30/30/40 is 0.32, 0.32, 0.41 (largest remainder would give
|
||||||
|
/// 0.32, 0.31, 0.42).
|
||||||
|
#[test]
|
||||||
|
fn a_last_absorbs_close_gives_the_rounding_to_the_last_member() {
|
||||||
|
let mut l = books_with("4000", "Sales", AccountType::Revenue, false);
|
||||||
|
let capital: Vec<AccountId> = ["3001", "3002", "3003"]
|
||||||
|
.iter()
|
||||||
|
.map(|code| {
|
||||||
|
l.add_account(code, "Capital", AccountType::Equity, None, false)
|
||||||
|
.unwrap()
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let e = l.create_entry(date(5), "sale");
|
||||||
|
let m = Money::new(cents(105), Currency::USD);
|
||||||
|
l.add_line(e, 1, Side::Debit, m).unwrap();
|
||||||
|
l.add_line(e, 2, Side::Credit, m).unwrap();
|
||||||
|
l.post_entry(e).unwrap();
|
||||||
|
let jan = l.create_period("2026-01", date(1), date(31)).unwrap();
|
||||||
|
let members = capital
|
||||||
|
.iter()
|
||||||
|
.zip(["0.3", "0.3", "0.4"])
|
||||||
|
.map(|(&capital, share)| ledger_core::MemberClose {
|
||||||
|
capital,
|
||||||
|
share: share.parse().unwrap(),
|
||||||
|
distributions: None,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
l.close_period_to(jan, &ledger_core::CloseTo::MembersLastAbsorbs(members))
|
||||||
|
.unwrap();
|
||||||
|
let got: Vec<Decimal> = capital
|
||||||
|
.iter()
|
||||||
|
.map(|&a| l.account_balance(a).unwrap())
|
||||||
|
.collect();
|
||||||
|
assert_eq!(got, vec![cents(32), cents(32), cents(41)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// B-12: a period whose closing date already lies in a closed period
|
||||||
|
/// can't close; the refusal names that period.
|
||||||
|
#[test]
|
||||||
|
fn a_period_ending_inside_a_closed_period_cannot_close() {
|
||||||
|
let mut c = fixture_company();
|
||||||
|
let q1 = c
|
||||||
|
.ledger
|
||||||
|
.create_period(
|
||||||
|
"2026-Q1",
|
||||||
|
date(1),
|
||||||
|
NaiveDate::from_ymd_opt(2026, 3, 31).unwrap(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let jan = c
|
||||||
|
.ledger
|
||||||
|
.create_period("2026-01", date(1), date(31))
|
||||||
|
.unwrap();
|
||||||
|
c.ledger.close_period(q1).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
c.ledger.close_period(jan),
|
||||||
|
Err(LedgerError::ClosedPeriod(q1, date(31).to_string()))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replay (for journaling stores) rebuilds invoice and bill records
|
||||||
|
/// exactly: ids follow the allocator, payments accumulate, vendors tag
|
||||||
|
/// the entries. A record replayed out of order is refused.
|
||||||
|
#[test]
|
||||||
|
fn replayed_documents_and_payments_rebuild_the_records() {
|
||||||
|
use ledger_core::DocumentStatus;
|
||||||
|
let mut l = books_with("2000", "Accounts Payable", AccountType::Liability, false);
|
||||||
|
let c = l.add_customer("Acme").unwrap();
|
||||||
|
let v = l.add_vendor("Vend").unwrap();
|
||||||
|
|
||||||
|
let inv = l.next_invoice_id();
|
||||||
|
assert_eq!(
|
||||||
|
l.replay_invoice(inv + 1, c, date(5), date(30), doc(100), 10),
|
||||||
|
Err(LedgerError::ReplayIdMismatch(inv + 1, inv))
|
||||||
|
);
|
||||||
|
l.replay_invoice(inv, c, date(5), date(30), doc(100), 10)
|
||||||
|
.unwrap();
|
||||||
|
// Each later record replays at the id the allocator now predicts.
|
||||||
|
let mut ids = vec![inv];
|
||||||
|
for entry in [13, 14] {
|
||||||
|
let next = l.next_invoice_id();
|
||||||
|
l.replay_invoice(next, c, date(5), date(30), doc(1), entry)
|
||||||
|
.unwrap();
|
||||||
|
ids.push(next);
|
||||||
|
}
|
||||||
|
ids.sort();
|
||||||
|
ids.dedup();
|
||||||
|
assert_eq!(ids.len(), 3);
|
||||||
|
assert!(ids.iter().all(|&i| l.get_invoice(i).is_some()));
|
||||||
|
l.replay_invoice_payment(inv, Decimal::from(30), 11)
|
||||||
|
.unwrap();
|
||||||
|
l.replay_invoice_payment(inv, Decimal::from(20), 12)
|
||||||
|
.unwrap();
|
||||||
|
let i = l.get_invoice(inv).unwrap();
|
||||||
|
assert_eq!(i.amount_paid, Decimal::from(50));
|
||||||
|
assert_eq!(i.balance(), Decimal::from(50));
|
||||||
|
assert_eq!(i.status, DocumentStatus::Partial);
|
||||||
|
|
||||||
|
let bill = l.next_bill_id();
|
||||||
|
assert_eq!(
|
||||||
|
l.replay_bill(bill + 1, v, date(5), date(30), doc(100), 20),
|
||||||
|
Err(LedgerError::ReplayIdMismatch(bill + 1, bill))
|
||||||
|
);
|
||||||
|
assert_eq!(l.entry_vendor(20), None);
|
||||||
|
l.replay_bill(bill, v, date(5), date(30), doc(100), 20)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(l.entry_vendor(20), Some(v));
|
||||||
|
let mut ids = vec![bill];
|
||||||
|
for entry in [23, 24] {
|
||||||
|
let next = l.next_bill_id();
|
||||||
|
l.replay_bill(next, v, date(5), date(30), doc(1), entry)
|
||||||
|
.unwrap();
|
||||||
|
ids.push(next);
|
||||||
|
}
|
||||||
|
ids.sort();
|
||||||
|
ids.dedup();
|
||||||
|
assert_eq!(ids.len(), 3);
|
||||||
|
l.replay_bill_payment(bill, Decimal::from(30), 21).unwrap();
|
||||||
|
l.replay_bill_payment(bill, Decimal::from(70), 22).unwrap();
|
||||||
|
let b = l.get_bill(bill).unwrap();
|
||||||
|
assert_eq!(b.amount_paid, Decimal::from(100));
|
||||||
|
assert_eq!(b.status, DocumentStatus::Paid);
|
||||||
|
assert_eq!(l.entry_vendor(22), Some(v));
|
||||||
|
}
|
||||||
@@ -153,3 +153,33 @@ fn accounts_must_fit_their_roles() {
|
|||||||
p.revenue.clear();
|
p.revenue.clear();
|
||||||
assert!(invalid(p), "needs revenue");
|
assert!(invalid(p), "needs revenue");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_zero_payout_has_no_bank_line() {
|
||||||
|
let b = books();
|
||||||
|
// 10.00 of sales, 3.00 of fees, a 7.00 refund: nothing deposited.
|
||||||
|
let p = Payout {
|
||||||
|
deposit_account: b.bank,
|
||||||
|
deposit: dec("0.00"),
|
||||||
|
revenue: vec![line(b.sales, "10.00")],
|
||||||
|
fees: vec![line(b.fees, "3.00")],
|
||||||
|
adjustments: vec![PayoutAdjustment {
|
||||||
|
account: b.refunds,
|
||||||
|
side: Side::Debit,
|
||||||
|
amount: dec("7.00"),
|
||||||
|
class: None,
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
let (plan, totals) = payout_plan(&p, b.l.chart()).unwrap();
|
||||||
|
assert_eq!(totals.deposit, Decimal::ZERO);
|
||||||
|
assert_eq!(
|
||||||
|
plan.iter()
|
||||||
|
.map(|(a, s, m, _)| (*a, *s, *m))
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
[
|
||||||
|
(b.fees, Side::Debit, dec("3.00")),
|
||||||
|
(b.refunds, Side::Debit, dec("7.00")),
|
||||||
|
(b.sales, Side::Credit, dec("10.00")),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -224,3 +224,100 @@ fn recurring_entries_generate_drafts_never_posts() {
|
|||||||
assert!(matches!(bad, Err(LedgerError::InvalidSchedule(_))));
|
assert!(matches!(bad, Err(LedgerError::InvalidSchedule(_))));
|
||||||
let _ = b.revenue;
|
let _ = b.revenue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn schedule_length_is_one_to_six_hundred_months() {
|
||||||
|
let b = books();
|
||||||
|
let sched = |months| NewSchedule {
|
||||||
|
description: "Insurance".into(),
|
||||||
|
kind: ScheduleKind::PrepaidExpense,
|
||||||
|
total: usd("600"),
|
||||||
|
start: d(1, 1),
|
||||||
|
months,
|
||||||
|
balance_account: b.prepaid,
|
||||||
|
pl_account: b.insurance,
|
||||||
|
};
|
||||||
|
let chart = b.l.chart();
|
||||||
|
for ok in [1, 600] {
|
||||||
|
assert!(
|
||||||
|
ledger_core::schedules::validate_schedule(&sched(ok), chart).is_ok(),
|
||||||
|
"{ok} months"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for bad in [0, 601] {
|
||||||
|
assert!(
|
||||||
|
matches!(
|
||||||
|
ledger_core::schedules::validate_schedule(&sched(bad), chart),
|
||||||
|
Err(LedgerError::InvalidSchedule(_))
|
||||||
|
),
|
||||||
|
"{bad} months"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn recurring_templates_take_any_number_of_lines_and_may_end_on_their_start() {
|
||||||
|
let mut b = books();
|
||||||
|
// Three balanced lines are a valid template.
|
||||||
|
b.l.register_recurring(NewRecurring {
|
||||||
|
description: "Rent and insurance".into(),
|
||||||
|
lines: vec![
|
||||||
|
(b.rent, Side::Debit, usd("2000")),
|
||||||
|
(b.insurance, Side::Debit, usd("100")),
|
||||||
|
(b.cash, Side::Credit, usd("2100")),
|
||||||
|
],
|
||||||
|
frequency: Frequency::Monthly,
|
||||||
|
start: d(1, 1),
|
||||||
|
end: None,
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
// An end on the start date is one occurrence, not an error.
|
||||||
|
let once =
|
||||||
|
b.l.register_recurring(NewRecurring {
|
||||||
|
description: "One-off".into(),
|
||||||
|
lines: vec![
|
||||||
|
(b.rent, Side::Debit, usd("2000")),
|
||||||
|
(b.cash, Side::Credit, usd("2000")),
|
||||||
|
],
|
||||||
|
frequency: Frequency::Monthly,
|
||||||
|
start: d(5, 1),
|
||||||
|
end: Some(d(5, 1)),
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
let r = b.l.recurring_entries().find(|r| r.id == once).unwrap();
|
||||||
|
assert_eq!(r.due(d(12, 31)), [d(5, 1)]);
|
||||||
|
// An end before the start is.
|
||||||
|
let bad = b.l.register_recurring(NewRecurring {
|
||||||
|
description: "Backwards".into(),
|
||||||
|
lines: vec![
|
||||||
|
(b.rent, Side::Debit, usd("2000")),
|
||||||
|
(b.cash, Side::Credit, usd("2000")),
|
||||||
|
],
|
||||||
|
frequency: Frequency::Monthly,
|
||||||
|
start: d(5, 2),
|
||||||
|
end: Some(d(5, 1)),
|
||||||
|
});
|
||||||
|
assert!(matches!(bad, Err(LedgerError::InvalidSchedule(_))));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn frequencies_parse_and_step_from_the_start() {
|
||||||
|
assert_eq!(Frequency::parse("weekly"), Some(Frequency::Weekly));
|
||||||
|
assert_eq!(Frequency::parse(" Monthly "), Some(Frequency::Monthly));
|
||||||
|
assert_eq!(Frequency::parse("QUARTERLY"), Some(Frequency::Quarterly));
|
||||||
|
assert_eq!(Frequency::parse("annually"), Some(Frequency::Yearly));
|
||||||
|
assert_eq!(Frequency::parse("fortnightly"), None);
|
||||||
|
// Weekly: the kth occurrence is 7k days on.
|
||||||
|
let start = d(1, 1);
|
||||||
|
for (k, want) in [(0, d(1, 1)), (1, d(1, 8)), (2, d(1, 15)), (3, d(1, 22))] {
|
||||||
|
assert_eq!(Frequency::Weekly.nth(start, k), Some(want), "week {k}");
|
||||||
|
}
|
||||||
|
assert_eq!(Frequency::Quarterly.nth(start, 2), Some(d(7, 1)));
|
||||||
|
for (k, year) in [(1, 2027), (2, 2028), (3, 2029)] {
|
||||||
|
assert_eq!(
|
||||||
|
Frequency::Yearly.nth(start, k),
|
||||||
|
NaiveDate::from_ymd_opt(year, 1, 1),
|
||||||
|
"year {k}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -959,3 +959,317 @@ fn closes_journaled_under_the_old_rule_replay_with_it() {
|
|||||||
[cents("33.33"), cents("33.33"), cents("33.34")]
|
[cents("33.33"), cents("33.33"), cents("33.34")]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Boundaries, signs, and labels found untested by mutation testing
|
||||||
|
// (cargo-mutants on src/reports.rs). Expected values are hand-computed.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_balance_checks_catch_an_imbalance() {
|
||||||
|
let b = books(false);
|
||||||
|
let mut tb = trial_balance_as_of(&b.ledger, d(2, 28));
|
||||||
|
assert!(tb.balanced());
|
||||||
|
tb.total_credits += cents("0.01");
|
||||||
|
assert!(!tb.balanced(), "a one-cent difference is out of balance");
|
||||||
|
|
||||||
|
let mut bs = balance_sheet(&b.ledger).unwrap();
|
||||||
|
assert!(bs.balanced());
|
||||||
|
bs.liabilities += cents("0.01");
|
||||||
|
assert!(!bs.balanced());
|
||||||
|
|
||||||
|
let mut bss = balance_sheet_as_of(&b.ledger, d(2, 28)).unwrap();
|
||||||
|
assert!(bss.balanced());
|
||||||
|
bss.total_assets -= cents("0.01");
|
||||||
|
assert!(!bss.balanced());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_one_day_general_ledger_is_valid_and_a_backwards_one_is_not() {
|
||||||
|
let b = books(false);
|
||||||
|
// Jan 15 only: cash begins at the Jan 2 contribution, then the 2,500
|
||||||
|
// client payment lands.
|
||||||
|
let gl = general_ledger(&b.ledger, d(1, 15), d(1, 15)).unwrap();
|
||||||
|
let cash = gl.accounts.iter().find(|a| a.code == "1000").unwrap();
|
||||||
|
assert_eq!(cash.beginning_balance, dec(10_000));
|
||||||
|
assert_eq!(cash.postings.len(), 1);
|
||||||
|
assert_eq!(cash.ending_balance, dec(12_500));
|
||||||
|
assert!(matches!(
|
||||||
|
general_ledger(&b.ledger, d(1, 16), d(1, 15)),
|
||||||
|
Err(LedgerError::InvalidDateRange { .. })
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cf_params(start: NaiveDate, end: NaiveDate, cash: AccountId) -> CashFlowParams {
|
||||||
|
CashFlowParams {
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
cash_accounts: vec![cash],
|
||||||
|
financing_accounts: vec![],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reconciles(cf: &ledger_core::CashFlowStatement) {
|
||||||
|
// B-14 / cash_flow INVARIANT 1: the three sections sum to the change in
|
||||||
|
// cash, exactly.
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cash_flow_starts_counting_on_the_start_date() {
|
||||||
|
let b = books(false);
|
||||||
|
// A one-day period is a valid range. The Jan 15 payment is in it, not
|
||||||
|
// in the beginning balance.
|
||||||
|
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));
|
||||||
|
reconciles(&cf);
|
||||||
|
// Backwards is refused.
|
||||||
|
assert!(matches!(
|
||||||
|
cash_flow(&b.ledger, &cf_params(d(1, 16), d(1, 15), b.cash)),
|
||||||
|
Err(LedgerError::InvalidDateRange { .. })
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cash_flow_labels_say_which_way_each_balance_moved() {
|
||||||
|
let mut l = Ledger::new(Currency::USD);
|
||||||
|
let cash = l
|
||||||
|
.add_account("1000", "Cash", AccountType::Asset, None, false)
|
||||||
|
.unwrap();
|
||||||
|
let ar = l
|
||||||
|
.add_account("1100", "Receivable", AccountType::Asset, None, false)
|
||||||
|
.unwrap();
|
||||||
|
let ap = l
|
||||||
|
.add_account("2000", "Payable", AccountType::Liability, None, false)
|
||||||
|
.unwrap();
|
||||||
|
let loan = l
|
||||||
|
.add_account("2500", "Loan", AccountType::Liability, None, false)
|
||||||
|
.unwrap();
|
||||||
|
let capital = l
|
||||||
|
.add_account("3000", "Capital", AccountType::Equity, None, false)
|
||||||
|
.unwrap();
|
||||||
|
let rev = l
|
||||||
|
.add_account("4000", "Revenue", AccountType::Revenue, None, false)
|
||||||
|
.unwrap();
|
||||||
|
let exp = l
|
||||||
|
.add_account("6000", "Expense", AccountType::Expense, None, false)
|
||||||
|
.unwrap();
|
||||||
|
// January: every account starts February with a balance.
|
||||||
|
post(&mut l, d(1, 2), "Contribution", cash, capital, 10_000);
|
||||||
|
post(&mut l, d(1, 3), "Invoice", ar, rev, 1_000);
|
||||||
|
post(&mut l, d(1, 4), "Bill", exp, ap, 500);
|
||||||
|
post(&mut l, d(1, 5), "Borrow", cash, loan, 2_000);
|
||||||
|
// February: every balance goes up.
|
||||||
|
post(&mut l, d(2, 3), "Invoice", ar, rev, 100);
|
||||||
|
post(&mut l, d(2, 4), "Bill", exp, ap, 50);
|
||||||
|
post(&mut l, d(2, 5), "Borrow", cash, loan, 300);
|
||||||
|
post(&mut l, d(2, 6), "Contribution", cash, capital, 400);
|
||||||
|
// March: every balance comes back down.
|
||||||
|
post(&mut l, d(3, 3), "Collect", cash, ar, 100);
|
||||||
|
post(&mut l, d(3, 4), "Pay bill", ap, cash, 50);
|
||||||
|
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 s = |x: &str| x.to_string();
|
||||||
|
let params = |start, end| CashFlowParams {
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
cash_accounts: vec![cash],
|
||||||
|
financing_accounts: vec![loan],
|
||||||
|
};
|
||||||
|
|
||||||
|
// February: net income 100 - 50 = 50; receivable up 100 (cash out),
|
||||||
|
// payable up 50 (cash in): operations 0. Borrowed 300, contributed 400.
|
||||||
|
let feb = cash_flow(&l, ¶ms(d(2, 1), d(2, 28))).unwrap();
|
||||||
|
assert_eq!(feb.net_income, dec(50));
|
||||||
|
assert_eq!(
|
||||||
|
lines(&feb.working_capital),
|
||||||
|
[
|
||||||
|
(s("Increase in 1100 Receivable"), dec(-100)),
|
||||||
|
(s("Increase in 2000 Payable"), dec(50)),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
lines(&feb.financing),
|
||||||
|
[
|
||||||
|
(s("Proceeds from 2500 Loan"), dec(300)),
|
||||||
|
(s("Owner contributions — 3000 Capital"), dec(400)),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
assert_eq!(feb.cash_from_operations, dec(0));
|
||||||
|
assert_eq!(feb.net_change_in_cash, dec(700));
|
||||||
|
reconciles(&feb);
|
||||||
|
|
||||||
|
// March: the mirror image. Collected 100, paid 50, repaid 300,
|
||||||
|
// distributed 400.
|
||||||
|
let mar = cash_flow(&l, ¶ms(d(3, 1), d(3, 31))).unwrap();
|
||||||
|
assert_eq!(mar.net_income, dec(0));
|
||||||
|
assert_eq!(
|
||||||
|
lines(&mar.working_capital),
|
||||||
|
[
|
||||||
|
(s("Decrease in 1100 Receivable"), dec(100)),
|
||||||
|
(s("Decrease in 2000 Payable"), dec(-50)),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
lines(&mar.financing),
|
||||||
|
[
|
||||||
|
(s("Repayment of 2500 Loan"), dec(-300)),
|
||||||
|
(s("Owner distributions — 3000 Capital"), dec(-400)),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
assert_eq!(mar.net_change_in_cash, dec(-650));
|
||||||
|
reconciles(&mar);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_loss_on_disposal_moves_out_of_operating_into_investing() {
|
||||||
|
let mut l = Ledger::new(Currency::USD);
|
||||||
|
let cash = l
|
||||||
|
.add_account("1000", "Cash", AccountType::Asset, None, false)
|
||||||
|
.unwrap();
|
||||||
|
let equipment = l
|
||||||
|
.add_account("1500", "Equipment", AccountType::Asset, None, false)
|
||||||
|
.unwrap();
|
||||||
|
let accum = l
|
||||||
|
.add_account("1501", "Accum Depr", AccountType::Asset, None, false)
|
||||||
|
.unwrap();
|
||||||
|
let capital = l
|
||||||
|
.add_account("3000", "Capital", AccountType::Equity, None, false)
|
||||||
|
.unwrap();
|
||||||
|
let depr = l
|
||||||
|
.add_account("6500", "Depreciation", AccountType::Expense, None, false)
|
||||||
|
.unwrap();
|
||||||
|
let loss = l
|
||||||
|
.add_account(
|
||||||
|
"6900",
|
||||||
|
"Loss on Disposal",
|
||||||
|
AccountType::Expense,
|
||||||
|
None,
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
l.register_asset(ledger_core::NewAsset {
|
||||||
|
description: "Laptop".to_string(),
|
||||||
|
purchase_date: d(1, 10),
|
||||||
|
cost: usd(1_000),
|
||||||
|
salvage_value: usd(0),
|
||||||
|
useful_life_months: 10,
|
||||||
|
cost_account: equipment,
|
||||||
|
accum_depr_account: accum,
|
||||||
|
expense_account: depr,
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
post(&mut l, d(1, 5), "Contribution", cash, capital, 5_000);
|
||||||
|
post(&mut l, d(1, 10), "Buy laptop", equipment, cash, 1_000);
|
||||||
|
post(&mut l, d(1, 31), "Depreciation", depr, accum, 100);
|
||||||
|
// Sold for 600 at a book value of 900: a 300 loss.
|
||||||
|
let e = l.create_entry(d(2, 15), "Sell laptop");
|
||||||
|
l.add_line(e, cash, Side::Debit, usd(600)).unwrap();
|
||||||
|
l.add_line(e, accum, Side::Debit, usd(100)).unwrap();
|
||||||
|
l.add_line(e, loss, Side::Debit, usd(300)).unwrap();
|
||||||
|
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.
|
||||||
|
let feb = cash_flow(&l, &cf_params(d(2, 1), d(2, 28), cash)).unwrap();
|
||||||
|
assert_eq!(feb.net_income, dec(-300));
|
||||||
|
assert_eq!(
|
||||||
|
lines(&feb.working_capital),
|
||||||
|
[(
|
||||||
|
"Less: gain on sale of fixed assets (in investing)".to_string(),
|
||||||
|
dec(300)
|
||||||
|
)]
|
||||||
|
);
|
||||||
|
assert_eq!(feb.cash_from_operations, dec(0));
|
||||||
|
assert_eq!(
|
||||||
|
lines(&feb.investing),
|
||||||
|
[("Sale of fixed assets — 1000 Cash".to_string(), dec(600))]
|
||||||
|
);
|
||||||
|
assert_eq!(feb.net_change_in_cash, dec(600));
|
||||||
|
reconciles(&feb);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_contribution_on_the_start_date_is_a_contribution_not_beginning_capital() {
|
||||||
|
let (l, members) = partnership(false);
|
||||||
|
// The partnership fixture's contributions are dated Jan 2. Starting the
|
||||||
|
// statement that day must give the same rows as all of January.
|
||||||
|
let me = members_equity(&l, d(1, 2), d(1, 31), &members).unwrap();
|
||||||
|
let row = |c: &ledger_core::MemberEquityColumn| (c.beginning, c.contributions, c.ending);
|
||||||
|
assert_eq!(
|
||||||
|
row(&me.members[0]),
|
||||||
|
(cents("0"), cents("600"), cents("933.34"))
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
row(&me.members[1]),
|
||||||
|
(cents("0"), cents("400"), cents("733.33"))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn distribution_accounts_reduce_capital_and_returns_reduce_distributions() {
|
||||||
|
let mut l = Ledger::new(Currency::USD);
|
||||||
|
let cash = l
|
||||||
|
.add_account("1000", "Cash", AccountType::Asset, None, false)
|
||||||
|
.unwrap();
|
||||||
|
let cap_a = l
|
||||||
|
.add_account("3000", "Capital — Alice", AccountType::Equity, None, false)
|
||||||
|
.unwrap();
|
||||||
|
let cap_b = l
|
||||||
|
.add_account("3001", "Capital — Bob", AccountType::Equity, None, false)
|
||||||
|
.unwrap();
|
||||||
|
let dist_a = l
|
||||||
|
.add_account("3100", "Dist — Alice", AccountType::Equity, None, false)
|
||||||
|
.unwrap();
|
||||||
|
let dist_b = l
|
||||||
|
.add_account("3101", "Dist — Bob", AccountType::Equity, None, false)
|
||||||
|
.unwrap();
|
||||||
|
post(&mut l, d(1, 2), "Alice invests", cash, cap_a, 100);
|
||||||
|
post(&mut l, d(1, 2), "Bob invests", cash, cap_b, 100);
|
||||||
|
post(&mut l, d(1, 20), "Bob's distribution", dist_b, cash, 20);
|
||||||
|
// February: Bob takes 30 and hands 10 back: 20 distributed, net.
|
||||||
|
post(&mut l, d(2, 5), "Bob's distribution", dist_b, cash, 30);
|
||||||
|
post(&mut l, d(2, 6), "Bob returns some", cash, dist_b, 10);
|
||||||
|
let half = cents("0.5");
|
||||||
|
let members = vec![
|
||||||
|
MemberCapital {
|
||||||
|
name: "Alice".into(),
|
||||||
|
capital_account: cap_a,
|
||||||
|
distribution_account: Some(dist_a),
|
||||||
|
share: half,
|
||||||
|
},
|
||||||
|
MemberCapital {
|
||||||
|
name: "Bob".into(),
|
||||||
|
capital_account: cap_b,
|
||||||
|
distribution_account: Some(dist_b),
|
||||||
|
share: half,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
// No close: Bob's January distribution is still in his distributions
|
||||||
|
// account, and it comes off his capital, not the partnership's.
|
||||||
|
let feb = members_equity(&l, d(2, 1), d(2, 28), &members).unwrap();
|
||||||
|
let row = |c: &ledger_core::MemberEquityColumn| {
|
||||||
|
(c.beginning, c.contributions, c.distributions, c.ending)
|
||||||
|
};
|
||||||
|
assert_eq!(row(&feb.members[0]), (dec(100), dec(0), dec(0), dec(100)));
|
||||||
|
assert_eq!(row(&feb.members[1]), (dec(80), dec(0), dec(20), dec(60)));
|
||||||
|
assert_eq!(
|
||||||
|
feb.total.ending,
|
||||||
|
balance_sheet_as_of(&l, d(2, 28)).unwrap().total_equity
|
||||||
|
);
|
||||||
|
assert!(feb.members.iter().all(|m| m.other.is_zero()));
|
||||||
|
}
|
||||||
@@ -228,3 +228,39 @@ fn forms_parse_from_common_spellings() {
|
|||||||
}
|
}
|
||||||
assert_eq!(TaxForm::parse("990"), None);
|
assert_eq!(TaxForm::parse("990"), None);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cost_of_goods_reduces_net_like_a_deduction() {
|
||||||
|
let mut l = books();
|
||||||
|
let cash = l.chart().find_by_code("1000").unwrap().id;
|
||||||
|
let cogs = l
|
||||||
|
.add_account("5000", "Cost of sales", AccountType::Expense, None, false)
|
||||||
|
.unwrap();
|
||||||
|
l.set_account_tax(cogs, Some("cost_of_goods_sold"), None)
|
||||||
|
.unwrap();
|
||||||
|
let e = l.create_entry(d(2, 1), "x");
|
||||||
|
let m = Money::new(dec("1000"), Currency::USD);
|
||||||
|
l.add_line(e, cogs, Side::Debit, m).unwrap();
|
||||||
|
l.add_line(e, cash, Side::Credit, m).unwrap();
|
||||||
|
l.post_entry(e).unwrap();
|
||||||
|
let ts = tax_summary(&l, d(1, 1), d(3, 31), Some(TaxForm::Form1065)).unwrap();
|
||||||
|
assert_eq!(ts.cost_of_goods.len(), 1);
|
||||||
|
assert_eq!(ts.cost_of_goods[0].book, dec("1000"));
|
||||||
|
assert_eq!(ts.cost_of_goods[0].tax, dec("1000"));
|
||||||
|
// The fixture's nets, less 1,000 of cost of goods sold.
|
||||||
|
assert_eq!(ts.book_net(), dec("4276.67"));
|
||||||
|
assert_eq!(ts.tax_net(), dec("4483.33"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn forms_have_their_irs_names() {
|
||||||
|
let labels: Vec<&str> = TaxForm::ALL.iter().map(|f| f.label()).collect();
|
||||||
|
assert_eq!(
|
||||||
|
labels,
|
||||||
|
["Schedule C", "Form 1065", "Form 1120-S", "Form 1120"]
|
||||||
|
);
|
||||||
|
// A label parses back to its form.
|
||||||
|
for f in TaxForm::ALL {
|
||||||
|
assert_eq!(TaxForm::parse(f.label()), Some(f));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -62,7 +62,9 @@ miri:
|
|||||||
|
|
||||||
# Mutation testing of the core: do the tests catch planted bugs?
|
# Mutation testing of the core: do the tests catch planted bugs?
|
||||||
mutants:
|
mutants:
|
||||||
cargo mutants -p ledger-core --exclude crates/ledger-core/src/proofs.rs -j 4 --timeout 120
|
# Core and store tests (the store's replay tests reach Ledger::replay_*);
|
||||||
|
# the whole workspace would rebuild the viewer for every mutant.
|
||||||
|
cargo mutants -p ledger-core --exclude crates/ledger-core/src/proofs.rs --test-package ledger-core --test-package ledger-store -j 4 --timeout 120
|
||||||
|
|
||||||
# Benchmarks of the core's hot paths (no budget gates them yet)
|
# Benchmarks of the core's hot paths (no budget gates them yet)
|
||||||
bench:
|
bench:
|
||||||
|
|||||||
Reference in new issue
Block a user