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
458 lines
17 KiB
Rust
458 lines
17 KiB
Rust
//! Starter charts of accounts by entity type.
|
|
//!
|
|
//! A new business shouldn't start from a blank chart, and the right chart
|
|
//! depends on what the business *is*: a sole proprietor has an owner's
|
|
//! capital and draws; a partnership (or multi-member LLC) has a capital and
|
|
//! distributions account per partner and pays partners guaranteed payments;
|
|
//! an S corporation pays officer wages and makes shareholder distributions;
|
|
//! a C corporation pays dividends and income tax. Each template is a
|
|
//! proposal for the agent to review with the human before importing (via
|
|
//! `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;
|
|
|
|
/// The kinds of business a template exists for.
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
pub enum EntityType {
|
|
/// Sole proprietorship or single-member LLC (Schedule C).
|
|
SoleProprietorship,
|
|
/// Partnership or multi-member LLC (Form 1065).
|
|
Partnership,
|
|
/// S corporation, including an LLC electing S status (Form 1120-S).
|
|
SCorporation,
|
|
/// C corporation (Form 1120).
|
|
CCorporation,
|
|
}
|
|
|
|
impl EntityType {
|
|
/// Parse "sole_proprietorship", "partnership", "s_corporation",
|
|
/// "c_corporation" and common spellings ("llc" alone is ambiguous).
|
|
pub fn parse(s: &str) -> Option<EntityType> {
|
|
let k: String = s
|
|
.to_lowercase()
|
|
.chars()
|
|
.filter(|c| c.is_ascii_alphanumeric())
|
|
.collect();
|
|
match k.as_str() {
|
|
"soleproprietorship" | "soleprop" | "singlememberllc" | "smllc" => {
|
|
Some(EntityType::SoleProprietorship)
|
|
}
|
|
"partnership" | "multimemberllc" | "mmllc" => Some(EntityType::Partnership),
|
|
"scorporation" | "scorp" | "s" => Some(EntityType::SCorporation),
|
|
"ccorporation" | "ccorp" | "c" | "corporation" => Some(EntityType::CCorporation),
|
|
_ => None,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// A starter chart plus the settings that go with it.
|
|
#[derive(Clone, Debug)]
|
|
pub struct ChartTemplate {
|
|
/// Accounts, parents first.
|
|
pub rows: Vec<ChartRow>,
|
|
/// The `wanderbooks.toml` additions: tax form, owners, close settings.
|
|
pub settings_toml: String,
|
|
/// What the human should decide or know, in plain words.
|
|
pub notes: Vec<String>,
|
|
}
|
|
|
|
impl ChartTemplate {
|
|
/// The accounts as chart CSV with the tax_category column.
|
|
pub fn csv(&self) -> String {
|
|
let mut out = String::from("code,name,type,parent_code,tax_category\n");
|
|
for r in &self.rows {
|
|
let field = |s: &str| {
|
|
if s.contains([',', '"']) {
|
|
format!("\"{}\"", s.replace('"', "\"\""))
|
|
} else {
|
|
s.to_string()
|
|
}
|
|
};
|
|
out.push_str(&format!(
|
|
"{},{},{},{},{}\n",
|
|
r.code,
|
|
field(&r.name),
|
|
r.account_type,
|
|
r.parent_code.as_deref().unwrap_or(""),
|
|
r.tax_category.as_deref().unwrap_or("")
|
|
));
|
|
}
|
|
out
|
|
}
|
|
}
|
|
|
|
fn acct(code: &str, name: &str, t: &str, tax: Option<&str>) -> ChartRow {
|
|
ChartRow {
|
|
code: code.to_string(),
|
|
name: name.to_string(),
|
|
account_type: t.to_string(),
|
|
parent_code: None,
|
|
tax_category: tax.map(str::to_string),
|
|
}
|
|
}
|
|
|
|
/// Equal shares for `n` owners as percents to two places, the last
|
|
/// absorbing the remainder so they sum to exactly 100.
|
|
fn equal_shares(n: usize) -> Vec<Decimal> {
|
|
let each = (Decimal::from(100) / Decimal::from(n as u64)).round_dp(2);
|
|
let mut v = vec![each; n];
|
|
if let Some((last, rest)) = v.split_last_mut() {
|
|
*last = Decimal::from(100) - rest.iter().sum::<Decimal>();
|
|
}
|
|
v
|
|
}
|
|
|
|
/// Build the template for `entity` with these owners (names; at least two
|
|
/// for a partnership, which gets a capital and distributions account each).
|
|
pub fn chart_template(entity: EntityType, owners: &[String]) -> Result<ChartTemplate, String> {
|
|
use EntityType::*;
|
|
if entity == Partnership && owners.len() < 2 {
|
|
return Err("a partnership template needs at least two owners' names".to_string());
|
|
}
|
|
if owners.len() > 90 {
|
|
return Err("at most 90 owners".to_string());
|
|
}
|
|
let owed_to = match entity {
|
|
SoleProprietorship => "Due to Owner",
|
|
Partnership => "Due to Members",
|
|
SCorporation | CCorporation => "Due to Shareholders",
|
|
};
|
|
let mut rows = vec![
|
|
acct("1000", "Business Checking", "asset", None),
|
|
acct("1100", "Savings", "asset", None),
|
|
acct("1200", "Accounts Receivable", "asset", None),
|
|
acct("1300", "Prepaid Expenses", "asset", None),
|
|
acct("1500", "Equipment", "asset", None),
|
|
acct("1590", "Accumulated Depreciation", "asset", None),
|
|
acct("2000", "Accounts Payable", "liability", None),
|
|
acct("2100", "Credit Card", "liability", None),
|
|
acct("2200", "Sales Tax Payable", "liability", None),
|
|
acct("2400", "Deferred Revenue", "liability", None),
|
|
acct("2500", owed_to, "liability", None),
|
|
acct("2700", "Loans Payable", "liability", None),
|
|
];
|
|
let mut settings = String::new();
|
|
let mut notes = vec![
|
|
"Rename, add, or drop accounts before importing; codes are a convention, not a rule."
|
|
.to_string(),
|
|
format!(
|
|
"{owed_to}: business expenses an owner paid personally, until the business reimburses them."
|
|
),
|
|
];
|
|
match entity {
|
|
SoleProprietorship => {
|
|
rows.push(acct("3000", "Owner's Capital", "equity", None));
|
|
rows.push(acct("3100", "Owner's Draws", "equity", None));
|
|
settings.push_str("[company]\ntax_form = \"schedule_c\"\n\n[books]\n# Net income and draws close into the owner's capital.\nclose_into = \"3000\"\nclose_sweep = [\"3100\"]\n");
|
|
notes
|
|
.push("Money you take out is a draw (3100), not a wage or an expense.".to_string());
|
|
}
|
|
Partnership => {
|
|
let shares = equal_shares(owners.len());
|
|
settings.push_str("[company]\ntax_form = \"1065\"\n\n# Each partner's share drives allocations and closes. Equal shares are a\n# placeholder: use the operating agreement's numbers.\n");
|
|
for (i, name) in owners.iter().enumerate() {
|
|
let cap = format!("{}", 3000 + i);
|
|
let dist = format!("{}", 3100 + i);
|
|
rows.push(acct(&cap, &format!("Capital — {name}"), "equity", None));
|
|
rows.push(acct(
|
|
&dist,
|
|
&format!("Distributions — {name}"),
|
|
"equity",
|
|
None,
|
|
));
|
|
settings.push_str(&format!(
|
|
"\n[[company.members]]\nname = \"{name}\"\nshare = \"{}%\"\ncapital_account = \"{cap}\"\ndistribution_account = \"{dist}\"\n",
|
|
shares[i]
|
|
));
|
|
}
|
|
notes.push("A partner paid for work gets guaranteed payments (7200), not wages; profit taken out is a distribution.".to_string());
|
|
notes.push(
|
|
"Shares default to equal; set them from the operating agreement.".to_string(),
|
|
);
|
|
}
|
|
SCorporation | CCorporation => {
|
|
rows.push(acct("3000", "Common Stock", "equity", None));
|
|
rows.push(acct("3100", "Additional Paid-in Capital", "equity", None));
|
|
if entity == SCorporation {
|
|
rows.push(acct("3200", "Shareholder Distributions", "equity", None));
|
|
settings.push_str("[company]\ntax_form = \"1120s\"\n\n[books]\n# Net income and distributions close into Retained Earnings (3999).\nclose_sweep = [\"3200\"]\n");
|
|
notes.push("Owner-employees must take reasonable W-2 wages (officer compensation, 7200) before distributions.".to_string());
|
|
} else {
|
|
rows.push(acct("3200", "Dividends Paid", "equity", None));
|
|
settings.push_str("[company]\ntax_form = \"1120\"\n\n[books]\n# Net income and dividends close into Retained Earnings (3999).\nclose_sweep = [\"3200\"]\n");
|
|
notes.push("The corporation pays its own income tax (7900, not deductible); dividends are not expenses.".to_string());
|
|
}
|
|
rows.push(acct("3999", "Retained Earnings", "equity", None));
|
|
}
|
|
}
|
|
rows.extend([
|
|
acct("4000", "Sales", "revenue", Some("gross_receipts")),
|
|
acct(
|
|
"4100",
|
|
"Refunds and Allowances",
|
|
"revenue",
|
|
Some("returns_allowances"),
|
|
),
|
|
acct(
|
|
"4900",
|
|
"Interest Income",
|
|
"revenue",
|
|
Some("interest_income"),
|
|
),
|
|
acct("4950", "Other Income", "revenue", Some("other_income")),
|
|
acct(
|
|
"5000",
|
|
"Cost of Goods Sold",
|
|
"expense",
|
|
Some("cost_of_goods_sold"),
|
|
),
|
|
acct("6000", "Advertising", "expense", Some("advertising")),
|
|
acct("6050", "Bank Fees", "expense", Some("other_deductions")),
|
|
acct(
|
|
"6060",
|
|
"Platform and Payment Processing Fees",
|
|
"expense",
|
|
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("6200", "Depreciation", "expense", Some("depreciation")),
|
|
acct(
|
|
"6250",
|
|
"Dues and Memberships",
|
|
"expense",
|
|
Some("other_deductions"),
|
|
),
|
|
acct("6300", "Insurance", "expense", Some("insurance")),
|
|
acct(
|
|
"6350",
|
|
"Interest Expense",
|
|
"expense",
|
|
Some("interest_expense"),
|
|
),
|
|
acct(
|
|
"6400",
|
|
"Legal and Professional Fees",
|
|
"expense",
|
|
Some("legal_professional"),
|
|
),
|
|
acct("6450", "Meals", "expense", Some("meals")),
|
|
acct("6500", "Office Expense", "expense", Some("office_expense")),
|
|
acct(
|
|
"6550",
|
|
"Organizational and Start-up Costs",
|
|
"expense",
|
|
Some("organization_startup"),
|
|
),
|
|
acct("6600", "Rent", "expense", Some("rent_property")),
|
|
acct(
|
|
"6650",
|
|
"Repairs and Maintenance",
|
|
"expense",
|
|
Some("repairs"),
|
|
),
|
|
acct(
|
|
"6700",
|
|
"Software and Subscriptions",
|
|
"expense",
|
|
Some("other_deductions"),
|
|
),
|
|
acct("6750", "Supplies", "expense", Some("supplies")),
|
|
acct(
|
|
"6800",
|
|
"Taxes and Licenses",
|
|
"expense",
|
|
Some("taxes_licenses"),
|
|
),
|
|
acct("6850", "Travel", "expense", Some("travel")),
|
|
acct("6900", "Utilities", "expense", Some("utilities")),
|
|
acct(
|
|
"6950",
|
|
"Penalties and Nondeductible Expenses",
|
|
"expense",
|
|
Some("nondeductible"),
|
|
),
|
|
acct(
|
|
"7100",
|
|
"Employee Benefits",
|
|
"expense",
|
|
Some("employee_benefits"),
|
|
),
|
|
]);
|
|
match entity {
|
|
Partnership => rows.push(acct(
|
|
"7200",
|
|
"Guaranteed Payments to Partners",
|
|
"expense",
|
|
Some("guaranteed_payments"),
|
|
)),
|
|
SCorporation | CCorporation => rows.push(acct(
|
|
"7200",
|
|
"Officer Compensation",
|
|
"expense",
|
|
Some("officer_compensation"),
|
|
)),
|
|
SoleProprietorship => {}
|
|
}
|
|
if entity == CCorporation {
|
|
rows.push(acct(
|
|
"7900",
|
|
"Income Tax Expense",
|
|
"expense",
|
|
Some("nondeductible"),
|
|
));
|
|
}
|
|
Ok(ChartTemplate {
|
|
rows,
|
|
settings_toml: settings,
|
|
notes,
|
|
})
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::chart_csv::parse_chart_csv;
|
|
|
|
#[test]
|
|
fn templates_round_trip_through_the_chart_parser() {
|
|
for e in [
|
|
EntityType::SoleProprietorship,
|
|
EntityType::Partnership,
|
|
EntityType::SCorporation,
|
|
EntityType::CCorporation,
|
|
] {
|
|
let t = chart_template(e, &["Ann Lee".into(), "Bo, Jr.".into(), "Cy".into()]).unwrap();
|
|
let parsed = parse_chart_csv(&t.csv()).unwrap();
|
|
assert_eq!(parsed, t.rows, "{e:?}");
|
|
// Codes are unique.
|
|
let mut codes: Vec<&str> = t.rows.iter().map(|r| r.code.as_str()).collect();
|
|
codes.sort_unstable();
|
|
codes.dedup();
|
|
assert_eq!(codes.len(), t.rows.len(), "{e:?}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn equity_fits_the_entity() {
|
|
let names = |t: &ChartTemplate| -> Vec<String> {
|
|
t.rows
|
|
.iter()
|
|
.filter(|r| r.account_type == "equity")
|
|
.map(|r| r.name.clone())
|
|
.collect()
|
|
};
|
|
let sole = chart_template(EntityType::SoleProprietorship, &[]).unwrap();
|
|
assert_eq!(names(&sole), ["Owner's Capital", "Owner's Draws"]);
|
|
assert!(sole.settings_toml.contains("close_into = \"3000\""));
|
|
|
|
let p = chart_template(
|
|
EntityType::Partnership,
|
|
&["A".into(), "B".into(), "C".into()],
|
|
)
|
|
.unwrap();
|
|
assert_eq!(
|
|
names(&p),
|
|
[
|
|
"Capital — A",
|
|
"Distributions — A",
|
|
"Capital — B",
|
|
"Distributions — B",
|
|
"Capital — C",
|
|
"Distributions — C"
|
|
]
|
|
);
|
|
// Three equal shares that sum to exactly 100.
|
|
assert!(p.settings_toml.contains("share = \"33.33%\""));
|
|
assert!(p.settings_toml.contains("share = \"33.34%\""));
|
|
assert!(
|
|
p.rows
|
|
.iter()
|
|
.any(|r| r.tax_category.as_deref() == Some("guaranteed_payments"))
|
|
);
|
|
assert!(chart_template(EntityType::Partnership, &["solo".into()]).is_err());
|
|
|
|
let s = chart_template(EntityType::SCorporation, &[]).unwrap();
|
|
assert!(names(&s).contains(&"Shareholder Distributions".to_string()));
|
|
assert!(
|
|
s.rows
|
|
.iter()
|
|
.any(|r| r.tax_category.as_deref() == Some("officer_compensation"))
|
|
);
|
|
let c = chart_template(EntityType::CCorporation, &[]).unwrap();
|
|
assert!(names(&c).contains(&"Dividends Paid".to_string()));
|
|
assert!(c.rows.iter().any(|r| r.name == "Income Tax Expense"));
|
|
}
|
|
|
|
#[test]
|
|
fn every_category_is_valid_for_the_entitys_return() {
|
|
use ledger_core::{AccountType, TaxForm, tax::check_category};
|
|
for (e, form) in [
|
|
(EntityType::SoleProprietorship, TaxForm::ScheduleC),
|
|
(EntityType::Partnership, TaxForm::Form1065),
|
|
(EntityType::SCorporation, TaxForm::Form1120S),
|
|
(EntityType::CCorporation, TaxForm::Form1120),
|
|
] {
|
|
for r in chart_template(e, &["A".into(), "B".into()]).unwrap().rows {
|
|
let Some(cat) = &r.tax_category else { continue };
|
|
let t = if r.account_type == "revenue" {
|
|
AccountType::Revenue
|
|
} else {
|
|
AccountType::Expense
|
|
};
|
|
check_category(cat, t, Some(form))
|
|
.unwrap_or_else(|err| panic!("{e:?} {}: {err}", r.code));
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn entity_types_parse() {
|
|
assert_eq!(
|
|
EntityType::parse("multi-member LLC"),
|
|
Some(EntityType::Partnership)
|
|
);
|
|
assert_eq!(EntityType::parse("S corp"), Some(EntityType::SCorporation));
|
|
assert_eq!(
|
|
EntityType::parse("sole_proprietorship"),
|
|
Some(EntityType::SoleProprietorship)
|
|
);
|
|
assert_eq!(EntityType::parse("LLC"), None, "ambiguous");
|
|
}
|
|
}
|