import: templates match the engine's control accounts; report specs
check / check (push) Canceled after 0s
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:
1 parent
ea180d1d8e
commit
e52b88433c
3 files changed
+225
-12
No files matched your search
@@ -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(),
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
Reference in new issue
Block a user