Files
wanderbooks/crates/ledger-import/src/templates.rs
T
swaits 6c4480cdc8 feat(package): aging CSVs in the export and the CPA package (P4b S6)
The CPA's first tie-out is that the aging equals the control account, so
books.export and the CPA package now write ar_aging.csv and ap_aging.csv
from one builder: one row per open document (number, party, dates, terms,
total, paid, discounted, outstanding, bucket, days overdue) and a TOTAL
line carrying the outstanding sum, the GL account and balance, and the
difference (always 0.00; B-23, B-38 INVARIANT 1). XLSX and PDF lay the
same aging out as a statement with the tie-out beneath it.

The package README states the tie-out, the gross discount method (A-8)
and the direct write-off method (A-5). The chart templates gain 6995
Purchase Discounts so no template puts another account at a control
role's code (4100 and 6060 already match). The viewer's Money out list
gains the discount-deadline column and colours a due-soon or
discount-open date; the screenshot is re-rendered.

Test: the_aging_csvs_tie_to_the_control_check in ledger-package; the
existing package file-order test and mcp_journeys see the two new files.
2026-10-27 12:59:40 -06:00

464 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(
"6995",
"Purchase Discounts",
"expense",
Some("other_deductions"),
),
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");
}
}