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
@@ -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 = ?",
|
||||
|
||||
Reference in new issue
Block a user