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

+18
View File
@@ -56,6 +56,12 @@ impl AccountType {
}
}
/// The longest name a new account (or a rename) may take, in characters:
/// one line that every report prints, as long as the company's own name
/// may be ([`crate::settings::MAX_TEXT`]). The command's bound, never
/// replay's (rules.md B-28): a journaled name of any length replays.
pub const MAX_ACCOUNT_NAME: usize = crate::settings::MAX_TEXT;
/// A single account in the chart of accounts.
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct Account {
@@ -143,6 +149,18 @@ impl ChartOfAccounts {
Ok(())
}
/// Give an account a new name. Only the account must exist: what a
/// new name may be is the command's to check
/// ([`crate::Ledger::rename_account`]), never replay's (rules.md B-28).
pub fn set_name(&mut self, id: AccountId, name: &str) -> Result<(), LedgerError> {
let a = self
.accounts
.get_mut(&id)
.ok_or(LedgerError::UnknownAccount(id))?;
a.name = name.to_string();
Ok(())
}
/// Set (or clear) an account's tax category and deductible percent.
pub fn set_tax(
&mut self,
+384 -1
View File
@@ -18,7 +18,8 @@
//! ```
use crate::LedgerError;
use chrono::NaiveDate;
use crate::settings::Company;
use chrono::{Datelike, NaiveDate};
/// The first day the books keep.
pub const FIRST_DAY: NaiveDate = match NaiveDate::from_ymd_opt(1, 1, 1) {
@@ -46,6 +47,207 @@ pub fn check(d: NaiveDate) -> Result<NaiveDate, LedgerError> {
}
}
/// The month and day a fiscal year starts on, as one every year has: the
/// books' `company.fiscal_year_start` always is (a new value is the first
/// of a month; a journaled one is a day 2001 has, and 2001 is no leap
/// year). Any other counts as January 1.
fn year_start(md: (u32, u32)) -> (u32, u32) {
if NaiveDate::from_ymd_opt(2001, md.0, md.1).is_some() {
md
} else {
(1, 1)
}
}
/// The first day of the fiscal year that holds `d` (a fiscal year
/// starting on month-day `md`), before any clamp: in year 0 for a day
/// early in year 1, which chrono holds and the books' calendar doesn't.
pub(crate) fn fiscal_year_start_unclamped(md: (u32, u32), d: NaiveDate) -> NaiveDate {
let (m, day) = year_start(md);
// A day every year has (year_start), in d's year or the one before:
// chrono holds both for every date it holds but its very first year,
// where the year before falls back to d itself.
let on = |y: i32| NaiveDate::from_ymd_opt(y, m, day).unwrap_or(d);
let this = on(d.year());
if this <= d { this } else { on(d.year() - 1) }
}
/// The fiscal year that holds `d`, `(first day, last day)`, unclamped
/// (`Company::fiscal_year_containing`): the last day is the day before
/// the next start, so the two can't disagree.
pub(crate) fn fiscal_year_unclamped(md: (u32, u32), d: NaiveDate) -> (NaiveDate, NaiveDate) {
let start = fiscal_year_start_unclamped(md, d);
let (m, day) = year_start(md);
let end = NaiveDate::from_ymd_opt(start.year() + 1, m, day)
.and_then(|next| next.pred_opt())
.unwrap_or(NaiveDate::MAX);
(start, end)
}
/// The first day of the fiscal year that holds `d`, for a fiscal year
/// starting on month-day `md` (`company.fiscal_year_start`), clamped to
/// [`FIRST_DAY`].
///
/// ```text
/// GOAL: the books' annual period: the one fact the fiscal year to date,
/// the CPA package's year, and every default period derive from.
/// SOURCE: IRS Pub. 538 (docs/spec/sources/irs-periods-and-methods.md):
/// "A tax year is an annual accounting period for keeping records
/// and reporting income and expenses"; rules.md B-28 (no date
/// outside the calendar).
/// INVARIANTS:
/// 1. For d in the calendar: FIRST_DAY <= result <= d, and d is before
/// the same month-day a year after the result (unless clamped).
/// 2. The result is md in d's year when that is on or before d, else md
/// in the year before; clamped to FIRST_DAY only when that is year
/// 0 (a non-January fiscal year, d early in year 1).
/// 3. NEVER panics, for any md and any chrono date.
/// ```
pub fn fiscal_year_start_on(md: (u32, u32), d: NaiveDate) -> NaiveDate {
fiscal_year_start_unclamped(md, d).max(FIRST_DAY)
}
/// What an omitted date means, by the kind of report (rules.md B-30): the
/// text every tool description and date parameter shows, so none can say
/// other than [`PeriodDefaults`] does.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum PeriodKind {
/// A report over a period (`start`, `end`): profit and loss, cash
/// flow, tax summary, members' equity, the general ledger and
/// journal, an account's register.
Statement,
/// A report at an instant (`as_of`): balance sheet, trial balance,
/// aging, the fixed asset register, the CPA package.
AsOf,
/// A count by calendar year (`year`): 1099s and party totals.
CalendarYear,
}
impl PeriodKind {
/// The one sentence a tool's description gives for its dates.
pub const fn describe(self) -> &'static str {
match self {
PeriodKind::Statement => {
"Dates are YYYY-MM-DD. Omitted, the period is the fiscal year to date (company.fiscal_year_start): end defaults to today, start to the first day of the fiscal year that holds end. A start after the end is refused."
}
PeriodKind::AsOf => "as_of is YYYY-MM-DD; omitted, today in the company's time zone.",
PeriodKind::CalendarYear => {
"year defaults to this calendar year (1099s and party totals count by calendar year)."
}
}
}
}
/// A period report's `start` parameter, as every schema documents it.
pub const START_DOC: &str = "First day, YYYY-MM-DD. Omitted: the first day of the fiscal year that holds end (company.fiscal_year_start). A start after the end is refused.";
/// A period report's `end` parameter, as every schema documents it.
pub const END_DOC: &str = "Last day, YYYY-MM-DD. Omitted: today in the company's time zone.";
/// A point-in-time report's `as_of` parameter, as every schema documents
/// it.
pub const AS_OF_DOC: &str =
"As of this day, YYYY-MM-DD. Omitted: today in the company's time zone.";
/// A calendar-year count's `year` parameter, as every schema documents it.
pub const YEAR_DOC: &str =
"Calendar year. Omitted: this calendar year (1099s and party totals count by calendar year).";
/// The defaults an omitted date takes, today, under the company's fiscal
/// year: the one rule (rules.md B-30) the report tools, the viewer and
/// the tool descriptions share. Built by [`period_defaults`].
///
/// ```text
/// GOAL: one meaning for "no dates", everywhere: a report asked for
/// without dates covers this fiscal year so far.
/// SOURCE: rules.md B-30; IRS Pub. 538 ("an annual accounting period for
/// keeping records and reporting income and expenses"); 26 U.S.C.
/// §6041(a) ("in any calendar year") and §6041A(a)(2) ("during
/// such calendar year") for the calendar-year counts; DECISION
/// (owner, 2026-10-20, plan/served.md P3 "Settled in design"):
/// the fiscal year to date, everywhere.
/// INVARIANTS:
/// 1. statement Ok implies start <= end, both in the calendar.
/// 2. An omitted end is today; an omitted as_of is today.
/// 3. An omitted start is the first day of the fiscal year that holds
/// the resolved end, clamped to FIRST_DAY.
/// 4. Never swapped or clamped to fit: Err iff the resolved start is
/// after the resolved end.
/// 5. statement_range() == statement(None, None).
/// SCENARIOS:
/// 1. IF both dates are given and start > end: SHOULD refuse with
/// InvalidDateRange.
/// 2. IF only start is given and it is after today: SHOULD refuse with
/// StartAfterToday, saying the end defaulted to today.
/// 3. IF a given date is outside the calendar: SHOULD refuse with
/// DateOutOfRange.
/// ```
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct PeriodDefaults {
fiscal_year_start: (u32, u32),
today: NaiveDate,
}
/// The defaults an omitted date takes for `company`, on `today` (in the
/// company's time zone).
pub fn period_defaults(company: &Company, today: NaiveDate) -> PeriodDefaults {
PeriodDefaults {
fiscal_year_start: company.fiscal_year_start,
today,
}
}
impl PeriodDefaults {
/// The day the defaults are for.
pub fn today(&self) -> NaiveDate {
self.today
}
/// A period report with both dates omitted: the first day of the
/// fiscal year that holds today, through today (INVARIANT 5).
pub fn statement_range(&self) -> (NaiveDate, NaiveDate) {
(
fiscal_year_start_on(self.fiscal_year_start, self.today),
self.today,
)
}
/// A period report's dates as given (`None`: omitted), resolved
/// (INVARIANTS 1-4).
pub fn statement(
&self,
start: Option<NaiveDate>,
end: Option<NaiveDate>,
) -> Result<(NaiveDate, NaiveDate), LedgerError> {
let end_given = end.map(check).transpose()?;
let start_given = start.map(check).transpose()?;
let end = end_given.unwrap_or(self.today);
let start =
start_given.unwrap_or_else(|| fiscal_year_start_on(self.fiscal_year_start, end));
if start > end {
return Err(if end_given.is_some() {
LedgerError::InvalidDateRange {
start: start.to_string(),
end: end.to_string(),
}
} else {
LedgerError::StartAfterToday {
start: start.to_string(),
today: end.to_string(),
}
});
}
Ok((start, end))
}
/// A point-in-time report's day: the one given, else today.
pub fn as_of(&self, given: Option<NaiveDate>) -> NaiveDate {
given.unwrap_or(self.today)
}
/// A calendar-year count's year when none is given: today's.
pub fn calendar_year(&self) -> i32 {
self.today.year()
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -79,4 +281,185 @@ mod tests {
assert!(d.pred_opt().is_some() && d.succ_opt().is_some());
}
}
fn d(y: i32, m: u32, day: u32) -> NaiveDate {
NaiveDate::from_ymd_opt(y, m, day).unwrap()
}
fn company(md: (u32, u32)) -> Company {
Company {
fiscal_year_start: md,
..Company::default()
}
}
/// fiscal_year_start_on INVARIANT 2: md in d's year when on or before
/// d, else the year before; for a calendar year, a July year and a
/// December year, on the start day and the day before it.
#[test]
fn the_fiscal_year_start_is_the_last_start_on_or_before() {
for (md, day, want) in [
((1, 1), d(2026, 10, 20), d(2026, 1, 1)),
((1, 1), d(2026, 1, 1), d(2026, 1, 1)),
((1, 1), d(2025, 12, 31), d(2025, 1, 1)),
((7, 1), d(2026, 10, 20), d(2026, 7, 1)),
((7, 1), d(2026, 7, 1), d(2026, 7, 1)),
((7, 1), d(2026, 6, 30), d(2025, 7, 1)),
((7, 1), d(2026, 2, 28), d(2025, 7, 1)),
((12, 1), d(2026, 11, 30), d(2025, 12, 1)),
((12, 1), d(2026, 12, 1), d(2026, 12, 1)),
((12, 1), d(2026, 12, 31), d(2026, 12, 1)),
// A journaled start that isn't the first (replay is lenient).
((7, 15), d(2026, 7, 14), d(2025, 7, 15)),
((7, 15), d(2026, 7, 15), d(2026, 7, 15)),
// The calendar's last day.
((7, 1), LAST_DAY, d(9999, 7, 1)),
((1, 1), LAST_DAY, d(9999, 1, 1)),
] {
assert_eq!(fiscal_year_start_on(md, day), want, "{md:?} {day}");
}
}
/// INVARIANT 2's clamp: a July year holding a day early in year 1
/// began in year 0, which the books' calendar doesn't keep.
#[test]
fn a_fiscal_year_from_year_zero_starts_on_the_first_day() {
assert_eq!(fiscal_year_start_unclamped((7, 1), d(1, 3, 1)), d(0, 7, 1));
assert_eq!(fiscal_year_start_on((7, 1), d(1, 3, 1)), FIRST_DAY);
assert_eq!(fiscal_year_start_on((7, 1), d(1, 7, 1)), d(1, 7, 1));
assert_eq!(fiscal_year_start_on((1, 1), FIRST_DAY), FIRST_DAY);
}
/// INVARIANT 3: no month-day and no chrono date panics; a month-day
/// no year has counts as January 1.
#[test]
fn no_month_day_or_date_panics() {
assert_eq!(fiscal_year_start_on((2, 30), d(2026, 5, 5)), d(2026, 1, 1));
assert_eq!(fiscal_year_start_on((13, 1), d(2026, 5, 5)), d(2026, 1, 1));
assert_eq!(fiscal_year_start_on((0, 0), d(2026, 5, 5)), d(2026, 1, 1));
for md in [(1, 1), (7, 1), (12, 31), (2, 28)] {
for day in [NaiveDate::MIN, NaiveDate::MAX, FIRST_DAY, LAST_DAY] {
let _ = fiscal_year_start_on(md, day);
let _ = fiscal_year_start_unclamped(md, day);
}
}
}
/// PeriodDefaults INVARIANTS 2, 3, 5: omitted dates.
#[test]
fn omitted_dates_are_the_fiscal_year_to_date() {
let today = d(2026, 10, 20);
let p = period_defaults(&company((1, 1)), today);
assert_eq!(p.statement_range(), (d(2026, 1, 1), today));
assert_eq!(p.statement(None, None), Ok(p.statement_range()));
// Only end: that end's fiscal year, to it.
assert_eq!(
p.statement(None, Some(d(2025, 6, 30))),
Ok((d(2025, 1, 1), d(2025, 6, 30)))
);
// Only start: through today.
assert_eq!(
p.statement(Some(d(2024, 3, 15)), None),
Ok((d(2024, 3, 15), today))
);
let july = period_defaults(&company((7, 1)), today);
assert_eq!(july.statement_range(), (d(2026, 7, 1), today));
assert_eq!(
july.statement(None, Some(d(2026, 6, 30))),
Ok((d(2025, 7, 1), d(2026, 6, 30)))
);
assert_eq!(
july.statement(None, Some(d(1, 3, 1))),
Ok((FIRST_DAY, d(1, 3, 1)))
);
assert_eq!(
july.statement(None, Some(LAST_DAY)),
Ok((d(9999, 7, 1), LAST_DAY))
);
assert_eq!(p.as_of(None), today);
assert_eq!(p.as_of(Some(d(2025, 12, 31))), d(2025, 12, 31));
assert_eq!(p.calendar_year(), 2026);
assert_eq!(july.calendar_year(), 2026);
assert_eq!(p.today(), today);
}
/// PeriodDefaults INVARIANT 4 and SCENARIOS 1-3: never swapped.
#[test]
fn a_start_after_the_end_is_refused_never_swapped() {
let today = d(2026, 10, 20);
let p = period_defaults(&company((1, 1)), today);
// One day long is a period.
assert_eq!(p.statement(Some(today), Some(today)), Ok((today, today)));
assert_eq!(
p.statement(Some(d(2026, 1, 2)), Some(d(2026, 1, 1))),
Err(LedgerError::InvalidDateRange {
start: "2026-01-02".into(),
end: "2026-01-01".into()
})
);
assert_eq!(
p.statement(Some(d(2026, 12, 31)), Some(d(2026, 1, 1))),
Err(LedgerError::InvalidDateRange {
start: "2026-12-31".into(),
end: "2026-01-01".into()
})
);
// The end defaulted: the message says so.
let e = p.statement(Some(d(2027, 1, 1)), None).unwrap_err();
assert_eq!(
e,
LedgerError::StartAfterToday {
start: "2027-01-01".into(),
today: "2026-10-20".into()
}
);
assert!(e.to_string().contains("today"), "{e}");
assert!(e.to_string().contains("pass end"), "{e}");
assert_eq!(
p.statement(Some(d(2026, 10, 21)), None).map(|_| ()),
Err(LedgerError::StartAfterToday {
start: "2026-10-21".into(),
today: "2026-10-20".into()
})
);
assert_eq!(p.statement(Some(today), None), Ok((today, today)));
// Outside the calendar.
assert_eq!(
p.statement(None, Some(NaiveDate::MAX)),
Err(LedgerError::DateOutOfRange(NaiveDate::MAX))
);
assert_eq!(
p.statement(Some(NaiveDate::MIN), Some(today)),
Err(LedgerError::DateOutOfRange(NaiveDate::MIN))
);
}
/// The descriptions say what the resolver does (B-30), in the words
/// every tool shows.
#[test]
fn the_descriptions_name_the_rule() {
let s = PeriodKind::Statement.describe();
assert!(s.contains("fiscal year to date"));
assert!(s.contains("end defaults to today"));
assert!(s.contains("A start after the end is refused"));
assert!(PeriodKind::AsOf.describe().contains("today"));
assert!(
PeriodKind::CalendarYear
.describe()
.contains("calendar year")
);
for text in [
s,
PeriodKind::AsOf.describe(),
PeriodKind::CalendarYear.describe(),
START_DOC,
END_DOC,
AS_OF_DOC,
YEAR_DOC,
] {
for stale in ["all time", "beginning of time", "first entry"] {
assert!(!text.contains(stale), "{text}");
}
}
}
}
+80 -1
View File
@@ -306,6 +306,59 @@ pub enum LedgerError {
#[error("account name must not be empty")]
EmptyAccountName,
/// A rename to the name the account already has: there is nothing to
/// journal.
#[error("account {code} is already named {name:?}: nothing to rename")]
AccountNameUnchanged {
/// The account's code.
code: String,
/// Its name.
name: String,
},
/// A new name (a new account's, or a rename's) that another account
/// under the same parent already has, ignoring capitals: QuickBooks
/// matches accounts by name (docs/spec/sources/intuit-iif.md), so the
/// CPA's import would merge the two.
#[error(
"{sibling} already has the name {name:?} under the same parent (names compare ignoring capitals): QuickBooks matches accounts by name and would merge the two, so choose another name"
)]
DuplicateAccountName {
/// The name refused.
name: String,
/// The account that has it, `"CODE Name"`.
sibling: String,
},
/// A new account name holding a run of nine digits: it reads as a full
/// SSN, EIN or account number, and every report prints it.
#[error(
"the account name holds nine digits in a row, which read as a full SSN, EIN or account number, and every report prints it: write only the last four (a full TIN goes only into the vault, books.set_tin)"
)]
AccountNameHoldsTin,
/// A new account name (a new account's, or a rename's) that isn't one
/// line of text within the bound ([`crate::account::MAX_ACCOUNT_NAME`]
/// characters): every report, export and the IIF print it on one line.
#[error("account name {0}")]
InvalidAccountName(String),
/// A rename's reason the journal can't keep: blank, too long, holding
/// a control character or a full TIN.
#[error("rename reason: {0}")]
InvalidRenameReason(String),
/// `policy.require_tax_category = refuse`, and a revenue or expense
/// account (not a placeholder) would be created, or left, without a
/// tax category.
#[error("{}", tax_category_required_text(account, *clearing))]
TaxCategoryRequired {
/// The account, `"CODE Name"`.
account: String,
/// Whether the command would clear the category it has.
clearing: bool,
},
/// The referenced parent account does not exist.
#[error("unknown parent account id {0}")]
UnknownParentAccount(u64),
@@ -388,7 +441,7 @@ pub enum LedgerError {
#[error("invalid members: {0}")]
InvalidMembers(String),
/// Cash-flow period end is before period start.
/// A report period's end is before its start.
#[error("invalid date range: end {end} is before start {start}")]
InvalidDateRange {
/// Period start, YYYY-MM-DD.
@@ -420,6 +473,18 @@ pub enum LedgerError {
#[error("cash flow account roles: {0}")]
CashFlowRoles(String),
/// A report period given only a start, after today: an omitted end is
/// today (rules.md B-30), so the period would end before it starts.
#[error(
"invalid date range: start {start} is after the end, which defaults to today ({today}): pass end"
)]
StartAfterToday {
/// Period start, YYYY-MM-DD.
start: String,
/// Today, the end an omitted end means, YYYY-MM-DD.
today: String,
},
/// The referenced accounting period does not exist.
#[error("unknown period id {0}")]
UnknownPeriod(u64),
@@ -567,3 +632,17 @@ pub enum LedgerError {
)]
ApprovalNeedsDraft(String),
}
/// The refusal [`LedgerError::TaxCategoryRequired`] gives: what to pass,
/// and where the categories are listed.
fn tax_category_required_text(account: &str, clearing: bool) -> String {
if clearing {
format!(
"policy.require_tax_category is refuse: {account} keeps a tax category (give it another with books.set_tax_category; books.tax_categories lists them)"
)
} else {
format!(
"policy.require_tax_category is refuse: give {account} a tax_category (books.tax_categories lists them)"
)
}
}
+226 -24
View File
@@ -749,9 +749,13 @@ impl Ledger {
/// Add an account to the chart. Returns its id.
///
/// ```text
/// GOAL: register an account under a unique code.
/// GOAL: register an account under a unique code and a name no
/// sibling has.
/// SOURCE: rules.md B-4 (the five elements); R-4; DECISION (owner,
/// 2026-10-13): a child has its parent's type.
/// 2026-10-13): a child has its parent's type; rules.md B-31
/// (names: QuickBooks matches accounts by name,
/// docs/spec/sources/intuit-iif.md; no full TIN in what every
/// report prints, B-26; DECISION, owner, 2026-10-20).
/// SCENARIOS:
/// 1. IF the code is empty or taken: SHOULD refuse.
/// 2. IF the parent doesn't exist: SHOULD refuse.
@@ -759,10 +763,23 @@ impl Ledger {
/// - SHOULD refuse with ParentTypeMismatch
/// - NEVER add the account
/// BECAUSE a child subdivides its parent.
/// 4. IF the name is blank: SHOULD refuse with EmptyAccountName.
/// 5. IF an account under the same parent (or, for a top-level
/// account, another top-level one) has the name, ignoring
/// capitals and surrounding space: SHOULD refuse with
/// DuplicateAccountName.
/// 6. IF the name holds a run of nine digits (a full SSN, EIN or
/// account number): SHOULD refuse with AccountNameHoldsTin.
/// 7. IF the name, trimmed, is longer than MAX_ACCOUNT_NAME
/// characters, or holds a line break, tab or other control
/// character: SHOULD refuse with InvalidAccountName
/// BECAUSE every report, export and the IIF print it on one line.
/// 8. The name is kept trimmed, as a rename keeps it.
/// ```
///
/// Replay of journals written before the parent rule uses
/// [`Self::replay_add_account`].
/// Replay of journals written before these rules uses
/// [`Self::replay_add_account`]; the accounts the engine creates for a
/// control role use [`Self::add_control_account`].
pub fn add_account(
&mut self,
code: &str,
@@ -777,7 +794,8 @@ impl Ledger {
if code.trim().is_empty() {
return Err(LedgerError::EmptyAccountCode);
}
if name.trim().is_empty() {
let name = name.trim();
if name.is_empty() {
return Err(LedgerError::EmptyAccountName);
}
if let Some(p) = parent.and_then(|p| self.chart.get(p))
@@ -790,9 +808,66 @@ impl Ledger {
found: account_type,
});
}
if self.chart.find_by_code(code).is_some() {
return Err(LedgerError::DuplicateAccountCode(code.to_string()));
}
self.check_account_name(None, name, parent)?;
self.replay_add_account(code, name, account_type, parent, placeholder)
}
/// Create the account a control role posts to, at its code with its
/// name (invoices, bills, payroll and closes create them when first
/// needed). The engine's own names: never refused for a sibling's
/// name, and never for a missing tax category (rules.md B-32), so an
/// invoice is never refused over a chart label.
pub fn add_control_account(&mut self, c: &ControlAccount) -> Result<AccountId, LedgerError> {
self.replay_add_account(c.code, c.name, c.account_type, None, false)
}
/// The name rules for a new name (a new account's, or a rename's),
/// given trimmed and not blank: one line of at most
/// [`crate::account::MAX_ACCOUNT_NAME`] characters, no run of nine
/// digits, and none of `account`'s siblings under `parent` has it,
/// ignoring capitals and surrounding space ([`Self::add_account`]
/// SCENARIOS 5-7). `account` is the one being renamed, never its own
/// sibling.
fn check_account_name(
&self,
account: Option<AccountId>,
name: &str,
parent: Option<AccountId>,
) -> Result<(), LedgerError> {
let max = crate::account::MAX_ACCOUNT_NAME;
if name.chars().count() > max {
return Err(LedgerError::InvalidAccountName(format!(
"is longer than {max} characters: every report prints it on one line"
)));
}
if name.chars().any(char::is_control) {
return Err(LedgerError::InvalidAccountName(
"holds a line break, tab or other control character: every report prints it on one line".into(),
));
}
if crate::parties::has_full_tin(name) {
return Err(LedgerError::AccountNameHoldsTin);
}
let key = crate::parties::name_key(name);
let mut siblings: Vec<&Account> = self
.chart
.all()
.filter(|a| a.parent == parent && Some(a.id) != account)
.filter(|a| crate::parties::name_key(&a.name) == key)
.collect();
siblings.sort_by_key(|a| a.id);
match siblings.first() {
Some(a) => Err(LedgerError::DuplicateAccountName {
name: name.trim().to_string(),
sibling: format!("{} {}", a.code, a.name),
}),
None => Ok(()),
}
}
/// [`Self::add_account`] without the rules added on 2026-10-13 (a
/// child's type; a blank code or name), for replaying journals written before them. Never
/// for new work.
@@ -846,6 +921,141 @@ impl Ledger {
self.chart.set_code(account, code)
}
/// Give an account a new name: `name`, trimmed.
///
/// ```text
/// GOAL: let the human rename an account without touching its history.
/// SOURCE: rules.md B-31; DECISION (owner, plan/served.md 4.9 and P3
/// "Settled in design", 2026-10-20); rules.md B-8 (posted is
/// forever: entries name accounts by id, so a name is a label).
/// INVARIANTS:
/// 1. Only the name changes: the account's id, code, type, parent,
/// placeholder flag, tax category, and every entry posted to it
/// stay as they were, so no entry, balance or statement amount
/// changes; every report, past periods included, prints the new
/// name.
/// 2. A refusal MUST leave the books unchanged.
/// 3. The rules are the command's: replay takes any journaled name
/// for an account that exists (Self::replay_rename_account,
/// rules.md B-28).
/// SCENARIOS:
/// 1. IF the account is unknown: SHOULD refuse with UnknownAccount.
/// 2. IF the name is blank: SHOULD refuse with EmptyAccountName.
/// 3. IF the name, trimmed, is the one it has: SHOULD refuse with
/// AccountNameUnchanged (nothing to journal).
/// 4. IF another account under the same parent has it, ignoring
/// capitals and surrounding space: SHOULD refuse with
/// DuplicateAccountName.
/// 5. IF it holds a run of nine digits: SHOULD refuse with
/// AccountNameHoldsTin.
/// 6. IF it is longer than MAX_ACCOUNT_NAME characters or holds a
/// control character: SHOULD refuse with InvalidAccountName (the
/// rules a new account's name meets, Self::add_account).
/// 7. Approval rules, roles, settings and closed periods never
/// block it: they name accounts by code or id, and a name is no
/// posting.
/// ```
pub fn rename_account(&mut self, account: AccountId, name: &str) -> Result<(), LedgerError> {
self.check_rename_account(account, name)?;
self.chart.set_name(account, name.trim())
}
/// The validation behind [`Self::rename_account`], for stores that
/// journal before mutating.
pub fn check_rename_account(&self, account: AccountId, name: &str) -> Result<(), LedgerError> {
let a = self
.chart
.get(account)
.ok_or(LedgerError::UnknownAccount(account))?;
let name = name.trim();
if name.is_empty() {
return Err(LedgerError::EmptyAccountName);
}
if name == a.name {
return Err(LedgerError::AccountNameUnchanged {
code: a.code.clone(),
name: a.name.clone(),
});
}
self.check_account_name(Some(account), name, a.parent)
}
/// Replay a journaled rename: the account must exist; the name is
/// taken as journaled, whatever later versions refuse in a new one
/// (rules.md B-28).
pub fn replay_rename_account(
&mut self,
account: AccountId,
name: &str,
) -> Result<(), LedgerError> {
self.chart.set_name(account, name)
}
/// Check a rename's reason before it is journaled: one to
/// [`crate::settings::MAX_LONG_TEXT`] characters, line breaks and tabs
/// allowed but no other control character, and no full TIN (rules.md
/// B-26). Returns it trimmed.
pub fn check_rename_reason(reason: &str) -> Result<String, LedgerError> {
let r = crate::settings::long_text("reason", reason, crate::settings::MAX_LONG_TEXT)
.map_err(LedgerError::InvalidRenameReason)?;
if crate::parties::has_full_tin(&r) {
return Err(LedgerError::InvalidRenameReason(
"reason holds nine digits in a row, which read as a full SSN or EIN: write only the last four".into(),
));
}
Ok(r)
}
/// Whether `policy.require_tax_category` lets an account be created
/// with `category` (rules.md B-32): under `refuse`, a revenue or
/// expense account that isn't a header needs one. The command's check
/// only, never replay's (rules.md B-28); the accounts the engine
/// creates for a control role never meet it.
pub fn check_tax_category_required(
&self,
code: &str,
name: &str,
account_type: AccountType,
placeholder: bool,
category: Option<&str>,
) -> Result<(), LedgerError> {
if self.settings.require_tax_category() == crate::settings::TaxCategoryPolicy::Refuse
&& crate::tax::needs_category(account_type, placeholder)
&& category.is_none()
{
return Err(LedgerError::TaxCategoryRequired {
account: format!("{} {}", code.trim(), name.trim()),
clearing: false,
});
}
Ok(())
}
/// Whether `policy.require_tax_category` lets `account`'s category be
/// set to `category` (rules.md B-32): under `refuse`, an account that
/// needs one ([`crate::tax::account_needs_category`]) keeps one. The
/// command's check only, never replay's.
pub fn check_tax_category_kept(
&self,
account: AccountId,
category: Option<&str>,
) -> Result<(), LedgerError> {
let a = self
.chart
.get(account)
.ok_or(LedgerError::UnknownAccount(account))?;
if self.settings.require_tax_category() == crate::settings::TaxCategoryPolicy::Refuse
&& category.is_none()
&& crate::tax::account_needs_category(a)
{
return Err(LedgerError::TaxCategoryRequired {
account: format!("{} {}", a.code, a.name),
clearing: a.tax_category.is_some(),
});
}
Ok(())
}
/// The validation behind [`Self::renumber_account`], for stores that
/// journal before mutating.
pub fn check_renumber_account(
@@ -2582,18 +2792,10 @@ impl Ledger {
if let Some(&id) = self.roles.get(RETAINED_EARNINGS.key) {
return Ok(id);
}
Ok(
match self.chart.find_by_code(periods::RETAINED_EARNINGS_CODE) {
Some(a) => a.id,
None => self.add_account(
periods::RETAINED_EARNINGS_CODE,
"Retained Earnings",
AccountType::Equity,
None,
false,
)?,
},
)
Ok(match self.chart.find_by_code(RETAINED_EARNINGS.code) {
Some(a) => a.id,
None => self.add_control_account(&RETAINED_EARNINGS)?,
})
}
/// Validate a close target: every account an equity account, targets
@@ -3777,18 +3979,18 @@ impl Ledger {
fn ensure_control(&mut self, c: &ControlAccount) -> AccountId {
match self.roles.get(c.key) {
Some(&id) => id,
None => self.ensure_account(c.code, c.name, c.account_type),
None => self.ensure_account(c),
}
}
/// Ensure an account exists by code, creating it with the given type
/// and name if missing. Returns the account id.
fn ensure_account(&mut self, code: &str, name: &str, account_type: AccountType) -> AccountId {
if let Some(a) = self.chart.find_by_code(code) {
/// Ensure a control role's account exists at its code, creating it
/// (with the role's name and type: [`Self::add_control_account`]) if
/// missing. Returns the account id.
fn ensure_account(&mut self, c: &ControlAccount) -> AccountId {
if let Some(a) = self.chart.find_by_code(c.code) {
return a.id;
}
self.add_account(code, name, account_type, None, false)
.expect("fresh code cannot fail")
self.add_control_account(c).expect("fresh code cannot fail")
}
/// Create a sales invoice. Auto-posts: Dr `1200` Accounts Receivable,
+3 -1
View File
@@ -92,4 +92,6 @@ pub use schedules::{
ScheduleId, ScheduleKind, TemplateLine,
};
pub use shares::{PartyMember, PartyShare, Share};
pub use tax::{TaxCategory, TaxForm, TaxKind, TaxSummary, TaxSummaryLine, tax_summary};
pub use tax::{
BookOnlyLine, TaxCategory, TaxForm, TaxKind, TaxSummary, TaxSummaryLine, tax_summary,
};
+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() {
+100
View File
@@ -148,7 +148,23 @@ pub const CATEGORIES: &[TaxCategory] = &[
100,
"Portfolio interest: partnerships and S corps state it separately (Schedule K). Interest earned in the business itself (on customer accounts, say) is ordinary income: use other_income.",
),
cat(
"form_4797_gain_loss",
"Gain or loss on business property (Form 4797)",
Income,
ALL,
100,
"Gain or loss on selling or disposing of business property (the gain or loss account of books.dispose_asset). Form 4797 figures the tax gain from the tax basis, not the book one, and splits it: the ordinary part goes on Form 1065 line 6, Form 1120-S line 4 or Form 1120 line 9, and a partnership or S corp states net section 1231 gain separately (Schedule K line 10 or 9). On Schedule C books it goes on Form 4797, never Schedule C. The tax summary lists it apart, at its book amount and in no total: the CPA figures it.",
),
cat("other_income", "Other income", Income, ALL, 100, ""),
cat(
"tax_exempt_income",
"Tax-exempt income",
Income,
ENTITIES,
0,
"Tax-exempt interest and other tax-exempt income: on no line of page 1 (Form 1065 Schedule K lines 18a-18b, Form 1120-S Schedule K lines 16a-16b, Form 1120 Schedule K item 9 and Schedule M-1 line 7). None of it is taxable: a book-to-tax difference, the income twin of nondeductible.",
),
cat(
"cost_of_goods_sold",
"Cost of goods sold",
@@ -379,6 +395,44 @@ pub fn check_category(
Ok(c)
}
/// Whether an account of `account_type` maps to a line of the return:
/// revenue and expense accounts do, unless a header (a placeholder takes
/// no postings, so nothing of its own reaches the return).
///
/// ```text
/// GOAL: the accounts a tax category is for (rules.md B-32).
/// SOURCE: IRS Pub. 583, "Your books must show your gross income, as well
/// as your deductions and credits"; Rev. Proc. 98-25 §5.01, a
/// relationship between "the amounts ... by account and the
/// taxpayer's return" (docs/spec/sources/irs-records.md); DECISION
/// (owner, 2026-10-20): header accounts are exempt.
/// ```
pub fn needs_category(account_type: AccountType, placeholder: bool) -> bool {
matches!(account_type, AccountType::Revenue | AccountType::Expense) && !placeholder
}
/// Whether `account` maps to a line of the return ([`needs_category`]
/// of its type and header flag). A header never has a posting of its own
/// to report: no entry posts to one, now or on replay
/// (`JournalEntry::validate` refuses it), and an account's header flag
/// never changes.
pub fn account_needs_category(account: &crate::Account) -> bool {
needs_category(account.account_type, account.placeholder)
}
/// Every account that maps to a line of the return and has no tax
/// category ([`account_needs_category`]), with or without activity,
/// sorted by code: what Needs attention and the digest list.
pub fn uncategorized_accounts(ledger: &Ledger) -> Vec<&crate::Account> {
let mut out: Vec<&crate::Account> = ledger
.chart()
.all()
.filter(|a| a.tax_category.is_none() && account_needs_category(a))
.collect();
out.sort_by(|a, b| a.code.cmp(&b.code).then(a.id.cmp(&b.id)));
out
}
/// One category's line on the tax summary.
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct TaxSummaryLine {
@@ -405,6 +459,20 @@ pub struct Uncategorized {
pub book: Decimal,
}
/// A category's book amount that the summary can't place on the return
/// or tax: see [`TaxSummary::form_4797`].
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct BookOnlyLine {
/// Category key.
pub category: String,
/// Category label.
pub label: String,
/// The accounts that roll into it, `"CODE Name"`.
pub accounts: Vec<String>,
/// Book amount: a gain positive, a loss negative.
pub book: Decimal,
}
/// The tax summary for a period: book amounts by tax category, deductible
/// amounts, and every account still needing a category.
#[derive(Clone, Debug, Serialize, Deserialize)]
@@ -427,6 +495,17 @@ pub struct TaxSummary {
/// irs-tax-lines.md). Not in [`TaxSummary::book_net`] or `tax_net`.
#[serde(default)]
pub separately_stated: Vec<TaxSummaryLine>,
/// Gains and losses on business property ([`on_form_4797`]), at their
/// book amounts, with no tax amount and in no total
/// ([`TaxSummary::book_net`], `tax_net`): Form 4797 figures the tax
/// gain from the tax basis (book and tax depreciation differ) and
/// splits ordinary gain from net section 1231 gain, and the split
/// decides where it goes (Form 1065 line 6 or Schedule K line 10; Form
/// 1120-S line 4 or Schedule K line 9; Form 1120 line 9; on Schedule C
/// books Form 4797 itself, never Schedule C: docs/spec/sources/
/// irs-tax-lines.md). The CPA figures it, on every return alike.
#[serde(default)]
pub form_4797: Vec<BookOnlyLine>,
/// P&L accounts with activity and no category.
pub uncategorized: Vec<Uncategorized>,
}
@@ -506,11 +585,21 @@ pub fn tax_summary(
cost_of_goods: vec![],
deductions: vec![],
separately_stated: vec![],
form_4797: vec![],
uncategorized,
};
// Catalog order, so the summary reads like the return.
for c in CATEGORIES {
if let Some(line) = by_cat.remove(c.key) {
if on_form_4797(c.key) {
out.form_4797.push(BookOnlyLine {
category: line.category,
label: line.label,
accounts: line.accounts,
book: line.book,
});
continue;
}
if separately_stated(c.key, form) {
out.separately_stated.push(line);
continue;
@@ -525,6 +614,17 @@ pub fn tax_summary(
Ok(out)
}
/// Whether this category is a gain or loss on business property, which
/// Form 4797 figures from the tax basis, on every return
/// ([`TaxSummary::form_4797`]). Form 1065 line 6 and Form 1120-S line 4:
/// "Include only ordinary gains or losses"; Schedule K: "Net section 1231
/// gain (loss) (attach Form 4797)"; the Schedule C instructions: "Form
/// 4797 to report sales, exchanges, and involuntary conversions ... of
/// trade or business property".
pub fn on_form_4797(key: &str) -> bool {
key == "form_4797_gain_loss"
}
/// Whether a partnership or S corporation states this category separately
/// to its owners (Schedule K) rather than in ordinary income. Form 1065
/// and 1120-S instructions: Schedule K takes "only taxable portfolio