P3: rename accounts, one set of period defaults, tax categories warn or refuse (defects 15, 16, 8)
check / check (push) Canceled after 0s
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:
69 files changed
+8063
-570
No files matched your search
@@ -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:?}");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user