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

+25
View File
@@ -6,6 +6,7 @@
//! grouping on type, so getting this right is the whole game. //! grouping on type, so getting this right is the whole game.
use crate::{Currency, LedgerError}; use crate::{Currency, LedgerError};
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::HashMap; use std::collections::HashMap;
@@ -72,6 +73,14 @@ pub struct Account {
pub placeholder: bool, pub placeholder: bool,
/// Day one: always the ledger's base currency (see R-5). /// Day one: always the ledger's base currency (see R-5).
pub currency: Currency, pub currency: Currency,
/// Tax category key (see [`crate::tax`]), for revenue and expense
/// accounts.
#[serde(default)]
pub tax_category: Option<String>,
/// Percent deductible, overriding the category's default (e.g. 50 for
/// meals, 0 for penalties).
#[serde(default)]
pub deductible_percent: Option<Decimal>,
} }
/// The registry of accounts. Owns id allocation for accounts it holds. /// The registry of accounts. Owns id allocation for accounts it holds.
@@ -116,6 +125,22 @@ impl ChartOfAccounts {
self.accounts.values() self.accounts.values()
} }
/// Set (or clear) an account's tax category and deductible percent.
pub fn set_tax(
&mut self,
id: AccountId,
category: Option<String>,
deductible_percent: Option<Decimal>,
) -> Result<(), LedgerError> {
let a = self
.accounts
.get_mut(&id)
.ok_or(LedgerError::UnknownAccount(id))?;
a.tax_category = category;
a.deductible_percent = deductible_percent;
Ok(())
}
/// Number of accounts in the chart. /// Number of accounts in the chart.
pub fn len(&self) -> usize { pub fn len(&self) -> usize {
self.accounts.len() self.accounts.len()
+5
View File
@@ -132,6 +132,11 @@ pub enum LedgerError {
#[error("purchase date plus useful life overflows the calendar")] #[error("purchase date plus useful life overflows the calendar")]
AssetDateOverflow, AssetDateOverflow,
/// A tax category doesn't exist, doesn't fit the account, or has a bad
/// deductible percent.
#[error("invalid tax category: {0}")]
InvalidTaxCategory(String),
/// Partnership members are misconfigured (shares, capital accounts). /// Partnership members are misconfigured (shares, capital accounts).
#[error("invalid members: {0}")] #[error("invalid members: {0}")]
InvalidMembers(String), InvalidMembers(String),
+45
View File
@@ -228,10 +228,55 @@ impl Ledger {
parent, parent,
placeholder, placeholder,
currency: self.base_currency, currency: self.base_currency,
tax_category: None,
deductible_percent: None,
})?; })?;
Ok(id) Ok(id)
} }
/// Set (or clear, with `None`) an account's tax category, and optionally
/// override the category's deductible percent (0 to 100). The category
/// must fit the account's type (see [`crate::tax::check_category`]).
pub fn set_account_tax(
&mut self,
account: AccountId,
category: Option<&str>,
deductible_percent: Option<Decimal>,
) -> Result<(), LedgerError> {
self.check_account_tax(account, category, deductible_percent)?;
self.chart
.set_tax(account, category.map(str::to_string), deductible_percent)
}
/// The validation behind [`Self::set_account_tax`], for stores that
/// journal before mutating.
pub fn check_account_tax(
&self,
account: AccountId,
category: Option<&str>,
deductible_percent: Option<Decimal>,
) -> Result<(), LedgerError> {
let a = self
.chart
.get(account)
.ok_or(LedgerError::UnknownAccount(account))?;
if let Some(key) = category {
crate::tax::check_category(key, a.account_type, None)?;
} else if deductible_percent.is_some() {
return Err(LedgerError::InvalidTaxCategory(
"a deductible percent needs a tax category".to_string(),
));
}
if let Some(p) = deductible_percent
&& (p < Decimal::ZERO || p > Decimal::from(100))
{
return Err(LedgerError::InvalidTaxCategory(format!(
"deductible percent {p} must be between 0 and 100"
)));
}
Ok(())
}
/// Start a draft journal entry. Returns its id. /// Start a draft journal entry. Returns its id.
/// ///
/// This is the unchecked constructor: replay and internal callers /// This is the unchecked constructor: replay and internal callers
+2
View File
@@ -27,6 +27,7 @@ pub mod money;
pub mod payroll; pub mod payroll;
pub mod periods; pub mod periods;
pub mod reports; pub mod reports;
pub mod tax;
pub use account::{Account, AccountId, AccountType, ChartOfAccounts, Side}; pub use account::{Account, AccountId, AccountType, ChartOfAccounts, Side};
pub use arap::{ pub use arap::{
@@ -51,3 +52,4 @@ pub use reports::{
StatementLine, TrialBalance, TrialBalanceLine, balance_sheet_as_of, cash_flow, general_ledger, StatementLine, TrialBalance, TrialBalanceLine, balance_sheet_as_of, cash_flow, general_ledger,
members_equity, profit_and_loss_for_period, trial_balance_as_of, members_equity, profit_and_loss_for_period, trial_balance_as_of,
}; };
pub use tax::{TaxCategory, TaxForm, TaxKind, TaxSummary, TaxSummaryLine, tax_summary};
+513
View File
@@ -0,0 +1,513 @@
//! Tax categories: where each revenue and expense account lands on the
//! business's income tax return, and how much of it is deductible.
//!
//! The books stay book-basis; this is the bridge a CPA (or tax software)
//! walks across. Categories are *stable semantic keys* ("meals",
//! "guaranteed_payments") rather than form line numbers, which move from
//! year to year; each category says which returns it applies to. A
//! deductible percentage (meals 50%, penalties 0%) turns book amounts into
//! deductible amounts and surfaces the book-to-tax differences (Schedule
//! M-1 items) instead of hiding them.
//!
//! Scope is deliberate, like fixed assets: no tax depreciation (MACRS,
//! Section 179, bonus), no basis computations, no filing. Not tax advice.
use crate::{AccountType, Ledger, LedgerError};
use chrono::NaiveDate;
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
/// The federal income tax return a business files.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, PartialOrd, Ord)]
#[serde(rename_all = "snake_case")]
pub enum TaxForm {
/// Sole proprietorship or single-member LLC: Schedule C (Form 1040).
ScheduleC,
/// Partnership or multi-member LLC: Form 1065.
Form1065,
/// S corporation: Form 1120-S.
Form1120S,
/// C corporation: Form 1120.
Form1120,
}
impl TaxForm {
/// Every form.
pub const ALL: [TaxForm; 4] = [
TaxForm::ScheduleC,
TaxForm::Form1065,
TaxForm::Form1120S,
TaxForm::Form1120,
];
/// "Form 1065", "Schedule C", ...
pub fn label(&self) -> &'static str {
match self {
TaxForm::ScheduleC => "Schedule C",
TaxForm::Form1065 => "Form 1065",
TaxForm::Form1120S => "Form 1120-S",
TaxForm::Form1120 => "Form 1120",
}
}
/// Parse "schedule_c", "1065", "1120s", "1120" (and their labels).
pub fn parse(s: &str) -> Option<TaxForm> {
let k: String = s
.to_lowercase()
.chars()
.filter(|c| c.is_ascii_alphanumeric())
.collect();
match k.as_str() {
"schedulec" | "c" => Some(TaxForm::ScheduleC),
"1065" | "form1065" => Some(TaxForm::Form1065),
"1120s" | "form1120s" => Some(TaxForm::Form1120S),
"1120" | "form1120" => Some(TaxForm::Form1120),
_ => None,
}
}
}
/// Where a category sits on the return.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TaxKind {
/// Income (revenue accounts).
Income,
/// Cost of goods sold (expense accounts).
CostOfGoods,
/// A deduction (expense accounts).
Deduction,
}
/// One tax category.
#[derive(Clone, Copy, Debug, Serialize)]
pub struct TaxCategory {
/// Stable key, e.g. `"meals"`.
pub key: &'static str,
/// Human label, e.g. `"Meals"`.
pub label: &'static str,
/// Income, COGS, or deduction.
pub kind: TaxKind,
/// The returns it appears on.
pub forms: &'static [TaxForm],
/// Percent deductible by default (100 for most; meals 50).
pub default_deductible_percent: u32,
/// What to know about it, for the agent and the CPA.
pub note: &'static str,
}
const ALL: &[TaxForm] = &TaxForm::ALL;
const CORPS: &[TaxForm] = &[TaxForm::Form1120S, TaxForm::Form1120];
const fn cat(
key: &'static str,
label: &'static str,
kind: TaxKind,
forms: &'static [TaxForm],
pct: u32,
note: &'static str,
) -> TaxCategory {
TaxCategory {
key,
label,
kind,
forms,
default_deductible_percent: pct,
note,
}
}
use TaxKind::{CostOfGoods, Deduction, Income};
/// The catalog.
pub const CATEGORIES: &[TaxCategory] = &[
cat(
"gross_receipts",
"Gross receipts or sales",
Income,
ALL,
100,
"Sales recorded gross (platform and processor fees are a separate deduction).",
),
cat(
"returns_allowances",
"Returns and allowances",
Income,
ALL,
100,
"Refunds and allowances; reduces gross receipts.",
),
cat(
"interest_income",
"Interest income",
Income,
ALL,
100,
"Pass-throughs report it separately to owners (portfolio income).",
),
cat("other_income", "Other income", Income, ALL, 100, ""),
cat(
"cost_of_goods_sold",
"Cost of goods sold",
CostOfGoods,
ALL,
100,
"Inventory and direct costs of what was sold.",
),
cat("advertising", "Advertising", Deduction, ALL, 100, ""),
cat(
"bad_debts",
"Bad debts",
Deduction,
ALL,
100,
"Only debts previously included in income (accrual basis).",
),
cat(
"car_truck",
"Car and truck expenses",
Deduction,
ALL,
100,
"Actual costs or the standard mileage rate, not both.",
),
cat(
"charitable_contributions",
"Charitable contributions",
Deduction,
ALL,
100,
"Separately stated to owners for partnerships and S corps; limited for C corps; not a Schedule C deduction.",
),
cat(
"commissions_fees",
"Commissions and fees",
Deduction,
ALL,
100,
"Includes app store, marketplace, and payment processor fees.",
),
cat(
"contract_labor",
"Contract labor",
Deduction,
ALL,
100,
"Payments to non-employees; see 1099 reporting.",
),
cat(
"depreciation",
"Depreciation (book)",
Deduction,
ALL,
100,
"Book depreciation; tax depreciation (MACRS, Section 179, bonus) is computed separately by the CPA.",
),
cat(
"employee_benefits",
"Employee benefit programs",
Deduction,
ALL,
100,
"",
),
cat(
"guaranteed_payments",
"Guaranteed payments to partners",
Deduction,
&[TaxForm::Form1065],
100,
"Payments to partners for services or capital, regardless of profit; reported on each partner's K-1.",
),
cat(
"insurance",
"Insurance (other than health)",
Deduction,
ALL,
100,
"",
),
cat("interest_expense", "Interest", Deduction, ALL, 100, ""),
cat(
"legal_professional",
"Legal and professional services",
Deduction,
ALL,
100,
"Attorneys, accountants, consultants.",
),
cat(
"meals",
"Meals",
Deduction,
ALL,
50,
"Generally 50% deductible; the other half is a book-to-tax difference.",
),
cat("office_expense", "Office expense", Deduction, ALL, 100, ""),
cat(
"officer_compensation",
"Compensation of officers",
Deduction,
CORPS,
100,
"Wages paid to officer-shareholders through payroll.",
),
cat(
"organization_startup",
"Organizational and start-up costs",
Deduction,
ALL,
100,
"Tax: up to $5,000 each deductible in the first year, the rest amortized over 180 months; the CPA computes it.",
),
cat(
"pension_profit_sharing",
"Pension and profit-sharing plans",
Deduction,
ALL,
100,
"",
),
cat(
"rent_equipment",
"Rent or lease: vehicles, machinery, equipment",
Deduction,
ALL,
100,
"",
),
cat(
"rent_property",
"Rent or lease: other business property",
Deduction,
ALL,
100,
"",
),
cat(
"repairs",
"Repairs and maintenance",
Deduction,
ALL,
100,
"",
),
cat(
"salaries_wages",
"Salaries and wages",
Deduction,
ALL,
100,
"Employees (not owners of a partnership, whose pay is guaranteed payments).",
),
cat("supplies", "Supplies", Deduction, ALL, 100, ""),
cat(
"taxes_licenses",
"Taxes and licenses",
Deduction,
ALL,
100,
"Payroll taxes, business licenses, state fees; not federal income tax.",
),
cat(
"travel",
"Travel",
Deduction,
ALL,
100,
"Lodging and transportation away from home; meals go under Meals.",
),
cat("utilities", "Utilities", Deduction, ALL, 100, ""),
cat(
"other_deductions",
"Other deductions",
Deduction,
ALL,
100,
"Software, subscriptions, bank fees, dues, and the rest; the CPA itemizes.",
),
cat(
"nondeductible",
"Nondeductible expenses",
Deduction,
ALL,
0,
"Fines, penalties, political contributions, federal income tax: a book expense with no deduction.",
),
];
/// Look up a category by key.
pub fn category(key: &str) -> Option<&'static TaxCategory> {
CATEGORIES.iter().find(|c| c.key == key)
}
/// Check that `key` can tag an account of `account_type` and, when a form
/// is known, that it appears on that form.
pub fn check_category(
key: &str,
account_type: AccountType,
form: Option<TaxForm>,
) -> Result<&'static TaxCategory, LedgerError> {
let c = category(key)
.ok_or_else(|| LedgerError::InvalidTaxCategory(format!("unknown tax category {key:?}")))?;
let fits = match c.kind {
TaxKind::Income => matches!(account_type, AccountType::Revenue | AccountType::Expense),
TaxKind::CostOfGoods | TaxKind::Deduction => account_type == AccountType::Expense,
};
if !fits {
return Err(LedgerError::InvalidTaxCategory(format!(
"{key} is for {} accounts, not {account_type:?}",
if c.kind == TaxKind::Income {
"revenue"
} else {
"expense"
}
)));
}
if let Some(f) = form
&& !c.forms.contains(&f)
{
return Err(LedgerError::InvalidTaxCategory(format!(
"{key} does not appear on {}",
f.label()
)));
}
Ok(c)
}
/// One category's line on the tax summary.
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct TaxSummaryLine {
/// Category key.
pub category: String,
/// Category label.
pub label: String,
/// The accounts that roll into it, `"CODE Name"`.
pub accounts: Vec<String>,
/// Book amount: revenue for income, expense for COGS and deductions.
pub book: Decimal,
/// Deductible (or taxable) amount after the deductible percentage.
pub tax: Decimal,
}
/// An account with P&L activity and no tax category.
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct Uncategorized {
/// `"CODE Name"`.
pub account: String,
/// Revenue or Expense.
pub account_type: AccountType,
/// Book amount (normal side).
pub book: Decimal,
}
/// The tax summary for a period: book amounts by tax category, deductible
/// amounts, and every account still needing a category.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct TaxSummary {
/// First date, inclusive.
pub start: NaiveDate,
/// Last date, inclusive.
pub end: NaiveDate,
/// The return, when known.
pub form: Option<TaxForm>,
/// Income categories.
pub income: Vec<TaxSummaryLine>,
/// Cost of goods sold.
pub cost_of_goods: Vec<TaxSummaryLine>,
/// Deductions.
pub deductions: Vec<TaxSummaryLine>,
/// P&L accounts with activity and no category.
pub uncategorized: Vec<Uncategorized>,
}
impl TaxSummary {
/// Book net income of the categorized accounts.
pub fn book_net(&self) -> Decimal {
sum(&self.income, |l| l.book)
- sum(&self.cost_of_goods, |l| l.book)
- sum(&self.deductions, |l| l.book)
}
/// Income less deductible amounts, before the CPA's other adjustments.
pub fn tax_net(&self) -> Decimal {
sum(&self.income, |l| l.tax)
- sum(&self.cost_of_goods, |l| l.tax)
- sum(&self.deductions, |l| l.tax)
}
}
fn sum(lines: &[TaxSummaryLine], f: fn(&TaxSummaryLine) -> Decimal) -> Decimal {
lines.iter().map(f).sum()
}
/// Build the tax summary for `[start, end]`, closing entries excluded (like
/// the P&L). Deductible amounts round to the cent, half away from zero.
pub fn tax_summary(
ledger: &Ledger,
start: NaiveDate,
end: NaiveDate,
form: Option<TaxForm>,
) -> Result<TaxSummary, LedgerError> {
let pl = crate::reports::profit_and_loss_for_period(ledger, start, end, None)?;
let mut by_cat: BTreeMap<&'static str, TaxSummaryLine> = BTreeMap::new();
let mut uncategorized = Vec::new();
for line in pl.revenue.iter().chain(pl.expenses.iter()) {
let account = ledger.chart().find_by_code(&line.code).ok_or_else(|| {
LedgerError::InvalidTaxCategory(format!("unknown account {}", line.code))
})?;
let name = format!("{} {}", account.code, account.name);
let Some(c) = account.tax_category.as_deref().and_then(category) else {
uncategorized.push(Uncategorized {
account: name,
account_type: account.account_type,
book: line.amount,
});
continue;
};
// Income from an expense-type account (e.g. a contra) counts with
// its sign flipped, so returns reduce receipts.
let book = if c.kind == TaxKind::Income && account.account_type == AccountType::Expense {
-line.amount
} else {
line.amount
};
let pct = account
.deductible_percent
.unwrap_or_else(|| Decimal::from(c.default_deductible_percent));
let tax = (book * pct / Decimal::from(100))
.round_dp_with_strategy(2, rust_decimal::RoundingStrategy::MidpointAwayFromZero);
let entry = by_cat.entry(c.key).or_insert_with(|| TaxSummaryLine {
category: c.key.to_string(),
label: c.label.to_string(),
accounts: vec![],
book: Decimal::ZERO,
tax: Decimal::ZERO,
});
entry.accounts.push(name);
entry.book += book;
entry.tax += tax;
}
let mut out = TaxSummary {
start,
end,
form,
income: vec![],
cost_of_goods: vec![],
deductions: vec![],
uncategorized,
};
// Catalog order, so the summary reads like the return.
for c in CATEGORIES {
if let Some(line) = by_cat.remove(c.key) {
match c.kind {
TaxKind::Income => out.income.push(line),
TaxKind::CostOfGoods => out.cost_of_goods.push(line),
TaxKind::Deduction => out.deductions.push(line),
}
}
}
Ok(out)
}
+230
View File
@@ -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);
}
+36 -8
View File
@@ -24,16 +24,19 @@ pub struct ChartRow {
pub account_type: String, pub account_type: String,
/// Parent account code, if any. /// Parent account code, if any.
pub parent_code: Option<String>, pub parent_code: Option<String>,
/// Optional fifth column: the tax category key (see
/// `ledger_core::tax`).
pub tax_category: Option<String>,
} }
/// Parse errors. /// Parse errors.
#[derive(Debug, Error, PartialEq)] #[derive(Debug, Error, PartialEq)]
pub enum ChartCsvError { pub enum ChartCsvError {
/// Wrong column count. /// Wrong column count: (found, expected by the header).
#[error("expected 4 columns (code,name,type,parent_code), found {0}")] #[error("expected {1} columns to match the header, found {0}")]
BadColumnCount(usize), BadColumnCount(usize, usize),
/// Missing or bad header. /// 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, BadHeader,
/// Unknown account type. /// Unknown account type.
#[error("bad account type {0:?}: expected asset, liability, equity, revenue, or expense")] #[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> { pub fn parse_chart_csv(text: &str) -> Result<Vec<ChartRow>, ChartCsvError> {
let mut reader = csv::ReaderBuilder::new() let mut reader = csv::ReaderBuilder::new()
.has_headers(false) .has_headers(false)
.flexible(true)
.trim(csv::Trim::All) .trim(csv::Trim::All)
.from_reader(text.as_bytes()); .from_reader(text.as_bytes());
let mut out = Vec::new(); let mut out = Vec::new();
let mut width = 4;
for (idx, record) in reader.records().enumerate() { for (idx, record) in reader.records().enumerate() {
let record = record.map_err(|e| ChartCsvError::Csv(e.to_string()))?; let record = record.map_err(|e| ChartCsvError::Csv(e.to_string()))?;
let fields: Vec<&str> = record.iter().collect(); 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 { if idx == 0 {
let lower: Vec<String> = fields.iter().map(|f| f.to_lowercase()).collect(); 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); return Err(ChartCsvError::BadHeader);
} };
continue; continue;
} }
if fields.len() != 4 { if fields.len() != width {
return Err(ChartCsvError::BadColumnCount(fields.len())); return Err(ChartCsvError::BadColumnCount(fields.len(), width));
} }
let account_type = fields[2].to_lowercase(); let account_type = fields[2].to_lowercase();
match account_type.as_str() { match account_type.as_str() {
@@ -82,6 +91,11 @@ pub fn parse_chart_csv(text: &str) -> Result<Vec<ChartRow>, ChartCsvError> {
} else { } else {
Some(parent_code.to_string()) 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) Ok(out)
@@ -101,6 +115,20 @@ mod tests {
assert_eq!(rows[2].account_type, "revenue"); 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] #[test]
fn bad_type_rejected() { fn bad_type_rejected() {
let csv = "code,name,type,parent_code\n1000,Cash,money,\n"; let csv = "code,name,type,parent_code\n1000,Cash,money,\n";
+79
View File
@@ -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". /// "October 11, 2026".
pub fn long_date(d: NaiveDate) -> String { pub fn long_date(d: NaiveDate) -> String {
d.format("%B %-d, %Y").to_string() d.format("%B %-d, %Y").to_string()
+60
View File
@@ -25,6 +25,9 @@ pub struct Company {
pub members: Vec<Member>, pub members: Vec<Member>,
/// "Accrual" or "Cash", for report footers. Default "Accrual". /// "Accrual" or "Cash", for report footers. Default "Accrual".
pub basis: String, 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. /// One member of a multi-member LLC.
@@ -45,6 +48,7 @@ impl Default for Company {
fiscal_year_start: (1, 1), fiscal_year_start: (1, 1),
members: Vec::new(), members: Vec::new(),
basis: "Accrual".to_string(), basis: "Accrual".to_string(),
tax_form: None,
} }
} }
} }
@@ -87,6 +91,8 @@ struct BooksToml {
struct CompanyToml { struct CompanyToml {
name: Option<String>, name: Option<String>,
fiscal_year_start: Option<String>, fiscal_year_start: Option<String>,
tax_form: Option<String>,
tax_classification: Option<String>,
#[serde(default)] #[serde(default)]
members: Vec<MemberToml>, members: Vec<MemberToml>,
} }
@@ -104,6 +110,24 @@ struct BooksSection {
accounting_basis: Option<String>, 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". /// Parse a share: "50%" or "0.5".
fn parse_share(name: &str, s: &str) -> Result<Decimal, String> { fn parse_share(name: &str, s: &str) -> Result<Decimal, String> {
let s = s.trim(); 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%")); 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 { if let Some(basis) = raw.books.accounting_basis {
company.basis = match basis.trim().to_lowercase().as_str() { company.basis = match basis.trim().to_lowercase().as_str() {
"accrual" => "Accrual".to_string(), "accrual" => "Accrual".to_string(),
@@ -282,6 +314,34 @@ mod tests {
assert!(parse("[books]\naccounting_basis = \"modified\"\n").is_err()); 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] #[test]
fn rejects_bad_settings() { fn rejects_bad_settings() {
assert!(parse("[company]\nfiscal_year_start = \"02-29\"\n").is_err()); assert!(parse("[company]\nfiscal_year_start = \"02-29\"\n").is_err());
+155 -3
View File
@@ -512,6 +512,8 @@ impl BooksServer {
} else { } else {
balance balance
}), }),
tax_category: a.tax_category.clone(),
deductible_percent: a.deductible_percent.map(|p| p.to_string()),
}); });
} }
Ok(Json(ChartOut { Ok(Json(ChartOut {
@@ -1359,6 +1361,66 @@ impl BooksServer {
Ok(Json(ImportBankCsvOut { imported, count })) 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( #[tool(
name = "books.import_chart_csv", 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." 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 rows = ledger_import::parse_chart_csv(&p.csv_text).map_err(|e| e.to_string())?;
let mut store = self.lock()?; let mut store = self.lock()?;
// Validate all rows first: nothing is half-imported. // 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 { for row in &rows {
if store.ledger().chart().find_by_code(&row.code).is_some() { if store.ledger().chart().find_by_code(&row.code).is_some() {
return Err(format!("duplicate account code: {}", row.code)); 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(); let mut created = Vec::new();
for row in rows { for row in rows {
@@ -1397,9 +1475,14 @@ impl BooksServer {
), ),
None => None, None => None,
}; };
store let id = store
.add_account(&row.code, &row.name, account_type, parent, false) .add_account(&row.code, &row.name, account_type, parent, false)
.map_err(|e| e.to_string())?; .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()); created.push(row.code.clone());
} }
let count = created.len(); let count = created.len();
@@ -1510,12 +1593,13 @@ impl BooksServer {
"trial_balance" => self.export_trial_balance(&format, p.as_of.as_deref())?, "trial_balance" => self.export_trial_balance(&format, p.as_of.as_deref())?,
"general_ledger" => self.export_general_ledger(&format, &p)?, "general_ledger" => self.export_general_ledger(&format, &p)?,
"members_equity" => self.export_members_equity(&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())?, "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())?, "balance_sheet" => self.export_balance_sheet(&format, p.as_of.as_deref())?,
"cash_flow" => self.export_cash_flow(&format, &p)?, "cash_flow" => self.export_cash_flow(&format, &p)?,
_ => { _ => {
return Err(format!( 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 p.report
)); ));
} }
@@ -1566,6 +1650,13 @@ impl BooksServer {
}; };
report::trial_balance(&reports::trial_balance_as_of(ledger, as_of), as_of) 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" => { "members_equity" => {
let (start, end) = statement_range(ledger, p.start.as_deref(), p.end.as_deref())?; let (start, end) = statement_range(ledger, p.start.as_deref(), p.end.as_deref())?;
let me = let me =
@@ -1575,7 +1666,7 @@ impl BooksServer {
} }
_ => { _ => {
return Err(format!( 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())?; .map_err(|e| e.to_string())?;
statements.push(("members-equity", report::members_equity(&me))); 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(( statements.push((
"trial-balance", "trial-balance",
report::trial_balance(&reports::trial_balance_as_of(ledger, as_of), as_of), report::trial_balance(&reports::trial_balance_as_of(ledger, as_of), as_of),
@@ -2878,6 +2976,60 @@ impl BooksServer {
Ok(csv) 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> { fn export_members_equity(&self, format: &str, p: &ExportParams) -> Result<String, String> {
let store = self.lock()?; let store = self.lock()?;
let ledger = store.ledger(); 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. /// Current balance in the account's normal side, as a decimal string.
/// Positive means a normal balance. /// Positive means a normal balance.
pub balance: String, 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`. /// 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. // Tool listing: 23 narrow tools, chart is read-only.
let list = mcp.request("tools/list", json!({})); let list = mcp.request("tools/list", json!({}));
let tools = list["result"]["tools"].as_array().unwrap(); 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(); let chart = tools.iter().find(|t| t["name"] == "books.chart").unwrap();
assert_eq!(chart["annotations"]["readOnlyHint"], true); assert_eq!(chart["annotations"]["readOnlyHint"], true);
assert!( assert!(
@@ -882,6 +882,8 @@ fn mcp_statement_pdfs_and_cpa_package_over_stdio() {
"general-ledger.xlsx", "general-ledger.xlsx",
"profit-and-loss.pdf", "profit-and-loss.pdf",
"profit-and-loss.xlsx", "profit-and-loss.xlsx",
"tax-summary.pdf",
"tax-summary.xlsx",
"trial-balance.pdf", "trial-balance.pdf",
"trial-balance.xlsx" "trial-balance.xlsx"
] ]
@@ -899,7 +901,7 @@ fn mcp_period_statements_over_stdio() {
let toml = dir.join("wanderbooks.toml"); let toml = dir.join("wanderbooks.toml");
std::fs::write( std::fs::write(
&toml, &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(); .unwrap();
let data = dir.join("books-data"); 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"]["contributions"], "1000.00");
assert_eq!(me["total"]["net_income"], "4.00"); assert_eq!(me["total"]["net_income"], "4.00");
assert_eq!(me["total"]["ending"], "1004.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")
);
} }
+7 -1
View File
@@ -5,7 +5,7 @@
//! is the current version. //! is the current version.
/// Current schema version. /// Current schema version.
pub const SCHEMA_VERSION: u32 = 5; pub const SCHEMA_VERSION: u32 = 6;
/// Migrations, in order. `MIGRATIONS[i]` migrates from version `i` to `i+1`. /// Migrations, in order. `MIGRATIONS[i]` migrates from version `i` to `i+1`.
pub const MIGRATIONS: &[&str] = &[ pub const MIGRATIONS: &[&str] = &[
@@ -179,5 +179,11 @@ ALTER TABLE lines ADD COLUMN class_id INTEGER;
-- keeps the filing and the withdrawal. -- keeps the filing and the withdrawal.
ALTER TABLE attachments ADD COLUMN withdrawn_at TEXT; ALTER TABLE attachments ADD COLUMN withdrawn_at TEXT;
ALTER TABLE attachments ADD COLUMN withdrawn_reason TEXT; ALTER TABLE attachments ADD COLUMN withdrawn_reason TEXT;
"#,
r#"
-- v6: tax categories on accounts (where each account lands on the return,
-- and how much of it is deductible). Percent is TEXT, never REAL.
ALTER TABLE accounts ADD COLUMN tax_category TEXT;
ALTER TABLE accounts ADD COLUMN deductible_percent TEXT;
"#, "#,
]; ];
+47
View File
@@ -1789,6 +1789,30 @@ impl Store {
Ok((id, sha, dedup)) Ok((id, sha, dedup))
} }
/// Set (or clear) an account's tax category and deductible percent.
/// Journaled as `account.tax_set`.
pub fn set_account_tax(
&mut self,
account: AccountId,
category: Option<&str>,
deductible_percent: Option<Decimal>,
) -> Result<(), StoreError> {
self.ledger
.check_account_tax(account, category, deductible_percent)?;
let line = self.append_journal(
"account.tax_set",
json!({
"account_id": account,
"tax_category": category,
"deductible_percent": deductible_percent.map(|p| p.to_string()),
}),
)?;
self.ledger
.set_account_tax(account, category, deductible_percent)?;
apply_line_to_sqlite(&self.conn, &line)?;
Ok(())
}
/// Link a previously filed (unlinked) receipt to an entry. A receipt /// Link a previously filed (unlinked) receipt to an entry. A receipt
/// already linked elsewhere must be unlinked first, so moving evidence /// already linked elsewhere must be unlinked first, so moving evidence
/// off an entry always goes through [`Store::unlink_receipt`] and its /// off an entry always goes through [`Store::unlink_receipt`] and its
@@ -2712,6 +2736,19 @@ fn replay_line_into_ledger(
))); )));
} }
} }
"account.tax_set" => {
let pct = match p.get("deductible_percent").and_then(|v| v.as_str()) {
Some(s) => Some(s.parse().map_err(|_| {
StoreError::JournalCorrupt("account.tax_set deductible_percent".into())
})?),
None => None,
};
ledger.set_account_tax(
get_u64(p, "account_id")?,
p.get("tax_category").and_then(|v| v.as_str()),
pct,
)?;
}
"receipt.attached" "receipt.attached"
| "receipt.linked" | "receipt.linked"
| "receipt.unlinked" | "receipt.unlinked"
@@ -3074,6 +3111,16 @@ fn apply_line_to_sqlite(conn: &Connection, line: &JournalLine) -> Result<(), Sto
], ],
)?; )?;
} }
"account.tax_set" => {
conn.execute(
"UPDATE accounts SET tax_category = ?, deductible_percent = ? WHERE id = ?",
params![
p.get("tax_category").and_then(|v| v.as_str()),
p.get("deductible_percent").and_then(|v| v.as_str()),
get_u64(p, "account_id")? as i64,
],
)?;
}
"receipt.unlinked" => { "receipt.unlinked" => {
conn.execute( conn.execute(
"UPDATE attachments SET entry_id = NULL WHERE id = ?", "UPDATE attachments SET entry_id = NULL WHERE id = ?",
+19
View File
@@ -218,6 +218,25 @@ posting.
get "Members' equity" with members named; the balance sheet separates get "Members' equity" with members named; the balance sheet separates
retained earnings from the fiscal year's net income (loss). retained earnings from the fiscal year's net income (loss).
## Tools — tax categories
- `books.tax_categories {}` → `{ form?, categories: [{ key, label, kind,
default_deductible_percent, note }] }`: where an account can land on the
income tax return, filtered to the business's return when known
(`[company] tax_form`, or derived from `tax_classification`). Stable
semantic keys (`meals`, `guaranteed_payments`, `commissions_fees`, ...),
never form line numbers, which change by year.
- `books.set_tax_category { account, category?, deductible_percent? }`:
map a revenue or expense account to a category (null clears),
optionally overriding its deductible percent. Journaled as
`account.tax_set`.
- `books.export { report: "tax_summary" }`: book vs. tax amounts by
category, the difference where they disagree (meals 50%, penalties 0%),
and every P&L account still uncategorized. In the CPA package for the
fiscal year to date.
- `books.import_chart_csv` accepts an optional fifth `tax_category` column,
validated before anything is created.
## Tools — import ## Tools — import
- `books.import_bank_csv { csv_text, bank_account_code, default_offset_code, - `books.import_bank_csv { csv_text, bank_account_code, default_offset_code,
+1
View File
@@ -24,6 +24,7 @@ No other statuses.
| General ledger by account (running balance, split) | Reports → General Ledger | partial | hand-verified fixture, ties to trial balance; no golden file yet | | General ledger by account (running balance, split) | Reports → General Ledger | partial | hand-verified fixture, ties to trial balance; no golden file yet |
| Trial balance as of a date | Reports → Trial Balance (as of) | partial | hand-verified fixture; no golden file yet | | Trial balance as of a date | Reports → Trial Balance (as of) | partial | hand-verified fixture; no golden file yet |
| Statement of changes in members' equity | Reports → Statement of Changes in Equity | partial | hand-verified two-member fixture incl. rounding and a close; no golden file yet | | Statement of changes in members' equity | Reports → Statement of Changes in Equity | partial | hand-verified two-member fixture incl. rounding and a close; no golden file yet |
| Tax line mapping + tax summary | Accounts → Tax line mapping; Tax reports | partial | hand-verified fixture (50% meals, nondeductible, contra income); no golden file yet |
| Reversing entries | Reverse transaction | done | linked via `reversal_of` | | Reversing entries | Reverse transaction | done | linked via `reversal_of` |
| Canonical report snapshots (JSON) | — (our oracle format) | done | `reports::snapshot` | | Canonical report snapshots (JSON) | — (our oracle format) | done | `reports::snapshot` |
| Fixed-asset register + straight-line depreciation | Fixed Asset register | done | `assets.rs`; golden-covered schedule; idempotent runs; disposal with gain/loss | | Fixed-asset register + straight-line depreciation | Fixed Asset register | done | `assets.rs`; golden-covered schedule; idempotent runs; disposal with gain/loss |
+8
View File
@@ -65,6 +65,14 @@ requirement conflict, the requirement wins.
to the cent, the last member absorbing rounding, so every row's total is to the cent, the last member absorbing rounding, so every row's total is
exact. Any equity change the roll-forward doesn't explain is shown as exact. Any equity change the roll-forward doesn't explain is shown as
"other", never hidden. "other", never hidden.
- **R-9d — Tax categories.** Revenue and expense accounts map to stable
tax categories (`ledger_core::tax::CATEGORIES`: key, label, income /
cost of goods / deduction, the returns it appears on — Schedule C, 1065,
1120-S, 1120 — default deductible percent, guidance), with an optional
per-account deductible percent. `tax_summary(start, end, form)` groups
the P&L by category with book and deductible amounts and lists
uncategorized accounts. Book basis only: no tax depreciation, basis, or
filing; not tax advice.
- **R-10 — Canonical snapshots.** `reports::snapshot()` renders the three - **R-10 — Canonical snapshots.** `reports::snapshot()` renders the three
core reports as deterministic JSON (decimal strings, sorted lines, fixed core reports as deterministic JSON (decimal strings, sorted lines, fixed
keys). Two engines fed the same books must produce identical output. This keys). Two engines fed the same books must produce identical output. This
+7
View File
@@ -214,4 +214,11 @@ interface is MCP, not a cloned GUI. The first storage + MCP slice is done
accounts carry allocated earnings; the members'-equity statement treats accounts carry allocated earnings; the members'-equity statement treats
those closes as income, not contributions, and the balance sheet shows those closes as income, not contributions, and the balance sheet shows
only unallocated earnings. Replay now checks closing lines, not just ids. only unallocated earnings. Replay now checks closing lines, not just ids.
- 2026-10-11: Tax categories (Stephen: wanderbooks is a general MCP-first
accounting application, so features ship for every small business, not
when Wanderland needs them). Accounts map to stable categories rather than
form line numbers; each category knows its returns and default
deductible percent; the tax summary shows book vs. tax by category and
every uncategorized account. tax_form comes from config or the tax
classification. Schema v6.