import: templates match the engine's control accounts; report specs
check / check (push) Canceled after 0s

- 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
This commit is contained in:
swaitsandClaude Opus 5.5 committed 2026-10-13 21:01:51 -06:00
1 parent ea180d1d8e
commit e52b88433c
3 files changed
+225 -12

No files matched your search

+83 -3
View File
@@ -134,6 +134,16 @@ fn spacer() -> Row {
} }
/// Finish a statement: currency signs on sums, rules above them. /// 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<Row>) -> Statement { pub fn statement(title: &str, period: String, columns: &[&str], mut rows: Vec<Row>) -> Statement {
let mut above: Option<RowKind> = None; let mut above: Option<RowKind> = None;
for r in &mut rows { for r in &mut rows {
@@ -163,6 +173,16 @@ fn account_lines(lines: &[StatementLine]) -> impl Iterator<Item = Row> + '_ {
} }
/// Lay out a profit and loss statement. /// 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 { pub fn profit_and_loss(pl: &ProfitAndLossStatement) -> Statement {
let mut rows = vec![section("Revenue")]; let mut rows = vec![section("Revenue")];
rows.extend(account_lines(&pl.revenue)); rows.extend(account_lines(&pl.revenue));
@@ -192,6 +212,20 @@ pub struct YearEarnings {
/// Lay out a balance sheet. /// 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 /// Without members (corporate convention), equity ends with "Retained
/// earnings" — the Retained Earnings account plus earlier years' earnings /// earnings" — the Retained Earnings account plus earlier years' earnings
/// not yet closed — and the fiscal year's "Net income (loss)". /// 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). /// 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 { pub fn cash_flow(cf: &CashFlowStatement, members: &[Member]) -> Statement {
let mut rows = vec![section("Operating activities")]; let mut rows = vec![section("Operating activities")];
rows.push(one(RowKind::Line, "Net income (loss)", cf.net_income)); 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 /// Lay out a trial balance as of a date: Debit and Credit columns, a zero
/// side left blank. /// 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 { pub fn trial_balance(tb: &TrialBalance, as_of: NaiveDate) -> Statement {
let nz = |d: Decimal| if d.is_zero() { None } else { Some(d) }; let nz = |d: Decimal| if d.is_zero() { None } else { Some(d) };
let mut rows: Vec<Row> = tb let mut rows: Vec<Row> = 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 /// Lay out the statement of changes in members' equity: one column per
/// member and a total. Distributions print as reductions. /// 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 { pub fn members_equity(me: &MembersEquityStatement) -> Statement {
let cols: Vec<_> = me let cols: Vec<_> = me
.members .members
@@ -510,9 +577,22 @@ pub fn period_label(start: NaiveDate, end: NaiveDate) -> String {
/// Exact accounting format: thousands separators, two decimals, negatives /// Exact accounting format: thousands separators, two decimals, negatives
/// in parentheses. "1,234.56", "(207.68)", "0.00". Never a float. /// 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 { pub fn format_amount(d: Decimal) -> String {
let negative = d.is_sign_negative() && !d.is_zero(); let rounded = d.round_dp_with_strategy(2, rust_decimal::RoundingStrategy::MidpointAwayFromZero);
let fixed = format!("{:.2}", d.abs()); 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 (int, frac) = fixed.split_once('.').expect("two decimals");
let mut grouped = String::new(); let mut grouped = String::new();
for (i, c) in int.chars().enumerate() { for (i, c) in int.chars().enumerate() {
@@ -542,7 +622,7 @@ pub struct AmountParts {
/// Split an amount for aligned accounting display. Exact: no floats. /// Split an amount for aligned accounting display. Exact: no floats.
pub fn amount_parts(d: Decimal) -> AmountParts { 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 { AmountParts {
digits: format_amount(rounded.abs()), digits: format_amount(rounded.abs()),
negative: rounded.is_sign_negative() && !rounded.is_zero(), negative: rounded.is_sign_negative() && !rounded.is_zero(),
+34 -9
View File
@@ -10,6 +10,24 @@
//! `books.import_chart_csv`), with tax categories already assigned, plus //! `books.import_chart_csv`), with tax categories already assigned, plus
//! the `wanderbooks.toml` settings that go with it (tax form, owners, and //! the `wanderbooks.toml` settings that go with it (tax form, owners, and
//! where period closes go). //! 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 crate::chart_csv::ChartRow;
use rust_decimal::Decimal; use rust_decimal::Decimal;
@@ -130,7 +148,6 @@ pub fn chart_template(entity: EntityType, owners: &[String]) -> Result<ChartTemp
acct("2000", "Accounts Payable", "liability", None), acct("2000", "Accounts Payable", "liability", None),
acct("2100", "Credit Card", "liability", None), acct("2100", "Credit Card", "liability", None),
acct("2200", "Sales Tax Payable", "liability", None), acct("2200", "Sales Tax Payable", "liability", None),
acct("2300", "Payroll Liabilities", "liability", None),
acct("2400", "Deferred Revenue", "liability", None), acct("2400", "Deferred Revenue", "liability", None),
acct("2500", owed_to, "liability", None), acct("2500", owed_to, "liability", None),
acct("2700", "Loans Payable", "liability", None), acct("2700", "Loans Payable", "liability", None),
@@ -213,11 +230,26 @@ pub fn chart_template(entity: EntityType, owners: &[String]) -> Result<ChartTemp
acct("6000", "Advertising", "expense", Some("advertising")), acct("6000", "Advertising", "expense", Some("advertising")),
acct("6050", "Bank Fees", "expense", Some("other_deductions")), acct("6050", "Bank Fees", "expense", Some("other_deductions")),
acct( acct(
"6100", "6060",
"Platform and Payment Processing Fees", "Platform and Payment Processing Fees",
"expense", "expense",
Some("commissions_fees"), Some("commissions_fees"),
), ),
// Payroll posts to the engine's control accounts by code (6100,
// 6110; the withholding payables 2110-2150 are created when payroll
// first runs), so the template's payroll accounts are those.
acct(
"6100",
"Salary & Wage Expense",
"expense",
Some("salaries_wages"),
),
acct(
"6110",
"Payroll Tax Expense",
"expense",
Some("taxes_licenses"),
),
acct("6150", "Contract Labor", "expense", Some("contract_labor")), acct("6150", "Contract Labor", "expense", Some("contract_labor")),
acct("6200", "Depreciation", "expense", Some("depreciation")), acct("6200", "Depreciation", "expense", Some("depreciation")),
acct( acct(
@@ -275,13 +307,6 @@ pub fn chart_template(entity: EntityType, owners: &[String]) -> Result<ChartTemp
"expense", "expense",
Some("nondeductible"), Some("nondeductible"),
), ),
acct(
"7000",
"Salaries and Wages",
"expense",
Some("salaries_wages"),
),
acct("7050", "Payroll Taxes", "expense", Some("taxes_licenses")),
acct( acct(
"7100", "7100",
"Employee Benefits", "Employee Benefits",
+108
View File
@@ -0,0 +1,108 @@
//! The starter charts, checked against the engine they'll run in
//! (templates.rs INVARIANTS 1-3).
use ledger_core::tax::{TaxForm, check_category};
use ledger_core::{
AccountType, ControlAccount, Currency, GENERAL_EXPENSES, Ledger, PAYABLES, PAYROLL,
RECEIVABLES, RETAINED_EARNINGS, REVENUE,
};
use ledger_import::{EntityType, chart_template};
fn ty(s: &str) -> 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<String> = ["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::<rust_decimal::Decimal>().unwrap())
.sum();
assert_eq!(total, rust_decimal::Decimal::from(100));
}