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
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user