P3: rename accounts, one set of period defaults, tax categories warn or refuse (defects 15, 16, 8)
check / check (push) Canceled after 0s

books.rename_account; account names trimmed, bounded, refused when a
sibling's or TIN-shaped; books.show refuses what its target ignores. A
report asked for without dates covers this fiscal year so far, everywhere
(one rule for tools, descriptions and the viewer; the IIF and CPA package
keep their own dates). policy.require_tax_category warns or refuses; a
Form 4797 gain is shown apart on every return.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
This commit is contained in:
swaitsandClaude Opus 5.5 committed 2026-10-21 10:41:24 -06:00
1 parent 8255ac2103
commit 9ab003d0d1
69 files changed
+8063 -570

No files matched your search

+333
View File
@@ -342,3 +342,336 @@ fn a_sole_proprietor_cannot_deduct_a_gift_on_schedule_c() {
.is_ok()
);
}
// ---------------------------------------------------------------------------
// Which accounts need a category, and the policy (P3, defect 8; rules.md
// B-32).
// ---------------------------------------------------------------------------
fn set_policy(l: &mut Ledger, value: &str) {
use ledger_core::settings::{Change, Source};
let src = Source {
decided_by: "Owner".into(),
decided_on: d(10, 20),
citation: None,
note: None,
};
let ch = l
.check_settings(
&[Change {
key: "policy.require_tax_category".into(),
value: Some(serde_json::json!(value)),
}],
&src,
)
.unwrap();
l.apply_settings(&ch, &src, 1).unwrap();
}
/// needs_category: revenue and expense accounts that aren't headers. A
/// header never reaches the return: no entry posts to one, a replayed one
/// included (review R13 supposed an old journal could; replay refuses it).
#[test]
fn revenue_and_expense_accounts_need_a_category_headers_dont() {
use ledger_core::tax::needs_category;
for (t, header, needs) in [
(AccountType::Revenue, false, true),
(AccountType::Expense, false, true),
(AccountType::Revenue, true, false),
(AccountType::Expense, true, false),
(AccountType::Asset, false, false),
(AccountType::Liability, false, false),
(AccountType::Equity, false, false),
(AccountType::Asset, true, false),
] {
assert_eq!(needs_category(t, header), needs, "{t:?} header {header}");
}
let mut l = Ledger::new(Currency::USD);
let cash = l
.add_account("1000", "Cash", AccountType::Asset, None, false)
.unwrap();
let group = l
.add_account("6000", "Operating", AccountType::Expense, None, true)
.unwrap();
let a = l.chart().get(group).unwrap().clone();
assert!(!ledger_core::tax::account_needs_category(&a));
let e = l.create_entry(d(2, 1), "a posting to a header");
let m = Money::new(dec("5.00"), Currency::USD);
l.replay_add_line(e, group, Side::Debit, m).unwrap();
l.replay_add_line(e, cash, Side::Credit, m).unwrap();
assert_eq!(
l.replay_post_entry(e),
Err(LedgerError::PlaceholderAccount("6000".into()))
);
}
/// uncategorized_accounts (repro 1.4.2-1.4.3): every revenue and expense
/// account without a category, active or not, last year's included,
/// sorted by code; headers without postings and categorized accounts
/// aren't listed.
#[test]
fn every_uncategorized_pl_account_is_listed_active_or_not() {
let mut l = Ledger::new(Currency::USD);
let cash = l
.add_account("1000", "Cash", AccountType::Asset, None, false)
.unwrap();
l.add_account("6000", "Operating", AccountType::Expense, None, true)
.unwrap();
let software = l
.add_account("6150", "Software", AccountType::Expense, None, false)
.unwrap();
let last_year = l
.add_account("6160", "Hosting", AccountType::Expense, None, false)
.unwrap();
let sales = l
.add_account("4000", "Sales", AccountType::Revenue, None, false)
.unwrap();
l.set_account_tax(sales, Some("gross_receipts"), None)
.unwrap();
l.add_account("4900", "Other income", AccountType::Revenue, None, false)
.unwrap();
let e = l.create_entry(NaiveDate::from_ymd_opt(2025, 6, 1).unwrap(), "last year");
let m = Money::new(dec("12.00"), Currency::USD);
l.add_line(e, last_year, Side::Debit, m).unwrap();
l.add_line(e, cash, Side::Credit, m).unwrap();
l.post_entry(e).unwrap();
let listed: Vec<String> = ledger_core::tax::uncategorized_accounts(&l)
.into_iter()
.map(|a| format!("{} {}", a.code, a.name))
.collect();
assert_eq!(
listed,
["4900 Other income", "6150 Software", "6160 Hosting"]
);
l.set_account_tax(software, Some("other_deductions"), None)
.unwrap();
assert_eq!(ledger_core::tax::uncategorized_accounts(&l).len(), 2);
}
/// check_tax_category_required's truth table: refused iff the policy is
/// refuse, the account needs a category (revenue or expense, not a
/// header) and none is given; the message names the account and where
/// the categories are.
#[test]
fn the_policy_refuses_exactly_a_needed_category_left_out() {
use ledger_core::settings::TaxCategoryPolicy;
let types = [
AccountType::Asset,
AccountType::Liability,
AccountType::Equity,
AccountType::Revenue,
AccountType::Expense,
];
for policy in ["warn", "refuse"] {
let mut l = Ledger::new(Currency::USD);
set_policy(&mut l, policy);
let refuse = l.settings().require_tax_category() == TaxCategoryPolicy::Refuse;
assert_eq!(refuse, policy == "refuse");
for t in types {
for header in [false, true] {
for category in [None, Some("other_income")] {
let got =
l.check_tax_category_required("6150", "Software", t, header, category);
let needs = matches!(t, AccountType::Revenue | AccountType::Expense) && !header;
if refuse && needs && category.is_none() {
let e = got.unwrap_err();
assert_eq!(
e,
LedgerError::TaxCategoryRequired {
account: "6150 Software".into(),
clearing: false
}
);
assert!(e.to_string().contains("books.tax_categories"), "{e}");
} else {
assert_eq!(got, Ok(()), "{policy} {t:?} {header} {category:?}");
}
}
}
}
}
}
/// check_tax_category_kept: under refuse, an account that needs a
/// category can't be left without one (cleared, or "cleared" while it has
/// none); setting one, or any account under warn, passes.
#[test]
fn under_refuse_a_needed_category_is_never_cleared() {
let mut l = Ledger::new(Currency::USD);
let software = l
.add_account("6150", "Software", AccountType::Expense, None, false)
.unwrap();
let bare = l
.add_account("6160", "Hosting", AccountType::Expense, None, false)
.unwrap();
let cash = l
.add_account("1000", "Cash", AccountType::Asset, None, false)
.unwrap();
l.set_account_tax(software, Some("other_deductions"), None)
.unwrap();
for id in [software, bare, cash] {
assert_eq!(l.check_tax_category_kept(id, None), Ok(()));
}
set_policy(&mut l, "refuse");
assert_eq!(
l.check_tax_category_kept(software, None),
Err(LedgerError::TaxCategoryRequired {
account: "6150 Software".into(),
clearing: true
})
);
assert_eq!(
l.check_tax_category_kept(bare, None),
Err(LedgerError::TaxCategoryRequired {
account: "6160 Hosting".into(),
clearing: false
})
);
assert_eq!(l.check_tax_category_kept(cash, None), Ok(()));
assert_eq!(
l.check_tax_category_kept(software, Some("supplies")),
Ok(())
);
assert_eq!(
l.check_tax_category_kept(999, None),
Err(LedgerError::UnknownAccount(999))
);
// The engine's own set_account_tax (which replay calls) never checks
// the policy (rules.md B-28): a journaled clear replays.
l.set_account_tax(software, None, None).unwrap();
}
/// rules.md B-32 (review R3): every account a tool can require has a
/// category that fits it on every return: a disposal's gain or loss
/// account (revenue or expense: Form 4797), tax-exempt income on the
/// entity returns, and each control role's account.
#[test]
fn every_account_a_tool_requires_has_a_fitting_category_on_every_return() {
for form in TaxForm::ALL {
for t in [AccountType::Revenue, AccountType::Expense] {
assert!(
check_category("form_4797_gain_loss", t, Some(form)).is_ok(),
"{form:?} {t:?}"
);
}
for c in ledger_core::control_accounts()
.filter(|c| matches!(c.account_type, AccountType::Revenue | AccountType::Expense))
{
let fits = ledger_core::tax::CATEGORIES
.iter()
.any(|cat| check_category(cat.key, c.account_type, Some(form)).is_ok());
assert!(fits, "{} on {form:?}", c.code);
}
let exempt = check_category("tax_exempt_income", AccountType::Revenue, Some(form));
assert_eq!(exempt.is_ok(), form != TaxForm::ScheduleC, "{form:?}");
}
let c = ledger_core::tax::category("tax_exempt_income").unwrap();
assert_eq!(c.default_deductible_percent, 0);
let c = ledger_core::tax::category("form_4797_gain_loss").unwrap();
assert_eq!(c.default_deductible_percent, 100);
}
/// Tax-exempt income (Form 1065 Schedule K 18a-18b) is book income with
/// no taxable amount: 0% of 300.00 is 0.00, so book net and tax net
/// differ by it, the income twin of nondeductible.
#[test]
fn tax_exempt_income_is_book_income_with_no_tax_amount() {
let mut l = Ledger::new(Currency::USD);
let cash = l
.add_account("1000", "Cash", AccountType::Asset, None, false)
.unwrap();
let muni = l
.add_account(
"4800",
"Municipal bond interest",
AccountType::Revenue,
None,
false,
)
.unwrap();
l.set_account_tax(muni, Some("tax_exempt_income"), None)
.unwrap();
let e = l.create_entry(d(3, 1), "coupon");
let m = Money::new(dec("300.00"), Currency::USD);
l.add_line(e, cash, Side::Debit, m).unwrap();
l.add_line(e, muni, Side::Credit, m).unwrap();
l.post_entry(e).unwrap();
let s = tax_summary(&l, d(1, 1), d(12, 31), Some(TaxForm::Form1065)).unwrap();
assert_eq!(s.income.len(), 1);
assert_eq!(s.income[0].category, "tax_exempt_income");
assert_eq!(s.income[0].book, dec("300.00"));
assert_eq!(s.income[0].tax, dec("0.00"));
assert_eq!(s.book_net(), dec("300.00"));
assert_eq!(s.tax_net(), dec("0.00"));
}
/// Oracle: docs/spec/sources/irs-tax-lines.md. A gain or loss on business
/// property is never ordinary income the summary can place: Form 1065
/// line 6 and Form 1120-S line 4 take "only ordinary gains or losses"
/// from Form 4797, net section 1231 gain is Schedule K line 10 (1065) or
/// line 9 (1120-S), and the Schedule C instructions send sales of
/// business property to "Form 4797", not Schedule C. Form 4797 figures
/// the gain from the tax basis, which the books don't keep. So on every
/// return (and with none set) the 150.00 gain and the 40.00 loss are
/// listed apart at their book amounts (110.00 net), outside income and
/// both nets: the nets are the 1,000.00 of sales alone.
#[test]
fn a_gain_on_business_property_is_figured_on_form_4797_on_every_return() {
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, cat: &str| {
let id = l.add_account(code, name, t, None, false).unwrap();
l.set_account_tax(id, Some(cat), None).unwrap();
id
};
let sales = acct("4000", "Sales", AccountType::Revenue, "gross_receipts");
let gain = acct(
"4700",
"Gain on disposal",
AccountType::Revenue,
"form_4797_gain_loss",
);
let loss = acct(
"6700",
"Loss on disposal",
AccountType::Expense,
"form_4797_gain_loss",
);
let usd = |s: &str| Money::new(dec(s), Currency::USD);
for (dr, cr, amt) in [
(cash, sales, "1000.00"),
(cash, gain, "150.00"),
(loss, cash, "40.00"),
] {
let e = l.create_entry(d(4, 1), "x");
l.add_line(e, dr, Side::Debit, usd(amt)).unwrap();
l.add_line(e, cr, Side::Credit, usd(amt)).unwrap();
l.post_entry(e).unwrap();
}
for form in [None].into_iter().chain(TaxForm::ALL.into_iter().map(Some)) {
let ts = tax_summary(&l, d(1, 1), d(12, 31), form).unwrap();
let keys = |ls: &[ledger_core::TaxSummaryLine]| -> Vec<String> {
ls.iter().map(|x| x.category.clone()).collect()
};
assert_eq!(keys(&ts.income), ["gross_receipts"], "{form:?}");
assert!(ts.deductions.is_empty(), "{form:?}");
assert!(ts.separately_stated.is_empty(), "{form:?}");
assert_eq!(
ts.form_4797,
[ledger_core::BookOnlyLine {
category: "form_4797_gain_loss".into(),
label: "Gain or loss on business property (Form 4797)".into(),
accounts: vec![
"4700 Gain on disposal".into(),
"6700 Loss on disposal".into()
],
book: dec("110.00"),
}],
"{form:?}"
);
assert_eq!(ts.book_net(), dec("1000.00"), "{form:?}");
assert_eq!(ts.tax_net(), dec("1000.00"), "{form:?}");
}
}