feat: tax categories and the tax summary report

wanderbooks is a general MCP-first accounting application: every
business needs its accounts mapped to where they land on its income tax
return, and its CPA needs book-to-tax differences surfaced, not hidden.

- ledger_core::tax: a catalog of stable categories (gross_receipts,
  commissions_fees, meals, guaranteed_payments, officer_compensation,
  nondeductible, ...) with kind (income, cost of goods, deduction), the
  returns each appears on (Schedule C, 1065, 1120-S, 1120), a default
  deductible percent (meals 50, nondeductible 0), and guidance. Keys,
  not line numbers: form lines move from year to year.
- Accounts carry tax_category and an optional deductible_percent
  override, validated against the account type (and the return, at the
  MCP layer), journaled as account.tax_set, replayed, projected (schema
  v6).
- tax_summary(start, end, form): the P&L grouped by category, book and
  deductible amounts (half away from zero to the cent), contra income
  (refunds) reducing receipts, and every account still uncategorized.
- MCP: books.tax_categories (filtered to the configured return),
  books.set_tax_category, a tax_summary report in every format and in
  the CPA package, chart output with categories, and a fifth
  tax_category column in chart CSV imports, validated before anything is
  created. [company] tax_form, or derived from tax_classification.

Scope stays book basis: no tax depreciation, basis, or filing.

Tests: a hand-computed quarter (50% meals 333.33 -> 166.67, penalties at
0%, refunds as negative receipts, guaranteed payments, one uncategorized
account; book net 5,276.67 vs tax 5,483.33); overrides; category/account
/return validation; form parsing and derivation; CSV parsing; stdio:
filtered catalog, mapping, refusal, summary, all-or-nothing import.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MtYEaR6M6Ybdhm6X98BWNu
This commit is contained in:
swaitsandClaude Opus 5.5 committed 2026-10-11 20:53:56 -07:00
1 parent 8164abb2b8
commit f858fb4cfe
18 files changed
+1352 -14

No files matched your search

