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
+672 -89
View File
@@ -5,25 +5,31 @@
//! Each example is a setup, events and expected values worked by hand from
//! its cited source (`bookspec/README.md` gives the shape). Every example
//! must load, whatever it asks or whichever appendix it is in: its shape,
//! its accounts, fixed assets, members and payees, and every event, an
//! entry, a reversal or a close, posting entry #N as event N. The queries
//! the core answers (`cash_flow`, `trial_balance`, `profit_and_loss`,
//! `vendor_1099_paid`, `entry`, `balances`, `members_equity`) are then
//! checked value by value, each after the event it names (`after`) or the
//! last; the formats appendix's (`iif_lines`, `iif_changes`) are checked by
//! ledger-package (`the_bookspec_iif_example_matches_the_engine`), and a
//! query no crate answers is refused. A key the shape doesn't have is
//! refused too, never ignored.
//! its accounts, fixed assets, members and payees, every event (an entry,
//! a reversal, a close or a bill, posting entry #N as event N) and every
//! change that posts no entry (a rename, a policy). The queries the core
//! answers (`cash_flow`, `trial_balance`, `profit_and_loss`,
//! `vendor_1099_paid`, `entry`, `balances`, `members_equity`, `period`,
//! `day`, `calendar_year`, `account_names`, `uncategorized_accounts`) are
//! then checked value by value, and the commands it tries
//! (`rename_account`, `add_account`, `set_tax_category`) accepted or
//! refused on a copy of the books, each after the event it names (`after`)
//! or the last; the formats appendix's (`iif_lines`, `iif_changes`) are
//! checked by ledger-package (`the_bookspec_iif_example_matches_the_engine`),
//! and a query no crate answers is refused. A key the shape doesn't have
//! is refused too, never ignored.
use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
use chrono::NaiveDate;
use ledger_core::calendar::{PeriodDefaults, period_defaults};
use ledger_core::settings::Company;
use ledger_core::{
AccountType, CashFlowParams, CashFlowSection, CashFlowStatement, CloseTo, Currency, EntryId,
Form1099, Ledger, MemberCapital, MemberClose, Money, NewAsset, PaymentAccounts,
ReconcilingKind, Side, VendorId, cash_flow, members_equity, profit_and_loss_for_period,
trial_balance_as_of, vendor_1099_report_with_members,
AccountType, CashFlowParams, CashFlowSection, CashFlowStatement, CloseTo, Currency,
DocumentLine, EntryId, Form1099, Ledger, LedgerError, MemberCapital, MemberClose, Money,
NewAsset, PaymentAccounts, ReconcilingKind, Side, VendorId, cash_flow, members_equity,
profit_and_loss_for_period, trial_balance_as_of, vendor_1099_report_with_members,
};
use rust_decimal::Decimal;
use serde::Deserialize;
@@ -43,6 +49,9 @@ struct Example {
appendix: Option<String>,
setup: Setup,
events: Vec<Event>,
/// Changes that post no entry, each made after the event it names.
#[serde(default)]
changes: Vec<ChangeSpec>,
expect: Vec<Expect>,
}
@@ -66,6 +75,11 @@ struct Setup {
members: Vec<MemberSpec>,
#[serde(default)]
payees: Vec<PayeeSpec>,
/// The day the books are asked on, in the company's time zone: what
/// an omitted date means (rules.md B-30). A query that omits a date
/// needs it.
#[serde(default)]
today: Option<String>,
}
#[derive(Deserialize)]
@@ -78,6 +92,25 @@ struct AccountSpec {
/// The parent account's code, declared before it.
#[serde(default)]
parent: Option<String>,
/// A header account: it groups others and takes no postings.
#[serde(default)]
header: bool,
/// Its tax category, a key of the catalog (rules.md B-32).
#[serde(default)]
tax_category: Option<String>,
}
impl AccountSpec {
fn account_type(&self, id: &str) -> AccountType {
match self.kind.as_str() {
"asset" => AccountType::Asset,
"liability" => AccountType::Liability,
"equity" => AccountType::Equity,
"revenue" => AccountType::Revenue,
"expense" => AccountType::Expense,
other => panic!("{id}: account type {other:?}"),
}
}
}
#[derive(Deserialize)]
@@ -108,8 +141,8 @@ struct PayeeSpec {
form: String,
}
/// One event: an entry (`lines`), a reversal (`reverses`) or a close
/// (`close`), exactly one of them.
/// One event: an entry (`lines`), a reversal (`reverses`), a close
/// (`close`) or a bill (`bill`), exactly one of them.
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct Event {
@@ -120,11 +153,48 @@ struct Event {
reverses: Option<usize>,
#[serde(default)]
close: Option<CloseSpec>,
#[serde(default)]
bill: Option<BillSpec>,
/// The payee (in `setup.payees`) the entry paid.
#[serde(default)]
payee: Option<String>,
}
/// A bill from `vendor` for `amount`, dated and due on the event's date:
/// Dr General Expenses (6999), Cr Accounts Payable (2000), each created
/// with its fixed name when first needed, as the engine creates a control
/// role's account (rules.md B-32).
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct BillSpec {
vendor: String,
amount: String,
}
/// A change to the books that posts no entry, made right after event
/// `after` (counting from 1) once the expectations asked after that event
/// are checked: a rename (`rename`) or the tax-category policy
/// (`require_tax_category`), exactly one of them.
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct ChangeSpec {
after: usize,
#[serde(default)]
rename: Option<RenameSpec>,
/// `policy.require_tax_category`: "warn" (the books' default) or
/// "refuse" (rules.md B-32).
#[serde(default)]
require_tax_category: Option<String>,
}
/// Rename the account `code` to `name` (rules.md B-31).
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct RenameSpec {
code: String,
name: String,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct LineSpec {
@@ -158,7 +228,24 @@ struct Expect {
entry: Option<EntryId>,
/// The books after event N, not after the last.
after: Option<usize>,
values: Values,
/// The account a `rename_account` or `set_tax_category` command
/// names, by code.
code: Option<String>,
/// The new name a `rename_account` command gives.
name: Option<String>,
/// The category a `set_tax_category` command sets (absent: it clears
/// the account's).
category: Option<String>,
/// The account an `add_account` command adds.
account: Option<AccountSpec>,
/// A command the engine accepts: `true` (never `false`: a command is
/// accepted or `refused`).
accepted: Option<bool>,
/// A command, or a `period`, the engine refuses: why, in the words
/// of [`refusal`].
refused: Option<String>,
#[serde(default)]
values: Option<Values>,
}
/// What a query expects: figures by key (every figure an exact decimal
@@ -182,53 +269,120 @@ impl Values {
}
impl Expect {
/// The values a query expects; panics when it gives none.
fn values(&self, id: &str) -> &Values {
self.values
.as_ref()
.unwrap_or_else(|| panic!("{id}: {} expects values", self.query))
}
/// The figures a figure query expects; panics on lines or rows.
fn figures(&self, id: &str) -> &BTreeMap<String, String> {
match &self.values {
match self.values(id) {
Values::Figures(f) => f,
_ => panic!("{id}: {} expects figures (a table)", self.query),
}
}
/// The period `[from, to]` a period query covers; panics on `as_of`.
fn period(&self, id: &str) -> (NaiveDate, NaiveDate) {
/// The period `[from, to]` a period query covers, each date omitted
/// resolved by the one rule (rules.md B-30) on `setup.today`; panics
/// on `as_of`, on an omitted date without `setup.today`, and on a
/// period the rule refuses.
fn period(&self, ex: &Example) -> (NaiveDate, NaiveDate) {
self.resolved_period(ex)
.unwrap_or_else(|e| panic!("{}: {}: {e}", ex.id, self.query))
}
/// [`Self::period`], or the rule's refusal.
fn resolved_period(&self, ex: &Example) -> Result<(NaiveDate, NaiveDate), LedgerError> {
let id = &ex.id;
assert!(
self.as_of.is_none(),
"{id}: {} covers a period (from, to), not as_of",
self.query
);
let from = date(
self.from
.as_deref()
.unwrap_or_else(|| panic!("{id}: {} needs from", self.query)),
id,
);
let to = date(
self.to
.as_deref()
.unwrap_or_else(|| panic!("{id}: {} needs to", self.query)),
id,
);
assert!(from <= to, "{id}: {} from {from} after to {to}", self.query);
(from, to)
let from = self.from.as_deref().map(|s| date(s, id));
let to = self.to.as_deref().map(|s| date(s, id));
// A `period` query asks the rule itself, both dates given or not.
if let (Some(from), Some(to)) = (from, to)
&& self.query != "period"
{
assert!(from <= to, "{id}: {} from {from} after to {to}", self.query);
return Ok((from, to));
}
defaults(ex, &self.query).statement(from, to)
}
/// The day an as-of query is made as of; panics on `from` or `to`.
fn day(&self, id: &str) -> NaiveDate {
/// The day an as-of query is made as of (omitted: `setup.today`,
/// rules.md B-30); panics on `from` or `to`.
fn day(&self, ex: &Example) -> NaiveDate {
let id = &ex.id;
assert!(
self.from.is_none() && self.to.is_none(),
"{id}: {} is as of a day (as_of), not a period",
self.query
);
date(
self.as_of
.as_deref()
.unwrap_or_else(|| panic!("{id}: {} needs as_of", self.query)),
id,
)
match self.as_of.as_deref() {
Some(s) => date(s, id),
None => defaults(ex, &self.query).as_of(None),
}
}
}
/// What an omitted date means for `ex` (rules.md B-30): its fiscal year
/// on `setup.today`, which `query` needs.
fn defaults(ex: &Example, query: &str) -> PeriodDefaults {
let today = ex.setup.today.as_deref().unwrap_or_else(|| {
panic!(
"{}: {query} omits a date: setup.today says what it means (rules.md B-30)",
ex.id
)
});
let company = Company {
fiscal_year_start: fiscal_year_start(ex),
..Company::default()
};
period_defaults(&company, date(today, &format!("{} today", ex.id)))
}
/// `setup.fiscal_year_start`, MM-DD, as (month, day).
fn fiscal_year_start(ex: &Example) -> (u32, u32) {
let s = &ex.setup.fiscal_year_start;
let (m, d) = s
.split_once('-')
.unwrap_or_else(|| panic!("{}: fiscal_year_start {s:?}", ex.id));
let md = (
m.parse()
.unwrap_or_else(|_| panic!("{}: fiscal_year_start {s:?}", ex.id)),
d.parse()
.unwrap_or_else(|_| panic!("{}: fiscal_year_start {s:?}", ex.id)),
);
assert!(
NaiveDate::from_ymd_opt(2001, md.0, md.1).is_some(),
"{}: fiscal_year_start {s:?}",
ex.id
);
md
}
/// A refusal in the examples' words (bookspec/README.md), engine-neutral:
/// what was refused, never the engine's message.
fn refusal(e: &LedgerError) -> String {
match e {
LedgerError::InvalidDateRange { .. } => "end_before_start",
LedgerError::StartAfterToday { .. } => "start_after_today",
LedgerError::EmptyAccountName => "blank",
LedgerError::AccountNameUnchanged { .. } => "unchanged",
LedgerError::DuplicateAccountName { .. } => "sibling_has_it",
LedgerError::AccountNameHoldsTin => "holds_a_tin",
LedgerError::InvalidAccountName(_) => "not_one_line",
LedgerError::DuplicateAccountCode(_) => "code_taken",
LedgerError::TaxCategoryRequired { .. } => "tax_category_required",
other => return format!("another refusal: {other}"),
}
.to_string()
}
fn examples_dir() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("../../bookspec/examples")
}
@@ -260,6 +414,8 @@ struct Books<'a> {
members: Vec<MemberCapital>,
/// The payees' vendor records, by name.
payees: BTreeMap<String, VendorId>,
/// The vendors bills came from, by name (a payee's record if one).
vendors: BTreeMap<String, VendorId>,
}
impl<'a> Books<'a> {
@@ -281,15 +437,10 @@ impl<'a> Books<'a> {
ex.id,
s.basis
);
let (m, d) = s
.fiscal_year_start
.split_once('-')
.unwrap_or_else(|| panic!("{}: fiscal_year_start {:?}", ex.id, s.fiscal_year_start));
assert!(
NaiveDate::from_ymd_opt(2001, m.parse().unwrap(), d.parse().unwrap()).is_some(),
"{}: fiscal_year_start",
ex.id
);
fiscal_year_start(ex);
if let Some(today) = &s.today {
date(today, &format!("{} today", ex.id));
}
assert!(
matches!(
s.rounding.as_str(),
@@ -318,18 +469,15 @@ impl<'a> Books<'a> {
.id
};
for a in &s.accounts {
let t = match a.kind.as_str() {
"asset" => AccountType::Asset,
"liability" => AccountType::Liability,
"equity" => AccountType::Equity,
"revenue" => AccountType::Revenue,
"expense" => AccountType::Expense,
other => panic!("{}: account type {other:?}", ex.id),
};
// A parent is declared before its subaccounts.
let parent = a.parent.as_deref().map(|p| id(&l, p));
l.add_account(&a.code, &a.name, t, parent, false)
let account = l
.add_account(&a.code, &a.name, a.account_type(&ex.id), parent, a.header)
.unwrap_or_else(|e| panic!("{}: account {}: {e}", ex.id, a.code));
if let Some(c) = &a.tax_category {
l.set_account_tax(account, Some(c), None)
.unwrap_or_else(|e| panic!("{}: account {}: {e}", ex.id, a.code));
}
}
let first = ex
.events
@@ -392,6 +540,7 @@ impl<'a> Books<'a> {
l,
currency,
members,
vendors: payees.clone(),
payees,
}
}
@@ -409,10 +558,45 @@ impl<'a> Books<'a> {
let ex = &self.ex.id;
let when = date(&e.date, &format!("{ex} event {n}"));
assert!(
e.payee.is_none() || (e.reverses.is_none() && e.close.is_none()),
"{ex} event {n}: a reversal takes its original's payee, and a close has none"
e.payee.is_none() || (e.reverses.is_none() && e.close.is_none() && e.bill.is_none()),
"{ex} event {n}: a reversal takes its original's payee, a bill names its vendor, and a close has none"
);
let posted = match (&e.close, e.reverses, e.lines.is_empty()) {
(None, None, true) if e.bill.is_some() => {
let b = e.bill.as_ref().unwrap();
let amount = exact(&b.amount, ex);
assert!(
amount > Decimal::ZERO,
"{ex} event {n}: amounts are positive"
);
let vendor = match self.vendors.get(&b.vendor) {
Some(&v) => v,
None => {
let v = self
.l
.add_vendor(&b.vendor)
.unwrap_or_else(|err| panic!("{ex} event {n}: {err}"));
self.vendors.insert(b.vendor.clone(), v);
v
}
};
let bill = self
.l
.create_bill(
vendor,
when,
when,
vec![DocumentLine {
description: e.description.clone(),
amount,
}],
)
.unwrap_or_else(|err| panic!("{ex} event {n}: {err}"));
self.l.get_bill(bill).expect("the bill just made").entry_id
}
(_, _, _) if e.bill.is_some() => {
panic!("{ex} event {n}: an event is one of lines, reverses, close or bill")
}
(Some(c), None, true) => {
let p = self
.l
@@ -487,7 +671,7 @@ impl<'a> Books<'a> {
.unwrap_or_else(|err| panic!("{ex} event {n}: {err}"));
entry
}
_ => panic!("{ex} event {n}: an event is one of lines, reverses or close"),
_ => panic!("{ex} event {n}: an event is one of lines, reverses, close or bill"),
};
assert_eq!(posted, n, "{ex}: event {n} must be entry #{n}");
if let Some(p) = &e.payee {
@@ -502,12 +686,60 @@ impl<'a> Books<'a> {
}
}
/// The books an example sets up, with every event applied and nothing
/// checked.
impl Books<'_> {
/// Make a change that posts no entry (after event `after`).
fn change(&mut self, c: &ChangeSpec) {
let ex = &self.ex.id;
let after = c.after;
match (&c.rename, &c.require_tax_category) {
(Some(r), None) => {
let account = self.id(&r.code);
self.l
.rename_account(account, &r.name)
.unwrap_or_else(|e| panic!("{ex} change after {after}: {e}"));
}
(None, Some(policy)) => {
assert!(
matches!(policy.as_str(), "warn" | "refuse"),
"{ex} change after {after}: require_tax_category {policy:?}"
);
use ledger_core::settings::{Change, Source};
let source = Source {
decided_by: "bookspec".into(),
decided_on: date("2026-01-01", ex),
citation: None,
note: None,
};
let changes = self
.l
.check_settings(
&[Change {
key: "policy.require_tax_category".into(),
value: Some(serde_json::json!(policy)),
}],
&source,
)
.unwrap_or_else(|e| panic!("{ex} change after {after}: {e}"));
self.l
.apply_settings(&changes, &source, 1)
.unwrap_or_else(|e| panic!("{ex} change after {after}: {e}"));
}
_ => panic!(
"{ex} change after {after}: a change is one of rename or require_tax_category"
),
}
}
}
/// The books an example sets up, with every event and change applied and
/// nothing checked.
fn load(ex: &Example) -> Books<'_> {
let mut b = Books::new(ex);
for (i, e) in ex.events.iter().enumerate() {
b.apply(i as EntryId + 1, e);
for c in ex.changes.iter().filter(|c| c.after == i + 1) {
b.change(c);
}
}
b
}
@@ -605,7 +837,7 @@ fn reconciliation_key(l: &ledger_core::ReconcilingLine) -> String {
/// Check one `cash_flow` expectation, every value it gives.
fn check_cash_flow(ex: &Example, l: &Ledger, members: &[MemberCapital], x: &Expect) {
let (start, end) = x.period(&ex.id);
let (start, end) = x.period(ex);
let values = x.figures(&ex.id);
let params = CashFlowParams {
start,
@@ -687,7 +919,7 @@ fn exactly(ex: &Example, x: &Expect, keys: &BTreeSet<String>) {
/// the day, debits less credits (a credit balance negative), every
/// account listed (rules.md B-6, B-33).
fn check_trial_balance(ex: &Example, l: &Ledger, x: &Expect) {
let as_of = x.day(&ex.id);
let as_of = x.day(ex);
let tb = trial_balance_as_of(l, as_of);
assert!(tb.balanced(), "{}: the trial balance balances", ex.id);
let keys = ex.setup.accounts.iter().map(|a| a.code.clone()).collect();
@@ -709,7 +941,7 @@ fn check_trial_balance(ex: &Example, l: &Ledger, x: &Expect) {
/// left out, and `net_income`, revenue less expenses; every revenue and
/// expense account listed (rules.md B-14, B-33).
fn check_profit_and_loss(ex: &Example, l: &Ledger, x: &Expect) {
let (start, end) = x.period(&ex.id);
let (start, end) = x.period(ex);
let pl = profit_and_loss_for_period(l, start, end, None)
.unwrap_or_else(|e| panic!("{}: {e}", ex.id));
let mut keys: BTreeSet<String> = ex
@@ -750,7 +982,14 @@ fn check_vendor_1099_paid(
payees: &BTreeMap<String, VendorId>,
x: &Expect,
) {
let (from, to) = x.period(&ex.id);
// A calendar-year count, never the fiscal default (rules.md B-30):
// both dates given.
assert!(
x.from.is_some() && x.to.is_some(),
"{}: vendor_1099_paid gives from and to",
ex.id
);
let (from, to) = x.period(ex);
let year = chrono::Datelike::year(&to);
assert_eq!(
Some(from),
@@ -805,14 +1044,16 @@ fn check_format_shape(ex: &Example, x: &Expect) {
ex.id,
x.query
);
x.day(&ex.id);
// ledger-package reads the as_of itself: given, never defaulted.
assert!(x.as_of.is_some(), "{}: {} gives as_of", ex.id, x.query);
x.day(ex);
assert!(
x.after.is_none(),
"{}: {}: a format query is of the books after every event",
ex.id,
x.query
);
match (x.query.as_str(), &x.values) {
match (x.query.as_str(), x.values(&ex.id)) {
("iif_lines", Values::Lines(_)) => {}
("iif_changes", Values::Rows(rows)) => {
for r in rows {
@@ -859,7 +1100,7 @@ fn check_entry(ex: &Example, l: &Ledger, x: &Expect) {
/// of the day on its normal side (so a member's capital after a loss is
/// negative); the accounts named are the example's choice.
fn check_balances(ex: &Example, l: &Ledger, x: &Expect) {
let as_of = x.day(&ex.id);
let as_of = x.day(ex);
let tb = trial_balance_as_of(l, as_of);
for (code, want) in x.figures(&ex.id) {
let want = exact(want, &format!("{} {code}", ex.id));
@@ -886,7 +1127,7 @@ fn check_balances(ex: &Example, l: &Ledger, x: &Expect) {
/// and `ending`; the figures named are the example's choice (rules.md
/// B-13).
fn check_members_equity(ex: &Example, l: &Ledger, members: &[MemberCapital], x: &Expect) {
let (from, to) = x.period(&ex.id);
let (from, to) = x.period(ex);
assert!(
!members.is_empty() && members.iter().all(|m| m.share > Decimal::ZERO),
"{}: members_equity needs the members and their shares",
@@ -919,24 +1160,262 @@ fn check_members_equity(ex: &Example, l: &Ledger, members: &[MemberCapital], x:
}
}
/// The commands an example may try: each on a copy of the books, so it
/// changes nothing that is checked after it.
const COMMANDS: [&str; 3] = ["rename_account", "add_account", "set_tax_category"];
/// The account `code` names, for a command.
fn command_account(ex: &Example, l: &Ledger, x: &Expect) -> ledger_core::AccountId {
let code = x
.code
.as_deref()
.unwrap_or_else(|| panic!("{}: {} names its account (code)", ex.id, x.query));
l.chart()
.find_by_code(code)
.unwrap_or_else(|| panic!("{}: no account {code}", ex.id))
.id
}
/// Every balance, by code: what a change that moves no amount leaves as
/// it was.
fn every_balance(l: &Ledger) -> Vec<(String, Decimal, Decimal)> {
trial_balance_as_of(l, ledger_core::calendar::LAST_DAY)
.lines
.into_iter()
.map(|t| (t.code, t.debit, t.credit))
.collect()
}
/// Try one command on a copy of the books, as the books' own command does
/// it (the engine's checks, then the policy's: rules.md B-31, B-32), and
/// check it is accepted or refused for the reason given.
fn check_command(ex: &Example, l: &Ledger, x: &Expect) {
let mut copy = l.clone();
let result: Result<(), LedgerError> = match x.query.as_str() {
"rename_account" => {
let account = command_account(ex, l, x);
let name = x
.name
.as_deref()
.unwrap_or_else(|| panic!("{}: rename_account gives the name", ex.id));
copy.rename_account(account, name).inspect(|()| {
// A rename changes the label, nothing else (rules.md B-31).
assert_eq!(
copy.chart().get(account).unwrap().name,
name.trim(),
"{}: rename_account keeps the name trimmed",
ex.id
);
assert_eq!(
every_balance(&copy),
every_balance(l),
"{}: a rename moves no amount",
ex.id
);
})
}
"add_account" => {
let a = x
.account
.as_ref()
.unwrap_or_else(|| panic!("{}: add_account gives the account", ex.id));
let kind = a.account_type(&ex.id);
let parent = a.parent.as_deref().map(|p| {
l.chart()
.find_by_code(p)
.unwrap_or_else(|| panic!("{}: no account {p}", ex.id))
.id
});
copy.add_account(&a.code, &a.name, kind, parent, a.header)
.and_then(|id| match &a.tax_category {
Some(c) => copy.set_account_tax(id, Some(c), None),
None => Ok(()),
})
.and_then(|()| {
l.check_tax_category_required(
&a.code,
&a.name,
kind,
a.header,
a.tax_category.as_deref(),
)
})
}
"set_tax_category" => {
let account = command_account(ex, l, x);
let category = x.category.as_deref();
copy.set_account_tax(account, category, None)
.and_then(|()| l.check_tax_category_kept(account, category))
}
q => unreachable!("{q} is no command"),
};
match (result, &x.refused) {
(Ok(()), None) => {}
(Err(e), Some(want)) => {
assert_eq!(refusal(&e), *want, "{}: {} refused ({e})", ex.id, x.query)
}
(Err(e), None) => panic!("{}: {} refused ({e}), expected accepted", ex.id, x.query),
(Ok(()), Some(want)) => panic!("{}: {} accepted, expected refused: {want}", ex.id, x.query),
}
}
/// Check one `period` expectation: the period a period report asked with
/// these dates covers, by the one rule (rules.md B-30), as `from` and
/// `to`; or the rule's refusal.
fn check_period(ex: &Example, x: &Expect) {
match (x.resolved_period(ex), &x.refused) {
(Ok((from, to)), None) => {
exactly(ex, x, &["from".to_string(), "to".to_string()].into());
let f = x.figures(&ex.id);
assert_eq!(from, date(&f["from"], &ex.id), "{}: period from", ex.id);
assert_eq!(to, date(&f["to"], &ex.id), "{}: period to", ex.id);
}
(Err(e), Some(want)) => assert_eq!(refusal(&e), *want, "{}: period refused ({e})", ex.id),
(Err(e), None) => panic!("{}: period refused ({e})", ex.id),
(Ok((from, to)), Some(want)) => {
panic!("{}: period {from} .. {to}, expected refused: {want}", ex.id)
}
}
}
/// Check one `day` expectation: the day a report as of a day is made as
/// of (rules.md B-30), as `as_of`.
fn check_day(ex: &Example, x: &Expect) {
exactly(ex, x, &["as_of".to_string()].into());
let want = date(&x.figures(&ex.id)["as_of"], &ex.id);
assert_eq!(x.day(ex), want, "{}: day", ex.id);
}
/// Check one `calendar_year` expectation: the year a calendar-year count
/// (1099s, party totals) asked without one counts (rules.md B-30).
fn check_calendar_year(ex: &Example, x: &Expect) {
assert!(
x.from.is_none() && x.to.is_none() && x.as_of.is_none(),
"{}: calendar_year takes no dates",
ex.id
);
exactly(ex, x, &["year".to_string()].into());
let want: i32 = x.figures(&ex.id)["year"]
.parse()
.unwrap_or_else(|e| panic!("{}: calendar_year year: {e}", ex.id));
assert_eq!(
defaults(ex, &x.query).calendar_year(),
want,
"{}: calendar_year",
ex.id
);
}
/// Check one `account_names` expectation: each account's name, by code,
/// as the chart holds it and every report prints it (the trial balance
/// and the profit and loss, over every date: past periods too, rules.md
/// B-31); every account listed.
fn check_account_names(ex: &Example, l: &Ledger, x: &Expect) {
assert!(
x.from.is_none() && x.to.is_none() && x.as_of.is_none(),
"{}: account_names takes no dates",
ex.id
);
let keys = l.chart().all().map(|a| a.code.clone()).collect();
exactly(ex, x, &keys);
let (first, last) = (
ledger_core::calendar::FIRST_DAY,
ledger_core::calendar::LAST_DAY,
);
let tb = trial_balance_as_of(l, last);
let pl = profit_and_loss_for_period(l, first, last, None)
.unwrap_or_else(|e| panic!("{}: {e}", ex.id));
for (code, want) in x.figures(&ex.id) {
let a = l.chart().find_by_code(code).unwrap();
assert_eq!(&a.name, want, "{}: account {code}'s name", ex.id);
let printed = tb.lines.iter().map(|t| (&t.code, &t.name)).chain(
pl.revenue
.iter()
.chain(&pl.expenses)
.map(|s| (&s.code, &s.name)),
);
for (c, name) in printed.filter(|(c, _)| *c == code) {
assert_eq!(name, want, "{}: a report prints {c} as {name:?}", ex.id);
}
}
}
/// Check one `uncategorized_accounts` expectation: every account that
/// maps to a line of the return and has no tax category, with activity or
/// not, as `"<code> <name>"`, by code (rules.md B-32).
fn check_uncategorized_accounts(ex: &Example, l: &Ledger, x: &Expect) {
assert!(
x.from.is_none() && x.to.is_none() && x.as_of.is_none(),
"{}: uncategorized_accounts takes no dates",
ex.id
);
let Values::Lines(want) = x.values(&ex.id) else {
panic!("{}: uncategorized_accounts expects a list", ex.id)
};
let got: Vec<String> = ledger_core::tax::uncategorized_accounts(l)
.into_iter()
.map(|a| format!("{} {}", a.code, a.name))
.collect();
assert_eq!(&got, want, "{}: uncategorized_accounts", ex.id);
}
/// Check one expectation against the books as they stand: a query the
/// core answers, value by value; a format query's shape, its values left
/// to the test [`FORMAT_QUERIES`] names (returned); a query no crate
/// answers is refused.
fn check(b: &Books<'_>, x: &Expect) -> Option<&'static str> {
let (ex, l) = (b.ex, &b.l);
assert!(
!x.values.is_empty(),
"{}: {} expects nothing",
ex.id,
x.query
let q = x.query.as_str();
let only = |given: bool, key: &str, queries: &[&str]| {
assert!(
!given || queries.contains(&q),
"{}: {q}: {key} is for {} only",
ex.id,
queries.join(" and ")
);
};
only(x.entry.is_some(), "entry", &["entry"]);
only(
x.code.is_some(),
"code",
&["rename_account", "set_tax_category"],
);
only(x.name.is_some(), "name", &["rename_account"]);
only(x.category.is_some(), "category", &["set_tax_category"]);
only(x.account.is_some(), "account", &["add_account"]);
only(x.accepted.is_some(), "accepted", &COMMANDS);
only(
x.refused.is_some(),
"refused",
&[
"rename_account",
"add_account",
"set_tax_category",
"period",
],
);
if COMMANDS.contains(&q) {
// A command is accepted or refused, and shows no values.
assert!(
x.values.is_none() && (x.accepted == Some(true)) != x.refused.is_some(),
"{}: {q} is a command: accepted = true, or refused, and no values",
ex.id
);
check_command(ex, l, x);
return None;
}
// Anything else shows its values; a period may be refused instead.
assert!(
x.entry.is_none() || x.query == "entry",
"{}: only an entry query names an entry",
x.refused.is_some() != x.values.as_ref().is_some_and(|v| !v.is_empty()),
"{}: {q} expects values (a period: values or refused)",
ex.id
);
match x.query.as_str() {
match q {
"period" => check_period(ex, x),
"day" => check_day(ex, x),
"calendar_year" => check_calendar_year(ex, x),
"account_names" => check_account_names(ex, l, x),
"uncategorized_accounts" => check_uncategorized_accounts(ex, l, x),
"cash_flow" => check_cash_flow(ex, l, &b.members, x),
"trial_balance" => check_trial_balance(ex, l, x),
"profit_and_loss" => check_profit_and_loss(ex, l, x),
@@ -957,16 +1436,20 @@ fn check(b: &Books<'_>, x: &Expect) -> Option<&'static str> {
}
/// Load an example and check every expectation: each one with `after = N`
/// on the books after event N, the rest after the last. Gives each query
/// checked, with the test of another crate that checks its values, if
/// any.
/// on the books after event N (before the changes made after it), the
/// rest after the last event and every change. Gives each query checked,
/// with the test of another crate that checks its values, if any.
fn run(ex: &Example) -> Vec<(String, Option<&'static str>)> {
for x in &ex.expect {
let afters = ex
.expect
.iter()
.map(|x| x.after)
.chain(ex.changes.iter().map(|c| Some(c.after)));
for after in afters {
assert!(
x.after.is_none_or(|n| n >= 1 && n <= ex.events.len()),
"{}: after = {:?}: events count from 1, to {}",
after.is_none_or(|n| n >= 1 && n <= ex.events.len()),
"{}: after = {after:?}: events count from 1, to {}",
ex.id,
x.after,
ex.events.len()
);
}
@@ -977,6 +1460,9 @@ fn run(ex: &Example) -> Vec<(String, Option<&'static str>)> {
for x in ex.expect.iter().filter(|x| x.after == Some(i + 1)) {
out.push((x.query.clone(), check(&b, x)));
}
for c in ex.changes.iter().filter(|c| c.after == i + 1) {
b.change(c);
}
}
for x in ex.expect.iter().filter(|x| x.after.is_none()) {
out.push((x.query.clone(), check(&b, x)));
@@ -1021,8 +1507,9 @@ fn every_bookspec_example_holds() {
}
}
// Each query the examples use ran: the cash flow's six examples at
// least, the as-of and 1099 examples', the members closes' and the
// formats appendix's.
// least, the as-of and 1099 examples', the members closes', the
// formats appendix's, and P3's default period, rename and tax
// category examples'.
for (q, at_least) in [
("cash_flow", 6),
("trial_balance", 2),
@@ -1033,6 +1520,14 @@ fn every_bookspec_example_holds() {
("members_equity", 5),
("iif_lines", 1),
("iif_changes", 1),
("period", 6),
("day", 1),
("calendar_year", 1),
("account_names", 2),
("uncategorized_accounts", 2),
("rename_account", 5),
("add_account", 4),
("set_tax_category", 2),
] {
let n = checked.get(q).copied().unwrap_or(0);
assert!(n >= at_least, "{q} ran {n} times, fewer than {at_least}");
@@ -1259,3 +1754,91 @@ fn a_members_close_declared_with_another_rounding_is_refused() {
)
});
}
/// A period's dates are the rule's (rules.md B-30): an example saying the
/// fiscal year to date starts on January 1 under a July fiscal year fails.
#[test]
#[should_panic(expected = "period from")]
fn a_period_other_than_the_rules_is_refused() {
run_edited("default-period-fiscal-year-to-date.toml", |t| {
t.replacen(
"values = { from = \"2026-07-01\", to = \"2026-10-20\" }",
"values = { from = \"2026-01-01\", to = \"2026-10-20\" }",
1,
)
});
}
/// A refusal is checked for its reason: a start after today is not "the
/// end before the start".
#[test]
#[should_panic(expected = "period refused")]
fn a_refusal_for_another_reason_is_refused() {
run_edited("default-period-fiscal-year-to-date.toml", |t| {
t.replacen(
"refused = \"start_after_today\"",
"refused = \"end_before_start\"",
1,
)
});
}
/// A query that omits a date needs `setup.today`: without it, nothing
/// says what the omitted date means.
#[test]
#[should_panic(expected = "setup.today says what it means")]
fn an_omitted_date_without_today_is_refused() {
run_edited("default-period-fiscal-year-to-date.toml", |t| {
t.replacen("today = \"2026-10-20\"\n", "", 1)
});
}
/// `account_names` lists every account: one left out is refused.
#[test]
#[should_panic(expected = "lists exactly what it covers")]
fn account_names_leaving_an_account_out_is_refused() {
run_edited("rename-changes-no-amount.toml", |t| {
t.replacen(", \"6160\" = \"Hosting\" }", " }", 1)
});
}
/// A change is made after the event it names, once that event's
/// expectations are checked: asked after the rename, the old name fails.
#[test]
#[should_panic(expected = "account 6150's name")]
fn a_change_is_made_after_its_events_expectations() {
run_edited("rename-changes-no-amount.toml", |t| {
t.replacen(
"\"6150\" = \"Software subscriptions\", \"6160\" = \"Hosting\" }",
"\"6150\" = \"Software\", \"6160\" = \"Hosting\" }",
1,
)
});
}
/// A command is tried under the policy in force when it is asked: without
/// the change to refuse, the account without a category is accepted.
#[test]
#[should_panic(expected = "accepted, expected refused: tax_category_required")]
fn a_command_runs_under_the_policy_in_force() {
run_edited("tax-category-required.toml", |t| {
t.replacen(
"[[changes]]\nafter = 1\nrequire_tax_category = \"refuse\"\n",
"",
1,
)
});
}
/// A command is accepted or refused, never both, and shows no values.
#[test]
#[should_panic(expected = "is a command")]
fn a_command_with_values_is_refused() {
run_edited("tax-category-required.toml", |t| {
t.replacen(
"category = \"other_deductions\"\naccepted = true\n",
"category = \"other_deductions\"\naccepted = true\nvalues = { x = \"1\" }\n",
1,
)
});
}
+8 -2
View File
@@ -1423,8 +1423,14 @@ fn a_last_absorbs_close_gives_the_rounding_to_the_last_member() {
let capital: Vec<AccountId> = ["3001", "3002", "3003"]
.iter()
.map(|code| {
l.add_account(code, "Capital", AccountType::Equity, None, false)
.unwrap()
l.add_account(
code,
&format!("Capital {code}"),
AccountType::Equity,
None,
false,
)
.unwrap()
})
.collect();
let e = l.create_entry(date(5), "sale");
+83 -10
View File
@@ -53,8 +53,10 @@ struct MPeriod {
#[derive(Clone, Debug, Default)]
struct Model {
/// (id, code, type, placeholder)
accounts: Vec<(u64, String, AccountType, bool)>,
/// (id, code, type, placeholder, name). The name is outside the Lean
/// model (its chart is code and type): kept here so the two agree on
/// renames' refusals, and a rename moves nothing else (rules.md B-31).
accounts: Vec<(u64, String, AccountType, bool, String)>,
drafts: Vec<MEntry>,
posted: Vec<MEntry>,
periods: Vec<MPeriod>,
@@ -79,7 +81,7 @@ impl Model {
}
}
fn account(&self, id: u64) -> Option<&(u64, String, AccountType, bool)> {
fn account(&self, id: u64) -> Option<&(u64, String, AccountType, bool, String)> {
self.accounts.iter().find(|a| a.0 == id)
}
@@ -101,13 +103,53 @@ impl Model {
.sum()
}
fn add_account(&mut self, code: &str, t: AccountType, placeholder: bool) -> bool {
if code.is_empty() || self.accounts.iter().any(|a| a.1 == code) {
fn add_account(&mut self, code: &str, name: &str, t: AccountType, placeholder: bool) -> bool {
if code.is_empty()
|| self.accounts.iter().any(|a| a.1 == code)
|| !self.name_free(None, name)
{
return false;
}
self.add_engine_account(code, name, t, placeholder);
true
}
/// An account the engine makes for a control role: its fixed name,
/// never refused for one.
fn add_engine_account(&mut self, code: &str, name: &str, t: AccountType, placeholder: bool) {
self.accounts
.push((self.next_account, code.into(), t, placeholder));
.push((self.next_account, code.into(), t, placeholder, name.into()));
self.next_account += 1;
}
/// rules.md B-31: a name is free for `account` (None: a new one) when
/// it isn't blank, holds no nine digits in a row, and no other account
/// (all are top-level here, so all are siblings) has it, ignoring
/// capitals and surrounding space.
fn name_free(&self, account: Option<u64>, name: &str) -> bool {
let key = name.trim().to_lowercase();
let nine_digits = name
.as_bytes()
.windows(9)
.any(|w| w.iter().all(u8::is_ascii_digit));
!key.is_empty()
&& !nine_digits
&& !self
.accounts
.iter()
.any(|a| Some(a.0) != account && a.4.trim().to_lowercase() == key)
}
/// rules.md B-31: rename an account that exists to a free name other
/// than its own; only the name changes.
fn rename(&mut self, id: u64, name: &str) -> bool {
let Some(i) = self.accounts.iter().position(|a| a.0 == id) else {
return false;
};
if name.trim() == self.accounts[i].4 || !self.name_free(Some(id), name) {
return false;
}
self.accounts[i].4 = name.trim().to_string();
true
}
@@ -278,7 +320,12 @@ impl Model {
let re = match self.accounts.iter().find(|a| a.1 == "3999") {
Some(a) => a.0,
None => {
self.add_account("3999", AccountType::Equity, false);
self.add_engine_account(
"3999",
"Retained Earnings",
AccountType::Equity,
false,
);
self.next_account - 1
}
};
@@ -314,6 +361,21 @@ impl Model {
// ---------------------------------------------------------------------------
const CODES: [&str; 7] = ["1000", "2000", "3000", "3999", "4000", "6000", "1900"];
/// New names: a free one, its own in other capitals and spacing, a
/// sibling's (the accounts are named for their codes), blank, nine
/// digits, the engine's own name for retained earnings.
const NAMES: [&str; 10] = [
"Software",
"SOFTWARE",
" software ",
"Hosting",
"",
" ",
"Acct 123456789",
"1000",
"Retained Earnings",
"Rent",
];
const TYPES: [AccountType; 5] = [
AccountType::Asset,
AccountType::Liability,
@@ -333,6 +395,9 @@ enum Cmd {
Reverse(usize, u32),
CreatePeriod(u32, u32),
Close(usize),
/// Rename an account (rules.md B-31): `Step.record` in the Lean model,
/// whose chart has no names.
Rename(usize, usize),
}
fn day(n: u32) -> NaiveDate {
@@ -353,6 +418,7 @@ fn cmd() -> impl Strategy<Value = Cmd> {
2 => (0usize..8, 0u32..180).prop_map(|(e, d)| Cmd::Reverse(e, d)),
2 => (0u32..180, 0u32..60).prop_map(|(s, len)| Cmd::CreatePeriod(s, s + len)),
2 => (0usize..4).prop_map(Cmd::Close),
2 => (0usize..8, 0..NAMES.len()).prop_map(|(a, n)| Cmd::Rename(a, n)),
]
}
@@ -368,7 +434,7 @@ fn apply(l: &mut Ledger, m: &mut Model, c: &Cmd) -> (bool, bool) {
Cmd::AddAccount(code, t, h) => (
l.add_account(CODES[code], CODES[code], TYPES[t], None, h)
.is_ok(),
m.add_account(CODES[code], TYPES[t], h),
m.add_account(CODES[code], CODES[code], TYPES[t], h),
),
Cmd::Draft(d) => {
let got = l.try_create_entry(day(d), "x").ok();
@@ -437,6 +503,10 @@ fn apply(l: &mut Ledger, m: &mut Model, c: &Cmd) -> (bool, bool) {
let id = ids.get(i).copied().unwrap_or(999);
(l.close_period(id).is_ok(), m.close(i))
}
Cmd::Rename(a, n) => {
let a = pick(&accounts, a);
(l.rename_account(a, NAMES[n]).is_ok(), m.rename(a, NAMES[n]))
}
}
}
@@ -444,9 +514,12 @@ fn apply(l: &mut Ledger, m: &mut Model, c: &Cmd) -> (bool, bool) {
fn agree(l: &Ledger, m: &Model) -> Result<(), TestCaseError> {
// Chart.
prop_assert_eq!(l.chart().len(), m.accounts.len());
for (id, code, t, h) in &m.accounts {
for (id, code, t, h, name) in &m.accounts {
let a = l.chart().get(*id).expect("account");
prop_assert_eq!((&a.code, a.account_type, a.placeholder), (code, *t, *h));
prop_assert_eq!(
(&a.code, a.account_type, a.placeholder, &a.name),
(code, *t, *h, name)
);
}
// The posted log, in order, line for line.
let log: Vec<_> = l.posted_entries().collect();
@@ -6,3 +6,4 @@
# everyone who runs the test benefits from these saved cases.
cc 973b72cb286deab3cabcf8a48a797a1aa76e660f1a6f3a7046d02f655ad467a6 # shrinks to cents = 8074248648, shares = [0.0877914951989026063100137174, 0.327846364883401920438957476, 0.2235939643347050754458161866, 0.3607681755829903978052126200]
cc 181075cd6abb81d059781749aadb13e990e59e6658279089e173fe655bd6bcf2 # shrinks to cents = 4209937262, shares = [0.3215789473684210526315789474, 0.2426315789473684210526315789, 0.1663157894736842105263157895, 0.2694736842105263157894736842]
cc 92b493a8beea164b334d7fe6be1609fe029de4e35b22cadf9034736edc8703f5 # shrinks to md = (2, 5), today = 0001-01-01, start = None, end = None
+86
View File
@@ -385,3 +385,89 @@ proptest! {
prop_assert_eq!(loss, gain.iter().map(|p| -*p).collect::<Vec<_>>());
}
}
/// A fiscal year start as the books may hold one: any month and day 2001
/// has (a new setting is the first of a month; replay takes any day every
/// year has, rules.md B-28).
fn arb_fiscal_start() -> impl Strategy<Value = (u32, u32)> {
(1u32..=12, 1u32..=31).prop_filter("a day 2001 has", |&(m, d)| {
NaiveDate::from_ymd_opt(2001, m, d).is_some()
})
}
/// A day in the books' calendar, 0001-01-01 through 9999-12-31.
fn arb_calendar_day() -> impl Strategy<Value = NaiveDate> {
let span = (ledger_core::calendar::LAST_DAY - ledger_core::calendar::FIRST_DAY).num_days();
prop_oneof![
// Near the edges as often as anywhere: year 1 under a July year
// starts in year 0 (clamped), 9999-12-31 is the last day.
(0i64..800).prop_map(|n| ledger_core::calendar::FIRST_DAY + chrono::Days::new(n as u64)),
(0i64..800).prop_map(|n| ledger_core::calendar::LAST_DAY - chrono::Days::new(n as u64)),
(0i64..=span).prop_map(|n| ledger_core::calendar::FIRST_DAY + chrono::Days::new(n as u64)),
]
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(5000))]
/// rules.md B-30, PeriodDefaults INVARIANTS 1-5, for every fiscal
/// year start the books hold, every today in the calendar, and every
/// mix of given and omitted dates: the resolved dates are the given
/// ones or the defaults, never swapped; Err exactly when the resolved
/// start is after the resolved end, as InvalidDateRange when both
/// were given and StartAfterToday when the end was defaulted.
#[test]
fn prop_period_defaults_resolve(
md in arb_fiscal_start(),
today in arb_calendar_day(),
start in prop::option::of(arb_calendar_day()),
end in prop::option::of(arb_calendar_day()),
) {
use ledger_core::calendar::{FIRST_DAY, fiscal_year_start_on, period_defaults};
use chrono::Datelike;
let company = ledger_core::settings::Company {
fiscal_year_start: md,
..Default::default()
};
let p = period_defaults(&company, today);
let want_end = end.unwrap_or(today);
let fy = fiscal_year_start_on(md, want_end);
let want_start = start.unwrap_or(fy);
// The fiscal year start of the end: on or before it, md in its
// own year, and the next one after the end (unless year 0's,
// clamped to the calendar's first day).
prop_assert!(fy <= want_end && fy >= FIRST_DAY);
let next = NaiveDate::from_ymd_opt(fy.year() + 1, md.0, md.1).unwrap();
if (fy.month(), fy.day()) == md {
prop_assert!(want_end < next, "{fy} {want_end}");
let this = NaiveDate::from_ymd_opt(want_end.year(), md.0, md.1).unwrap();
prop_assert_eq!(fy, if this <= want_end { this } else {
NaiveDate::from_ymd_opt(want_end.year() - 1, md.0, md.1).unwrap()
});
} else {
// Clamped: the year began in year 0.
prop_assert_eq!(fy, FIRST_DAY);
prop_assert!(want_end < NaiveDate::from_ymd_opt(1, md.0, md.1).unwrap());
}
match p.statement(start, end) {
Ok((s, e)) => {
prop_assert!(want_start <= want_end);
prop_assert_eq!((s, e), (want_start, want_end));
}
Err(err) => {
prop_assert!(want_start > want_end);
let (s, e) = (want_start.to_string(), want_end.to_string());
let expected = if end.is_some() {
ledger_core::LedgerError::InvalidDateRange { start: s, end: e }
} else {
ledger_core::LedgerError::StartAfterToday { start: s, today: e }
};
prop_assert_eq!(err, expected);
}
}
prop_assert_eq!(p.statement(None, None), Ok(p.statement_range()));
prop_assert_eq!(p.as_of(None), today);
prop_assert_eq!(p.as_of(start), start.unwrap_or(today));
prop_assert_eq!(p.calendar_year(), today.year());
}
}
+358
View File
@@ -2539,3 +2539,361 @@ fn renumber_refusals_leave_the_books_unchanged() {
l.renumber_account(hosting, "6999").unwrap();
l.renumber_account(cash, "1200").unwrap();
}
// ---------------------------------------------------------------------
// Ledger::rename_account (P3, defect 15; rules.md B-31).
// ---------------------------------------------------------------------
proptest! {
/// rename_account INVARIANT 1: only the name changes. Every other part
/// of the ledger is equal, every account keeps its other fields, and
/// the trial balance is the same line for line apart from that name.
#[test]
fn rename_account_changes_only_the_name(
postings in proptest::collection::vec((0usize..4, 0usize..4, 1i64..1_000_000), 0..12),
which in 0usize..4,
name in "[A-Za-z][A-Za-z &-]{0,30}",
) {
let mut l = Ledger::new(Currency::USD);
let ids: Vec<_> = [
("1000", AccountType::Asset),
("2000", AccountType::Liability),
("4000", AccountType::Revenue),
("6100", AccountType::Expense),
]
.iter()
.map(|(c, t)| l.add_account(c, c, *t, None, false).unwrap())
.collect();
for (dr, cr, c) in postings {
if dr == cr {
continue;
}
let e = l.create_entry(d(2026, 1, 5), "x");
let m = Money::new(Decimal::new(c, 2), Currency::USD);
l.add_line(e, ids[dr], Side::Debit, m).unwrap();
l.add_line(e, ids[cr], Side::Credit, m).unwrap();
l.post_entry(e).unwrap();
}
// A name the account doesn't have and no sibling has (the others
// are named for their codes: digits).
let new = format!("Renamed {}", name.trim());
let before = l.clone();
l.rename_account(ids[which], &new).unwrap();
prop_assert_eq!(l.differs_from(&before), vec!["chart"]);
for id in &ids {
let (a, b) = (l.chart().get(*id).unwrap(), before.chart().get(*id).unwrap());
let mut b = b.clone();
if *id == ids[which] {
b.name = new.trim().to_string();
}
prop_assert_eq!(a, &b);
}
let (t1, t0) = (trial_balance(&l), trial_balance(&before));
prop_assert_eq!(t1.total_debits, t0.total_debits);
prop_assert_eq!(t1.total_credits, t0.total_credits);
let pick = |t: &ledger_core::TrialBalance| {
let mut v: Vec<_> = t.lines.iter().map(|x| (x.code.clone(), x.debit, x.credit)).collect();
v.sort();
v
};
prop_assert_eq!(pick(&t1), pick(&t0));
for e in l.posted_entries() {
prop_assert_eq!(Some(e), before.get_entry(e.id()));
}
}
}
fn named_chart() -> (Ledger, AccountId, AccountId, AccountId) {
let mut l = Ledger::new(Currency::USD);
let ops = l
.add_account("6000", "Operations", AccountType::Expense, None, true)
.unwrap();
let software = l
.add_account("6150", "Software", AccountType::Expense, Some(ops), false)
.unwrap();
let hosting = l
.add_account("6160", "Hosting", AccountType::Expense, Some(ops), false)
.unwrap();
l.add_account("6900", "Bank Fees", AccountType::Expense, None, false)
.unwrap();
(l, ops, software, hosting)
}
/// rename_account SCENARIOS 1-5 and INVARIANT 2: an unknown account, a
/// blank name, the name it has (trimmed), a sibling's name ignoring
/// capitals and surrounding space (top-level accounts are siblings too),
/// and a run of nine digits are refused, leaving the books as they were.
#[test]
fn rename_account_refusals_leave_the_books_unchanged() {
let (mut l, ops, software, hosting) = named_chart();
let before = l.clone();
for (id, name, want) in [
(999, "Tools", LedgerError::UnknownAccount(999)),
(software, "", LedgerError::EmptyAccountName),
(software, " ", LedgerError::EmptyAccountName),
(
software,
" Software ",
LedgerError::AccountNameUnchanged {
code: "6150".into(),
name: "Software".into(),
},
),
(
software,
"hosting",
LedgerError::DuplicateAccountName {
name: "hosting".into(),
sibling: "6160 Hosting".into(),
},
),
(
software,
" HOSTING ",
LedgerError::DuplicateAccountName {
name: "HOSTING".into(),
sibling: "6160 Hosting".into(),
},
),
(
ops,
"bank fees",
LedgerError::DuplicateAccountName {
name: "bank fees".into(),
sibling: "6900 Bank Fees".into(),
},
),
(software, "Card 123456789", LedgerError::AccountNameHoldsTin),
(
software,
"Acct 12-345-6789",
LedgerError::AccountNameHoldsTin,
),
] {
let checked = l.check_rename_account(id, name);
assert_eq!(l.rename_account(id, name), checked, "{name:?}");
assert_eq!(checked, Err(want), "{name:?}");
assert!(l.differs_from(&before).is_empty(), "{name:?}");
}
// Its own name in other capitals is a rename; a name an account under
// another parent has is no sibling's; the last four of a number are
// kept; the name is kept trimmed.
l.rename_account(software, "SOFTWARE").unwrap();
assert_eq!(l.chart().get(software).unwrap().name, "SOFTWARE");
l.rename_account(hosting, " Bank Fees ").unwrap();
assert_eq!(l.chart().get(hosting).unwrap().name, "Bank Fees");
l.rename_account(software, "Card ending 6789").unwrap();
assert_eq!(l.chart().get(software).unwrap().name, "Card ending 6789");
}
/// rename_account SCENARIO 6: a closed period, a role and a control code
/// never block a rename (they name accounts by code or id, and a name is
/// no posting).
#[test]
fn a_closed_period_or_a_role_never_blocks_a_rename() {
let b = books();
let mut l = b.l;
post(
&mut l,
d(2026, 1, 10),
&[
(b.exp, Side::Debit, "10.00"),
(b.cash, Side::Credit, "10.00"),
],
);
let p = l
.create_period("January", d(2026, 1, 1), d(2026, 1, 31))
.unwrap();
l.close_period(p).unwrap();
l.assign_role("general_expense", Some(b.exp)).unwrap();
l.rename_account(b.exp, "Software subscriptions").unwrap();
assert_eq!(l.chart().get(b.exp).unwrap().name, "Software subscriptions");
assert_eq!(l.role_account(&ledger_core::GENERAL_EXPENSES), Some(b.exp));
}
/// rename_account INVARIANT 3 (rules.md B-28's pair): replay takes a
/// journaled name the command now refuses (blank, a sibling's, nine
/// digits, unchanged) for an account that exists, and refuses only an
/// unknown account.
#[test]
fn a_journaled_rename_replays_past_the_command_rules() {
let (mut l, _, software, _) = named_chart();
for name in ["", "Hosting", "Card 123456789", "Hosting"] {
l.replay_rename_account(software, name).unwrap();
assert_eq!(l.chart().get(software).unwrap().name, name);
}
assert_eq!(
l.replay_rename_account(999, "x"),
Err(LedgerError::UnknownAccount(999))
);
}
/// add_account SCENARIOS 5-6 (rules.md B-31): a new account named like a
/// sibling (ignoring capitals and surrounding space; top-level accounts
/// are siblings) or holding a run of nine digits is refused, using up no
/// id; under another parent the name is free. Replay takes them all, and
/// the accounts the engine creates for a control role are never refused
/// for a sibling's name.
#[test]
fn a_new_account_named_like_a_sibling_or_holding_a_tin_is_refused() {
let (mut l, ops, _, _) = named_chart();
let before = l.clone();
for (name, parent, want) in [
(
"software",
Some(ops),
LedgerError::DuplicateAccountName {
name: "software".into(),
sibling: "6150 Software".into(),
},
),
(
" BANK FEES",
None,
LedgerError::DuplicateAccountName {
name: "BANK FEES".into(),
sibling: "6900 Bank Fees".into(),
},
),
("Loan 123 45 6789", None, LedgerError::AccountNameHoldsTin),
] {
assert_eq!(
l.add_account("6200", name, AccountType::Expense, parent, false),
Err(want),
"{name:?}"
);
assert!(l.differs_from(&before).is_empty(), "{name:?}");
}
// A code in use is still the first refusal.
assert_eq!(
l.add_account("6150", "Software", AccountType::Expense, Some(ops), false),
Err(LedgerError::DuplicateAccountCode("6150".into()))
);
// Another parent: no sibling.
l.add_account("6200", "Software", AccountType::Expense, None, false)
.unwrap();
// Replay takes what an earlier version wrote.
l.replay_add_account("6201", "software", AccountType::Expense, Some(ops), false)
.unwrap();
l.replay_add_account("6202", "Loan 123456789", AccountType::Expense, None, false)
.unwrap();
// A chart with its own top-level "Revenue" under another code: an
// invoice still creates the revenue role's account (4000 Revenue).
let mut l = Ledger::new(Currency::USD);
l.add_account("4100", "Revenue", AccountType::Revenue, None, false)
.unwrap();
let c = l.add_customer("Pine Street Studio").unwrap();
l.create_invoice(
c,
d(2026, 3, 1),
d(2026, 3, 31),
vec![DocumentLine {
description: "work".into(),
amount: dec("100.00"),
}],
)
.unwrap();
assert_eq!(l.chart().find_by_code("4000").unwrap().name, "Revenue");
}
/// check_rename_reason: one line or more of text, at most 2,000
/// characters, no control character but line breaks and tabs, no run of
/// nine digits; kept trimmed.
#[test]
fn a_rename_reason_is_bounded_text_without_a_tin() {
assert_eq!(
Ledger::check_rename_reason(" owner asked\n2026-10-21 "),
Ok("owner asked\n2026-10-21".to_string())
);
for bad in [
String::new(),
" ".into(),
"a\u{7}b".into(),
"x".repeat(2_001),
"their number is 123456789".into(),
"SSN 123-45-6789".into(),
] {
assert!(
matches!(
Ledger::check_rename_reason(&bad),
Err(LedgerError::InvalidRenameReason(_))
),
"{bad:?}"
);
}
assert!(Ledger::check_rename_reason(&"x".repeat(2_000)).is_ok());
}
/// add_account SCENARIOS 7-8 and rename_account SCENARIO 6 (rules.md
/// B-31): a new name is one line of at most MAX_ACCOUNT_NAME characters,
/// kept trimmed, by a new account and a rename alike: 200 characters are
/// taken, 201 refused, and so is a line break, a tab or another control
/// character; surrounding space is dropped the same way on both paths.
/// Replay takes any journaled name (B-28).
#[test]
fn a_new_name_is_one_trimmed_line_of_bounded_length() {
use ledger_core::account::MAX_ACCOUNT_NAME;
assert_eq!(MAX_ACCOUNT_NAME, 200);
let (mut l, ops, software, _) = named_chart();
let before = l.clone();
let long = "N".repeat(MAX_ACCOUNT_NAME + 1);
for name in [
long.as_str(),
"Two\nlines",
"Tab\there",
"Bell\u{7}",
"Escape \u{1b}[31m",
"Next\u{85}line",
] {
let add = l.add_account("6200", name, AccountType::Expense, Some(ops), false);
assert!(
matches!(add, Err(LedgerError::InvalidAccountName(_))),
"{name:?}: {add:?}"
);
let rename = l.check_rename_account(software, name);
assert!(
matches!(rename, Err(LedgerError::InvalidAccountName(_))),
"{name:?}: {rename:?}"
);
assert_eq!(l.rename_account(software, name), rename);
assert!(l.differs_from(&before).is_empty(), "{name:?}");
}
let id = l
.add_account(
"6200",
&format!(" {} ", "N".repeat(MAX_ACCOUNT_NAME)),
AccountType::Expense,
Some(ops),
false,
)
.unwrap();
assert_eq!(
l.chart().get(id).unwrap().name,
"N".repeat(MAX_ACCOUNT_NAME)
);
let id = l
.add_account(
"6210",
" Domains \t",
AccountType::Expense,
Some(ops),
false,
)
.unwrap();
assert_eq!(l.chart().get(id).unwrap().name, "Domains");
l.rename_account(id, " Domain names\t").unwrap();
assert_eq!(l.chart().get(id).unwrap().name, "Domain names");
// Replay: as journaled.
let id = l
.replay_add_account(
"6220",
" Two\nlines ",
AccountType::Expense,
Some(ops),
false,
)
.unwrap();
assert_eq!(l.chart().get(id).unwrap().name, " Two\nlines ");
l.replay_rename_account(id, &long).unwrap();
assert_eq!(l.chart().get(id).unwrap().name, long);
}
+32 -6
View File
@@ -838,7 +838,12 @@ fn every_balance_sheet_role_holds_while_its_account_has_a_balance() {
held.push((c, cur));
}
for (i, (c, cur)) in held.iter().enumerate() {
let target = add(&mut l, &format!("{}", 9100 + i), "Target", c.account_type);
let target = add(
&mut l,
&format!("{}", 9100 + i),
&format!("Target {i}"),
c.account_type,
);
assert!(
matches!(
refuses(&mut l, c.key, Some(target)),
@@ -1078,16 +1083,37 @@ fn pool_books() -> (Ledger, Vec<AccountId>) {
.into_iter()
.enumerate()
{
ids.push(add(&mut l, &format!("{}", 8000 + 10 * i), "spare a", t));
ids.push(add(&mut l, &format!("{}", 8001 + 10 * i), "spare b", t));
ids.push(add(
&mut l,
&format!("{}", 8000 + 10 * i),
&format!("spare a {i}"),
t,
));
ids.push(add(
&mut l,
&format!("{}", 8001 + 10 * i),
&format!("spare b {i}"),
t,
));
ids.push(
l.add_account(&format!("{}", 8002 + 10 * i), "group", t, None, true)
.unwrap(),
l.add_account(
&format!("{}", 8002 + 10 * i),
&format!("group {i}"),
t,
None,
true,
)
.unwrap(),
);
}
while ids.len() < POOL {
let n = ids.len();
ids.push(add(&mut l, &format!("{}", 8500 + n), "extra", Expense));
ids.push(add(
&mut l,
&format!("{}", 8500 + n),
&format!("extra {n}"),
Expense,
));
}
assert_eq!(ids.len(), POOL);
(l, ids)
+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:?}");
}
}