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

+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()