feat: tax categories and the tax summary report
wanderbooks is a general MCP-first accounting application: every business needs its accounts mapped to where they land on its income tax return, and its CPA needs book-to-tax differences surfaced, not hidden. - ledger_core::tax: a catalog of stable categories (gross_receipts, commissions_fees, meals, guaranteed_payments, officer_compensation, nondeductible, ...) with kind (income, cost of goods, deduction), the returns each appears on (Schedule C, 1065, 1120-S, 1120), a default deductible percent (meals 50, nondeductible 0), and guidance. Keys, not line numbers: form lines move from year to year. - Accounts carry tax_category and an optional deductible_percent override, validated against the account type (and the return, at the MCP layer), journaled as account.tax_set, replayed, projected (schema v6). - tax_summary(start, end, form): the P&L grouped by category, book and deductible amounts (half away from zero to the cent), contra income (refunds) reducing receipts, and every account still uncategorized. - MCP: books.tax_categories (filtered to the configured return), books.set_tax_category, a tax_summary report in every format and in the CPA package, chart output with categories, and a fifth tax_category column in chart CSV imports, validated before anything is created. [company] tax_form, or derived from tax_classification. Scope stays book basis: no tax depreciation, basis, or filing. Tests: a hand-computed quarter (50% meals 333.33 -> 166.67, penalties at 0%, refunds as negative receipts, guaranteed payments, one uncategorized account; book net 5,276.67 vs tax 5,483.33); overrides; category/account /return validation; form parsing and derivation; CSV parsing; stdio: filtered catalog, mapping, refusal, summary, all-or-nothing import. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MtYEaR6M6Ybdhm6X98BWNu
This commit is contained in:
18 files changed
+1352
-14
No files matched your search
@@ -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<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.
|
||||
@@ -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<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.
|
||||
pub fn len(&self) -> usize {
|
||||
self.accounts.len()
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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<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.
|
||||
///
|
||||
/// This is the unchecked constructor: replay and internal callers
|
||||
|
||||
@@ -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};
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -24,16 +24,19 @@ pub struct ChartRow {
|
||||
pub account_type: String,
|
||||
/// Parent account code, if any.
|
||||
pub parent_code: Option<String>,
|
||||
/// Optional fifth column: the tax category key (see
|
||||
/// `ledger_core::tax`).
|
||||
pub tax_category: Option<String>,
|
||||
}
|
||||
|
||||
/// Parse errors.
|
||||
#[derive(Debug, Error, PartialEq)]
|
||||
pub enum ChartCsvError {
|
||||
/// Wrong column count.
|
||||
#[error("expected 4 columns (code,name,type,parent_code), found {0}")]
|
||||
BadColumnCount(usize),
|
||||
/// Wrong column count: (found, expected by the header).
|
||||
#[error("expected {1} columns to match the header, found {0}")]
|
||||
BadColumnCount(usize, usize),
|
||||
/// Missing or bad header.
|
||||
#[error("bad header: expected code,name,type,parent_code")]
|
||||
#[error("bad header: expected code,name,type,parent_code[,tax_category]")]
|
||||
BadHeader,
|
||||
/// Unknown account type.
|
||||
#[error("bad account type {0:?}: expected asset, liability, equity, revenue, or expense")]
|
||||
@@ -48,9 +51,11 @@ pub enum ChartCsvError {
|
||||
pub fn parse_chart_csv(text: &str) -> Result<Vec<ChartRow>, ChartCsvError> {
|
||||
let mut reader = csv::ReaderBuilder::new()
|
||||
.has_headers(false)
|
||||
.flexible(true)
|
||||
.trim(csv::Trim::All)
|
||||
.from_reader(text.as_bytes());
|
||||
let mut out = Vec::new();
|
||||
let mut width = 4;
|
||||
for (idx, record) in reader.records().enumerate() {
|
||||
let record = record.map_err(|e| ChartCsvError::Csv(e.to_string()))?;
|
||||
let fields: Vec<&str> = record.iter().collect();
|
||||
@@ -59,13 +64,17 @@ pub fn parse_chart_csv(text: &str) -> Result<Vec<ChartRow>, ChartCsvError> {
|
||||
}
|
||||
if idx == 0 {
|
||||
let lower: Vec<String> = fields.iter().map(|f| f.to_lowercase()).collect();
|
||||
if lower != ["code", "name", "type", "parent_code"] {
|
||||
width = if lower == ["code", "name", "type", "parent_code"] {
|
||||
4
|
||||
} else if lower == ["code", "name", "type", "parent_code", "tax_category"] {
|
||||
5
|
||||
} else {
|
||||
return Err(ChartCsvError::BadHeader);
|
||||
}
|
||||
};
|
||||
continue;
|
||||
}
|
||||
if fields.len() != 4 {
|
||||
return Err(ChartCsvError::BadColumnCount(fields.len()));
|
||||
if fields.len() != width {
|
||||
return Err(ChartCsvError::BadColumnCount(fields.len(), width));
|
||||
}
|
||||
let account_type = fields[2].to_lowercase();
|
||||
match account_type.as_str() {
|
||||
@@ -82,6 +91,11 @@ pub fn parse_chart_csv(text: &str) -> Result<Vec<ChartRow>, ChartCsvError> {
|
||||
} else {
|
||||
Some(parent_code.to_string())
|
||||
},
|
||||
tax_category: fields
|
||||
.get(4)
|
||||
.map(|s| s.trim())
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(str::to_string),
|
||||
});
|
||||
}
|
||||
Ok(out)
|
||||
@@ -101,6 +115,20 @@ mod tests {
|
||||
assert_eq!(rows[2].account_type, "revenue");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_optional_tax_category_column() {
|
||||
let csv = "code,name,type,parent_code,tax_category\n4000,Sales,revenue,,gross_receipts\n1000,Cash,asset,,\n";
|
||||
let rows = parse_chart_csv(csv).unwrap();
|
||||
assert_eq!(rows[0].tax_category.as_deref(), Some("gross_receipts"));
|
||||
assert_eq!(rows[1].tax_category, None);
|
||||
// Rows must match the header's width.
|
||||
let short = "code,name,type,parent_code,tax_category\n1000,Cash,asset,\n";
|
||||
assert_eq!(
|
||||
parse_chart_csv(short),
|
||||
Err(ChartCsvError::BadColumnCount(4, 5))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_type_rejected() {
|
||||
let csv = "code,name,type,parent_code\n1000,Cash,money,\n";
|
||||
|
||||
@@ -400,6 +400,85 @@ pub fn members_equity(me: &MembersEquityStatement) -> Statement {
|
||||
)
|
||||
}
|
||||
|
||||
/// Lay out the tax summary: book and tax amounts by tax category, the
|
||||
/// difference where they disagree (meals at 50%, penalties at 0%), and any
|
||||
/// account still needing a category.
|
||||
pub fn tax_summary(ts: &ledger_core::TaxSummary) -> Statement {
|
||||
let diff = |b: Decimal, t: Decimal| if b == t { None } else { Some(b - t) };
|
||||
let lines = |ls: &[ledger_core::TaxSummaryLine]| -> Vec<Row> {
|
||||
ls.iter()
|
||||
.map(|l| {
|
||||
let codes: Vec<&str> = l
|
||||
.accounts
|
||||
.iter()
|
||||
.map(|a| a.split_whitespace().next().unwrap_or(""))
|
||||
.collect();
|
||||
row(
|
||||
RowKind::Line,
|
||||
format!("{} ({})", l.label, codes.join(", ")),
|
||||
vec![Some(l.book), Some(l.tax), diff(l.book, l.tax)],
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
let total = |kind, label: &str, ls: &[ledger_core::TaxSummaryLine]| {
|
||||
let b: Decimal = ls.iter().map(|l| l.book).sum();
|
||||
let t: Decimal = ls.iter().map(|l| l.tax).sum();
|
||||
row(kind, label, vec![Some(b), Some(t), diff(b, t)])
|
||||
};
|
||||
let mut rows = vec![section("Income")];
|
||||
rows.extend(lines(&ts.income));
|
||||
rows.push(total(RowKind::Subtotal, "Total income", &ts.income));
|
||||
if !ts.cost_of_goods.is_empty() {
|
||||
rows.push(spacer());
|
||||
rows.push(section("Cost of goods sold"));
|
||||
rows.extend(lines(&ts.cost_of_goods));
|
||||
rows.push(total(
|
||||
RowKind::Subtotal,
|
||||
"Total cost of goods sold",
|
||||
&ts.cost_of_goods,
|
||||
));
|
||||
}
|
||||
rows.push(spacer());
|
||||
rows.push(section("Deductions"));
|
||||
rows.extend(lines(&ts.deductions));
|
||||
rows.push(total(RowKind::Subtotal, "Total deductions", &ts.deductions));
|
||||
rows.push(spacer());
|
||||
rows.push(row(
|
||||
RowKind::Total,
|
||||
"Income less deductions",
|
||||
vec![
|
||||
Some(ts.book_net()),
|
||||
Some(ts.tax_net()),
|
||||
diff(ts.book_net(), ts.tax_net()),
|
||||
],
|
||||
));
|
||||
if !ts.uncategorized.is_empty() {
|
||||
rows.push(spacer());
|
||||
rows.push(section("Not yet categorized"));
|
||||
rows.push(subheading(
|
||||
"Assign each a tax category (books.set_tax_category); not included above:",
|
||||
));
|
||||
rows.extend(ts.uncategorized.iter().map(|u| {
|
||||
row(
|
||||
RowKind::Line,
|
||||
u.account.clone(),
|
||||
vec![Some(u.book), None, None],
|
||||
)
|
||||
}));
|
||||
}
|
||||
let title = match ts.form {
|
||||
Some(f) => format!("Tax Summary ({} categories)", f.label()),
|
||||
None => "Tax Summary".to_string(),
|
||||
};
|
||||
statement(
|
||||
&title,
|
||||
period_label(ts.start, ts.end),
|
||||
&["Book", "Tax", "Difference"],
|
||||
rows,
|
||||
)
|
||||
}
|
||||
|
||||
/// "October 11, 2026".
|
||||
pub fn long_date(d: NaiveDate) -> String {
|
||||
d.format("%B %-d, %Y").to_string()
|
||||
|
||||
@@ -25,6 +25,9 @@ pub struct Company {
|
||||
pub members: Vec<Member>,
|
||||
/// "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<ledger_core::TaxForm>,
|
||||
}
|
||||
|
||||
/// 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<String>,
|
||||
fiscal_year_start: Option<String>,
|
||||
tax_form: Option<String>,
|
||||
tax_classification: Option<String>,
|
||||
#[serde(default)]
|
||||
members: Vec<MemberToml>,
|
||||
}
|
||||
@@ -104,6 +110,24 @@ struct BooksSection {
|
||||
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".
|
||||
fn parse_share(name: &str, s: &str) -> Result<Decimal, String> {
|
||||
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%"));
|
||||
}
|
||||
}
|
||||
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());
|
||||
|
||||
@@ -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<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(
|
||||
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<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> {
|
||||
let store = self.lock()?;
|
||||
let ledger = store.ledger();
|
||||
|
||||
@@ -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<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`.
|
||||
|
||||
@@ -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")
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
"#,
|
||||
];
|
||||
@@ -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<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
|
||||
/// 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 = ?",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user