Files
wanderbooks/crates/ledger-core/tests/invariants.rs
T
swaitsandClaude Opus 5.5 528c070605 feat: invoices and bills take a project, line by line (B-37 item 8)
A client project's invoiced revenue and a contractor's billed cost could
never count toward the project: books.create_invoice and books.create_bill
posted at once with untagged lines, and only a draft's line takes a class.
Job costing (B-37 item 3) collects a job's billings and the supplier
invoices for its direct costs on its cost sheet, so each document line
now takes an optional project.

The owner decided it on 2026-10-22 (plan D-43, rules.md B-37 item 8):
each line takes one project or none; a document spanning N projects is N
lines, each with its own amount; no percentage split, so every figure is
exact; a cost truly shared among projects names none on the document
and reaches projects only as entries of their own under the company's
monthly allocation convention, when books.conventions has one (otherwise
it stays untagged and the agent asks the human). The reasoning, stated
in B-37: job costing charges a job the costs traceable to it, whole, and
reaches shared costs only by allocation on a stated basis. ASC
340-40-25-7 counts as directly related both the costs traceable to a
contract (25-7(a), (b), (d), (e)) and allocations of costs that relate
directly to it (25-7(c)); 25-8 expenses general and administrative cost.
The books carry the first whole on the document's line and the second as
the allocation convention's own entries; neither is a percentage on the
document (sources/fasb-asc.md, 25-7 and 25-8 added). The handbook's
projects rule and the line's class parameter say the same, and no
handbook sentence lets a shared cost be split across a document's lines.

The accounting: the document's entry posts one revenue (an invoice's) or
expense (a bill's) line per project its lines name, in the order each is
first named, each the sum of that project's lines, and the untagged
lines' sum as one more line (arap::document_parts, conservation checked
by a proptest). A document naming no project therefore posts exactly the
one line every document posted before. The receivable or payable line is
never tagged: it is a balance-sheet line, which item 3 counts nothing;
the payments (receive_payment, pay_bill) move cash against it and take no
project either. Nothing but the projects moves: the P&L, the 1099
worksheet (payments made, B-19) and party totals (what moved the money,
B-35) read the same with or without the tags (twin-books test); the
projects with the untagged lines still partition the P&L.

- ledger-core: DocumentLine.class_id (serde-omitted when none, so old
  documents serialize as before); create_invoice / create_bill split the
  revenue or expense side by project and tag it; an unknown project is
  refused whole (atomically).
