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

+230
View File
@@ -0,0 +1,230 @@
//! Tax categories and the tax summary. Expected numbers computed by hand.
//!
//! The fixture: a partnership's first quarter.
//!
//! | account | category | % | book | deductible |
//! |-----------------------|-----------------------|---|---------|------------|
//! | 4000 Sales | gross_receipts | | 10,000 | 10,000 |
//! | 4100 Refunds (exp.) | returns_allowances | | 250 | (250) |
//! | 6000 Platform fees | commissions_fees | | 1,500 | 1,500 |
//! | 6100 Meals | meals |50%| 333.33 | 166.67 |
//! | 6200 Penalties | nondeductible | 0%| 40 | 0 |
//! | 6300 Software | other_deductions | | 600 | 600 |
//! | 6400 Partner pay | guaranteed_payments | | 2,000 | 2,000 |
//! | 6500 Misc | (none) | | 75 | uncategorized |
use chrono::NaiveDate;
use ledger_core::tax::{TaxForm, check_category, tax_summary};
use ledger_core::{AccountType, Currency, Ledger, LedgerError, Money, Side};
use rust_decimal::Decimal;
use std::str::FromStr;
fn d(m: u32, day: u32) -> NaiveDate {
NaiveDate::from_ymd_opt(2026, m, day).unwrap()
}
fn dec(s: &str) -> Decimal {
Decimal::from_str(s).unwrap()
}
fn books() -> Ledger {
let mut l = Ledger::new(Currency::USD);
let cash = l
.add_account("1000", "Cash", AccountType::Asset, None, false)
.unwrap();
let mut acct =
|code: &str, name: &str, t: AccountType, cat: Option<&str>, pct: Option<&str>| {
let id = l.add_account(code, name, t, None, false).unwrap();
if let Some(c) = cat {
l.set_account_tax(id, Some(c), pct.map(dec)).unwrap();
}
id
};
let sales = acct(
"4000",
"Sales",
AccountType::Revenue,
Some("gross_receipts"),
None,
);
let refunds = acct(
"4100",
"Refunds",
AccountType::Expense,
Some("returns_allowances"),
None,
);
let fees = acct(
"6000",
"Platform fees",
AccountType::Expense,
Some("commissions_fees"),
None,
);
let meals = acct("6100", "Meals", AccountType::Expense, Some("meals"), None);
let pen = acct(
"6200",
"Penalties",
AccountType::Expense,
Some("nondeductible"),
None,
);
let soft = acct(
"6300",
"Software",
AccountType::Expense,
Some("other_deductions"),
None,
);
let gp = acct(
"6400",
"Partner pay",
AccountType::Expense,
Some("guaranteed_payments"),
None,
);
let misc = acct("6500", "Misc", AccountType::Expense, None, None);
let mut post = |dr, cr, amt: &str| {
let e = l.create_entry(d(2, 1), "x");
l.add_line(e, dr, Side::Debit, Money::new(dec(amt), Currency::USD))
.unwrap();
l.add_line(e, cr, Side::Credit, Money::new(dec(amt), Currency::USD))
.unwrap();
l.post_entry(e).unwrap();
};
post(cash, sales, "10000");
for (a, amt) in [
(refunds, "250"),
(fees, "1500"),
(meals, "333.33"),
(pen, "40"),
(soft, "600"),
(gp, "2000"),
(misc, "75"),
] {
post(a, cash, amt);
}
l
}
#[test]
fn the_summary_groups_by_category_and_applies_deductible_percents() {
let l = books();
let ts = tax_summary(&l, d(1, 1), d(3, 31), Some(TaxForm::Form1065)).unwrap();
let rows = |lines: &[ledger_core::TaxSummaryLine]| -> Vec<(String, Decimal, Decimal)> {
lines
.iter()
.map(|l| (l.category.clone(), l.book, l.tax))
.collect()
};
// Refunds (an expense account) reduce receipts as negative income.
assert_eq!(
rows(&ts.income),
[
("gross_receipts".into(), dec("10000"), dec("10000")),
("returns_allowances".into(), dec("-250"), dec("-250")),
]
);
// Catalog order; meals at 50% (333.33 → 166.665 → 166.67, half away
// from zero); penalties at 0%.
assert_eq!(
rows(&ts.deductions),
[
("commissions_fees".into(), dec("1500"), dec("1500")),
("guaranteed_payments".into(), dec("2000"), dec("2000")),
("meals".into(), dec("333.33"), dec("166.67")),
("other_deductions".into(), dec("600"), dec("600")),
("nondeductible".into(), dec("40"), dec("0")),
]
);
assert_eq!(ts.uncategorized.len(), 1);
assert_eq!(ts.uncategorized[0].account, "6500 Misc");
assert_eq!(ts.uncategorized[0].book, dec("75"));
// Book: 9,750 − 4,473.33 = 5,276.67. Tax: 9,750 − 4,266.67 = 5,483.33.
assert_eq!(ts.book_net(), dec("5276.67"));
assert_eq!(ts.tax_net(), dec("5483.33"));
}
#[test]
fn an_override_beats_the_category_default() {
let mut l = books();
let meals = l.chart().find_by_code("6100").unwrap().id;
// A company party is 100% deductible: override the 50% default.
l.set_account_tax(meals, Some("meals"), Some(dec("100")))
.unwrap();
let ts = tax_summary(&l, d(1, 1), d(3, 31), None).unwrap();
let m = ts
.deductions
.iter()
.find(|x| x.category == "meals")
.unwrap();
assert_eq!(m.tax, dec("333.33"));
}
#[test]
fn categories_must_fit_the_account_and_the_return() {
let mut l = books();
let sales = l.chart().find_by_code("4000").unwrap().id;
let meals = l.chart().find_by_code("6100").unwrap().id;
let bad = |r: Result<(), LedgerError>| matches!(r, Err(LedgerError::InvalidTaxCategory(_)));
assert!(
bad(l.set_account_tax(sales, Some("meals"), None)),
"deduction on revenue"
);
assert!(
bad(l.set_account_tax(meals, Some("no_such"), None)),
"unknown key"
);
assert!(
bad(l.set_account_tax(meals, Some("meals"), Some(dec("101")))),
"over 100%"
);
assert!(
bad(l.set_account_tax(meals, None, Some(dec("50")))),
"percent without category"
);
// Guaranteed payments exist only on a partnership return.
assert!(
check_category(
"guaranteed_payments",
AccountType::Expense,
Some(TaxForm::Form1065)
)
.is_ok()
);
assert!(
check_category(
"guaranteed_payments",
AccountType::Expense,
Some(TaxForm::ScheduleC)
)
.is_err()
);
assert!(
check_category(
"officer_compensation",
AccountType::Expense,
Some(TaxForm::Form1120S)
)
.is_ok()
);
// Clearing works.
l.set_account_tax(meals, None, None).unwrap();
assert!(l.chart().get(meals).unwrap().tax_category.is_none());
}
#[test]
fn forms_parse_from_common_spellings() {
for (s, f) in [
("schedule_c", TaxForm::ScheduleC),
("Schedule C", TaxForm::ScheduleC),
("1065", TaxForm::Form1065),
("Form 1065", TaxForm::Form1065),
("1120s", TaxForm::Form1120S),
("1120-S", TaxForm::Form1120S),
("1120", TaxForm::Form1120),
] {
assert_eq!(TaxForm::parse(s), Some(f), "{s}");
}
assert_eq!(TaxForm::parse("990"), None);
}