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

+60
View File
@@ -25,6 +25,9 @@ pub struct Company {
pub members: Vec<Member>,
/// "Accrual" or "Cash", for report footers. Default "Accrual".
pub basis: String,
/// The income tax return the business files, if known: from
/// `tax_form`, else from `tax_classification`.
pub tax_form: Option<ledger_core::TaxForm>,
}
/// One member of a multi-member LLC.
@@ -45,6 +48,7 @@ impl Default for Company {
fiscal_year_start: (1, 1),
members: Vec::new(),
basis: "Accrual".to_string(),
tax_form: None,
}
}
}
@@ -87,6 +91,8 @@ struct BooksToml {
struct CompanyToml {
name: Option<String>,
fiscal_year_start: Option<String>,
tax_form: Option<String>,
tax_classification: Option<String>,
#[serde(default)]
members: Vec<MemberToml>,
}
@@ -104,6 +110,24 @@ struct BooksSection {
accounting_basis: Option<String>,
}
/// The return a federal tax classification files: "partnership" → 1065,
/// "s corporation" → 1120-S, "c corporation" → 1120, "sole proprietorship"
/// or "disregarded entity" → Schedule C. Unrecognized: unknown.
fn form_for_classification(c: &str) -> Option<ledger_core::TaxForm> {
let c = c.to_lowercase();
if c.contains("partnership") {
Some(ledger_core::TaxForm::Form1065)
} else if c.contains("s corp") || c.contains("s-corp") || c.contains("s_corp") {
Some(ledger_core::TaxForm::Form1120S)
} else if c.contains("c corp") || c.contains("c-corp") || c.contains("c_corp") {
Some(ledger_core::TaxForm::Form1120)
} else if c.contains("sole") || c.contains("disregarded") {
Some(ledger_core::TaxForm::ScheduleC)
} else {
None
}
}
/// Parse a share: "50%" or "0.5".
fn parse_share(name: &str, s: &str) -> Result<Decimal, String> {
let s = s.trim();
@@ -158,6 +182,14 @@ pub fn parse(text: &str) -> Result<BooksSettings, String> {
return Err(format!("member shares sum to {total}, not 100%"));
}
}
company.tax_form =
match (&raw.company.tax_form, &raw.company.tax_classification) {
(Some(f), _) => Some(ledger_core::TaxForm::parse(f).ok_or_else(|| {
format!("tax_form {f:?}: expected schedule_c, 1065, 1120s, or 1120")
})?),
(None, Some(c)) => form_for_classification(c),
(None, None) => None,
};
if let Some(basis) = raw.books.accounting_basis {
company.basis = match basis.trim().to_lowercase().as_str() {
"accrual" => "Accrual".to_string(),
@@ -282,6 +314,34 @@ mod tests {
assert!(parse("[books]\naccounting_basis = \"modified\"\n").is_err());
}
#[test]
fn tax_form_from_setting_or_classification() {
use ledger_core::TaxForm;
let form = |toml: &str| parse(toml).unwrap().company.tax_form;
assert_eq!(
form("[company]\ntax_form = \"1120s\"\n"),
Some(TaxForm::Form1120S)
);
assert_eq!(
form("[company]\ntax_classification = \"partnership\"\n"),
Some(TaxForm::Form1065)
);
assert_eq!(
form("[company]\ntax_classification = \"S corporation\"\n"),
Some(TaxForm::Form1120S)
);
assert_eq!(
form("[company]\ntax_classification = \"disregarded entity\"\n"),
Some(TaxForm::ScheduleC)
);
assert_eq!(
form("[company]\ntax_classification = \"partnership\"\ntax_form = \"1120\"\n"),
Some(TaxForm::Form1120)
);
assert_eq!(form(""), None);
assert!(parse("[company]\ntax_form = \"990\"\n").is_err());
}
#[test]
fn rejects_bad_settings() {
assert!(parse("[company]\nfiscal_year_start = \"02-29\"\n").is_err());
+155 -3
View File
@@ -512,6 +512,8 @@ impl BooksServer {
} else {
balance
}),
tax_category: a.tax_category.clone(),
deductible_percent: a.deductible_percent.map(|p| p.to_string()),
});
}
Ok(Json(ChartOut {
@@ -1359,6 +1361,66 @@ impl BooksServer {
Ok(Json(ImportBankCsvOut { imported, count }))
}
#[tool(
name = "books.tax_categories",
description = "The tax categories an account can map to (where it lands on the income tax return), filtered to the business's return when wanderbooks.toml says which (tax_form or tax_classification). Each has a default deductible percent (meals 50, nondeductible 0) and guidance. Categories are stable keys, not form line numbers.",
annotations(read_only_hint = true)
)]
async fn tax_categories(&self) -> Result<Json<TaxCategoriesOut>, String> {
let form = self.company.tax_form;
let categories = ledger_core::tax::CATEGORIES
.iter()
.filter(|c| form.is_none_or(|f| c.forms.contains(&f)))
.map(|c| TaxCategoryOut {
key: c.key.to_string(),
label: c.label.to_string(),
kind: match c.kind {
ledger_core::TaxKind::Income => "income",
ledger_core::TaxKind::CostOfGoods => "cost_of_goods",
ledger_core::TaxKind::Deduction => "deduction",
}
.to_string(),
default_deductible_percent: c.default_deductible_percent,
note: c.note.to_string(),
})
.collect();
Ok(Json(TaxCategoriesOut {
form: form.map(|f| f.label().to_string()),
categories,
}))
}
#[tool(
name = "books.set_tax_category",
description = "Map a revenue or expense account to a tax category (see books.tax_categories), optionally overriding its deductible percent; category null clears it. Drives the tax_summary report."
)]
async fn set_tax_category(
&self,
params: Parameters<SetTaxCategoryParams>,
) -> Result<Json<SetTaxCategoryOut>, String> {
let p = params.0;
let mut store = self.lock()?;
let id = resolve_account(&store, &p.account)?;
let pct = match &p.deductible_percent {
Some(s) => Some(parse_amount(s)?),
None => None,
};
if let Some(cat) = &p.category {
let t = store
.ledger()
.chart()
.get(id)
.expect("resolved")
.account_type;
ledger_core::tax::check_category(cat, t, self.company.tax_form)
.map_err(|e| e.to_string())?;
}
store
.set_account_tax(id, p.category.as_deref(), pct)
.map_err(|e| e.to_string())?;
Ok(Json(SetTaxCategoryOut { ok: true }))
}
#[tool(
name = "books.import_chart_csv",
description = "Import a chart of accounts from CSV (header: code,name,type,parent_code). Types: asset, liability, equity, revenue, expense. Parents must come before children. Fails on the first bad row; nothing is half-imported."
@@ -1371,10 +1433,26 @@ impl BooksServer {
let rows = ledger_import::parse_chart_csv(&p.csv_text).map_err(|e| e.to_string())?;
let mut store = self.lock()?;
// Validate all rows first: nothing is half-imported.
let parse_type = |t: &str| match t {
"asset" => Ok(AccountType::Asset),
"liability" => Ok(AccountType::Liability),
"equity" => Ok(AccountType::Equity),
"revenue" => Ok(AccountType::Revenue),
"expense" => Ok(AccountType::Expense),
_ => Err(format!("bad account type: {t}")),
};
for row in &rows {
if store.ledger().chart().find_by_code(&row.code).is_some() {
return Err(format!("duplicate account code: {}", row.code));
}
if let Some(cat) = &row.tax_category {
ledger_core::tax::check_category(
cat,
parse_type(&row.account_type)?,
self.company.tax_form,
)
.map_err(|e| format!("{}: {e}", row.code))?;
}
}
let mut created = Vec::new();
for row in rows {
@@ -1397,9 +1475,14 @@ impl BooksServer {
),
None => None,
};
store
let id = store
.add_account(&row.code, &row.name, account_type, parent, false)
.map_err(|e| e.to_string())?;
if let Some(cat) = &row.tax_category {
store
.set_account_tax(id, Some(cat), None)
.map_err(|e| e.to_string())?;
}
created.push(row.code.clone());
}
let count = created.len();
@@ -1510,12 +1593,13 @@ impl BooksServer {
"trial_balance" => self.export_trial_balance(&format, p.as_of.as_deref())?,
"general_ledger" => self.export_general_ledger(&format, &p)?,
"members_equity" => self.export_members_equity(&format, &p)?,
"tax_summary" => self.export_tax_summary(&format, &p)?,
"profit_and_loss" => self.export_pnl(&format, p.start.as_deref(), p.end.as_deref())?,
"balance_sheet" => self.export_balance_sheet(&format, p.as_of.as_deref())?,
"cash_flow" => self.export_cash_flow(&format, &p)?,
_ => {
return Err(format!(
"bad report {:?}: expected general_journal, general_ledger, trial_balance, profit_and_loss, balance_sheet, cash_flow, or members_equity",
"bad report {:?}: expected general_journal, general_ledger, trial_balance, profit_and_loss, balance_sheet, cash_flow, members_equity, or tax_summary",
p.report
));
}
@@ -1566,6 +1650,13 @@ impl BooksServer {
};
report::trial_balance(&reports::trial_balance_as_of(ledger, as_of), as_of)
}
"tax_summary" => {
let (start, end) = statement_range(ledger, p.start.as_deref(), p.end.as_deref())?;
report::tax_summary(
&ledger_core::tax_summary(ledger, start, end, self.company.tax_form)
.map_err(|e| e.to_string())?,
)
}
"members_equity" => {
let (start, end) = statement_range(ledger, p.start.as_deref(), p.end.as_deref())?;
let me =
@@ -1575,7 +1666,7 @@ impl BooksServer {
}
_ => {
return Err(format!(
"bad report {report:?}: expected profit_and_loss, balance_sheet, cash_flow, trial_balance, or members_equity"
"bad report {report:?}: expected profit_and_loss, balance_sheet, cash_flow, trial_balance, members_equity, or tax_summary"
));
}
})
@@ -2684,6 +2775,13 @@ impl BooksServer {
.map_err(|e| e.to_string())?;
statements.push(("members-equity", report::members_equity(&me)));
}
statements.push((
"tax-summary",
report::tax_summary(
&ledger_core::tax_summary(ledger, fy, as_of, self.company.tax_form)
.map_err(|e| e.to_string())?,
),
));
statements.push((
"trial-balance",
report::trial_balance(&reports::trial_balance_as_of(ledger, as_of), as_of),
@@ -2878,6 +2976,60 @@ impl BooksServer {
Ok(csv)
}
fn export_tax_summary(&self, format: &str, p: &ExportParams) -> Result<String, String> {
let store = self.lock()?;
let ledger = store.ledger();
let (start, end) = statement_range(ledger, p.start.as_deref(), p.end.as_deref())?;
let ts = ledger_core::tax_summary(ledger, start, end, self.company.tax_form)
.map_err(|e| e.to_string())?;
let line = |l: &ledger_core::TaxSummaryLine| {
serde_json::json!({
"category": l.category, "label": l.label, "accounts": l.accounts,
"book": money(l.book), "tax": money(l.tax),
})
};
if format == "json" {
return serde_json::to_string_pretty(&serde_json::json!({
"start": ts.start.to_string(), "end": ts.end.to_string(),
"form": ts.form.map(|f| f.label()),
"income": ts.income.iter().map(line).collect::<Vec<_>>(),
"cost_of_goods": ts.cost_of_goods.iter().map(line).collect::<Vec<_>>(),
"deductions": ts.deductions.iter().map(line).collect::<Vec<_>>(),
"uncategorized": ts.uncategorized.iter().map(|u| serde_json::json!({
"account": u.account, "book": money(u.book),
})).collect::<Vec<_>>(),
"book_net": money(ts.book_net()),
"tax_net": money(ts.tax_net()),
}))
.map_err(|e| e.to_string());
}
let mut csv = String::from("section,category,label,accounts,book,tax\n");
for (section, lines) in [
("income", &ts.income),
("cost_of_goods", &ts.cost_of_goods),
("deductions", &ts.deductions),
] {
for l in lines {
csv.push_str(&format!(
"{section},{},{},{},{},{}\n",
l.category,
csv_escape(&l.label),
csv_escape(&l.accounts.join("; ")),
money(l.book),
money(l.tax)
));
}
}
for u in &ts.uncategorized {
csv.push_str(&format!(
"uncategorized,,,{},{},\n",
csv_escape(&u.account),
money(u.book)
));
}
Ok(csv)
}
fn export_members_equity(&self, format: &str, p: &ExportParams) -> Result<String, String> {
let store = self.lock()?;
let ledger = store.ledger();
+47
View File
@@ -48,6 +48,53 @@ pub struct ChartAccount {
/// Current balance in the account's normal side, as a decimal string.
/// Positive means a normal balance.
pub balance: String,
/// Tax category key, if assigned.
pub tax_category: Option<String>,
/// Deductible percent override, if any.
pub deductible_percent: Option<String>,
}
/// Parameters for `books.set_tax_category`.
#[derive(Debug, Deserialize, JsonSchema)]
pub struct SetTaxCategoryParams {
/// Account code (or id).
pub account: String,
/// Tax category key from `books.tax_categories`; null clears it.
pub category: Option<String>,
/// Percent deductible, overriding the category default (decimal
/// string, 0 to 100), e.g. "100" for a fully deductible company party.
pub deductible_percent: Option<String>,
}
/// Result of `books.set_tax_category`.
#[derive(Debug, Serialize, JsonSchema)]
pub struct SetTaxCategoryOut {
/// True when set.
pub ok: bool,
}
/// One tax category.
#[derive(Debug, Serialize, JsonSchema)]
pub struct TaxCategoryOut {
/// Stable key.
pub key: String,
/// Label.
pub label: String,
/// "income", "cost_of_goods", or "deduction".
pub kind: String,
/// Percent deductible by default.
pub default_deductible_percent: u32,
/// Guidance.
pub note: String,
}
/// Result of `books.tax_categories`.
#[derive(Debug, Serialize, JsonSchema)]
pub struct TaxCategoriesOut {
/// The return the categories are filtered to, if configured.
pub form: Option<String>,
/// Categories.
pub categories: Vec<TaxCategoryOut>,
}
/// Result of `books.chart`.
+66 -2
View File
@@ -139,7 +139,7 @@ fn mcp_end_to_end_over_stdio() {
// Tool listing: 23 narrow tools, chart is read-only.
let list = mcp.request("tools/list", json!({}));
let tools = list["result"]["tools"].as_array().unwrap();
assert_eq!(tools.len(), 50, "expected 50 tools, got {}", tools.len());
assert_eq!(tools.len(), 52, "expected 52 tools, got {}", tools.len());
let chart = tools.iter().find(|t| t["name"] == "books.chart").unwrap();
assert_eq!(chart["annotations"]["readOnlyHint"], true);
assert!(
@@ -882,6 +882,8 @@ fn mcp_statement_pdfs_and_cpa_package_over_stdio() {
"general-ledger.xlsx",
"profit-and-loss.pdf",
"profit-and-loss.xlsx",
"tax-summary.pdf",
"tax-summary.xlsx",
"trial-balance.pdf",
"trial-balance.xlsx"
]
@@ -899,7 +901,7 @@ fn mcp_period_statements_over_stdio() {
let toml = dir.join("wanderbooks.toml");
std::fs::write(
&toml,
"[company]\nname = \"Test Co\"\nfiscal_year_start = \"02-01\"\n[[company.members]]\nname = \"Owner\"\nshare = \"100%\"\ncapital_account = \"3000\"\n[books]\nreceipt_threshold = \"5.00\"\n",
"[company]\nname = \"Test Co\"\nfiscal_year_start = \"02-01\"\ntax_classification = \"partnership\"\n[[company.members]]\nname = \"Owner\"\nshare = \"100%\"\ncapital_account = \"3000\"\n[books]\nreceipt_threshold = \"5.00\"\n",
)
.unwrap();
let data = dir.join("books-data");
@@ -1055,4 +1057,66 @@ fn mcp_period_statements_over_stdio() {
assert_eq!(me["total"]["contributions"], "1000.00");
assert_eq!(me["total"]["net_income"], "4.00");
assert_eq!(me["total"]["ending"], "1004.00");
// Tax categories: filtered to the partnership return.
let cats = mcp.call("books.tax_categories", json!({}));
assert_eq!(cats["form"], "Form 1065");
let keys: Vec<&str> = cats["categories"]
.as_array()
.unwrap()
.iter()
.map(|c| c["key"].as_str().unwrap())
.collect();
assert!(keys.contains(&"guaranteed_payments"));
assert!(
!keys.contains(&"officer_compensation"),
"a corporate category"
);
mcp.call(
"books.set_tax_category",
json!({"account": "4000", "category": "gross_receipts"}),
);
mcp.call(
"books.set_tax_category",
json!({"account": "6000", "category": "other_deductions"}),
);
let err = mcp.call_err(
"books.set_tax_category",
json!({"account": "4000", "category": "meals"}),
);
assert!(err.contains("expense"), "{err}");
let chart = mcp.call("books.chart", json!({}));
let sales = chart["accounts"]
.as_array()
.unwrap()
.iter()
.find(|a| a["code"] == "4000")
.unwrap()
.clone();
assert_eq!(sales["tax_category"], "gross_receipts");
// Jan-Feb: 6.50 of sales, 3.00 of software, all categorized.
let ts = mcp.call(
"books.export",
json!({"format": "json", "report": "tax_summary", "start": "2026-01-01", "end": "2026-02-28"}),
);
let ts: serde_json::Value = serde_json::from_str(ts["content"].as_str().unwrap()).unwrap();
assert_eq!(ts["form"], "Form 1065");
assert_eq!(ts["income"][0]["book"], "6.50");
assert_eq!(ts["deductions"][0]["category"], "other_deductions");
assert_eq!(ts["uncategorized"].as_array().unwrap().len(), 0);
assert_eq!(ts["tax_net"], "3.50");
// A chart import validates categories before creating anything.
let err = mcp.call_err(
"books.import_chart_csv",
json!({"csv_text": "code,name,type,parent_code,tax_category\n6100,Meals,expense,,meals\n4100,Fees,revenue,,commissions_fees\n"}),
);
assert!(err.contains("4100"), "{err}");
let chart = mcp.call("books.chart", json!({}));
assert!(
chart["accounts"]
.as_array()
.unwrap()
.iter()
.all(|a| a["code"] != "6100")
);
}