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
@@ -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";
|
||||
|
||||
@@ -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