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