+36 -8
View File
@@ -24,16 +24,19 @@ pub struct ChartRow {
pub account_type: String,
/// Parent account code, if any.
pub parent_code: Option<String>,
/// Optional fifth column: the tax category key (see
/// `ledger_core::tax`).
pub tax_category: Option<String>,
}
/// Parse errors.
#[derive(Debug, Error, PartialEq)]
pub enum ChartCsvError {
/// Wrong column count.
#[error("expected 4 columns (code,name,type,parent_code), found {0}")]
BadColumnCount(usize),
/// Wrong column count: (found, expected by the header).
#[error("expected {1} columns to match the header, found {0}")]
BadColumnCount(usize, usize),
/// Missing or bad header.
#[error("bad header: expected code,name,type,parent_code")]
#[error("bad header: expected code,name,type,parent_code[,tax_category]")]
BadHeader,
/// Unknown account type.
#[error("bad account type {0:?}: expected asset, liability, equity, revenue, or expense")]
@@ -48,9 +51,11 @@ pub enum ChartCsvError {
pub fn parse_chart_csv(text: &str) -> Result<Vec<ChartRow>, ChartCsvError> {
let mut reader = csv::ReaderBuilder::new()
.has_headers(false)
.flexible(true)
.trim(csv::Trim::All)
.from_reader(text.as_bytes());
let mut out = Vec::new();
let mut width = 4;
for (idx, record) in reader.records().enumerate() {
let record = record.map_err(|e| ChartCsvError::Csv(e.to_string()))?;
let fields: Vec<&str> = record.iter().collect();
@@ -59,13 +64,17 @@ pub fn parse_chart_csv(text: &str) -> Result<Vec<ChartRow>, ChartCsvError> {
}
if idx == 0 {
let lower: Vec<String> = fields.iter().map(|f| f.to_lowercase()).collect();
if lower != ["code", "name", "type", "parent_code"] {
width = if lower == ["code", "name", "type", "parent_code"] {
4
} else if lower == ["code", "name", "type", "parent_code", "tax_category"] {
5
} else {
return Err(ChartCsvError::BadHeader);
}
};
continue;
}
if fields.len() != 4 {
return Err(ChartCsvError::BadColumnCount(fields.len()));
if fields.len() != width {
return Err(ChartCsvError::BadColumnCount(fields.len(), width));
}
let account_type = fields[2].to_lowercase();
match account_type.as_str() {
@@ -82,6 +91,11 @@ pub fn parse_chart_csv(text: &str) -> Result<Vec<ChartRow>, ChartCsvError> {
} else {
Some(parent_code.to_string())
},
tax_category: fields
.get(4)
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.map(str::to_string),
});
}
Ok(out)
@@ -101,6 +115,20 @@ mod tests {
assert_eq!(rows[2].account_type, "revenue");
}
#[test]
fn parses_optional_tax_category_column() {
let csv = "code,name,type,parent_code,tax_category\n4000,Sales,revenue,,gross_receipts\n1000,Cash,asset,,\n";
let rows = parse_chart_csv(csv).unwrap();
assert_eq!(rows[0].tax_category.as_deref(), Some("gross_receipts"));
assert_eq!(rows[1].tax_category, None);
// Rows must match the header's width.
let short = "code,name,type,parent_code,tax_category\n1000,Cash,asset,\n";
assert_eq!(
parse_chart_csv(short),
Err(ChartCsvError::BadColumnCount(4, 5))
);
}
#[test]
fn bad_type_rejected() {
let csv = "code,name,type,parent_code\n1000,Cash,money,\n";
+79
View File
@@ -400,6 +400,85 @@ pub fn members_equity(me: &MembersEquityStatement) -> Statement {
)
}
/// Lay out the tax summary: book and tax amounts by tax category, the
/// difference where they disagree (meals at 50%, penalties at 0%), and any
/// account still needing a category.
pub fn tax_summary(ts: &ledger_core::TaxSummary) -> Statement {
let diff = |b: Decimal, t: Decimal| if b == t { None } else { Some(b - t) };
let lines = |ls: &[ledger_core::TaxSummaryLine]| -> Vec<Row> {
ls.iter()
.map(|l| {
let codes: Vec<&str> = l
.accounts
.iter()
.map(|a| a.split_whitespace().next().unwrap_or(""))
.collect();
row(
RowKind::Line,
format!("{} ({})", l.label, codes.join(", ")),
vec![Some(l.book), Some(l.tax), diff(l.book, l.tax)],
)
})
.collect()
};
let total = |kind, label: &str, ls: &[ledger_core::TaxSummaryLine]| {
let b: Decimal = ls.iter().map(|l| l.book).sum();
let t: Decimal = ls.iter().map(|l| l.tax).sum();
row(kind, label, vec![Some(b), Some(t), diff(b, t)])
};
let mut rows = vec![section("Income")];
rows.extend(lines(&ts.income));
rows.push(total(RowKind::Subtotal, "Total income", &ts.income));
if !ts.cost_of_goods.is_empty() {
rows.push(spacer());
rows.push(section("Cost of goods sold"));
rows.extend(lines(&ts.cost_of_goods));
rows.push(total(
RowKind::Subtotal,
"Total cost of goods sold",
&ts.cost_of_goods,
));
}
rows.push(spacer());
rows.push(section("Deductions"));
rows.extend(lines(&ts.deductions));
rows.push(total(RowKind::Subtotal, "Total deductions", &ts.deductions));
rows.push(spacer());
rows.push(row(
RowKind::Total,
"Income less deductions",
vec![
Some(ts.book_net()),
Some(ts.tax_net()),
diff(ts.book_net(), ts.tax_net()),
],
));
if !ts.uncategorized.is_empty() {
rows.push(spacer());
rows.push(section("Not yet categorized"));
rows.push(subheading(
"Assign each a tax category (books.set_tax_category); not included above:",
));
rows.extend(ts.uncategorized.iter().map(|u| {
row(
RowKind::Line,
u.account.clone(),
vec![Some(u.book), None, None],
)
}));
}
let title = match ts.form {
Some(f) => format!("Tax Summary ({} categories)", f.label()),
None => "Tax Summary".to_string(),
};
statement(
&title,
period_label(ts.start, ts.end),
&["Book", "Tax", "Difference"],
rows,
)
}
/// "October 11, 2026".
pub fn long_date(d: NaiveDate) -> String {
d.format("%B %-d, %Y").to_string()