- ledger-store: a document line's project is journaled as class_id on
  invoice.created / bill.created, and the entry's line as
  entry.line_class_set right after its entry.line_added (the entry.posted
  lines carry class_id, as a hand-built entry's do). Behind a NEW journal
  feature, document_line_projects (KNOWN_FEATURES, declared by new books,
  asked for before the first journaled line, not even the control
  accounts written on refusal); not `projects`, which the live books
  already declared: the owner consents again at the next upgrade. A
  malformed class_id is corrupt; null or absent is none. Untagged
  documents journal byte for byte as before; old journals replay
  unchanged.
- ledger-mcp: each line takes class (id or name, resolved like
  add_line's, every one before anything is written); the result gains
  notes for a finished or abandoned project, and on an invoice for an
  on-spec project (is there now an agreement? on spec a hoped-for
  payment is no receivable, B-37 item 4), tagged anyway; books lacking
  the feature refuse with format_upgrade_pending. books.get_entry shows a
  line's class_id and project. The handbook's "an invoice or bill takes no
  project yet" caveat now depends on the books: gone where the feature is
  declared, an "until their format upgrade" note where it isn't.
- ledger-gui: an invoice's or bill's page (its entry's) lists the
  document's lines, each project a link that opens it; the demo's client
  invoices are tagged to "Client work", whose snapshot is now its revenue
  less its contractors. Looking at the page in a narrow window found a
  tagged line's tag pushing the amounts past the pane (any tagged entry):
  the name is now cut to leave the tag room, the tag itself at most half
  the cell.
- bookspec: an `invoice` event, and `lines` with `project` on a bill or
  an invoice; example projects-invoice-and-bill-lines-count-by-project,
  worked by hand, both projects client work (Zeta 3,500.00 - 1,200.00 =
  2,300.00; Harbor 200.00;
  untagged -1,750.00; together the P&L's 750.00; Dana's 1099 payments
  1,250.00).
- fuzz: fuzz_store_replay can declare journal features.
- Tests: core, store, MCP, viewer, the P2-served cutover fixture and a
  viewer/server parity test (its month lists the same length, not
  empty); a tagged document's reversal nets its projects to zero, live
  and replayed; books.get_entry's class_id; the handbook served as a
  resource both ways; each fix broken to confirm its test fails
  (docs/testing.md). Docs: rules.md B-27 and B-37 items 3 and 8,
  sources/fasb-asc.md, plan/served.md D-43,
  mcp-interface.md, testing.md, parity.md, plan/STATUS.md.

No proof changed (no kernel arithmetic: document_parts sums with the
existing document_total).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
2026-10-22 19:28:40 -06:00

1550 lines
53 KiB
Rust

//! Invariant tests: the gates every change must pass.
//!
//! These are not example tests. They encode the accounting laws the engine
//! exists to uphold: entries balance, posted entries are immutable, the trial
//! balance balances, and the accounting equation holds. A feature that breaks
//! any of these is not a feature.
//!
//! Plus the golden test: the canonical fixture company's reports must match
//! `golden/fixture_company.json` byte-for-value. That file is the seed of the
//! semantic oracle (see `docs/oracle.md`).
use chrono::NaiveDate;
use ledger_core::reports::{balance_sheet, snapshot, trial_balance, unclosed_earnings};
use ledger_core::{
AccountId, AccountType, ChartOfAccounts, Currency, EntryStatus, JournalEntry, Ledger,
LedgerError, Money, Side,
};
use rust_decimal::Decimal;
// ---------------------------------------------------------------------------
// Fixture: a tiny service company, January 2026.
// ---------------------------------------------------------------------------
fn date(day: u32) -> NaiveDate {
NaiveDate::from_ymd_opt(2026, 1, day).unwrap()
}
fn usd(amount: u64) -> Money {
Money::new(Decimal::from(amount), Currency::USD)
}
/// Post a balanced entry in one call. Panics on programmer error: fixtures
/// must be valid by construction.
fn post(ledger: &mut Ledger, date: NaiveDate, description: &str, lines: &[(AccountId, Side, u64)]) {
let id = ledger.create_entry(date, description);
for (account, side, amount) in lines {
ledger.add_line(id, *account, *side, usd(*amount)).unwrap();
}
ledger.post_entry(id).unwrap();
}
struct Company {
ledger: Ledger,
cash: AccountId,
ar: AccountId,
equipment: AccountId,
_ap: AccountId,
equity: AccountId,
revenue: AccountId,
rent: AccountId,
}
fn fixture_company() -> Company {
let mut ledger = Ledger::new(Currency::USD);
let cash = ledger
.add_account("1000", "Cash", AccountType::Asset, None, false)
.unwrap();
let ar = ledger
.add_account(
"1200",
"Accounts Receivable",
AccountType::Asset,
None,
false,
)
.unwrap();
let equipment = ledger
.add_account("1500", "Equipment", AccountType::Asset, None, false)
.unwrap();
let ap = ledger
.add_account(
"2000",
"Accounts Payable",
AccountType::Liability,
None,
false,
)
.unwrap();
let equity = ledger
.add_account("3000", "Owner's Equity", AccountType::Equity, None, false)
.unwrap();
let revenue = ledger
.add_account("4000", "Service Revenue", AccountType::Revenue, None, false)
.unwrap();
let rent = ledger
.add_account("6000", "Rent Expense", AccountType::Expense, None, false)
.unwrap();
// Owner invests $10,000 cash.
post(
&mut ledger,
date(2),
"Owner investment",
&[(cash, Side::Debit, 10_000), (equity, Side::Credit, 10_000)],
);
// Buy equipment for $2,500 cash.
post(
&mut ledger,
date(5),
"Buy equipment",
&[(equipment, Side::Debit, 2_500), (cash, Side::Credit, 2_500)],
);
// Bill a client $4,000.
post(
&mut ledger,
date(10),
"Invoice client",
&[(ar, Side::Debit, 4_000), (revenue, Side::Credit, 4_000)],
);
// Pay January rent, $800.
post(
&mut ledger,
date(15),
"January rent",
&[(rent, Side::Debit, 800), (cash, Side::Credit, 800)],
);
// Collect $1,500 of receivables.
post(
&mut ledger,
date(20),
"Client payment",
&[(cash, Side::Debit, 1_500), (ar, Side::Credit, 1_500)],
);
Company {
ledger,
cash,
ar,
equipment,
_ap: ap,
equity,
revenue,
rent,
}
}
// ---------------------------------------------------------------------------
// Validation: the engine refuses bad entries.
// ---------------------------------------------------------------------------
#[test]
fn unbalanced_entry_is_rejected() {
let c = fixture_company();
let mut ledger = c.ledger;
let id = ledger.create_entry(date(21), "Bad entry");
ledger.add_line(id, c.cash, Side::Debit, usd(100)).unwrap();
ledger
.add_line(id, c.equity, Side::Credit, usd(99))
.unwrap();
let err = ledger.post_entry(id).unwrap_err();
assert_eq!(
err,
LedgerError::UnbalancedEntry {
debits: Decimal::from(100),
credits: Decimal::from(99),
}
);
}
#[test]
fn single_line_entry_is_rejected() {
let c = fixture_company();
let mut ledger = c.ledger;
let id = ledger.create_entry(date(21), "One line");
ledger.add_line(id, c.cash, Side::Debit, usd(100)).unwrap();
assert_eq!(ledger.post_entry(id).unwrap_err(), LedgerError::TooFewLines);
}
#[test]
fn zero_amount_line_is_rejected() {
let c = fixture_company();
let mut ledger = c.ledger;
let id = ledger.create_entry(date(21), "Zero line");
ledger.add_line(id, c.cash, Side::Debit, usd(100)).unwrap();
ledger.add_line(id, c.cash, Side::Debit, usd(0)).unwrap();
ledger
.add_line(id, c.equity, Side::Credit, usd(100))
.unwrap();
assert_eq!(
ledger.post_entry(id).unwrap_err(),
LedgerError::ZeroAmountLine
);
}
#[test]
fn negative_amount_line_is_rejected() {
let c = fixture_company();
let mut ledger = c.ledger;
let id = ledger.create_entry(date(21), "Negative line");
let minus = Money::new(Decimal::from(-100), Currency::USD);
ledger.add_line(id, c.cash, Side::Debit, minus).unwrap();
ledger.add_line(id, c.equity, Side::Credit, minus).unwrap();
assert_eq!(
ledger.post_entry(id).unwrap_err(),
LedgerError::NegativeAmountLine
);
}
fn kernel_max() -> Decimal {
ledger_core::kernel::MAX_LINE_AMOUNT
}
#[test]
fn out_of_range_amount_is_rejected() {
let c = fixture_company();
let mut ledger = c.ledger;
// Big enough to overflow a sum: an error, never a panic, as soon as
// the line is added (a draft is summed before it posts: its debits,
// its parties' shares; P0 review round 3, S1). Replayed as an earlier
// version wrote it (rules.md B-28): accepted into its draft, whose
// post refuses it, never a panic.
let id = ledger.create_entry(date(21), "Too big");
let old = ledger.create_entry(date(21), "Too big, journaled before the bound");
for amount in [
Decimal::MAX,
Decimal::MIN,
kernel_max() + Decimal::new(1, 2),
] {
let big = Money::new(amount, Currency::USD);
assert_eq!(
ledger.add_line(id, c.cash, Side::Debit, big).unwrap_err(),
LedgerError::AmountOutOfRange
);
ledger
.replay_add_line(old, c.cash, Side::Debit, big)
.unwrap();
}
let max_usd = Money::new(Decimal::MAX, Currency::USD);
ledger
.replay_add_line(old, c.equity, Side::Credit, max_usd)
.unwrap();
assert_eq!(
ledger.post_entry(old).unwrap_err(),
LedgerError::AmountOutOfRange
);
assert_eq!(
ledger.replay_post_entry(old).unwrap_err(),
LedgerError::AmountOutOfRange
);
// The largest line goes in, and posts.
let max = Money::new(kernel_max(), Currency::USD);
ledger.add_line(id, c.cash, Side::Debit, max).unwrap();
ledger.add_line(id, c.equity, Side::Credit, max).unwrap();
ledger.post_entry(id).unwrap();
// Too precise: refused as soon as it is added (finer than a cent)...
let id = ledger.create_entry(date(21), "Too precise");
let tiny = Money::new("0.00000000001".parse().unwrap(), Currency::USD);
assert!(matches!(
ledger.add_line(id, c.cash, Side::Debit, tiny),
Err(LedgerError::FinerThanMinorUnit { places: 2, .. })
));
// ...and out of range even for books replayed from before that rule.
ledger
.replay_add_line(id, c.cash, Side::Debit, tiny)
.unwrap();
ledger
.replay_add_line(id, c.equity, Side::Credit, tiny)
.unwrap();
assert_eq!(
ledger.replay_post_entry(id).unwrap_err(),
LedgerError::AmountOutOfRange
);
}
/// B-3 (DECISION, owner 2026-10-13: a ledger in one money holds no
/// fractions of its smallest unit). Regression: a payroll run posted
/// $65.27794 (found by the store's refusal property in CI, 2026-10-13).
#[test]
fn no_entry_posts_a_fraction_of_the_minor_unit() {
let c = fixture_company();
let mut ledger = c.ledger;
let usd = |s: &str| Money::new(s.parse().unwrap(), Currency::USD);
// Whole cents, with or without trailing zeros, are fine.
for ok in ["65.27", "65.270", "65"] {
let id = ledger.create_entry(date(21), "cents");
ledger.add_line(id, c.cash, Side::Debit, usd(ok)).unwrap();
ledger
.add_line(id, c.equity, Side::Credit, usd(ok))
.unwrap();
ledger.post_entry(id).unwrap();
}
// A fraction of a cent is refused when added...
let id = ledger.create_entry(date(21), "sub-cent");
assert!(matches!(
ledger.add_line(id, c.cash, Side::Debit, usd("65.27794")),
Err(LedgerError::FinerThanMinorUnit { places: 2, .. })
));
// ...and when posted, whatever path put the line on the draft.
ledger
.replay_add_line(id, c.cash, Side::Debit, usd("65.275"))
.unwrap();
ledger
.replay_add_line(id, c.equity, Side::Credit, usd("65.275"))
.unwrap();
assert!(matches!(
ledger.post_entry(id),
Err(LedgerError::FinerThanMinorUnit { .. })
));
assert_eq!(
ledger.get_entry(id).unwrap().status(),
ledger_core::EntryStatus::Draft
);
// Books from before the rule still replay as booked.
ledger.replay_post_entry(id).unwrap();
}
#[test]
fn fx_overflow_is_an_error() {
let c = fixture_company();
let mut ledger = c.ledger;
let id = ledger.create_entry(date(21), "Huge FX");
assert_eq!(
ledger
.add_line_fx(
id,
c.cash,
Side::Debit,
Currency::EUR,
Decimal::MAX,
Decimal::from(2)
)
.unwrap_err(),
LedgerError::AmountOutOfRange
);
}
#[test]
fn unknown_account_is_rejected() {
// Refused when the line is added (since 2026-10-13); a line that got
// in anyway (replayed from an older journal) is still refused at post.
let c = fixture_company();
let mut ledger = c.ledger;
let id = ledger.create_entry(date(21), "Ghost account");
ledger.add_line(id, c.cash, Side::Debit, usd(100)).unwrap();
assert_eq!(
ledger.add_line(id, 999_999, Side::Credit, usd(100)),
Err(LedgerError::UnknownAccount(999_999))
);
ledger
.replay_add_line(id, 999_999, Side::Credit, usd(100))
.unwrap();
assert_eq!(
ledger.post_entry(id).unwrap_err(),
LedgerError::UnknownAccount(999_999)
);
}
#[test]
fn placeholder_account_is_rejected() {
let c = fixture_company();
let mut ledger = c.ledger;
let header = ledger
.add_account("100", "Assets header", AccountType::Asset, None, true)
.unwrap();
let id = ledger.create_entry(date(21), "Post to header");
assert_eq!(
ledger.add_line(id, header, Side::Debit, usd(100)),
Err(LedgerError::PlaceholderAccount("100".to_string()))
);
// Got in anyway (an older journal): still refused at post.
ledger
.replay_add_line(id, header, Side::Debit, usd(100))
.unwrap();
ledger
.add_line(id, c.equity, Side::Credit, usd(100))
.unwrap();
assert_eq!(
ledger.post_entry(id).unwrap_err(),
LedgerError::PlaceholderAccount("100".to_string())
);
}
#[test]
fn foreign_currency_line_is_rejected() {
let c = fixture_company();
let mut ledger = c.ledger;
let id = ledger.create_entry(date(21), "Euro line");
let eur = Money::new(Decimal::from(100), Currency::EUR);
let err = ledger.add_line(id, c.cash, Side::Debit, eur).unwrap_err();
assert_eq!(
err,
LedgerError::CurrencyMismatch {
expected: "USD".to_string(),
found: "EUR".to_string(),
}
);
}
/// 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]
fn duplicate_account_code_is_rejected() {
let c = fixture_company();
let mut ledger = c.ledger;
let err = ledger
.add_account("1000", "Cash again", AccountType::Asset, None, false)
.unwrap_err();
assert_eq!(err, LedgerError::DuplicateAccountCode("1000".to_string()));
}
// ---------------------------------------------------------------------------
// Immutability: posted entries can only be corrected by reversal.
// ---------------------------------------------------------------------------
#[test]
fn posted_entry_is_immutable() {
let c = fixture_company();
let mut ledger = c.ledger;
let id = ledger.create_entry(date(21), "To post");
ledger.add_line(id, c.cash, Side::Debit, usd(50)).unwrap();
ledger
.add_line(id, c.equity, Side::Credit, usd(50))
.unwrap();
ledger.post_entry(id).unwrap();
// Cannot add lines after posting.
assert_eq!(
ledger
.add_line(id, c.cash, Side::Debit, usd(1))
.unwrap_err(),
LedgerError::PostedEntryImmutable
);
// Cannot post twice.
assert_eq!(
ledger.post_entry(id).unwrap_err(),
LedgerError::EntryAlreadyPosted(id)
);
assert_eq!(ledger.get_entry(id).unwrap().status(), EntryStatus::Posted);
}
#[test]
fn reversal_nets_to_zero_and_links_back() {
let c = fixture_company();
let mut ledger = c.ledger;
let id = ledger.create_entry(date(21), "Mistake");
ledger.add_line(id, c.rent, Side::Debit, usd(500)).unwrap();
ledger.add_line(id, c.cash, Side::Credit, usd(500)).unwrap();
ledger.post_entry(id).unwrap();
assert_eq!(ledger.account_movement(c.rent), Decimal::from(1300));
let rev = ledger
.reverse_entry(id, date(22), "Reverse mistake")
.unwrap();
let rev_entry = ledger.get_entry(rev).unwrap();
assert_eq!(rev_entry.reversal_of(), Some(id));
assert_eq!(rev_entry.status(), EntryStatus::Posted);
// The mistake and its reversal cancel exactly: back to fixture values.
assert_eq!(ledger.account_movement(c.rent), Decimal::from(800));
assert_eq!(ledger.account_movement(c.cash), Decimal::from(8200));
// And the books still balance everywhere.
assert!(trial_balance(&ledger).balanced());
assert!(balance_sheet(&ledger).unwrap().balanced());
}
/// A posted mistake, for the reversal rules below.
fn posted_mistake(ledger: &mut Ledger, c: &Company) -> ledger_core::EntryId {
let id = ledger.create_entry(date(21), "Mistake");
ledger.add_line(id, c.rent, Side::Debit, usd(500)).unwrap();
ledger.add_line(id, c.cash, Side::Credit, usd(500)).unwrap();
ledger.post_entry(id).unwrap();
id
}
/// Spec (owner, 2026-10-13): an entry is reversed at most once. A second
/// reversal would undo the original twice.
#[test]
fn an_entry_is_reversed_at_most_once() {
let c = fixture_company();
let mut ledger = c.ledger.clone();
let id = posted_mistake(&mut ledger, &c);
let rev = ledger.reverse_entry(id, date(22), "Reverse").unwrap();
let before = ledger.next_entry_id();
assert_eq!(
ledger.reverse_entry(id, date(23), "Again").unwrap_err(),
LedgerError::EntryAlreadyReversed(id, rev)
);
// Refused means untouched: no entry allocated, no draft left behind.
assert_eq!(ledger.next_entry_id(), before);
assert_eq!(ledger.account_movement(c.rent), Decimal::from(800));
}
/// Spec: reversing the reversal re-instates the original, and is allowed
/// (once, like any other entry).
#[test]
fn a_reversal_can_itself_be_reversed_once() {
let c = fixture_company();
let mut ledger = c.ledger.clone();
let id = posted_mistake(&mut ledger, &c);
let rev = ledger.reverse_entry(id, date(22), "Reverse").unwrap();
let back = ledger.reverse_entry(rev, date(23), "Re-instate").unwrap();
assert_eq!(ledger.account_movement(c.rent), Decimal::from(1300));
assert_eq!(
ledger.reverse_entry(rev, date(24), "Again").unwrap_err(),
LedgerError::EntryAlreadyReversed(rev, back)
);
}
/// Spec (owner, 2026-10-13): a closing entry is never reversed. Undoing
/// it would leave the period marked closed with its earnings unswept.
#[test]
fn a_closing_entry_is_never_reversed() {
let c = fixture_company();
let mut ledger = c.ledger.clone();
let jan = ledger.create_period("2026-01", date(1), date(31)).unwrap();
let closing = ledger.close_period(jan).unwrap().unwrap();
let feb = NaiveDate::from_ymd_opt(2026, 2, 1).unwrap();
let before = ledger.next_entry_id();
assert_eq!(
ledger
.reverse_entry(closing, feb, "Undo close")
.unwrap_err(),
LedgerError::ClosingEntryIrreversible(closing)
);
assert_eq!(ledger.next_entry_id(), before);
}
/// Regression (max-testing pass, 2026-10-13): a reversal dated in a closed
/// period was refused only at post time, after allocating an id and
/// leaving a draft behind. Refused means untouched.
#[test]
fn a_reversal_into_a_closed_period_leaves_no_trace() {
let c = fixture_company();
let mut ledger = c.ledger.clone();
let id = posted_mistake(&mut ledger, &c);
let jan = ledger.create_period("2026-01", date(1), date(31)).unwrap();
// Nothing is open in January now except our posted mistake; close it.
ledger.close_period(jan).unwrap();
let before = ledger.next_entry_id();
assert!(matches!(
ledger.reverse_entry(id, date(25), "Into the closed month"),
Err(LedgerError::ClosedPeriod(..))
));
assert_eq!(ledger.next_entry_id(), before);
assert!(ledger.check_reversal(id, date(25)).is_err());
}
// ---------------------------------------------------------------------------
// Reports: the accounting laws hold.
// ---------------------------------------------------------------------------
#[test]
fn trial_balance_balances() {
let c = fixture_company();
let tb = trial_balance(&c.ledger);
assert!(tb.balanced());
assert_eq!(tb.total_debits, Decimal::from(14_000));
assert_eq!(tb.total_credits, Decimal::from(14_000));
// Six accounts with non-zero balances, sorted by code.
let codes: Vec<_> = tb.lines.iter().map(|l| l.code.as_str()).collect();
assert_eq!(codes, vec!["1000", "1200", "1500", "3000", "4000", "6000"]);
}
#[test]
fn accounting_equation_holds() {
let c = fixture_company();
let bs = balance_sheet(&c.ledger).unwrap();
assert!(bs.balanced());
assert_eq!(bs.assets, Decimal::from(13_200));
assert_eq!(bs.liabilities, Decimal::ZERO);
// Equity = owner's capital 10,000 + net income 3,200.
assert_eq!(bs.equity, Decimal::from(13_200));
assert_eq!(bs.current_earnings, Decimal::from(3_200));
}
#[test]
fn profit_and_loss_is_correct() {
let c = fixture_company();
let pl = unclosed_earnings(&c.ledger).unwrap();
assert_eq!(pl.revenue, Decimal::from(4_000));
assert_eq!(pl.expenses, Decimal::from(800));
assert_eq!(pl.net_income(), Decimal::from(3_200));
}
// ---------------------------------------------------------------------------
// Property-ish test: random balanced journals preserve every invariant.
// ---------------------------------------------------------------------------
/// Tiny deterministic PRNG. No dependencies, reproducible seeds.
struct Lcg(u64);
impl Lcg {
fn next(&mut self, bound: u64) -> u64 {
self.0 = self
.0
.wrapping_mul(6364136223846793005)
.wrapping_add(1442695040888963407);
(self.0 >> 33) % bound
}
}
/// Split `total` into `parts` positive whole-unit pieces.
fn split_total(rng: &mut Lcg, total: u64, parts: usize) -> Vec<u64> {
assert!(total >= parts as u64 && parts >= 1);
let mut cuts = vec![0u64, total];
while cuts.len() < parts + 1 {
let c = rng.next(total - 1) + 1; // 1..total
if !cuts.contains(&c) {
cuts.push(c);
}
}
cuts.sort_unstable();
cuts.windows(2).map(|w| w[1] - w[0]).collect()
}
#[test]
fn random_balanced_journals_preserve_invariants() {
let mut rng = Lcg(0x1234_5678_9abc_def0);
let c = fixture_company();
let mut ledger = c.ledger;
let accounts = [
c.cash,
c.ar,
c.equipment,
c._ap,
c.equity,
c.revenue,
c.rent,
];
for i in 0..300u32 {
let total = rng.next(100_000) + 100;
let nd = (rng.next(3) + 1) as usize;
let nc = (rng.next(3) + 1) as usize;
let id = ledger.create_entry(date(21), &format!("random {i}"));
for part in split_total(&mut rng, total, nd) {
let acct = accounts[rng.next(accounts.len() as u64) as usize];
ledger.add_line(id, acct, Side::Debit, usd(part)).unwrap();
}
for part in split_total(&mut rng, total, nc) {
let acct = accounts[rng.next(accounts.len() as u64) as usize];
ledger.add_line(id, acct, Side::Credit, usd(part)).unwrap();
}
ledger.post_entry(id).unwrap();
}
// Every invariant still holds after 300 random balanced entries.
let tb = trial_balance(&ledger);
assert!(tb.balanced());
assert!(balance_sheet(&ledger).unwrap().balanced());
// Conservation: the sum of all account movements is exactly zero.
let mut sum = Decimal::ZERO;
for a in accounts {
sum += ledger.account_movement(a);
}
assert!(sum.is_zero());
}
// ---------------------------------------------------------------------------
// Golden test: the oracle seed.
// ---------------------------------------------------------------------------
#[test]
fn golden_fixture_company_reports() {
let c = fixture_company();
let snap = snapshot(&c.ledger).unwrap();
let golden: serde_json::Value =
serde_json::from_str(include_str!("golden/fixture_company.json")).unwrap();
assert_eq!(snap, golden);
}
#[test]
fn parent_account_validation() {
let mut ledger = Ledger::new(Currency::USD);
// Unknown parent is rejected.
let err = ledger
.add_account("1100", "Child", AccountType::Asset, Some(999), false)
.unwrap_err();
assert_eq!(err, LedgerError::UnknownParentAccount(999));
// A real parent works, and the child records it.
let parent = ledger
.add_account("1000", "Parent", AccountType::Asset, None, true)
.unwrap();
let child = ledger
.add_account("1100", "Child", AccountType::Asset, Some(parent), false)
.unwrap();
assert_eq!(ledger.chart().get(child).unwrap().parent, Some(parent));
// Chart accessors report honestly.
assert!(Ledger::new(Currency::USD).chart().is_empty());
assert!(!ledger.chart().is_empty());
assert_eq!(ledger.chart().len(), 2);
assert!(ledger.chart().find_by_code("1000").is_some());
assert!(ledger.chart().find_by_code("nope").is_none());
}
#[test]
fn account_type_balance_sheet_predicate() {
assert!(AccountType::Asset.is_balance_sheet());
assert!(AccountType::Liability.is_balance_sheet());
assert!(AccountType::Equity.is_balance_sheet());
assert!(!AccountType::Revenue.is_balance_sheet());
assert!(!AccountType::Expense.is_balance_sheet());
}
#[test]
fn entry_movements_sum_to_zero() {
let mut c = fixture_company();
let cash = c.cash;
let revenue = c.revenue;
let id = c.ledger.create_entry(date(15), "sale");
c.ledger.add_line(id, cash, Side::Debit, usd(1000)).unwrap();
c.ledger
.add_line(id, revenue, Side::Credit, usd(1000))
.unwrap();
c.ledger.post_entry(id).unwrap();
let entry = c.ledger.get_entry(id).unwrap();
let m = entry.movements();
assert_eq!(m[&cash], Decimal::new(100000, 2));
assert_eq!(m[&revenue], Decimal::new(-100000, 2));
let total: Decimal = m.values().copied().sum();
assert_eq!(total, Decimal::ZERO);
}
// ---------------------------------------------------------------------------
// Cash flow statement (direct method, rules.md B-14).
// ---------------------------------------------------------------------------
use ledger_core::NewAsset;
use ledger_core::reports::{CashFlowParams, cash_flow};
fn cash_flow_params(cash: AccountId) -> CashFlowParams {
CashFlowParams {
start: date(1),
end: date(31),
cash_accounts: vec![cash],
financing_accounts: vec![],
investing_accounts: vec![],
members: vec![],
}
}
#[test]
fn cash_flow_reconciles_to_change_in_cash() {
let c = fixture_company();
let cf = cash_flow(&c.ledger, &cash_flow_params(c.cash)).unwrap();
// The three sections must sum to the net change in cash. This is the
// accounting law the statement exists to uphold.
let sections = cf.cash_from_operations + cf.cash_from_investing + cf.cash_from_financing;
assert_eq!(sections, cf.net_change_in_cash);
assert_eq!(cf.cash_ending - cf.cash_beginning, cf.net_change_in_cash);
// Hand-computed from the fixture:
// Cash: +10,000 (invest) - 2,500 (equipment) - 800 (rent) + 1,500 (collect)
// = 8,200. Beginning 0.
assert_eq!(cf.cash_beginning, Decimal::ZERO);
assert_eq!(cf.cash_ending, Decimal::new(820000, 2));
assert_eq!(cf.net_change_in_cash, Decimal::new(820000, 2));
// Net income: 4,000 revenue - 800 rent = 3,200.
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!(r.lines.iter().all(|l| l.label != "Depreciation"));
// Operating, by the direct method: collected 1,500 (class (a)); paid
// 2,500 for equipment (not a registered asset: operating, rules.md
// B-14 rule 12's limits) 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]
fn cash_flow_with_registered_asset_and_disposal() {
let mut ledger = Ledger::new(Currency::USD);
let cash = ledger
.add_account("1000", "Cash", AccountType::Asset, None, false)
.unwrap();
let cost = ledger
.add_account("1500", "Equipment at cost", AccountType::Asset, None, false)
.unwrap();
let accum = ledger
.add_account(
"1501",
"Accum. depr. — equip.",
AccountType::Asset,
None,
false,
)
.unwrap();
let depr_exp = ledger
.add_account(
"6000",
"Depreciation expense",
AccountType::Expense,
None,
false,
)
.unwrap();
ledger
.add_account("4000", "Service Revenue", AccountType::Revenue, None, false)
.unwrap();
let gain = ledger
.add_account("4100", "Gain on sale", AccountType::Revenue, None, false)
.unwrap();
let equity = ledger
.add_account("3000", "Owner's Equity", AccountType::Equity, None, false)
.unwrap();
// Owner invests $100,000.
post(
&mut ledger,
date(2),
"Owner investment",
&[
(cash, Side::Debit, 100_000),
(equity, Side::Credit, 100_000),
],
);
// Register a $60,000 van, 60-month life, bought Jan 15.
let asset = ledger
.register_asset(NewAsset {
description: "Van".to_string(),
in_service_date: NaiveDate::from_ymd_opt(2026, 1, 15).unwrap(),
cost: usd(60_000),
salvage_value: usd(0),
useful_life_months: 60,
cost_account: cost,
accum_depr_account: accum,
expense_account: depr_exp,
})
.unwrap();
// Buy the van for cash.
post(
&mut ledger,
date(15),
"Buy van",
&[(cost, Side::Debit, 60_000), (cash, Side::Credit, 60_000)],
);
// January depreciation: $1,000 * 17/31 = $548.39.
post(
&mut ledger,
date(31),
"January depreciation",
&[(depr_exp, Side::Debit, 548), (accum, Side::Credit, 548)],
);
// Sell the van Jan 31 for $59,600 cash. Book value 60,000 - 548 = 59,452.
// Gain = 59,600 - 59,452 = 148.
// Note: amounts are whole dollars here to keep the fixture simple.
let _ = asset;
post(
&mut ledger,
date(31),
"Sell van",
&[
(cash, Side::Debit, 59_600),
(accum, Side::Debit, 548),
(cost, Side::Credit, 60_000),
(gain, Side::Credit, 148),
],
);
let cf = cash_flow(&ledger, &cash_flow_params(cash)).unwrap();
// Reconciliation is the law.
let sections = cf.cash_from_operations + cf.cash_from_investing + cf.cash_from_financing;
assert_eq!(sections, cf.net_change_in_cash);
// 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.
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.
assert_eq!(cf.cash_from_financing, Decimal::new(10000000, 2));
// Sum: 0 - 400 + 100,000 = 99,600. Matches cash.
}
// ---------------------------------------------------------------------------
// Control accounts (owner, 2026-10-13): the engine posts invoices, bills,
// payroll, and closes to accounts it finds by code. When an account with
// that code exists but is the wrong kind, it refuses, says why, and changes
// nothing, rather than posting to it or forcing the chart into its
// numbering. SOURCE: rules.md B-4 (the elements), DECISION.
// ---------------------------------------------------------------------------
fn books_with(code: &str, name: &str, t: AccountType, placeholder: bool) -> Ledger {
let mut l = Ledger::new(Currency::USD);
l.add_account("1000", "Cash", AccountType::Asset, None, false)
.unwrap();
l.add_account(code, name, t, None, placeholder).unwrap();
l
}
fn doc(amount: u64) -> Vec<ledger_core::DocumentLine> {
vec![ledger_core::DocumentLine {
description: "work".into(),
amount: Decimal::from(amount),
class_id: None,
}]
}
/// Refused means untouched: same chart, same ids, nothing posted.
fn assert_untouched(before: &Ledger, after: &Ledger) {
assert_eq!(after.chart().len(), before.chart().len());
assert_eq!(after.next_entry_id(), before.next_entry_id());
assert_eq!(after.next_invoice_id(), before.next_invoice_id());
assert_eq!(after.next_bill_id(), before.next_bill_id());
assert_eq!(
after.posted_entries().count(),
before.posted_entries().count()
);
}
#[test]
fn an_invoice_refuses_a_1200_that_is_not_receivables() {
let mut l = books_with("1200", "Customer Deposits", AccountType::Liability, false);
let c = l.add_customer("Acme").unwrap();
let before = l.clone();
let err = l
.create_invoice(c, date(5), date(30), doc(100))
.unwrap_err();
assert_eq!(
err,
LedgerError::ControlAccountMismatch {
code: "1200".into(),
name: "Customer Deposits".into(),
role: "accounts receivable".into(),
expected: AccountType::Asset,
found: AccountType::Liability,
placeholder: false,
}
);
// The reason names the account, what it is, and what was needed.
let why = err.to_string();
assert!(
why.contains("1200") && why.contains("Customer Deposits"),
"{why}"
);
assert!(why.contains("Liability") && why.contains("Asset"), "{why}");
assert_untouched(&before, &l);
}
#[test]
fn an_invoice_refuses_a_placeholder_1200() {
let mut l = books_with("1200", "Receivables", AccountType::Asset, true);
let c = l.add_customer("Acme").unwrap();
let before = l.clone();
assert!(matches!(
l.create_invoice(c, date(5), date(30), doc(100)),
Err(LedgerError::ControlAccountMismatch {
placeholder: true,
..
})
));
assert_untouched(&before, &l);
}
#[test]
fn an_invoice_creates_nothing_when_its_second_account_is_wrong() {
// 1200 is missing (would be created) but 4000 is the wrong type: the
// refusal must come before 1200 is added.
let mut l = books_with("4000", "Owner Loans", AccountType::Liability, false);
let c = l.add_customer("Acme").unwrap();
let before = l.clone();
assert!(matches!(
l.create_invoice(c, date(5), date(30), doc(100)),
Err(LedgerError::ControlAccountMismatch { .. })
));
assert!(l.chart().find_by_code("1200").is_none());
assert_untouched(&before, &l);
}
#[test]
fn a_bill_refuses_a_2000_that_is_not_payables() {
let mut l = books_with("2000", "Savings", AccountType::Asset, false);
let v = l.add_vendor("Vend").unwrap();
let before = l.clone();
assert!(matches!(
l.create_bill(v, date(5), date(30), doc(100)),
Err(LedgerError::ControlAccountMismatch {
expected: AccountType::Liability,
..
})
));
assert_untouched(&before, &l);
}
#[test]
fn a_close_refuses_a_3999_that_is_not_equity() {
let mut l = books_with("3999", "Suspense", AccountType::Asset, false);
let sales = l
.add_account("4000", "Sales", AccountType::Revenue, None, false)
.unwrap();
let e = l.create_entry(date(5), "sale");
l.add_line(e, 1, Side::Debit, usd(100)).unwrap();
l.add_line(e, sales, Side::Credit, usd(100)).unwrap();
l.post_entry(e).unwrap();
let jan = l.create_period("2026-01", date(1), date(31)).unwrap();
let before = l.clone();
assert!(matches!(
l.close_period(jan),
Err(LedgerError::ControlAccountMismatch {
expected: AccountType::Equity,
..
})
));
assert_untouched(&before, &l);
assert!(l.closed_period_containing(date(5)).is_none());
}
// ---------------------------------------------------------------------------
// Chart, periods, and lines: rules confirmed by owner, 2026-10-13.
// ---------------------------------------------------------------------------
/// A child account subdivides its parent, so it is the same kind of
/// account. SOURCE: rules.md B-4; DECISION (owner, 2026-10-13).
#[test]
fn a_child_account_has_its_parents_type() {
let mut l = Ledger::new(Currency::USD);
let cash = l
.add_account("1000", "Cash", AccountType::Asset, None, true)
.unwrap();
l.add_account("1010", "Checking", AccountType::Asset, Some(cash), false)
.unwrap();
let before = l.chart().len();
assert_eq!(
l.add_account("1020", "Rent", AccountType::Expense, Some(cash), false),
Err(LedgerError::ParentTypeMismatch {
code: "1020".into(),
parent_code: "1000".into(),
expected: AccountType::Asset,
found: AccountType::Expense,
})
);
assert_eq!(l.chart().len(), before);
assert!(l.chart().find_by_code("1020").is_none());
}
/// Periods nest (a month in a quarter in a year) but never partially
/// overlap. DECISION (owner, 2026-10-13).
#[test]
fn periods_nest_but_never_partially_overlap() {
let d = |m: u32, day: u32| NaiveDate::from_ymd_opt(2026, m, day).unwrap();
let mut l = Ledger::new(Currency::USD);
l.create_period("2026-01", d(1, 1), d(1, 31)).unwrap();
l.create_period("2026-02", d(2, 1), d(2, 28)).unwrap();
// A quarter around both months, and a year around everything.
l.create_period("Q1 2026", d(1, 1), d(3, 31)).unwrap();
l.create_period("2026", d(1, 1), d(12, 31)).unwrap();
// A week inside January nests too.
l.create_period("week", d(1, 5), d(1, 11)).unwrap();
let next = l.next_period_id();
// Mid-January to mid-February straddles two months: refused.
assert!(matches!(
l.create_period("straddle", d(1, 15), d(2, 15)),
Err(LedgerError::PeriodsOverlap { .. })
));
// So is a quarter that starts inside Q1.
assert!(matches!(
l.create_period("odd quarter", d(2, 1), d(4, 30)),
Err(LedgerError::PeriodsOverlap { .. })
));
assert_eq!(l.next_period_id(), next, "refused means no id burned");
// Disjoint is fine.
l.create_period(
"2027-01",
NaiveDate::from_ymd_opt(2027, 1, 1).unwrap(),
NaiveDate::from_ymd_opt(2027, 1, 31).unwrap(),
)
.unwrap();
}
/// A line names a real, postable account when it is added, not only when
/// the entry posts. DECISION (owner, 2026-10-13); rules.md B-1.
#[test]
fn a_line_on_an_unknown_or_placeholder_account_is_refused_when_added() {
let c = fixture_company();
let mut l = c.ledger.clone();
let header = l
.add_account("1900", "Other assets", AccountType::Asset, None, true)
.unwrap();
let e = l.create_entry(date(21), "Lines");
assert_eq!(
l.add_line(e, 999_999, Side::Debit, usd(10)),
Err(LedgerError::UnknownAccount(999_999))
);
assert_eq!(
l.add_line(e, header, Side::Debit, usd(10)),
Err(LedgerError::PlaceholderAccount("1900".into()))
);
assert!(matches!(
l.add_line_fx(
e,
header,
Side::Debit,
Currency::EUR,
Decimal::from(10),
Decimal::from(2)
),
Err(LedgerError::PlaceholderAccount(_))
));
assert!(
l.get_entry(e).unwrap().lines().is_empty(),
"refused lines aren't added"
);
}
/// Spec (rules.md B-3; DECISION, owner 2026-10-13, after the GnuCash
/// cross-check): a foreign line is booked in the base currency's smallest
/// unit, half away from zero; the foreign original is kept.
#[test]
fn a_foreign_line_is_booked_in_whole_base_units() {
let book = |base: Currency, foreign: Currency, amount: &str, rate: &str| {
let mut l = Ledger::new(base);
let a = l
.add_account("1000", "Cash", AccountType::Asset, None, false)
.unwrap();
let e = l.create_entry(date(5), "fx");
let r = l.add_line_fx(
e,
a,
Side::Debit,
foreign,
amount.parse().unwrap(),
rate.parse().unwrap(),
);
r.map(|()| {
let line = l.get_entry(e).unwrap().lines()[0].clone();
(line.amount.amount, line.foreign_amount)
})
};
let dec = |s: &str| s.parse::<Decimal>().unwrap();
// 100.00 EUR x 1.0834567 = 108.34567 USD -> 108.35.
assert_eq!(
book(Currency::USD, Currency::EUR, "100.00", "1.0834567").unwrap(),
(dec("108.35"), Some(dec("100.00")))
);
// 10.00 USD x 151.237 = 1512.37 JPY -> 1512 (yen have no minor unit).
assert_eq!(
book(Currency::JPY, Currency::USD, "10.00", "151.237")
.unwrap()
.0,
dec("1512")
);
// Exactly half a cent rounds away from zero.
assert_eq!(
book(Currency::USD, Currency::EUR, "1", "0.125").unwrap().0,
dec("0.13")
);
// Rounds to nothing: refused.
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,
convention: ledger_core::ScheduleConvention::MonthlyEven,
};
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,
&format!("Capital {code}"),
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().unwrap(), 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));
}