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

+94 -14
View File
@@ -30,11 +30,12 @@
//! D-U-N-S number's own key aside (nine digits that are no TIN).
//! 5. Unset keys mean the defaults the settings file had: "Unnamed
//! company", a calendar year, accrual basis, receipts above 25.00,
//! parties suggested, approval rules off, the computer's time zone.
//! parties suggested, a missing tax category warned of (not
//! refused), approval rules off, the computer's time zone.
//! ```
use crate::TaxForm;
use chrono::{Datelike, NaiveDate};
use chrono::NaiveDate;
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
@@ -150,6 +151,8 @@ const ACCRUAL_CASH: &[(&str, &[&str])] = &[("accrual", &[]), ("cash", &[])];
const PARTY_POLICY: &[(&str, &[&str])] =
&[("off", &[]), ("suggest", &[]), ("require", &["required"])];
const APPROVAL_MODE: &[(&str, &[&str])] = &[("off", &[]), ("enforce", &[])];
const TAX_CATEGORY_POLICY: &[(&str, &[&str])] =
&[("warn", &[]), ("refuse", &["required", "require"])];
/// A return's key as a setting keeps it.
pub fn tax_form_key(f: TaxForm) -> &'static str {
@@ -359,6 +362,13 @@ pub static KEYS: &[Key] = &[
sensitivity: Sensitivity::Public,
doc: "Whether entries name their counterparty: off, suggest (a draft without one names the likely one), or require (posting is refused until an entry names a party or is marked as having none). Unset: suggest.",
},
Key {
name: "policy.require_tax_category",
toml: ("books", "require_tax_category"),
kind: Kind::Choice(TAX_CATEGORY_POLICY),
sensitivity: Sensitivity::Public,
doc: "Whether a revenue or expense account (not a header) must have a tax category, the line of the return it maps to: warn (it is created, the result and Needs attention say so) or refuse (books.add_account, books.import_chart_csv and books.set_tax_category won't leave one without; accounts invoices, bills and payroll create are never refused, and are listed). Existing accounts are untouched. Unset: warn.",
},
Key {
name: "policy.approval_rules",
toml: ("books", "approval_rules"),
@@ -491,22 +501,29 @@ impl Company {
.collect()
}
/// The fiscal year containing `d`: `(first day, last day)`, inclusive.
/// The fiscal year containing `d`: `(first day, last day)`, inclusive,
/// as chrono counts them (unclamped: a July year holding 0001-03-01
/// starts 0000-07-01). The first day is
/// [`crate::calendar::fiscal_year_start_on`]'s, before its clamp.
pub fn fiscal_year_containing(&self, d: NaiveDate) -> (NaiveDate, NaiveDate) {
let (m, day) = self.fiscal_year_start;
let on = |year| NaiveDate::from_ymd_opt(year, m, day).expect("validated month-day");
let start = if on(d.year()) <= d {
on(d.year())
} else {
on(d.year() - 1)
};
let end = on(start.year() + 1)
.pred_opt()
.expect("not the minimum date");
(start, end)
crate::calendar::fiscal_year_unclamped(self.fiscal_year_start, d)
}
}
/// `policy.require_tax_category`: whether a revenue or expense account
/// must map to a line of the return (rules.md B-32; DECISION, owner,
/// 2026-10-20: warn by default; a company may refuse).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum TaxCategoryPolicy {
/// An account is created without one, and the result and Needs
/// attention say so.
#[default]
Warn,
/// The chart tools refuse to create or leave a revenue or expense
/// account (not a header) without one.
Refuse,
}
/// `policy.parties`: whether entries name their counterparty
/// (docs/parties-and-attachments.md "Counterparties"; DECISION, owner,
/// 2026-10-15: the default suggests, books may require or turn it off).
@@ -1286,6 +1303,14 @@ impl Settings {
.unwrap_or_default()
}
/// `policy.require_tax_category` (unset: warn).
pub fn require_tax_category(&self) -> TaxCategoryPolicy {
match self.str("policy.require_tax_category") {
Some("refuse") => TaxCategoryPolicy::Refuse,
_ => TaxCategoryPolicy::Warn,
}
}
/// `policy.approval_rules`.
pub fn approval_rules(&self) -> ApprovalMode {
match self.str("policy.approval_rules") {
@@ -1563,6 +1588,61 @@ mod tests {
assert_eq!(s.get("company.name").unwrap().seq, 7);
}
/// policy.require_tax_category (rules.md B-32): warn or refuse, one
/// spelling each ("required" and "require" are refuse), nothing else
/// ("off" is no value: the warning is the floor); unset is warn.
#[test]
fn the_tax_category_policy_is_warn_or_refuse() {
let s = Settings::default();
assert_eq!(s.require_tax_category(), TaxCategoryPolicy::Warn);
let one = |v: &str| set(&s, "policy.require_tax_category", json!(v));
for (spelling, canon) in [
("warn", "warn"),
(" Warn ", "warn"),
("refuse", "refuse"),
("REFUSE", "refuse"),
("required", "refuse"),
("require", "refuse"),
] {
assert_eq!(
one(spelling).unwrap()[0].value,
Some(json!(canon)),
"{spelling}"
);
}
for bad in ["off", "none", "", "yes", "true"] {
assert!(one(bad).is_err(), "{bad}");
}
let mut s = Settings::default();
s.apply(&one("refuse").unwrap(), &src(), 3).unwrap();
assert_eq!(s.require_tax_category(), TaxCategoryPolicy::Refuse);
s.apply(&one("warn").unwrap(), &src(), 4).unwrap();
assert_eq!(s.require_tax_category(), TaxCategoryPolicy::Warn);
// Cleared: back to the default.
s.apply(
&[Change {
key: "policy.require_tax_category".into(),
value: None,
}],
&src(),
5,
)
.unwrap();
assert_eq!(s.require_tax_category(), TaxCategoryPolicy::Warn);
// A value only a later version writes reads as the default here
// (replay takes any string, INVARIANT 3).
s.apply(
&[Change {
key: "policy.require_tax_category".into(),
value: Some(json!("refuse_later")),
}],
&src(),
6,
)
.unwrap();
assert_eq!(s.require_tax_category(), TaxCategoryPolicy::Warn);
}
/// INVARIANT 4: no full TIN outside company.ein.
#[test]
fn no_full_tin_but_the_companys_ein() {