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:
18 files changed
+1352
-14
No files matched your search
@@ -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()
|
||||
|
||||
Reference in new issue
Block a user