From e52b88433cce2055cfb6727c78da3499b3e020af Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Tue, 13 Oct 2026 21:01:51 -0600 Subject: [PATCH] import: templates match the engine's control accounts; report specs - Starter charts put "Platform and Payment Processing Fees" at 6100, the code payroll posts wages to, so a template company that ran payroll would book its wages as processing fees; their own payroll accounts (7000, 7050, 2300) were never used. Now 6100 Salary & Wage Expense and 6110 Payroll Tax Expense (the engine's control accounts, with tax categories), fees at 6060, and the unused accounts gone (the withholding payables 2110-2150 are created when payroll first runs). New tests: every template imports with categories valid for its return; no template puts another account at a control code; a partnership names every partner and its shares sum to 100%. - format_amount printed a negative zero ("(0.00)" for -0.004); it rounds half away from zero first now, like amount_parts. - Specs (GOAL/SOURCE/INVARIANTS) for statement finishing, P&L, balance sheet, cash flow, trial balance, members' equity (each must foot as printed), format_amount, and the templates. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX --- crates/ledger-import/src/report.rs | 86 ++++++++++++++++++- crates/ledger-import/src/templates.rs | 43 ++++++++-- crates/ledger-import/tests/templates.rs | 108 ++++++++++++++++++++++++ 3 files changed, 225 insertions(+), 12 deletions(-) create mode 100644 crates/ledger-import/tests/templates.rs diff --git a/crates/ledger-import/src/report.rs b/crates/ledger-import/src/report.rs index cfcf33c..69d99fd 100644 --- a/crates/ledger-import/src/report.rs +++ b/crates/ledger-import/src/report.rs @@ -134,6 +134,16 @@ fn spacer() -> Row { } /// Finish a statement: currency signs on sums, rules above them. +/// +/// ```text +/// GOAL: the finishing marks an accountant puts on a statement. +/// SOURCE: CONVENTION (US small-business presentation, QuickBooks-style). +/// INVARIANTS: +/// 1. Subtotals and totals print the currency sign; nothing else does. +/// 2. A total always has a rule above it; a subtotal only when amounts +/// (lines or subtotals) sit above it in its section. +/// 3. Rows, labels, and amounts are kept exactly as given. +/// ``` pub fn statement(title: &str, period: String, columns: &[&str], mut rows: Vec) -> Statement { let mut above: Option = None; for r in &mut rows { @@ -163,6 +173,16 @@ fn account_lines(lines: &[StatementLine]) -> impl Iterator + '_ { } /// Lay out a profit and loss statement. +/// +/// ```text +/// GOAL: the P&L as printed, footing. +/// SOURCE: docs/spec/rules.md B-14; footing (each column adds to its +/// printed totals) is the reader's check on any statement. +/// INVARIANTS: +/// 1. Revenue lines sum to "Total revenue"; expense lines to "Total +/// expenses"; "Net income (loss)" = total revenue - total expenses. +/// 2. One line per account, "CODE Name", in the statement's order. +/// ``` pub fn profit_and_loss(pl: &ProfitAndLossStatement) -> Statement { let mut rows = vec![section("Revenue")]; rows.extend(account_lines(&pl.revenue)); @@ -192,6 +212,20 @@ pub struct YearEarnings { /// Lay out a balance sheet. /// +/// ```text +/// GOAL: the balance sheet as printed, footing and balancing. +/// SOURCE: rules.md B-4/B-5 (assets = liabilities + equity); footing. +/// INVARIANTS: +/// 1. Asset lines sum to "Total assets", liability lines to "Total +/// liabilities", equity lines (accounts and the earnings lines) to the +/// equity total. +/// 2. "Total liabilities and (members') equity" = total liabilities + +/// total equity = total assets. +/// 3. With members: equity is "Members' equity", each capital account +/// listed, then unallocated earnings; without, "Retained earnings" +/// and the year's "Net income (loss)". +/// ``` +/// /// Without members (corporate convention), equity ends with "Retained /// earnings" — the Retained Earnings account plus earlier years' earnings /// not yet closed — and the fiscal year's "Net income (loss)". @@ -296,6 +330,20 @@ fn flow_lines<'a>( } /// Lay out a cash flow statement (indirect method). +/// +/// ```text +/// GOAL: the cash flow statement as printed, footing and reconciling. +/// SOURCE: rules.md B-14 (ASC 230); footing. +/// INVARIANTS: +/// 1. Net income, depreciation, and the working-capital lines sum to +/// "Net cash from operating activities"; investing and financing lines +/// to theirs. +/// 2. The three section totals sum to "Net increase (decrease) in cash", +/// and beginning cash plus it is "Cash at end of period". +/// 3. A member's capital or distributions line names the member: +/// "Member contributions — Name" for cash in, "Member distributions +/// — Name" for cash out. +/// ``` pub fn cash_flow(cf: &CashFlowStatement, members: &[Member]) -> Statement { let mut rows = vec![section("Operating activities")]; rows.push(one(RowKind::Line, "Net income (loss)", cf.net_income)); @@ -351,6 +399,15 @@ pub fn cash_flow(cf: &CashFlowStatement, members: &[Member]) -> Statement { /// Lay out a trial balance as of a date: Debit and Credit columns, a zero /// side left blank. +/// +/// ```text +/// GOAL: the trial balance as printed, footing. +/// SOURCE: rules.md B-2 (debits equal credits); footing. +/// INVARIANTS: +/// 1. Each column's lines sum to its total; the totals are equal for +/// balanced books. +/// 2. A line shows its balance on one side only, the other blank. +/// ``` pub fn trial_balance(tb: &TrialBalance, as_of: NaiveDate) -> Statement { let nz = |d: Decimal| if d.is_zero() { None } else { Some(d) }; let mut rows: Vec = tb @@ -379,6 +436,16 @@ pub fn trial_balance(tb: &TrialBalance, as_of: NaiveDate) -> Statement { /// Lay out the statement of changes in members' equity: one column per /// member and a total. Distributions print as reductions. +/// +/// ```text +/// GOAL: each member's capital roll-forward as printed. +/// SOURCE: rules.md B-13 (Form 1065 Schedule K-1, Item L); footing. +/// INVARIANTS: +/// 1. In every column, beginning + contributions + net income + +/// distributions (printed negative) + other = ending. +/// 2. Across every row, the members' columns sum to the Total column. +/// 3. "Other changes" appears only when some column has any. +/// ``` pub fn members_equity(me: &MembersEquityStatement) -> Statement { let cols: Vec<_> = me .members @@ -510,9 +577,22 @@ pub fn period_label(start: NaiveDate, end: NaiveDate) -> String { /// Exact accounting format: thousands separators, two decimals, negatives /// in parentheses. "1,234.56", "(207.68)", "0.00". Never a float. +/// +/// ```text +/// GOAL: an amount as an accountant writes it. +/// SOURCE: CONVENTION (US accounting presentation; QuickBooks reports). +/// INVARIANTS: +/// 1. Exactly two decimals, rounded half away from zero; thousands +/// separated by commas; a negative in parentheses. +/// 2. NEVER a negative zero: an amount that rounds to 0.00 prints +/// "0.00" (found 2026-10-13: -0.004 printed "(0.00)"). +/// 3. Removing the commas and parentheses gives back the amount rounded +/// to the cent. +/// ``` pub fn format_amount(d: Decimal) -> String { - let negative = d.is_sign_negative() && !d.is_zero(); - let fixed = format!("{:.2}", d.abs()); + let rounded = d.round_dp_with_strategy(2, rust_decimal::RoundingStrategy::MidpointAwayFromZero); + let negative = rounded.is_sign_negative() && !rounded.is_zero(); + let fixed = format!("{:.2}", rounded.abs()); let (int, frac) = fixed.split_once('.').expect("two decimals"); let mut grouped = String::new(); for (i, c) in int.chars().enumerate() { @@ -542,7 +622,7 @@ pub struct AmountParts { /// Split an amount for aligned accounting display. Exact: no floats. pub fn amount_parts(d: Decimal) -> AmountParts { - let rounded = d.round_dp(2); + let rounded = d.round_dp_with_strategy(2, rust_decimal::RoundingStrategy::MidpointAwayFromZero); AmountParts { digits: format_amount(rounded.abs()), negative: rounded.is_sign_negative() && !rounded.is_zero(), diff --git a/crates/ledger-import/src/templates.rs b/crates/ledger-import/src/templates.rs index 078a34e..fb396fb 100644 --- a/crates/ledger-import/src/templates.rs +++ b/crates/ledger-import/src/templates.rs @@ -10,6 +10,24 @@ //! `books.import_chart_csv`), with tax categories already assigned, plus //! the `wanderbooks.toml` settings that go with it (tax form, owners, and //! where period closes go). +//! +//! ```text +//! GOAL: a chart a new business can import and run as is. +//! SOURCE: docs/spec/sources/irs-tax-lines.md (each category belongs on +//! the business's return); the engine's control accounts +//! (ledger_core: receivables, payables, revenue, retained earnings, +//! payroll), which it finds by code. +//! INVARIANTS: +//! 1. Every row imports: codes unique, parents before children, types +//! valid, and each tax category valid for the entity's return. +//! 2. An account at a control account's code MUST be that control +//! account (same type and name) +//! BECAUSE the engine posts by code: wages would land on whatever +//! sits at 6100 (found 2026-10-13: the templates put processing fees +//! there). +//! 3. A partnership gets a capital and a distributions account per +//! partner and shares that sum to 100%. +//! ``` use crate::chart_csv::ChartRow; use rust_decimal::Decimal; @@ -130,7 +148,6 @@ pub fn chart_template(entity: EntityType, owners: &[String]) -> Result Result Result AccountType { + match s { + "asset" => AccountType::Asset, + "liability" => AccountType::Liability, + "equity" => AccountType::Equity, + "revenue" => AccountType::Revenue, + "expense" => AccountType::Expense, + other => panic!("bad type {other}"), + } +} + +const ENTITIES: [(EntityType, TaxForm); 4] = [ + (EntityType::SoleProprietorship, TaxForm::ScheduleC), + (EntityType::Partnership, TaxForm::Form1065), + (EntityType::SCorporation, TaxForm::Form1120S), + (EntityType::CCorporation, TaxForm::Form1120), +]; + +/// INVARIANT 1: every template imports into fresh books, every tax +/// category valid for the entity's return. +#[test] +fn every_template_imports_with_categories_valid_for_its_return() { + let owners = ["Ada".to_string(), "Grace".to_string(), "Linus".to_string()]; + for (entity, form) in ENTITIES { + let t = chart_template(entity, &owners).unwrap(); + let mut l = Ledger::new(Currency::USD); + for r in &t.rows { + let parent = r + .parent_code + .as_ref() + .map(|p| l.chart().find_by_code(p).expect("parent first").id); + let id = l + .add_account(&r.code, &r.name, ty(&r.account_type), parent, false) + .unwrap_or_else(|e| panic!("{entity:?} {}: {e}", r.code)); + if let Some(cat) = &r.tax_category { + check_category(cat, ty(&r.account_type), Some(form)) + .unwrap_or_else(|e| panic!("{entity:?} {} {cat}: {e}", r.code)); + l.set_account_tax(id, Some(cat), None).unwrap(); + } + } + } +} + +/// INVARIANT 2: an account at a control account's code is that control +/// account: same type and name (the engine posts by code). +#[test] +fn no_template_puts_another_account_at_a_control_code() { + let controls: Vec<&ControlAccount> = [ + &RECEIVABLES, + &REVENUE, + &PAYABLES, + &GENERAL_EXPENSES, + &RETAINED_EARNINGS, + ] + .into_iter() + .chain(PAYROLL.iter()) + .collect(); + let owners = ["Ada".to_string(), "Grace".to_string()]; + for (entity, _) in ENTITIES { + let t = chart_template(entity, &owners).unwrap(); + for c in &controls { + if let Some(r) = t.rows.iter().find(|r| r.code == c.code) { + assert_eq!(ty(&r.account_type), c.account_type, "{entity:?} {}", c.code); + // Revenue's control is named generically; a template may + // call its 4000 "Sales". Every other control keeps its name. + if c.code != REVENUE.code { + assert_eq!(r.name, c.name, "{entity:?} {}", c.code); + } + } + } + } +} + +/// INVARIANT 3: a partnership has a capital and a distributions account +/// per partner, and shares summing to exactly 100%. +#[test] +fn a_partnership_template_names_every_partner_and_shares_sum_to_100() { + let owners: Vec = ["A", "B", "C"].iter().map(|s| s.to_string()).collect(); + let t = chart_template(EntityType::Partnership, &owners).unwrap(); + for name in &owners { + assert!(t.rows.iter().any(|r| r.name == format!("Capital — {name}"))); + assert!( + t.rows + .iter() + .any(|r| r.name == format!("Distributions — {name}")) + ); + } + let total: rust_decimal::Decimal = t + .settings_toml + .lines() + .filter_map(|l| { + l.strip_prefix("share = \"") + .and_then(|s| s.strip_suffix("%\"")) + }) + .map(|s| s.parse::().unwrap()) + .sum(); + assert_eq!(total, rust_decimal::Decimal::from(100)); +}