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 754060ccf0
commit e6c49d6650
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";