diff --git a/crates/ledger-core/src/account.rs b/crates/ledger-core/src/account.rs index 811728c..1ece5ea 100644 --- a/crates/ledger-core/src/account.rs +++ b/crates/ledger-core/src/account.rs @@ -6,6 +6,7 @@ //! grouping on type, so getting this right is the whole game. use crate::{Currency, LedgerError}; +use rust_decimal::Decimal; use serde::{Deserialize, Serialize}; use std::collections::HashMap; @@ -72,6 +73,14 @@ pub struct Account { pub placeholder: bool, /// Day one: always the ledger's base currency (see R-5). pub currency: Currency, + /// Tax category key (see [`crate::tax`]), for revenue and expense + /// accounts. + #[serde(default)] + pub tax_category: Option, + /// Percent deductible, overriding the category's default (e.g. 50 for + /// meals, 0 for penalties). + #[serde(default)] + pub deductible_percent: Option, } /// The registry of accounts. Owns id allocation for accounts it holds. @@ -116,6 +125,22 @@ impl ChartOfAccounts { self.accounts.values() } + /// Set (or clear) an account's tax category and deductible percent. + pub fn set_tax( + &mut self, + id: AccountId, + category: Option, + deductible_percent: Option, + ) -> 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. pub fn len(&self) -> usize { self.accounts.len() diff --git a/crates/ledger-core/src/error.rs b/crates/ledger-core/src/error.rs index 55a46ef..865c57d 100644 --- a/crates/ledger-core/src/error.rs +++ b/crates/ledger-core/src/error.rs @@ -132,6 +132,11 @@ pub enum LedgerError { #[error("purchase date plus useful life overflows the calendar")] 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). #[error("invalid members: {0}")] InvalidMembers(String), diff --git a/crates/ledger-core/src/ledger.rs b/crates/ledger-core/src/ledger.rs index 9a4a1a9..2921115 100644 --- a/crates/ledger-core/src/ledger.rs +++ b/crates/ledger-core/src/ledger.rs @@ -228,10 +228,55 @@ impl Ledger { parent, placeholder, currency: self.base_currency, + tax_category: None, + deductible_percent: None, })?; 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, + ) -> 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, + ) -> 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. /// /// This is the unchecked constructor: replay and internal callers diff --git a/crates/ledger-core/src/lib.rs b/crates/ledger-core/src/lib.rs index 8f32cc5..d6a831f 100644 --- a/crates/ledger-core/src/lib.rs +++ b/crates/ledger-core/src/lib.rs @@ -27,6 +27,7 @@ pub mod money; pub mod payroll; pub mod periods; pub mod reports; +pub mod tax; pub use account::{Account, AccountId, AccountType, ChartOfAccounts, Side}; pub use arap::{ @@ -51,3 +52,4 @@ pub use reports::{ StatementLine, TrialBalance, TrialBalanceLine, balance_sheet_as_of, cash_flow, general_ledger, members_equity, profit_and_loss_for_period, trial_balance_as_of, }; +pub use tax::{TaxCategory, TaxForm, TaxKind, TaxSummary, TaxSummaryLine, tax_summary}; diff --git a/crates/ledger-core/src/tax.rs b/crates/ledger-core/src/tax.rs new file mode 100644 index 0000000..eaf0824 --- /dev/null +++ b/crates/ledger-core/src/tax.rs @@ -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 { + 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, +) -> 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, + /// 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, + /// Income categories. + pub income: Vec, + /// Cost of goods sold. + pub cost_of_goods: Vec, + /// Deductions. + pub deductions: Vec, + /// P&L accounts with activity and no category. + pub uncategorized: Vec, +} + +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, +) -> Result { + 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) +} diff --git a/crates/ledger-core/tests/tax.rs b/crates/ledger-core/tests/tax.rs new file mode 100644 index 0000000..fd788a6 --- /dev/null +++ b/crates/ledger-core/tests/tax.rs @@ -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); +} diff --git a/crates/ledger-import/src/chart_csv.rs b/crates/ledger-import/src/chart_csv.rs index ee45bb1..1b201f6 100644 --- a/crates/ledger-import/src/chart_csv.rs +++ b/crates/ledger-import/src/chart_csv.rs @@ -24,16 +24,19 @@ pub struct ChartRow { pub account_type: String, /// Parent account code, if any. pub parent_code: Option, + /// Optional fifth column: the tax category key (see + /// `ledger_core::tax`). + pub tax_category: Option, } /// 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, 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, ChartCsvError> { } if idx == 0 { let lower: Vec = 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, 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"; diff --git a/crates/ledger-import/src/report.rs b/crates/ledger-import/src/report.rs index 513c592..7f056f5 100644 --- a/crates/ledger-import/src/report.rs +++ b/crates/ledger-import/src/report.rs @@ -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 { + 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() diff --git a/crates/ledger-mcp/src/company.rs b/crates/ledger-mcp/src/company.rs index bbafd8f..657c5e7 100644 --- a/crates/ledger-mcp/src/company.rs +++ b/crates/ledger-mcp/src/company.rs @@ -25,6 +25,9 @@ pub struct Company { pub members: Vec, /// "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, } /// 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, fiscal_year_start: Option, + tax_form: Option, + tax_classification: Option, #[serde(default)] members: Vec, } @@ -104,6 +110,24 @@ struct BooksSection { accounting_basis: Option, } +/// 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 { + 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 { let s = s.trim(); @@ -158,6 +182,14 @@ pub fn parse(text: &str) -> Result { 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()); diff --git a/crates/ledger-mcp/src/server.rs b/crates/ledger-mcp/src/server.rs index 53da119..18e872e 100644 --- a/crates/ledger-mcp/src/server.rs +++ b/crates/ledger-mcp/src/server.rs @@ -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, 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, + ) -> Result, 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 { + 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::>(), + "cost_of_goods": ts.cost_of_goods.iter().map(line).collect::>(), + "deductions": ts.deductions.iter().map(line).collect::>(), + "uncategorized": ts.uncategorized.iter().map(|u| serde_json::json!({ + "account": u.account, "book": money(u.book), + })).collect::>(), + "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 { let store = self.lock()?; let ledger = store.ledger(); diff --git a/crates/ledger-mcp/src/types.rs b/crates/ledger-mcp/src/types.rs index ce8b823..b1fc7b6 100644 --- a/crates/ledger-mcp/src/types.rs +++ b/crates/ledger-mcp/src/types.rs @@ -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, + /// Deductible percent override, if any. + pub deductible_percent: Option, +} + +/// 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, + /// Percent deductible, overriding the category default (decimal + /// string, 0 to 100), e.g. "100" for a fully deductible company party. + pub deductible_percent: Option, +} + +/// 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, + /// Categories. + pub categories: Vec, } /// Result of `books.chart`. diff --git a/crates/ledger-mcp/tests/mcp_stdio.rs b/crates/ledger-mcp/tests/mcp_stdio.rs index 8b739b9..0dcc18b 100644 --- a/crates/ledger-mcp/tests/mcp_stdio.rs +++ b/crates/ledger-mcp/tests/mcp_stdio.rs @@ -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") + ); } diff --git a/crates/ledger-store/src/schema.rs b/crates/ledger-store/src/schema.rs index f04b67c..69b803a 100644 --- a/crates/ledger-store/src/schema.rs +++ b/crates/ledger-store/src/schema.rs @@ -5,7 +5,7 @@ //! is the current 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`. pub const MIGRATIONS: &[&str] = &[ @@ -179,5 +179,11 @@ ALTER TABLE lines ADD COLUMN class_id INTEGER; -- keeps the filing and the withdrawal. ALTER TABLE attachments ADD COLUMN withdrawn_at 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; "#, ]; diff --git a/crates/ledger-store/src/store.rs b/crates/ledger-store/src/store.rs index 6ba3f92..4bc2d98 100644 --- a/crates/ledger-store/src/store.rs +++ b/crates/ledger-store/src/store.rs @@ -1789,6 +1789,30 @@ impl Store { 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, + ) -> 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 /// already linked elsewhere must be unlinked first, so moving evidence /// 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.linked" | "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" => { conn.execute( "UPDATE attachments SET entry_id = NULL WHERE id = ?", diff --git a/docs/mcp-interface.md b/docs/mcp-interface.md index 3c8c1ae..bc4aac7 100644 --- a/docs/mcp-interface.md +++ b/docs/mcp-interface.md @@ -218,6 +218,25 @@ posting. get "Members' equity" with members named; the balance sheet separates 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 - `books.import_bank_csv { csv_text, bank_account_code, default_offset_code, diff --git a/docs/parity.md b/docs/parity.md index 271baf3..cd600ef 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -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 | | 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 | +| 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` | | 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 | diff --git a/docs/requirements.md b/docs/requirements.md index c2f63d6..f143888 100644 --- a/docs/requirements.md +++ b/docs/requirements.md @@ -65,6 +65,14 @@ requirement conflict, the requirement wins. 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 "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 core reports as deterministic JSON (decimal strings, sorted lines, fixed keys). Two engines fed the same books must produce identical output. This diff --git a/plan/STATUS.md b/plan/STATUS.md index 3344c39..6eefdf5 100644 --- a/plan/STATUS.md +++ b/plan/STATUS.md @@ -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 those closes as income, not contributions, and the balance sheet shows 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.