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

+28
View File
@@ -537,6 +537,34 @@ open_at_start = "ask"
);
}
/// INVARIANT 4 for policy.require_tax_category (rules.md B-32): it is
/// `[books] require_tax_category` in a file, and round-trips; a file
/// without it clears it back to warn.
#[test]
fn the_tax_category_policy_round_trips() {
let file = FERNHILL.replace(
"parties = \"require\"\n",
"parties = \"require\"\nrequire_tax_category = \"required\"\n",
);
let s = imported(&file);
assert_eq!(
s.require_tax_category(),
ledger_core::settings::TaxCategoryPolicy::Refuse
);
let text = export(&s, "USD");
assert!(text.contains("require_tax_category = \"refuse\""), "{text}");
let back = import(&text, None, &s, "USD").unwrap();
assert!(back.changes.is_empty(), "{:?}\n{text}", back.changes);
let cleared = import(FERNHILL, None, &s, "USD").unwrap();
assert_eq!(
cleared.changes,
[Change {
key: "policy.require_tax_category".into(),
value: None
}]
);
}
/// INVARIANT 1: keys the file leaves out are cleared.
#[test]
fn what_the_file_leaves_out_is_cleared() {
+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:?}");
}
}
+47 -21
View File
@@ -374,15 +374,33 @@ impl App {
}
/// Go where the agent asked (`books.show`).
///
/// ```text
/// GOAL: the viewer shows what the agent was told it shows.
/// SOURCE: rules.md B-30 (one default period; a start after the end is
/// refused, never swapped); plan/served.md P3 defect 15.
/// INVARIANTS:
/// 1. A target's dates are taken as the server resolved them, an
/// omitted one (an earlier writer's) by the same rule
/// (Books::periods), never swapped, clamped or guessed.
/// 2. A target the viewer can't honour (an unknown page, report,
/// split or comparison; text that isn't a date; a start after
/// the end; an account, asset or party not in the books) is
/// ignored whole: the page, range and split stay as they were.
/// ```
pub fn show(&mut self, target: ledger_store::ops::ViewerTarget) {
use crate::nav::ReportKind;
use ledger_store::ops::ViewerTarget as T;
let Some(b) = self.books.clone() else {
return;
};
let date = |s: &Option<String>| {
s.as_deref()
.and_then(|d| d.parse::<chrono::NaiveDate>().ok())
// Some(None): omitted; None: text that isn't a date.
let date = |s: &Option<String>| match s.as_deref() {
None => Some(None),
Some(d) => d.parse::<chrono::NaiveDate>().ok().map(Some),
};
let dates = |start: &Option<String>, end: &Option<String>| {
b.periods().statement(date(start)?, date(end)?).ok()
};
let route = match target {
T::Page { page } => match page.as_str() {
@@ -401,15 +419,17 @@ impl App {
},
T::Entry { entry_id } => Route::Entry(entry_id),
T::Account { code, start, end } => {
use crate::pages::accounts::RegisterRange;
let Some(a) = b.ledger.chart().find_by_code(&code) else {
return;
};
self.state.accounts.range = match (date(&start), date(&end)) {
(None, None) => None,
(s, e) => Some((
s.unwrap_or_else(|| b.kpis.first_entry.unwrap_or(b.today)),
e.unwrap_or(b.today),
)),
let Some((s, e)) = dates(&start, &end) else {
return;
};
self.state.accounts.range = if start.is_none() && end.is_none() {
RegisterRange::Default
} else {
RegisterRange::Dates(s, e)
};
Route::Account(a.id)
}
@@ -420,17 +440,22 @@ impl App {
columns,
compare,
} => {
use crate::pages::reports::{Columns, Compare};
self.state.reports.columns = match columns.as_deref() {
use crate::pages::reports::{Columns, Compare, Range};
let columns = match columns.as_deref() {
None | Some("total") => Columns::Total,
Some("month") => Columns::Month,
Some("quarter") => Columns::Quarter,
Some("year") => Columns::Year,
_ => Columns::Total,
Some(_) => return,
};
self.state.reports.compare = match compare.as_deref() {
let compare = match compare.as_deref() {
None => Compare::None,
Some("prior_period") => Compare::PriorPeriod,
Some("prior_year") => Compare::PriorYear,
_ => Compare::None,
Some(_) => return,
};
let Some((s, e)) = dates(&start, &end) else {
return;
};
let kind = match report.as_str() {
"profit_and_loss" => ReportKind::ProfitAndLoss,
@@ -446,13 +471,14 @@ impl App {
"fixed_asset_register" => ReportKind::FixedAssetRegister,
_ => return,
};
if let (s, e) = (date(&start), date(&end))
&& (s.is_some() || e.is_some())
{
let (ds, de) = crate::pages::reports::Range::YearToDate.dates(&b);
self.state.reports.range =
crate::pages::reports::Range::Custom(s.unwrap_or(ds), e.unwrap_or(de));
}
self.state.reports.columns = columns;
self.state.reports.compare = compare;
// The dates as sent, by a preset's name when they are one
// (the default first: "Year to date"), else Custom.
self.state.reports.range = std::iter::once(Range::default())
.chain(Range::PRESETS)
.find(|r| r.dates(&b) == (s, e))
.unwrap_or(Range::Custom(s, e));
Route::Reports(kind)
}
T::Document { attachment_id } => Route::Document(attachment_id),
+35 -35
View File
@@ -12,7 +12,6 @@ use crate::books::{Attention, Books, Severity};
use crate::fmt;
use crate::nav::Route;
use chrono::Datelike;
use ledger_core::reports::profit_and_loss_for_period;
use ledger_core::{AccountType, EntryStatus, PeriodStatus, vendor_1099_report};
use rust_decimal::Decimal;
@@ -45,8 +44,11 @@ fn cant_compute(what: String, why: &ledger_core::LedgerError, target: Option<Rou
/// 2. One item per: draft entry (Todo); posted entry missing a receipt
/// (Problem; four or more are one combined item); recurring entry or
/// schedule due through today (Todo); period ended and still open
/// (Todo); fiscal-year account with activity and no tax category
/// (Info); legal rule known only through a past year (Todo); 1099
/// (Todo); the revenue and expense accounts (not headers) with no tax
/// category, with activity or not (ledger_core::tax::
/// uncategorized_accounts: one item; Info, Todo under
/// policy.require_tax_category = refuse); legal rule known only
/// through a past year (Todo); 1099
/// vendor without a W-9 (Problem); overdue bills, and overdue
/// invoices (Problem, one each, with the total); money owed to
/// owners (Info, per account with a balance); the database differing
@@ -242,38 +244,36 @@ pub fn find(b: &Books) -> Vec<Attention> {
}
}
let (fy_start, _) = b.fiscal_ytd();
if let Ok(pl) = profit_and_loss_for_period(&b.ledger, fy_start, today, None) {
let uncategorized: Vec<String> = pl
.revenue
.iter()
.chain(&pl.expenses)
.filter(|l| {
b.ledger
.chart()
.find_by_code(&l.code)
.is_some_and(|a| a.tax_category.is_none())
})
.map(|l| format!("{} {}", l.code, l.name))
.collect();
if !uncategorized.is_empty() {
out.push(Attention {
severity: Severity::Info,
title: format!(
"{} without a tax category",
fmt::count(uncategorized.len(), "account", "accounts")
),
detail: format!(
"{}. The tax summary lists them as uncategorized.",
uncategorized.join(", ")
),
target: Some(Route::Accounts),
prompt: format!(
"In wanderbooks, propose a tax category for each of these accounts ({}) using books.tax_categories, and set them with books.set_tax_category once I agree.",
uncategorized.join(", ")
),
});
}
// Every revenue and expense account (not a header) without a tax
// category, with activity or not (rules.md B-32): the one list the
// digest gives the agent too.
let uncategorized: Vec<String> = ledger_core::tax::uncategorized_accounts(&b.ledger)
.into_iter()
.map(|a| format!("{} {}", a.code, a.name))
.collect();
if !uncategorized.is_empty() {
let refuse = b.ledger.settings().require_tax_category()
== ledger_core::settings::TaxCategoryPolicy::Refuse;
out.push(Attention {
severity: if refuse {
Severity::Todo
} else {
Severity::Info
},
title: format!(
"{} without a tax category",
fmt::count(uncategorized.len(), "account", "accounts")
),
detail: format!(
"{}. Each revenue and expense account maps to a line of the return (the tax summary and the CPA package list it as uncategorized until then).",
uncategorized.join(", ")
),
target: Some(Route::Accounts),
prompt: format!(
"In wanderbooks, propose a tax category for each of these accounts ({}) using books.tax_categories, and set them with books.set_tax_category once I agree.",
uncategorized.join(", ")
),
});
}
// Rules set by law that wanderbooks may no longer know (rules.md;
+50 -11
View File
@@ -510,7 +510,8 @@ impl Integrity {
/// minus revenue, divided by the months of those the books cover
/// (from the first posted entry's month); Some only when positive,
/// rounded to the cent half away from zero.
/// 7. first_entry: the earliest posted entry's date.
/// 7. first_entry, last_entry: the earliest and the latest posted
/// entry's date.
/// ```
#[derive(Clone, Debug, Default)]
pub struct Kpis {
@@ -527,6 +528,9 @@ pub struct Kpis {
/// Average monthly net loss over the last three full months, if losing.
pub monthly_burn: Option<Decimal>,
pub first_entry: Option<NaiveDate>,
/// The latest posted entry's date (an entry may be dated after
/// today).
pub last_entry: Option<NaiveDate>,
}
impl Books {
@@ -846,12 +850,44 @@ impl Books {
(start, self.today)
}
/// The fiscal year to date.
/// The fiscal year to date: the fiscal year's first day through today
/// (the "Year to date" preset and the overview's figures). The
/// fiscal-year fact itself, not "what an omitted date means"
/// ([`Self::default_period`]): a label never follows a default.
pub fn fiscal_ytd(&self) -> (NaiveDate, NaiveDate) {
let (start, _) = self.settings.company.fiscal_year_containing(self.today);
let start = ledger_core::calendar::fiscal_year_start_on(
self.settings.company.fiscal_year_start,
self.today,
);
(start, self.today)
}
/// What omitted dates mean today, for these books: the one rule the
/// server's report tools, `books.show` and the viewer share (rules.md
/// B-30).
pub fn periods(&self) -> ledger_core::calendar::PeriodDefaults {
ledger_core::calendar::period_defaults(&self.settings.company, self.today)
}
/// Every date the books hold: the first posted entry (today when
/// there is none, or when it is later) through the later of today and
/// the last posted entry, so no entry dated after today is left out
/// ("Show all dates", "All time"; as `party_span`'s all time ends).
pub fn all_dates(&self) -> (NaiveDate, NaiveDate) {
let t = self.today;
(
self.kpis.first_entry.unwrap_or(t).min(t),
self.kpis.last_entry.unwrap_or(t).max(t),
)
}
/// The period a report or register opens on when nobody chose one:
/// the fiscal year to date (rules.md B-30), as the agent's report
/// tools give it for no dates.
pub fn default_period(&self) -> (NaiveDate, NaiveDate) {
self.periods().statement_range()
}
fn compute_kpis(&self) -> Kpis {
let l = &self.ledger;
let today = self.today;
@@ -880,12 +916,14 @@ impl Books {
let (fy_start, _) = self.fiscal_ytd();
let ytd = pl(fy_start, today);
let three = pl(ms - chrono::Months::new(3), prev_end);
let first_entry = self
.entries
.iter()
.filter(|e| e.status == EntryStatus::Posted)
.map(|e| e.date)
.min();
let posted = || {
self.entries
.iter()
.filter(|e| e.status == EntryStatus::Posted)
.map(|e| e.date)
};
let first_entry = posted().min();
let last_entry = posted().max();
let monthly_burn = three.and_then(|p| {
let loss = p.total_expenses - p.total_revenue;
// Only months the books cover count toward the average.
@@ -918,6 +956,7 @@ impl Books {
.unwrap_or_default(),
monthly_burn,
first_entry,
last_entry,
}
}
@@ -968,7 +1007,7 @@ impl Books {
/// Every party's [`PartyInfo`].
fn party_infos(&self) -> BTreeMap<PartyId, PartyInfo> {
let l = &self.ledger;
let year = self.today.year();
let year = self.periods().calendar_year();
let in_year = |d: NaiveDate| d.year() == year;
// A sum that can't be computed exactly stays None (INVARIANT 4a).
let add = |sum: &mut Option<Decimal>, v: Result<Decimal, LedgerError>| {
@@ -1386,7 +1425,7 @@ impl Books {
)
};
match period {
PartyPeriod::ThisYear => year(self.today.year()),
PartyPeriod::ThisYear => year(self.periods().calendar_year()),
PartyPeriod::LastYear => year(self.today.year() - 1),
PartyPeriod::Year(y) => year(y),
PartyPeriod::AllTime => {
+52 -13
View File
@@ -22,11 +22,37 @@ use rust_decimal::Decimal;
#[derive(Default)]
pub struct State {
/// Limit the register to these dates (set by drill-downs).
pub range: Option<(NaiveDate, NaiveDate)>,
/// Which dates the register shows.
pub range: RegisterRange,
pub show_empty: bool,
}
/// Which dates an account's register shows.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum RegisterRange {
/// What a register opens on when nobody chose dates: the fiscal year
/// to date (rules.md B-30, `Books::default_period`), its opening
/// balance carried in.
#[default]
Default,
/// Every date: the books' first entry through the later of today
/// and the last ("Show all dates", `Books::all_dates`).
All,
/// These dates: a drill-down's, or a `books.show` target's.
Dates(NaiveDate, NaiveDate),
}
impl RegisterRange {
/// The first and last day shown.
pub fn dates(self, b: &Books) -> (NaiveDate, NaiveDate) {
match self {
RegisterRange::Default => b.default_period(),
RegisterRange::All => b.all_dates(),
RegisterRange::Dates(s, e) => (s, e),
}
}
}
pub fn show_chart(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
let p = pal(ui.ctx());
page(ui, "accounts", |ui| {
@@ -152,7 +178,7 @@ pub fn show_chart(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
},
);
if let Some(id) = go {
shell.state.accounts.range = None;
shell.state.accounts.range = RegisterRange::Default;
shell.go(Route::Account(id));
}
ui.add_space(12.0);
@@ -165,12 +191,7 @@ pub fn show_account(ui: &mut Ui, b: &Books, id: u64, shell: &mut Shell<'_>) {
let Some(a) = b.ledger.chart().get(id) else {
return;
};
let first = b.kpis.first_entry.unwrap_or(b.today);
let (s, e) = shell
.state
.accounts
.range
.unwrap_or((first.min(b.today), b.today));
let (s, e) = shell.state.accounts.range.dates(b);
page(ui, "account", |ui| {
widgets::copyable(
ui,
@@ -226,8 +247,14 @@ pub fn show_account(ui: &mut Ui, b: &Books, id: u64, shell: &mut Shell<'_>) {
))
.font(theme::semibold(13.5)),
);
if shell.state.accounts.range.is_some() && ui.button("Show all dates").clicked() {
shell.state.accounts.range = None;
// "Show all dates" one click away from any other range; from
// all dates, back to the fiscal year to date.
if shell.state.accounts.range == RegisterRange::All {
if ui.button("Fiscal year to date").clicked() {
shell.state.accounts.range = RegisterRange::Default;
}
} else if ui.button("Show all dates").clicked() {
shell.state.accounts.range = RegisterRange::All;
}
if ui
.button(format!("{} Find in transactions", icon::LIST_BULLETS))
@@ -238,8 +265,20 @@ pub fn show_account(ui: &mut Ui, b: &Books, id: u64, shell: &mut Shell<'_>) {
}
});
ui.add_space(8.0);
let Ok(gl) = general_ledger(&b.ledger, s, e) else {
return;
let gl = match general_ledger(&b.ledger, s, e) {
Ok(gl) => gl,
Err(why) => {
// Never a blank page: say why these dates can't be shown.
widgets::card(ui, |ui| {
widgets::empty(
ui,
icon::WARNING,
"These dates can't be shown",
&format!("{why}. Choose other dates."),
)
});
return;
}
};
let Some(acct) = gl.accounts.iter().find(|x| x.code == a.code) else {
widgets::card(ui, |ui| {
+3 -2
View File
@@ -764,7 +764,7 @@ fn reconciliation(ui: &mut Ui, b: &Books, r: &AssetRegister, shell: &mut Shell<'
},
);
if let Some(id) = go {
shell.state.accounts.range = None;
shell.state.accounts.range = crate::pages::accounts::RegisterRange::Default;
shell.go(Route::Account(id));
}
}
@@ -927,7 +927,8 @@ pub fn show_asset(ui: &mut Ui, b: &Books, id: AssetId, shell: &mut Shell<'_>) {
)
.clicked()
{
shell.state.accounts.range = None;
shell.state.accounts.range =
crate::pages::accounts::RegisterRange::Default;
go = Some(Route::Account(acct));
}
ui.end_row();
+1 -1
View File
@@ -105,7 +105,7 @@ pub fn show(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
},
);
if r.interact(Sense::click()).clicked() {
shell.state.accounts.range = None;
shell.state.accounts.range = crate::pages::accounts::RegisterRange::Default;
shell.go(Route::Account(*id));
}
});
+35 -5
View File
@@ -368,6 +368,20 @@ pub fn describe(b: &Books, line: &JournalLine) -> Option<Event> {
.and_then(|v| v.as_u64())
.map(Route::Account),
),
// Its code and both names as journaled: a later rename or
// renumber never rewrites what this line said.
"account.renamed" => ev(
icon::TAG,
format!(
"Renamed {} from \u{201c}{}\u{201d} to \u{201c}{}\u{201d}",
s(p, "code"),
s(p, "previous"),
s(p, "name")
),
p.get("account_id")
.and_then(|v| v.as_u64())
.map(Route::Account),
),
"role.assigned" => {
let role = s(p, "role");
let control = ledger_core::control_account(role);
@@ -792,11 +806,27 @@ pub fn describe(b: &Books, line: &JournalLine) -> Option<Event> {
format!("Cut-off journal line set aside ({} bytes)", p["bytes"]),
None,
),
"journal.format_set" => ev(
icon::FILE_TEXT,
format!("Journal format {} declared", p["format"]),
None,
),
"journal.format_set" => {
// The features in force with it (P3: account_renamed,
// require_tax_category): what an earlier version can't read.
let features: Vec<&str> = p["features"]
.as_array()
.map(|a| a.iter().filter_map(|f| f.as_str()).collect())
.unwrap_or_default();
ev(
icon::FILE_TEXT,
if features.is_empty() {
format!("Journal format {} declared", p["format"])
} else {
format!(
"Journal format {} declared, with {}",
p["format"],
features.join(", ")
)
},
None,
)
}
"recurring.generated" => ev(
icon::REPEAT,
"Recurring draft created".into(),
+2 -1
View File
@@ -281,7 +281,8 @@ fn spending_changes(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
},
);
if let Some(id) = go {
shell.state.accounts.range = Some((ms - Months::new(3), b.today));
shell.state.accounts.range =
crate::pages::accounts::RegisterRange::Dates(ms - Months::new(3), b.today);
shell.go(Route::Account(id));
}
ui.add_space(16.0);
+33 -8
View File
@@ -22,6 +22,10 @@ use ledger_core::{AccountId, AccountType, MemberCapital, tax_summary, vendor_109
use rust_decimal::Decimal;
use std::collections::BTreeMap;
/// The range a report covers. The default (a report nobody chose dates
/// for) is the fiscal year to date: what an omitted date means
/// everywhere (rules.md B-30, `Books::default_period`; a test pins that
/// they agree).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum Range {
ThisMonth,
@@ -52,7 +56,7 @@ impl Range {
(s - Months::new(12), s - Days::new(1))
}
Range::Last12Months => (ms - Months::new(11), t),
Range::AllTime => (b.kpis.first_entry.unwrap_or(t).min(t), t),
Range::AllTime => b.all_dates(),
Range::Custom(s, e) => (s, e),
}
}
@@ -70,7 +74,8 @@ impl Range {
}
}
const PRESETS: [Range; 7] = [
/// The ranges the picker offers by name.
pub const PRESETS: [Range; 7] = [
Range::ThisMonth,
Range::LastMonth,
Range::ThisQuarter,
@@ -354,7 +359,7 @@ fn col_title(s: NaiveDate, e: NaiveDate, columns: Columns) -> String {
}
/// The most columns a split report shows.
const MAX_COLUMNS: i64 = 240;
const MAX_COLUMNS: i64 = ledger_store::ops::MAX_REPORT_COLUMNS;
/// How many columns splitting `s..=e` by `columns` makes, when that is
/// more than [`MAX_COLUMNS`]; counted from the months, before building
@@ -366,9 +371,8 @@ fn too_many_columns(s: NaiveDate, e: NaiveDate, columns: Columns) -> Option<i64>
Columns::Quarter => 3,
Columns::Year => 12,
};
let months = (i64::from(e.year()) - i64::from(s.year())) * 12 + i64::from(e.month())
- i64::from(s.month());
let n = months / per + 1;
// The count books.show refuses past, so the two never disagree.
let n = ledger_store::ops::report_columns(s, e, per);
(n > MAX_COLUMNS).then_some(n)
}
@@ -1028,6 +1032,19 @@ fn tax(b: &Books, s: NaiveDate, e: NaiveDate) -> Table {
&ts.separately_stated,
);
}
// Book amounts only: Form 4797 figures the tax gain from the tax
// basis, and its split decides the line (ledger_core::tax).
if !ts.form_4797.is_empty() {
t.section("Gains and losses on business property (Form 4797)");
for l in &ts.form_4797 {
t.rows.push(Row {
kind: Kind::Line,
label: l.label.clone(),
detail: l.accounts.join(", "),
cells: vec![Cell::of(l.book), Cell::none()],
});
}
}
if !ts.uncategorized.is_empty() {
t.section("Not categorized");
for u in &ts.uncategorized {
@@ -1056,6 +1073,9 @@ fn tax(b: &Books, s: NaiveDate, e: NaiveDate) -> Table {
}
let form = ts.form.map(fmt::tax_form).unwrap_or("not set");
t.note(format!("Return: {form}. Deductible percentages applied (meals 50%, penalties 0%). A worksheet for the CPA, not a filing."));
if !ts.form_4797.is_empty() {
t.note("Gains and losses on business property are book amounts, in no total: the CPA figures them on Form 4797.");
}
t
}
@@ -1460,6 +1480,11 @@ fn report(ui: &mut Ui, b: &Books, kind: ReportKind, shell: &mut Shell<'_>, picke
let s = widgets::date_picker(ui, "from", s);
ui.label(RichText::new("to").color(p.muted));
let e = widgets::date_picker(ui, "to", e);
// The human's own pickers: a "from" set past the "to" reads
// as the two in order. Never a books.show target's dates:
// App::show takes only a resolved range in order (rules.md
// B-30) and ignores any other, so this never changes what the
// agent was told the viewer shows.
st.range = Range::Custom(s.min(e), e.max(s));
}
if splittable {
@@ -1987,7 +2012,7 @@ fn render(ui: &mut Ui, t: &Table, pdf: &PdfInfo, shell: &mut Shell<'_>) {
start_pdf(ui.ctx(), st, t, pdf);
}
if let Some(d) = go {
shell.state.accounts.range = Some((d.start, d.end));
shell.state.accounts.range = crate::pages::accounts::RegisterRange::Dates(d.start, d.end);
shell.go(Route::Account(d.account));
}
}
@@ -2162,7 +2187,7 @@ fn gl(ui: &mut Ui, b: &Books, s: NaiveDate, e: NaiveDate, shell: &mut Shell<'_>)
}
if let Some(r) = go {
if let Route::Account(_) = r {
shell.state.accounts.range = Some((s, e));
shell.state.accounts.range = crate::pages::accounts::RegisterRange::Dates(s, e);
}
shell.go(r);
}
+2 -1
View File
@@ -30,6 +30,7 @@ use chrono::NaiveDate;
use egui_kittest::Harness;
use ledger_gui::books::{Books, PartyPeriod};
use ledger_gui::nav::{ReportKind, Route};
use ledger_gui::pages::accounts::RegisterRange;
use ledger_gui::pages::reports::{Columns, Compare, Range};
use ledger_gui::{App, Source, data};
use serde_json::json;
@@ -360,7 +361,7 @@ fn every_page_renders(shard: usize, of: usize) {
for at in edges {
h.state_mut().state_mut().assets.as_of = Some(at);
show(&mut h, Route::Assets);
h.state_mut().state_mut().accounts.range = Some((NaiveDate::MIN, at));
h.state_mut().state_mut().accounts.range = RegisterRange::Dates(NaiveDate::MIN, at);
for &a in &accounts {
show(&mut h, Route::Account(a));
}
+8 -1
View File
@@ -1,6 +1,7 @@
//! Screenshots of every screen, for eyes. Set `SHOT_BOOKS` to a books dir
//! (containing `books-data/`, its settings in its journal) and `SHOT_DIR` to
//! where the PNGs go; without them this test does nothing.
//! where the PNGs go; without them this test does nothing. `SHOT_REGISTER=all`
//! shows an account's register on every date instead of its default.
use egui_kittest::Harness;
use ledger_gui::nav::{ReportKind, Route};
@@ -153,6 +154,12 @@ fn screenshots() {
if std::env::var("SHOT_LIVE").is_ok() {
harness.state_mut().show_live_label();
}
// SHOT_REGISTER=all: a register showing every date (after "Show
// all dates"); otherwise it opens on the default period.
if std::env::var("SHOT_REGISTER").is_ok_and(|r| r == "all") {
harness.state_mut().state_mut().accounts.range =
ledger_gui::pages::accounts::RegisterRange::All;
}
harness.run_steps(4);
let ctx = harness.ctx.clone();
harness.state_mut().settle(&ctx);
+191
View File
@@ -118,3 +118,194 @@ fn show_requests_navigate_the_viewer() {
});
assert_eq!(h.state_mut().route(), &Route::Transactions);
}
/// The demo books (calendar fiscal year) in a viewer, at `today`.
fn demo(
tag: &str,
today: chrono::NaiveDate,
) -> (TestDir, Harness<'static, App>, ledger_gui::books::Books) {
demo_sized(tag, today, 920.0)
}
/// [`demo`] in a window `height` tall (the register's date row sits below
/// an account's cards: tall enough to click it).
fn demo_sized(
tag: &str,
today: chrono::NaiveDate,
height: f32,
) -> (TestDir, Harness<'static, App>, ledger_gui::books::Books) {
let dir = std::env::temp_dir().join(format!("ledger-gui-show-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let guard = TestDir(dir.clone());
ledger_gui::demo::build(&dir, today).unwrap();
let source = Source {
data_dir: dir.join("books-data"),
today: Some(today),
};
let books = data::load_once(&source).unwrap();
let copy = data::load_once(&source).unwrap();
let h = Harness::builder()
.with_size(egui::vec2(1440.0, height))
.build_eframe(move |cc| App::with_books(&cc.egui_ctx, books, true, Route::Overview));
(guard, h, copy)
}
fn day(y: i32, m: u32, d: u32) -> chrono::NaiveDate {
chrono::NaiveDate::from_ymd_opt(y, m, d).unwrap()
}
/// Defect 15, repros 1.1.1-1.1.2 (rules.md B-30): a target the viewer
/// can't honour is ignored whole, never swapped or guessed: a start after
/// the end (an earlier writer's, or a hand-edited ops/show.jsonl), text
/// that isn't a date, an unknown split or comparison. The page, the
/// report's range and split, and the register's range stay as they were.
#[test]
fn show_never_swaps_an_inverted_target_defect_15() {
use ledger_gui::pages::accounts::RegisterRange;
use ledger_gui::pages::reports::{Columns, Compare, Range};
let (_dir, mut h, _) = demo("inverted", day(2026, 10, 20));
h.state_mut().state_mut().reports.range = Range::LastMonth;
h.state_mut().state_mut().reports.columns = Columns::Quarter;
h.state_mut().state_mut().accounts.range = RegisterRange::All;
let report =
|start: Option<&str>, end: Option<&str>, columns: Option<&str>, compare: Option<&str>| {
ViewerTarget::Report {
report: "profit_and_loss".into(),
start: start.map(Into::into),
end: end.map(Into::into),
columns: columns.map(Into::into),
compare: compare.map(Into::into),
}
};
for target in [
report(Some("2026-12-31"), Some("2026-01-01"), None, None),
report(Some("2027-01-01"), None, None, None),
report(Some("January"), None, None, None),
report(None, Some("2026-13-01"), None, None),
report(None, None, Some("weekly"), None),
report(None, None, None, Some("prior_decade")),
ViewerTarget::Account {
code: "1000".into(),
start: Some("2026-12-31".into()),
end: Some("2026-01-01".into()),
},
ViewerTarget::Account {
code: "1000".into(),
start: Some("2027-01-01".into()),
end: None,
},
] {
h.state_mut().show(target.clone());
h.run_steps(2);
assert_eq!(h.state_mut().route(), &Route::Overview, "{target:?}");
let st = h.state_mut().state_mut();
assert_eq!(st.reports.range, Range::LastMonth, "{target:?}");
assert_eq!(st.reports.columns, Columns::Quarter, "{target:?}");
assert_eq!(st.reports.compare, Compare::None, "{target:?}");
assert_eq!(st.accounts.range, RegisterRange::All, "{target:?}");
}
}
/// Defect 16, repro 1.1.4 (rules.md B-30): a report target with only an
/// end covers that end's fiscal year to it (2025-01-01 to 2025-06-30, not
/// the swapped 2025-06-30 to 2026-01-01); with no dates, the default (the
/// fiscal year to date, named "Year to date"); dates that are a preset's
/// show under its name; an account target with no dates opens the
/// register on the default.
#[test]
fn show_report_with_only_end_uses_that_years_start_defect_16() {
use ledger_gui::pages::accounts::RegisterRange;
use ledger_gui::pages::reports::Range;
let today = day(2026, 10, 20);
let (_dir, mut h, books) = demo("only-end", today);
let report = |start: Option<&str>, end: Option<&str>| ViewerTarget::Report {
report: "profit_and_loss".into(),
start: start.map(Into::into),
end: end.map(Into::into),
columns: None,
compare: None,
};
h.state_mut().show(report(None, Some("2025-06-30")));
h.run_steps(2);
assert_eq!(
h.state_mut().state_mut().reports.range,
Range::Custom(day(2025, 1, 1), day(2025, 6, 30))
);
h.state_mut().state_mut().reports.range = Range::LastMonth;
h.state_mut().show(report(None, None));
h.run_steps(2);
assert_eq!(h.state_mut().state_mut().reports.range, Range::YearToDate);
assert_eq!(Range::YearToDate.dates(&books), (day(2026, 1, 1), today));
// What books.show sends for no dates: the same, by name.
h.state_mut()
.show(report(Some("2026-01-01"), Some("2026-10-20")));
h.run_steps(2);
assert_eq!(h.state_mut().state_mut().reports.range, Range::YearToDate);
h.state_mut()
.show(report(Some("2026-09-01"), Some("2026-09-30")));
h.run_steps(2);
assert_eq!(h.state_mut().state_mut().reports.range, Range::LastMonth);
// Only a start: through today.
h.state_mut().show(report(Some("2026-03-15"), None));
h.run_steps(2);
assert_eq!(
h.state_mut().state_mut().reports.range,
Range::Custom(day(2026, 3, 15), today)
);
// An account with no dates (an earlier writer's): the default.
h.state_mut().state_mut().accounts.range = RegisterRange::All;
h.state_mut().show(ViewerTarget::Account {
code: "1000".into(),
start: None,
end: None,
});
h.run_steps(2);
assert_eq!(
h.state_mut().state_mut().accounts.range,
RegisterRange::Default
);
assert_eq!(
RegisterRange::Default.dates(&books),
(day(2026, 1, 1), today)
);
}
/// Settled in design (P3, defect 16): an account's register opens on the
/// fiscal year to date with "Show all dates" one click away: after
/// books.show of an account (which sends the resolved dates), the button
/// shows every date, the books' first entry through today; from there,
/// "Fiscal year to date" goes back.
#[test]
fn show_all_dates_is_one_click_from_a_shown_register() {
use egui_kittest::kittest::Queryable;
use ledger_gui::pages::accounts::RegisterRange;
let today = day(2026, 10, 20);
let (_dir, mut h, books) = demo_sized("all-dates", today, 3000.0);
let id = books.ledger.chart().find_by_code("1000").unwrap().id;
h.state_mut().show(ViewerTarget::Account {
code: "1000".into(),
start: Some("2026-01-01".into()),
end: Some("2026-10-20".into()),
});
h.run_steps(2);
assert_eq!(h.state_mut().route(), &Route::Account(id));
assert_eq!(
h.state_mut().state_mut().accounts.range,
RegisterRange::Dates(day(2026, 1, 1), today)
);
h.get_by_label("Show all dates").click();
h.run_steps(2);
assert_eq!(h.state_mut().state_mut().accounts.range, RegisterRange::All);
let first = books.kpis.first_entry.unwrap();
assert_eq!(RegisterRange::All.dates(&books), (first.min(today), today));
assert!(
first < day(2026, 1, 1),
"the demo's books began before this year"
);
h.get_by_label("Fiscal year to date").click();
h.run_steps(2);
assert_eq!(
h.state_mut().state_mut().accounts.range,
RegisterRange::Default
);
}
+11 -1
View File
@@ -1064,8 +1064,18 @@ fn small_books(tag: &str) -> Small {
let cash = store
.add_account("1000", "Checking", AccountType::Asset, None, false)
.unwrap();
// Mapped to the return, so books with nothing else to do have
// nothing in Needs attention (rules.md B-32 lists every expense
// account without a category, used or not).
let rent = store
.add_account("6100", "Rent", AccountType::Expense, None, false)
.add_account_with_tax(
"6100",
"Rent",
AccountType::Expense,
None,
false,
Some(("rent_property", None)),
)
.unwrap();
Small {
dir,
+148
View File
@@ -163,6 +163,22 @@ proptest! {
fy = fy - Months::new(12);
}
prop_assert_eq!(b.fiscal_ytd(), (fy, t));
// One default period everywhere (rules.md B-30): what a report or
// register opens on is the fiscal year to date, the server's
// resolver's own answer for no dates, and party totals count this
// calendar year.
prop_assert_eq!(b.default_period(), (fy, t));
prop_assert_eq!(b.periods().statement(None, None), Ok((fy, t)));
prop_assert_eq!(ledger_gui::pages::reports::Range::default().dates(&b), (fy, t));
prop_assert_eq!(
ledger_gui::pages::accounts::RegisterRange::default().dates(&b),
(fy, t)
);
let jan1 = NaiveDate::from_ymd_opt(t.year(), 1, 1).unwrap();
prop_assert_eq!(
b.party_span(ledger_gui::books::PartyPeriod::ThisYear).0,
jan1
);
let ytd = pl(fy, t);
prop_assert_eq!(k.net_ytd, ytd.total_revenue - ytd.total_expenses);
let first = p.entries.iter().map(|e| t - Days::new(e.0)).min();
@@ -669,3 +685,135 @@ fn more_origins_and_parties() {
assert_eq!(r.origin, "Payroll taxes");
assert!(!r.missing_receipt, "a remittance isn't a hand-made entry");
}
/// Defect 8, repros 1.4.2 and 1.4.3 (rules.md B-32): Needs attention lists
/// every revenue and expense account without a tax category, with
/// activity or not: one never used, one used only last fiscal year; never
/// a header, an account with a category, or an asset. Info under warn,
/// Todo under refuse.
#[test]
fn needs_attention_flags_every_uncategorized_pl_account_defect_8() {
let today = NaiveDate::from_ymd_opt(2026, 10, 20).unwrap();
let d = dir("attention-tax");
let mut s = Store::create(&d.join("books-data"), Currency::USD).unwrap();
let cash = s
.add_account("1000", "Checking", AccountType::Asset, None, false)
.unwrap();
let ops = s
.add_account("6000", "Operating", AccountType::Expense, None, true)
.unwrap();
s.add_account("6150", "Software", AccountType::Expense, Some(ops), false)
.unwrap();
let hosting = s
.add_account("6160", "Hosting", AccountType::Expense, Some(ops), false)
.unwrap();
s.add_account_with_tax(
"4000",
"Sales",
AccountType::Revenue,
None,
false,
Some(("gross_receipts", None)),
)
.unwrap();
let e = s
.create_entry(NaiveDate::from_ymd_opt(2025, 6, 1).unwrap(), "last year")
.unwrap();
let m = Money::new(Decimal::new(1_200, 2), Currency::USD);
s.add_line(e, hosting, Side::Debit, m).unwrap();
s.add_line(e, cash, Side::Credit, m).unwrap();
s.post_entry(e).unwrap();
journal_settings(
&mut s,
"[company]\nname = \"Fernhill Labs LLC\"\n[books]\ncash_accounts = [\"1000\"]\n",
);
let source = Source {
data_dir: d.join("books-data"),
today: Some(today),
};
let item = |b: &ledger_gui::books::Books| {
ledger_gui::attention::find(b)
.into_iter()
.find(|a| a.title.contains("without a tax category"))
};
let b = data::load_once(&source).unwrap();
let a = item(&b).expect("listed");
assert_eq!(a.title, "2 accounts without a tax category");
assert!(
a.detail.starts_with("6150 Software, 6160 Hosting. Each revenue and expense account maps to a line of the return"),
"{}",
a.detail
);
assert_eq!(a.severity, ledger_gui::books::Severity::Info);
assert!(
a.prompt.contains("books.tax_categories") && a.prompt.contains("books.set_tax_category")
);
// Under refuse, a to-do.
s.change_settings(
&[ledger_core::settings::Change {
key: "policy.require_tax_category".into(),
value: Some(serde_json::json!("refuse")),
}],
&ledger_core::settings::Source {
decided_by: "Tobin Harlow".into(),
decided_on: today,
citation: None,
note: None,
},
)
.unwrap();
let b = data::load_once(&source).unwrap();
assert_eq!(
item(&b).unwrap().severity,
ledger_gui::books::Severity::Todo
);
// Categorized: no item at all.
for code in ["6150", "6160"] {
let id = s.ledger().chart().find_by_code(code).unwrap().id;
s.set_account_tax(id, Some("other_deductions"), None)
.unwrap();
}
let b = data::load_once(&source).unwrap();
assert!(item(&b).is_none());
}
/// "Show all dates" is every date (P3 settled in design, rules.md B-30):
/// the register's and the reports' all-dates range runs from the first
/// posted entry through the later of today and the last, so an entry
/// dated after today is never left out of it (as party totals' "All time"
/// already ends); with no such entry it ends today.
#[test]
fn all_dates_holds_an_entry_dated_after_today() {
use ledger_gui::pages::accounts::RegisterRange;
use ledger_gui::pages::reports::Range;
let today = NaiveDate::from_ymd_opt(2026, 10, 20).unwrap();
let d = dir("all-dates-future");
let mut s = Store::create(&d.join("books-data"), Currency::USD).unwrap();
let cash = s
.add_account("1000", "Checking", AccountType::Asset, None, false)
.unwrap();
let capital = s
.add_account("3000", "Capital", AccountType::Equity, None, false)
.unwrap();
let source = Source {
data_dir: d.join("books-data"),
today: Some(today),
};
let post = |s: &mut Store, day: NaiveDate| {
let e = s.create_entry(day, "x").unwrap();
let m = Money::new(Decimal::new(500, 2), Currency::USD);
s.add_line(e, cash, Side::Debit, m).unwrap();
s.add_line(e, capital, Side::Credit, m).unwrap();
s.post_entry(e).unwrap();
};
let first = NaiveDate::from_ymd_opt(2025, 3, 15).unwrap();
post(&mut s, first);
let b = data::load_once(&source).unwrap();
assert_eq!(b.all_dates(), (first, today));
let later = NaiveDate::from_ymd_opt(2026, 12, 1).unwrap();
post(&mut s, later);
let b = data::load_once(&source).unwrap();
assert_eq!(b.kpis.last_entry, Some(later));
assert_eq!(RegisterRange::All.dates(&b), (first, later));
assert_eq!(Range::AllTime.dates(&b), (first, later));
}
+19
View File
@@ -558,6 +558,25 @@ pub fn tax_summary(ts: &ledger_core::TaxSummary) -> Statement {
));
rows.extend(lines(&ts.separately_stated));
}
if !ts.form_4797.is_empty() {
rows.push(spacer());
rows.push(section("Gains and losses on business property (Form 4797)"));
rows.push(subheading(
"Book amounts; the CPA figures the tax gain from the tax basis and where it goes; not included above:",
));
rows.extend(ts.form_4797.iter().map(|l| {
let codes: Vec<&str> = l
.accounts
.iter()
.map(|a| a.split_whitespace().next().unwrap_or(""))
.collect();
row(
RowKind::Line,
format!("{} ({})", l.label, codes.join(", ")),
vec![Some(l.book), None, None],
)
}));
}
if !ts.uncategorized.is_empty() {
rows.push(spacer());
rows.push(section("Not yet categorized"));
+64 -6
View File
@@ -1630,7 +1630,10 @@ proptest! {
// A blank name is refused by the books; it can't reach the file.
let name = if name.trim().is_empty() { "x" } else { name.as_str() };
let parent = parent.map(|p| ids[p]);
ids.push(l.add_account(&code_of(i), name, AccountType::Expense, parent, false).unwrap());
// Replayed, as books written before P3 hold them: a sibling's
// name, a tab, nine digits (rules.md B-31 refuses them in a
// new name, never in a journaled one).
ids.push(l.replay_add_account(&code_of(i), name, AccountType::Expense, parent, false).unwrap());
}
let accounts = iif_accounts(&l, |_| QbAccountType::Expense);
match journal_to_iif(&accounts, &[]) {
@@ -1896,7 +1899,9 @@ fn iif_writes_plain_ascii_and_lists_every_change() {
("6050", "René 😀"),
("6070", "Wide number"),
] {
l.add_account(code, name, AccountType::Expense, None, false)
// Replayed: a tab in a name is refused in a new one (rules.md
// B-31), never in one books written before P3 hold.
l.replay_add_account(code, name, AccountType::Expense, None, false)
.unwrap();
}
let memos = [
@@ -2001,9 +2006,12 @@ fn iif_every_line_ends_cr_lf() {
#[test]
fn iif_distinct_accounts_never_share_a_name() {
let mut l = Ledger::new(Currency::USD);
// Replayed, as books written before P3 hold them: siblings of one
// name (rules.md B-31 refuses one in a new name, never in a journaled
// one), which the file must still keep apart.
let mut add = |code: &str, name: &str, parent: Option<&str>| {
let p = parent.map(|c| l.chart().find_by_code(c).unwrap().id);
l.add_account(code, name, AccountType::Expense, p, false)
l.replay_add_account(code, name, AccountType::Expense, p, false)
.unwrap();
};
add("6100", "Software", None);
@@ -2069,7 +2077,7 @@ fn iif_distinct_accounts_never_share_a_name() {
// tagged alike, so the match remains, and is refused naming both.
let mut l = Ledger::new(Currency::USD);
for code in ["6100", "6\u{FF11}00"] {
l.add_account(code, "Software", AccountType::Expense, None, false)
l.replay_add_account(code, "Software", AccountType::Expense, None, false)
.unwrap();
}
let accounts = iif_accounts(&l, |_| QbAccountType::Expense);
@@ -2401,7 +2409,10 @@ fn iif_warnings_group_the_memos_by_what_is_written_differently() {
#[test]
fn iif_warnings_never_hold_a_tin() {
let (mut l, cash, rev, _) = small_ledger();
l.add_account(
// Replayed, as books written before P3 hold them: a name holding a
// TIN, or a tab, is refused in a new name (rules.md B-31), never in a
// journaled one, so the file must still mask it.
l.replay_add_account(
"6300",
"Loan from 123-45-6789 \u{2014} member",
AccountType::Liability,
@@ -2411,7 +2422,7 @@ fn iif_warnings_never_hold_a_tin() {
.unwrap();
// A TIN only once written: its digits a tab apart, which the file
// writes a space apart.
l.add_account(
l.replay_add_account(
"6400",
"Loan 123\t45\t6789",
AccountType::Liability,
@@ -3905,6 +3916,53 @@ fn tax_summary_difference_and_schedule_k() {
);
}
/// A gain on business property (rules.md B-32; ledger_core::tax
/// `TaxSummary::form_4797`): on a Schedule C business's summary it is in
/// its own Form 4797 section, at its book amount with no tax amount, and
/// outside the net, never Schedule C income.
#[test]
fn tax_summary_lists_form_4797_apart() {
use ledger_core::tax::{TaxForm, tax_summary};
let mut l = Ledger::new(Currency::USD);
let cash = l
.add_account("1000", "Cash", AccountType::Asset, None, false)
.unwrap();
let mut acct = |code: &str, t, cat: &str| {
let id = l.add_account(code, code, t, None, false).unwrap();
l.set_account_tax(id, Some(cat), None).unwrap();
id
};
let sales = acct("4000", AccountType::Revenue, "gross_receipts");
let gain = acct("4700", AccountType::Revenue, "form_4797_gain_loss");
for (cr, c) in [(sales, 100_000), (gain, 7_500)] {
let e = l.create_entry(d(2026, 2, 1), "x");
l.add_line(e, cash, Side::Debit, usd(c)).unwrap();
l.add_line(e, cr, Side::Credit, usd(c)).unwrap();
l.post_entry(e).unwrap();
}
let ts = tax_summary(&l, d(2026, 1, 1), d(2026, 12, 31), Some(TaxForm::ScheduleC)).unwrap();
let st = report::tax_summary(&ts);
let at = |label: &str| {
st.rows
.iter()
.position(|r| r.label.starts_with(label))
.unwrap_or_else(|| panic!("no row {label}"))
};
let section = at("Gains and losses on business property (Form 4797)");
let line = at("Gain or loss on business property (Form 4797) (4700)");
assert!(line > section && section > at("Income less deductions"));
assert_eq!(st.rows[line].amounts, vec![Some(cents(7_500)), None, None]);
assert_eq!(
st.rows[at("Total income")].amounts[0],
Some(cents(100_000)),
"the gain is no Schedule C income"
);
assert_eq!(
st.rows[at("Income less deductions")].amounts,
vec![Some(cents(100_000)), Some(cents(100_000)), None]
);
}
/// The reconciliation's "Adjustments:" subheading appears only when there
/// is something to reconcile, and the noncash schedule says "None" when
/// nothing was disclosed (cash_flow INVARIANT 5). A contribution of
+1 -1
View File
@@ -2,4 +2,4 @@
- Invoices, bills, payroll, and closes post to the account filling each role (receivables, revenue, payables, general expense, retained earnings, payroll expense and withholdings); books.roles lists them.
- A role uses its default code (1200, 4000, 2000, 6999, 3999, 6100, 6110, 2110-2150) until it is assigned one of the chart's own accounts with books.assign_role (journaled). If the chart uses a default code for a different kind of account, the engine refuses and says which. It can't tell an account of the right type but another purpose (a hosting expense at payroll's 6100): check books.roles before running payroll, bills, or invoices.
- Never assign roles or renumber the human's accounts on your own: explain the conflict, and when they decide, assign the role to their account (best: their numbering stays) or books.renumber_account the account (its history stays).
- Never assign roles or renumber the human's accounts on your own: explain the conflict, and when they decide, assign the role to their account (best: their numbering stays) or books.renumber_account the account (its history stays); books.rename_account changes only its name, never a role.
@@ -8,6 +8,7 @@ The books keep their own record of how they are kept. Every agent, anywhere, rea
- Change settings with books.set_setting: one or more {key, value} together, with who decided (decided_by) and when (decided_on, default today). A value is checked and kept in one spelling; null clears a key back to its default. Change a setting when the human tells you a company fact or decides a policy, when a tool refuses naming a setting, when books.settings or books.digest lists a problem, and after books.renumber_account lists config_mentions.
- The company's EIN is the one identity number the settings keep; it is shown masked. No other setting, note or convention ever holds a full TIN: those go only into the vault (the parties-and-tins section).
- company.timezone sets what "today" means for the books.
- policy.require_tax_category is warn (unset: a revenue or expense account without a tax category is created, and 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; the accounts invoices, bills and payroll create are never refused, and their results name them). Before setting refuse, categorize with the human every account books.digest lists in uncategorized_accounts.
- books.export_settings writes the settings out as a settings file (TOML), each with who decided it. It is a copy: editing it changes nothing.
## Books that still read a settings file
@@ -1,6 +1,7 @@
# Tracing figures
- Reports are pure functions of the posted entries. When the human questions a figure, trace it to its entries with the tools: the report (books.trial_balance, books.profit_and_loss, books.balance_sheet, books.cash_flow), then books.search_entries and books.get_entry for the entries behind it, and books.show to put them on the human's screen.
- A report without dates covers the fiscal year to date, everywhere (the report tools, the export, books.show and the viewer); one with only an end date covers that date's fiscal year to it. {period_rule} The IIF is never a period: every posted entry from the first through its as_of. Every result names the dates it covers: say them when you quote a figure, and pass dates whenever the human means another period.
- A cash flow line names its entry (books.cash_flow: each line's entry, kind and accounts). An expense a member paid personally (or equipment a lender paid for) is cash borrowed and spent on its date: a financing line and an operating (or investing) line, each with on_behalf, the part no bank account moved; the reimbursement is a repayment. A noncash disclosure is an entry that moved no cash at all (property contributed, debt converted to capital). A warning names an entry whose later payment or collection the books can't classify, with the fix. A role refusal names the setting to change.
- Never guess, and never look for the books' files: the tools are the evidence.
- books.verify checks the books end to end when a number looks wrong or a human asks whether the books are intact.
+1
View File
@@ -1,4 +1,5 @@
# The viewer
- The human usually has the read-only viewer open beside you. When they ask to see something, and after you post, call books.show so it's on their screen: an entry, an account register, a report for any dates (by month, quarter or year, against the prior period or year), a receipt or document, an asset, a party, a reconciliation, or a search.
- books.show says which dates it showed (without dates, the fiscal year to date; a register opens on it too, with "Show all dates" one click away for the human). It refuses a start after the end, and options its target ignores (dates for an entry, a split for the trial balance, a start for a report as of a day, a split into more than 240 columns): pass only what the target takes.
- The viewer never changes the books; every change goes through these tools.
+25 -2
View File
@@ -43,7 +43,8 @@ pub const CONNECT_LEAD: usize = 512;
/// The handbook's sections, in reading order: (id, markdown). Each starts
/// with a `# Title` line; `{evidence_rule}` stands for the receipt rule in
/// force.
/// force, and `{period_rule}` for what omitted report dates mean
/// ([`period_rule`]).
pub const SECTIONS: &[(&str, &str)] = &[
("contract", include_str!("../handbook/contract.md")),
("entries", include_str!("../handbook/entries.md")),
@@ -116,7 +117,10 @@ pub const ALL: &str = "all";
/// The section `id` (or [`ALL`]), rendered for these settings and the
/// books' base currency (None: no such section).
pub fn section(id: &str, settings: &Settings, currency: &str) -> Option<String> {
let render = |md: &str| md.replace("{evidence_rule}", &evidence_rule(settings, currency));
let render = |md: &str| {
md.replace("{evidence_rule}", &evidence_rule(settings, currency))
.replace("{period_rule}", &period_rule())
};
if id == ALL {
let parts: Vec<String> = SECTIONS.iter().map(|(_, md)| render(md)).collect();
return Some(parts.join("\n"));
@@ -127,6 +131,17 @@ pub fn section(id: &str, settings: &Settings, currency: &str) -> Option<String>
.map(|(_, md)| render(md))
}
/// What omitted report dates mean (rules.md B-30), in the words every
/// report tool's description uses (`ledger_core::calendar::PeriodKind`).
pub fn period_rule() -> String {
use ledger_core::calendar::PeriodKind;
format!(
"{} For a report as of a day (balance sheet, trial balance, aging): {}",
PeriodKind::Statement.describe(),
PeriodKind::AsOf.describe()
)
}
/// The receipt rule in force (INVARIANT 2).
pub fn evidence_rule(settings: &Settings, currency: &str) -> String {
format!(
@@ -460,7 +475,14 @@ mod tests {
assert!(!title(md).is_empty());
let text = section(id, &s, "USD").unwrap();
assert!(!text.contains("{evidence_rule}"), "{id}");
assert!(!text.contains("{period_rule}"), "{id}");
}
// The default period, in the descriptions' own words (B-30).
assert!(
section("tracing-figures", &s, "USD")
.unwrap()
.contains(ledger_core::calendar::PeriodKind::Statement.describe())
);
assert!(
section("evidence-and-receipts", &s, "USD")
.unwrap()
@@ -497,6 +519,7 @@ mod tests {
at += found + one.len();
}
assert!(!all.contains("{evidence_rule}"));
assert!(!all.contains("{period_rule}"));
assert!(SECTIONS.iter().all(|(id, _)| *id != ALL));
}
+2 -1
View File
@@ -62,7 +62,7 @@ const C: Needs = PostWhenConfirmed;
const K: Needs = PostForApprovalRules;
/// Every tool and what it needs (INVARIANT 1).
pub const TABLE: [(&str, Needs); 111] = [
pub const TABLE: [(&str, Needs); 112] = [
("books.handbook", R),
("books.start", R),
// Settings are committed at once: the settings commit of the plan.
@@ -123,6 +123,7 @@ pub const TABLE: [(&str, Needs); 111] = [
("books.chart_template", R),
("books.tax_categories", R),
("books.renumber_account", P),
("books.rename_account", P),
("books.label_asset", W),
("books.correct_asset", P),
("books.set_asset_tax_basis", W),
File diff suppressed because it is too large. Load diff
+149 -21
View File
@@ -1,6 +1,7 @@
//! Tool parameter and output types. Amounts cross MCP as decimal strings,
//! never JSON floats.
use ledger_core::calendar::{AS_OF_DOC, END_DOC, START_DOC, YEAR_DOC};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
@@ -13,7 +14,10 @@ use serde::{Deserialize, Serialize};
pub struct AddAccountParams {
/// Account code, e.g. "1000". Must be unique.
pub code: String,
/// Human name, e.g. "Business Checking".
/// Human name, e.g. "Business Checking": one line of at most 200
/// characters, kept trimmed; no other account under the same parent
/// has it (ignoring capitals), and it holds no run of nine digits
/// (write an account number's last four).
pub name: String,
/// One of: asset, liability, equity, revenue, expense.
pub account_type: String,
@@ -21,6 +25,14 @@ pub struct AddAccountParams {
pub parent: Option<u64>,
/// True for header accounts that never take postings.
pub placeholder: Option<bool>,
/// The tax category of a revenue or expense account (a key from
/// `books.tax_categories`): the line of the return it maps to.
#[serde(default)]
pub tax_category: Option<String>,
/// Percent deductible, overriding the category's default (0 to 100),
/// as a decimal string. Needs tax_category.
#[serde(default)]
pub deductible_percent: Option<String>,
}
/// Result of `books.add_account`.
@@ -28,6 +40,45 @@ pub struct AddAccountParams {
pub struct AddAccountOut {
/// The new account's id.
pub account_id: u64,
/// What to tell the human: a revenue or expense account created
/// without a tax category (the tax summary lists it as uncategorized
/// until it has one).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub warnings: Vec<String>,
}
/// Parameters for `books.rename_account`.
#[derive(Debug, Deserialize, JsonSchema)]
pub struct RenameAccountParams {
/// The account's code (or id).
pub account: String,
/// Its new name, kept trimmed: one line of at most 200 characters,
/// not the one it has, not one another account under the same parent
/// has (ignoring capitals), and no run of nine digits.
pub name: String,
/// Why, in a line or two, for the journal (the human's words, or what
/// they asked). Optional.
#[serde(default)]
pub reason: Option<String>,
}
/// Result of `books.rename_account`.
#[derive(Debug, Serialize, JsonSchema)]
pub struct RenameAccountOut {
/// The account's id (unchanged).
pub id: u64,
/// Its code (unchanged).
pub code: String,
/// The name it had: the journal keeps it.
pub previous_name: String,
/// The name it has now.
pub name: String,
/// What else the new name changes: a set of accounts picked by name
/// it joins or leaves (cash and card accounts, when the settings name
/// none), conventions whose text names the old name, and the
/// QuickBooks import (IIF matches accounts by name).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub notes: Vec<String>,
}
/// One account in the chart.
@@ -474,17 +525,38 @@ pub struct ShowParams {
/// A page: overview, cash, profit, transactions, reports, accounts,
/// assets, parties, documents, company, journal.
pub page: Option<String>,
/// For account and report: first date, YYYY-MM-DD.
/// For an account or a report over a period: its first day (see
/// [`show_start_doc`]).
#[schemars(description = show_start_doc())]
pub start: Option<String>,
/// For account and report: last date, YYYY-MM-DD.
/// For an account or a report: its last day, or the day a report as
/// of a day is as of (see [`show_end_doc`]).
#[schemars(description = show_end_doc())]
pub end: Option<String>,
/// For a report: split into columns, "total" (default), "month",
/// "quarter", or "year" (P&L, balance sheet, cash flow).
pub columns: Option<String>,
/// For a report: compare with "prior_period" or "prior_year".
/// For a report: compare with "prior_period" or "prior_year" (P&L,
/// balance sheet, cash flow, tax summary; not with a split).
pub compare: Option<String>,
}
/// `books.show`'s `start`, in the one default's words
/// ([`ledger_core::calendar::START_DOC`]).
pub fn show_start_doc() -> String {
format!(
"For an account, or a report over a period (a balance sheet only when split or compared with the prior period). {START_DOC}"
)
}
/// `books.show`'s `end`, in the one default's words
/// ([`ledger_core::calendar::END_DOC`]).
pub fn show_end_doc() -> String {
format!(
"For an account or a report: the last day, or the day a report as of a day is as of (vendor_1099: a day in its year). {END_DOC}"
)
}
/// Result of `books.show`.
#[derive(Debug, Serialize, JsonSchema)]
pub struct ShowOut {
@@ -738,8 +810,9 @@ pub struct TrialBalanceLineOut {
/// Parameters for `books.trial_balance`.
#[derive(Debug, Default, Deserialize, JsonSchema)]
pub struct TrialBalanceParams {
/// As of this date, YYYY-MM-DD. Defaults to today.
/// As of this day ([`AS_OF_DOC`]).
#[serde(default)]
#[schemars(description = AS_OF_DOC)]
pub as_of: Option<String>,
}
@@ -761,9 +834,11 @@ pub struct TrialBalanceOut {
/// Parameters for `books.profit_and_loss`.
#[derive(Debug, Deserialize, JsonSchema)]
pub struct ProfitAndLossParams {
/// Period start, YYYY-MM-DD. Defaults to the beginning of time.
/// Period start ([`START_DOC`]).
#[schemars(description = START_DOC)]
pub start: Option<String>,
/// Period end, YYYY-MM-DD. Defaults to today.
/// Period end ([`END_DOC`]).
#[schemars(description = END_DOC)]
pub end: Option<String>,
/// Optional class name or id to filter to one division.
#[serde(default, skip_serializing_if = "Option::is_none")]
@@ -803,9 +878,11 @@ pub struct StatementLineOut {
/// Parameters for `books.cash_flow`.
#[derive(Debug, Deserialize, JsonSchema)]
pub struct CashFlowParams {
/// Period start, YYYY-MM-DD. Defaults to the beginning of time.
/// Period start ([`START_DOC`]).
#[schemars(description = START_DOC)]
pub start: Option<String>,
/// Period end, YYYY-MM-DD. Defaults to today.
/// Period end ([`END_DOC`]).
#[schemars(description = END_DOC)]
pub end: Option<String>,
/// Asset account codes treated as cash (e.g. `["1000", "1010"]`): the
/// statement explains the change in these accounts. Defaults to the
@@ -1081,12 +1158,17 @@ pub struct ImportChartCsvOut {
pub created: Vec<String>,
/// Number of accounts created.
pub count: usize,
/// What to tell the human: each revenue or expense account created
/// without a tax category.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub warnings: Vec<String>,
}
/// Parameters for `books.balance_sheet`.
#[derive(Debug, Deserialize, JsonSchema)]
pub struct BalanceSheetParams {
/// As of this date, YYYY-MM-DD. Defaults to today.
/// As of this day ([`AS_OF_DOC`]).
#[schemars(description = AS_OF_DOC)]
pub as_of: Option<String>,
}
@@ -1773,6 +1855,11 @@ pub struct DigestOut {
/// with a recorded statement) with activity not reconciled through the
/// end of last month: record each statement and reconcile it.
pub unreconciled_accounts: Vec<UnreconciledAccountOut>,
/// Revenue and expense accounts (not headers) with no tax category,
/// active or not, "CODE Name": the list the viewer's Needs attention
/// shows. Propose a category for each from books.tax_categories and
/// set it with books.set_tax_category once the human agrees.
pub uncategorized_accounts: Vec<String>,
}
/// An account due for reconciliation (`books.digest`).
@@ -1827,13 +1914,14 @@ pub struct ExportParams {
/// "journal", "trial_balance", "profit_and_loss", "balance_sheet", or
/// "cash_flow".
pub report: String,
/// Period start for profit_and_loss and cash_flow, YYYY-MM-DD.
/// Period start for the period reports (see [`export_start_doc`]).
#[schemars(description = export_start_doc())]
pub start: Option<String>,
/// Period end for profit_and_loss and cash_flow, YYYY-MM-DD.
/// Period end for the period reports (see [`export_end_doc`]).
#[schemars(description = export_end_doc())]
pub end: Option<String>,
/// As-of date for balance_sheet, trial_balance, fixed_asset_register,
/// and iif (every posted entry dated on or before it), YYYY-MM-DD.
/// Defaults to today.
/// As-of day for the reports as of a day (see [`export_as_of_doc`]).
#[schemars(description = export_as_of_doc())]
pub as_of: Option<String>,
/// Cash account codes for cash_flow, and for iif its BANK accounts.
/// Defaults to `[books] cash_accounts`; one or the other is required
@@ -1851,6 +1939,28 @@ pub struct ExportParams {
pub investing_accounts: Vec<String>,
}
/// `books.export`'s `start`, in the one default's words
/// ([`ledger_core::calendar::START_DOC`]).
pub fn export_start_doc() -> String {
format!(
"For the period reports: profit_and_loss, cash_flow, members_equity, tax_summary, general_ledger, general_journal (csv, json, xlsx; iif takes as_of instead). {START_DOC}"
)
}
/// `books.export`'s `end`, in the one default's words
/// ([`ledger_core::calendar::END_DOC`]).
pub fn export_end_doc() -> String {
format!("For the period reports, as for start. {END_DOC}")
}
/// `books.export`'s `as_of`, in the one default's words
/// ([`ledger_core::calendar::AS_OF_DOC`]).
pub fn export_as_of_doc() -> String {
format!(
"For balance_sheet, trial_balance, fixed_asset_register and iif (every posted entry dated on or before it). {AS_OF_DOC}"
)
}
/// Result of `books.export`.
#[derive(Debug, Serialize, JsonSchema)]
pub struct ExportOut {
@@ -2534,6 +2644,10 @@ pub struct CreateDocumentOut {
pub total: String,
/// The auto-posted journal entry's id.
pub entry_id: u64,
/// What to tell the human: a revenue or expense account this created
/// (Revenue 4000, General Expenses 6999) has no tax category yet.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub warnings: Vec<String>,
}
/// Parameters for `books.receive_payment` / `books.pay_bill`.
@@ -2563,8 +2677,10 @@ pub struct PaymentOut {
/// Parameters for `books.ar_aging` / `books.ap_aging`.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct AgingParams {
/// As-of date, YYYY-MM-DD.
pub as_of: String,
/// As of this day ([`AS_OF_DOC`]).
#[serde(default)]
#[schemars(description = AS_OF_DOC)]
pub as_of: Option<String>,
}
/// One row of an aging report.
@@ -2589,6 +2705,8 @@ pub struct AgingRowOut {
/// Output of `books.ar_aging` / `books.ap_aging`.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct AgingOut {
/// The day it is as of, YYYY-MM-DD.
pub as_of: String,
/// Rows, one per party with an outstanding balance.
pub rows: Vec<AgingRowOut>,
}
@@ -2669,6 +2787,11 @@ pub struct RunPayrollOut {
pub total_gross: String,
/// Total net pay, decimal string.
pub total_net: String,
/// What to tell the human: an expense account this created (Salary &
/// Wage Expense 6100, Payroll Tax Expense 6110) has no tax category
/// yet.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub warnings: Vec<String>,
}
/// Parameters for `books.remit_payroll_taxes`.
@@ -2780,7 +2903,9 @@ pub struct ProfitAndLossByClassOut {
/// Parameters for `books.export_cpa_package`.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct ExportCpaPackageParams {
/// As-of date, YYYY-MM-DD. Defaults to today.
/// As of this day ([`AS_OF_DOC`]); the period reports cover the
/// fiscal year that holds it, through it.
#[schemars(description = AS_OF_DOC)]
pub as_of: Option<String>,
/// Cash account codes the cash flow statement reconciles to, e.g.
/// `["1000"]`. Defaults to `[books] cash_accounts`; one or the other
@@ -3114,8 +3239,9 @@ pub struct ListPartiesParams {
/// Include archived parties (default false).
#[serde(default)]
pub include_archived: bool,
/// Calendar year for `paid` and `received` (default: this year, so
/// year to date).
/// Calendar year for `paid` and `received` ([`YEAR_DOC`]: this year,
/// so year to date).
#[schemars(description = YEAR_DOC)]
pub year: Option<i32>,
}
@@ -3124,7 +3250,9 @@ pub struct ListPartiesParams {
pub struct PartyParams {
/// The party: id or name.
pub party: String,
/// Calendar year for the totals (default: this year, so year to date).
/// Calendar year for the totals ([`YEAR_DOC`]: this year, so year to
/// date).
#[schemars(description = YEAR_DOC)]
pub year: Option<i32>,
}
+159
View File
@@ -1553,3 +1553,162 @@ fn old_book_answers_every_tool(dir: &Path, per_tool: usize) -> Vec<String> {
let _ = std::fs::remove_dir_all(&p.base);
failures
}
/// P3 (defects 8, 15, 16): renames, refused report ranges and tax
/// categories at creation, at their edges, are each answered (a result or
/// a refusal, never a stop), and the books reopen after them. A new name
/// is one line of at most 200 characters (ledger_core::account::
/// MAX_ACCOUNT_NAME): 200 is taken and read back whole; 201, a
/// 100,000-character one, a line break or a tab is refused.
#[test]
fn hostile_renames_ranges_and_categories_are_answered() {
let p = places("p3");
let mut m = Mcp::start(&p);
fixture(&mut m, &p);
let refused = |r: &Value| r["isError"] == json!(true);
for (tool, args, refuse) in [
(
"books.rename_account",
json!({"account": "6300", "name": ""}),
true,
),
(
"books.rename_account",
json!({"account": "6300", "name": " "}),
true,
),
(
"books.rename_account",
json!({"account": "9999", "name": "X"}),
true,
),
(
"books.rename_account",
json!({"account": "6300", "name": "insurance"}),
false,
),
(
"books.rename_account",
json!({"account": "6300", "name": "Contract labor"}),
true,
),
(
"books.rename_account",
json!({"account": "6300", "name": "Policy 123456789"}),
true,
),
(
"books.rename_account",
json!({"account": "6300", "name": "Policy ٠١٢٣٤٥٦٧٨"}),
true,
),
(
"books.rename_account",
json!({"account": "6300", "name": "Risk", "reason": "x".repeat(2_001)}),
true,
),
(
"books.rename_account",
json!({"account": "6300", "name": "Risk", "reason": "\u{0}"}),
true,
),
(
"books.show",
json!({"account": "1000", "start": "9999-12-31"}),
true,
),
(
"books.show",
json!({"report": "profit_and_loss", "start": "9999-12-31"}),
true,
),
(
"books.show",
json!({"report": "profit_and_loss", "end": "0001-01-01"}),
false,
),
(
"books.show",
json!({"report": "trial_balance", "end": "9999-12-31"}),
false,
),
(
"books.profit_and_loss",
json!({"start": "9999-12-31"}),
true,
),
("books.profit_and_loss", json!({"end": "0001-01-01"}), false),
(
"books.profit_and_loss_by_class",
json!({"start": "2026-12-31", "end": "2026-01-01"}),
true,
),
(
"books.cash_flow",
json!({"start": "9999-12-31", "cash_accounts": ["1000"]}),
true,
),
("books.ar_aging", json!({}), false),
("books.ap_aging", json!({"as_of": "9999-12-31"}), false),
(
"books.add_account",
json!({"code": "4100", "name": "Fees", "account_type": "revenue", "tax_category": "meals"}),
true,
),
(
"books.add_account",
json!({"code": "4101", "name": "Fees", "account_type": "revenue", "deductible_percent": "1e9"}),
true,
),
(
"books.rename_account",
json!({"account": "6300", "name": "N".repeat(201)}),
true,
),
(
"books.rename_account",
json!({"account": "6300", "name": "N".repeat(100_000)}),
true,
),
(
"books.rename_account",
json!({"account": "6300", "name": "Risk\nPolicy"}),
true,
),
(
"books.rename_account",
json!({"account": "6300", "name": "Risk\tPolicy"}),
true,
),
(
"books.add_account",
json!({"code": "6310", "name": "X".repeat(201), "account_type": "expense"}),
true,
),
(
"books.add_account",
json!({"code": "6311", "name": "Two\nlines", "account_type": "expense"}),
true,
),
] {
let r = pinned(&mut m, tool, args.clone());
assert_eq!(refused(&r), refuse, "{tool} {}: {r}", truncated(&args));
}
// A name of 200 characters: taken, and read back whole.
let long = "N".repeat(200);
let r = pinned(
&mut m,
"books.rename_account",
json!({"account": "1000", "name": long}),
);
assert!(!refused(&r), "{}", truncated(&r));
let chart = m.ok("books.chart", json!({}));
assert!(
chart["accounts"]
.as_array()
.unwrap()
.iter()
.any(|a| a["code"] == "1000" && a["name"].as_str().unwrap() == long)
);
reopens(m, &p, "P3's hostile inputs");
}
+467 -6
View File
@@ -178,7 +178,7 @@ fn mcp_end_to_end_over_stdio() {
// Tool listing: the narrow tools; chart is read-only.
let list = mcp.request("tools/list", json!({}));
let tools = list["result"]["tools"].as_array().unwrap();
assert_eq!(tools.len(), 108, "expected 108 tools, got {}", tools.len());
assert_eq!(tools.len(), 109, "expected 109 tools, got {}", tools.len());
// Files come in as tool input: books.begin_upload, never a local path.
assert!(tools.iter().any(|t| t["name"] == "books.begin_upload"));
assert!(!tools.iter().any(|t| t["name"] == "books.import_local_file"));
@@ -309,7 +309,7 @@ fn mcp_end_to_end_over_stdio() {
// Export works.
let export = mcp.call(
"books.export",
json!({"format": "csv", "report": "journal"}),
json!({"format": "csv", "report": "journal", "start": "2026-01-01", "end": "2026-12-31"}),
);
assert!(export["content"].as_str().unwrap().contains("January rent"));
@@ -1445,10 +1445,15 @@ fn mcp_period_statements_over_stdio() {
assert_eq!(feb["net_income"], "-0.50");
assert_eq!(feb["expense_accounts"][0]["code"], "6000");
assert_eq!(feb["expense_accounts"][0]["amount"], "3.00");
// No start: the statement begins at the first entry, not year 1.
let all = mcp.call("books.profit_and_loss", json!({"end": "2026-02-28"}));
assert_eq!(all["start"], "2026-01-02");
assert_eq!(all["revenue"], "6.50");
// No start (rules.md B-30): the fiscal year that holds the end, to
// it. The fiscal year starts 02-01, so January's 4.00 of sales is
// last year's, and February's 2.50 is this one's.
let ytd = mcp.call("books.profit_and_loss", json!({"end": "2026-02-28"}));
assert_eq!(ytd["start"], "2026-02-01");
assert_eq!(ytd["revenue"], "2.50");
let last = mcp.call("books.profit_and_loss", json!({"end": "2026-01-31"}));
assert_eq!(last["start"], "2025-02-01");
assert_eq!(last["revenue"], "4.00");
// As of Jan 31: cash 1,004.00 = capital 1,000.00 + earnings 4.00.
let jan = mcp.call("books.balance_sheet", json!({"as_of": "2026-01-31"}));
@@ -2775,3 +2780,459 @@ fn renumbering_an_account_keeps_its_history() {
"{codes:?}"
);
}
/// A server on books whose settings come from `toml`, initialized.
fn served(dir: &std::path::Path, toml: &str) -> Mcp {
let path = dir.join("wanderbooks.toml");
std::fs::write(&path, toml).unwrap();
let mut mcp = Mcp::spawn_with(
&dir.join("books-data"),
&[std::ffi::OsStr::new("--books-toml"), path.as_os_str()],
);
init(&mut mcp);
mcp
}
/// Post one balanced two-line entry over stdio.
fn post_pair(mcp: &mut Mcp, date: &str, dr: &str, cr: &str, amount: &str) -> u64 {
let e = mcp.call(
"books.draft_entry",
json!({"date": date, "description": "x", "no_party_reason": "test"}),
);
let id = e["entry_id"].as_u64().unwrap();
for (account, side) in [(dr, "debit"), (cr, "credit")] {
mcp.call(
"books.add_line",
json!({"entry_id": id, "account": account, "side": side, "amount": amount}),
);
}
mcp.call(
"books.post_entry",
json!({"entry_id": id, "confirmed": true}),
);
id
}
/// Defect 16 (rules.md B-30): one default period everywhere. On books with
/// a July fiscal year and a first entry in the fiscal year before, every
/// period report given only an end covers the fiscal year that holds it
/// (2025-07-01 through 2026-03-31, never the first entry's day, never
/// 0001-01-01): profit and loss, by class, cash flow, and the exported tax
/// summary, members' equity, general ledger and general journal (csv and
/// json, whose entries are the period's); the CPA package as of that day
/// covers the same. With no dates at all, every one of them returns the
/// same pair, today's fiscal year to date (the clock read before and
/// after, so midnight can't fail it).
#[test]
fn one_default_period_everywhere_defect_16() {
let dir = test_dir("one-default");
let mut mcp = served(
&dir,
"[company]\nname = \"Fernhill Labs LLC\"\nfiscal_year_start = \"07-01\"\ntax_classification = \"partnership\"\n[[company.members]]\nname = \"A\"\nshare = \"50%\"\ncapital_account = \"3000\"\n[[company.members]]\nname = \"B\"\nshare = \"50%\"\ncapital_account = \"3001\"\n[books]\nreceipt_threshold = \"1000000.00\"\ncash_accounts = [\"1000\"]\n",
);
mcp.call(
"books.import_chart_csv",
json!({"csv_text": "code,name,type,parent_code,tax_category\n1000,Cash,asset,,\n3000,Capital A,equity,,\n3001,Capital B,equity,,\n4000,Sales,revenue,,gross_receipts\n6000,Software,expense,,other_deductions\n"}),
);
let first = post_pair(&mut mcp, "2025-03-15", "1000", "3000", "500.00");
let mid = post_pair(&mut mcp, "2025-09-10", "1000", "4000", "120.00");
let late = post_pair(&mut mcp, "2026-02-01", "6000", "1000", "20.00");
let _after = post_pair(&mut mcp, "2026-05-01", "1000", "4000", "7.00");
let end = json!({"end": "2026-03-31"});
let pair = |v: &Value| {
(
v["start"].as_str().unwrap().to_string(),
v["end"].as_str().unwrap().to_string(),
)
};
let want = ("2025-07-01".to_string(), "2026-03-31".to_string());
let pl = mcp.call("books.profit_and_loss", end.clone());
assert_eq!(pair(&pl), want);
assert_eq!(pl["revenue"], "120.00");
assert_eq!(pl["expenses"], "20.00");
assert_eq!(
pair(&mcp.call("books.profit_and_loss_by_class", end.clone())),
want
);
assert_eq!(pair(&mcp.call("books.cash_flow", end.clone())), want);
for report in ["tax_summary", "members_equity", "general_ledger"] {
let out = mcp.call(
"books.export",
json!({"report": report, "format": "json", "end": "2026-03-31"}),
);
let v: Value = serde_json::from_str(out["content"].as_str().unwrap()).unwrap();
assert_eq!(pair(&v), want, "{report}");
}
// The general journal: the period's entries, in json and csv alike.
let out = mcp.call(
"books.export",
json!({"report": "general_journal", "format": "json", "end": "2026-03-31"}),
);
let v: Value = serde_json::from_str(out["content"].as_str().unwrap()).unwrap();
let ids: Vec<u64> = v
.as_array()
.unwrap()
.iter()
.map(|e| e["entry_id"].as_u64().unwrap())
.collect();
assert_eq!(ids, [mid, late], "not {first}, not after the end");
let csv = mcp.call(
"books.export",
json!({"report": "journal", "format": "csv", "end": "2026-03-31"}),
);
let rows = csv["content"].as_str().unwrap().lines().count();
assert_eq!(rows, 1 + 4, "header and two entries of two lines");
// An inverted range is refused by the journal too (review R1).
let err = mcp.call_err(
"books.export",
json!({"report": "general_journal", "format": "json", "start": "2026-12-31", "end": "2026-01-01"}),
);
assert!(err.contains("is before start"), "{err}");
// The CPA package as of that day: the same fiscal year to date.
let pkg = mcp.call(
"books.export_cpa_package",
json!({"as_of": "2026-03-31", "include_receipts": false, "include_tins": false}),
);
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(handed(&pkg["file"]))).unwrap();
let mut readme = String::new();
std::io::Read::read_to_string(&mut zip.by_name("README.txt").unwrap(), &mut readme).unwrap();
assert!(
readme.contains("Fiscal year to date: 2025-07-01 through 2026-03-31."),
"{readme}"
);
// No dates: the same pair from every period report.
let today = || ledger_core::settings::today_in(None);
let before = today();
let mut got = vec![
pair(&mcp.call("books.profit_and_loss", json!({}))),
pair(&mcp.call("books.profit_and_loss_by_class", json!({}))),
pair(&mcp.call("books.cash_flow", json!({}))),
];
for report in ["tax_summary", "members_equity", "general_ledger"] {
let out = mcp.call("books.export", json!({"report": report, "format": "json"}));
let v: Value = serde_json::from_str(out["content"].as_str().unwrap()).unwrap();
got.push(pair(&v));
}
let after = today();
let fy = |t: chrono::NaiveDate| {
let c = ledger_core::settings::Company {
fiscal_year_start: (7, 1),
..Default::default()
};
let (s, _) = ledger_core::calendar::period_defaults(&c, t).statement_range();
(s.to_string(), t.to_string())
};
for p in &got {
assert!(*p == fy(before) || *p == fy(after), "{p:?}");
}
assert!(
got.iter().all(|p| p == &got[0]) || before != after,
"{got:?}"
);
}
/// The entry ids (DOCNUM) of an IIF file's transactions, in file order.
fn iif_docnums(iif: &str) -> Vec<u64> {
let mut docnum = None;
let mut ids = Vec::new();
for line in iif.split("\r\n") {
let f: Vec<&str> = line.split('\t').collect();
match f[0] {
"!TRNS" => docnum = f.iter().position(|c| *c == "DOCNUM"),
"TRNS" => ids.push(f[docnum.expect("!TRNS before TRNS")].parse().unwrap()),
_ => {}
}
}
ids
}
/// The default period (rules.md B-30) against the IIF (B-34, D-36) and
/// the CPA package (B-33): a general journal asked for without dates is
/// the fiscal year to date, but the IIF is never a period. Without dates
/// it holds every posted entry from the first through today, under
/// either name of the report, and a start or end is refused; the
/// package's IIF holds every entry from the first through its as_of, the
/// one before its fiscal year included.
#[test]
fn the_default_period_never_shortens_the_iif_or_the_package() {
let dir = test_dir("default-vs-iif");
let mut mcp = served(
&dir,
"[company]\nname = \"Fernhill Labs LLC\"\nfiscal_year_start = \"07-01\"\n[books]\nreceipt_threshold = \"1000000.00\"\ncash_accounts = [\"1000\"]\n",
);
mcp.call(
"books.import_chart_csv",
json!({"csv_text": "code,name,type,parent_code,tax_category\n1000,Cash,asset,,\n3000,Capital,equity,,\n4000,Sales,revenue,,gross_receipts\n6000,Software,expense,,other_deductions\n"}),
);
let entries = [
("2025-03-15", "1000", "3000", "500.00"),
("2025-09-10", "1000", "4000", "120.00"),
("2026-02-01", "6000", "1000", "20.00"),
("2026-05-01", "1000", "4000", "7.00"),
]
.map(|(date, dr, cr, amount)| (date, post_pair(&mut mcp, date, dr, cr, amount)));
let every: Vec<u64> = entries.iter().map(|(_, id)| *id).collect();
let today = || ledger_core::settings::today_in(None);
let before = today();
// The general journal without dates: the fiscal year to date.
let out = mcp.call(
"books.export",
json!({"report": "general_journal", "format": "json"}),
);
let after = today();
let v: Value = serde_json::from_str(out["content"].as_str().unwrap()).unwrap();
let journal: Vec<u64> = v
.as_array()
.unwrap()
.iter()
.map(|e| e["entry_id"].as_u64().unwrap())
.collect();
let fytd = |t: chrono::NaiveDate| -> Vec<u64> {
let c = ledger_core::settings::Company {
fiscal_year_start: (7, 1),
..Default::default()
};
let (start, end) = ledger_core::calendar::period_defaults(&c, t).statement_range();
entries
.iter()
.filter(|(date, _)| {
let d = chrono::NaiveDate::parse_from_str(date, "%Y-%m-%d").unwrap();
start <= d && d <= end
})
.map(|(_, id)| *id)
.collect()
};
assert!(
journal == fytd(before) || journal == fytd(after),
"{journal:?}"
);
assert_ne!(journal, every, "the journal's default is a period");
// The IIF without dates: every posted entry through today, by either
// name of the report.
for report in ["general_journal", "journal", "GENERAL_JOURNAL"] {
let iif = mcp.call("books.export", json!({"report": report, "format": "iif"}));
assert_eq!(iif["report"], report.to_lowercase());
assert_eq!(
iif_docnums(iif["content"].as_str().unwrap()),
every,
"{report}"
);
}
for dates in [json!({"start": "2025-07-01"}), json!({"end": "2026-03-31"})] {
let mut args = json!({"report": "general_journal", "format": "iif"});
args.as_object_mut()
.unwrap()
.extend(dates.as_object().unwrap().clone());
let err = mcp.call_err("books.export", args);
assert!(err.contains("iif takes as_of, not start or end"), "{err}");
}
// The package as of 2026-03-31: its statements' fiscal year starts
// 2025-07-01, and its IIF still holds the entry before it.
let pkg = mcp.call(
"books.export_cpa_package",
json!({"as_of": "2026-03-31", "include_receipts": false, "include_tins": false}),
);
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(handed(&pkg["file"]))).unwrap();
let mut read = |name: &str| {
let mut text = String::new();
std::io::Read::read_to_string(&mut zip.by_name(name).unwrap(), &mut text).unwrap();
text
};
let readme = read("README.txt");
assert!(
readme.contains("Fiscal year to date: 2025-07-01 through 2026-03-31."),
"{readme}"
);
assert_eq!(iif_docnums(&read("general-journal.iif")), every[..3]);
}
/// Defect 15 (rules.md B-31) over the real binary: books.rename_account
/// gives an account a new name and nothing else. The chart shows it; a
/// past period's P&L export prints it; the general journal's IIF declares
/// the account by it; the books verify, the journal grew by the rename
/// and its feature's declaration only; refusals (a blank name, the name
/// it has, a sibling's ignoring capitals, nine digits) write nothing; and
/// a restarted server reads the new name back.
#[test]
fn rename_account_end_to_end_defect_15() {
let dir = test_dir("rename-e2e");
let mut mcp = served(
&dir,
"[company]\nname = \"Fernhill Labs LLC\"\n[books]\nreceipt_threshold = \"1000000.00\"\ncash_accounts = [\"1000\"]\n",
);
mcp.call(
"books.import_chart_csv",
json!({"csv_text": "code,name,type,parent_code,tax_category\n1000,Business Checking,asset,,\n3000,Capital,equity,,\n6000,Operating,expense,,other_deductions\n6150,Software,expense,6000,other_deductions\n6160,Hosting,expense,6000,other_deductions\n"}),
);
post_pair(&mut mcp, "2025-11-04", "1000", "3000", "800.00");
post_pair(&mut mcp, "2025-11-20", "6150", "1000", "29.00");
let verified = mcp.call("books.verify", json!({"blobs": false}));
let events = verified["events"].as_u64().unwrap();
for (name, says) in [
("", "must not be empty"),
(" Software ", "already named"),
("HOSTING", "same parent"),
("Card 1234 5678 9", "nine digits"),
] {
let err = mcp.call_err(
"books.rename_account",
json!({"account": "6150", "name": name}),
);
assert!(err.contains(says), "{name:?}: {err}");
}
assert_eq!(
mcp.call("books.verify", json!({"blobs": false}))["events"],
json!(events),
"refusals write nothing"
);
let out = mcp.call(
"books.rename_account",
json!({"account": "6150", "name": "Software subscriptions", "reason": "owner asked"}),
);
assert!(out["id"].as_u64().unwrap() > 0);
assert_eq!(out["code"], "6150");
assert_eq!(out["previous_name"], "Software");
assert_eq!(out["name"], "Software subscriptions");
let notes = out["notes"].to_string();
assert!(
notes.contains("QuickBooks matches accounts by name"),
"{notes}"
);
let after = mcp.call("books.verify", json!({"blobs": false}));
assert_eq!(after["status"], "ok");
assert_eq!(
after["events"],
json!(events + 2),
"the feature and the rename"
);
let chart = mcp.call("books.chart", json!({}));
let acct = chart["accounts"]
.as_array()
.unwrap()
.iter()
.find(|a| a["code"] == "6150")
.unwrap()
.clone();
assert_eq!(acct["name"], "Software subscriptions");
assert_eq!(acct["balance"], "29.00");
assert_eq!(acct["tax_category"], "other_deductions");
// A past period prints the new name; the amounts are the same.
let out = mcp.call(
"books.export",
json!({"report": "profit_and_loss", "format": "json", "start": "2025-11-01", "end": "2025-11-30"}),
);
let pl: Value = serde_json::from_str(out["content"].as_str().unwrap()).unwrap();
assert_eq!(pl["expense_accounts"][0]["name"], "Software subscriptions");
assert_eq!(pl["expense_accounts"][0]["amount"], "29.00");
let iif = mcp.call(
"books.export",
json!({"report": "journal", "format": "iif"}),
);
let iif = iif["content"].as_str().unwrap();
assert!(iif.contains("Operating:Software subscriptions"), "{iif}");
// A renamed parent: its children's full names follow, and the note
// says so.
let out = mcp.call(
"books.rename_account",
json!({"account": "6000", "name": "Operating expenses"}),
);
assert!(out["notes"].to_string().contains("2 sub-accounts"), "{out}");
// Renaming the bank account to a name without "checking" or "bank"
// moves nothing: books.cash_accounts names it by code.
let out = mcp.call(
"books.rename_account",
json!({"account": "1000", "name": "Treasury"}),
);
assert!(!out["notes"].to_string().contains("cash account"), "{out}");
drop(mcp);
let mut mcp = Mcp::spawn(&dir.join("books-data"));
init(&mut mcp);
let chart = mcp.call("books.chart", json!({}));
let names: Vec<(String, String)> = chart["accounts"]
.as_array()
.unwrap()
.iter()
.map(|a| {
(
a["code"].as_str().unwrap().to_string(),
a["name"].as_str().unwrap().to_string(),
)
})
.collect();
for want in [
("6150", "Software subscriptions"),
("6000", "Operating expenses"),
("1000", "Treasury"),
] {
assert!(
names.contains(&(want.0.to_string(), want.1.to_string())),
"{names:?}"
);
}
assert_eq!(
mcp.call("books.verify", json!({"blobs": false}))["status"],
"ok"
);
}
/// Review R10 (rules.md B-31): on books that name no cash accounts
/// (books.cash_accounts unset, so they are picked by name), a rename that
/// moves an account into or out of the cash or card accounts says so; one
/// that moves nothing says nothing of them. A convention naming the old
/// name is listed, and one that doesn't isn't.
#[test]
fn rename_says_what_the_new_name_moves_defect_15() {
let dir = test_dir("rename-sets");
let mut mcp = served(
&dir,
"[company]\nname = \"Fernhill Labs LLC\"\n[books]\nreceipt_threshold = \"1000000.00\"\n",
);
mcp.call(
"books.import_chart_csv",
json!({"csv_text": "code,name,type,parent_code,tax_category\n1000,Business Checking,asset,,\n1010,Petty Fund,asset,,\n2100,Visa,liability,,\n2200,Credit Card,liability,,\n6000,Software,expense,,other_deductions\n"}),
);
mcp.call(
"books.set_convention",
json!({"id": "software-note", "kind": "note", "title": "Subscriptions",
"body_md": "Every SaaS bill goes to Software.", "decided_by": "A"}),
);
let notes = |out: &Value| out["notes"].to_string();
for (account, name, says) in [
(
"1000",
"Treasury",
"1000 Treasury is no longer a cash account",
),
(
"1010",
"Petty Cash",
"1010 Petty Cash is now a cash account",
),
("2100", "Visa Card", "2100 Visa Card is now a card account"),
("2200", "Amex", "2200 Amex is no longer a card account"),
] {
let out = mcp.call(
"books.rename_account",
json!({"account": account, "name": name}),
);
assert!(notes(&out).contains(says), "{name}: {out}");
}
let out = mcp.call(
"books.rename_account",
json!({"account": "1010", "name": "Petty Cash Box"}),
);
assert!(!notes(&out).contains("cash account"), "{out}");
let out = mcp.call(
"books.rename_account",
json!({"account": "6000", "name": "Software subscriptions"}),
);
assert!(
notes(&out).contains("Convention software-note names \u{201c}Software\u{201d}"),
"{out}"
);
let out = mcp.call(
"books.rename_account",
json!({"account": "6000", "name": "Tools"}),
);
assert!(!notes(&out).contains("Convention"), "{out}");
}
+289 -17
View File
@@ -1092,8 +1092,8 @@ fn cash_flow_exports_equal_the_core_statement() {
assert_eq!(tool, j);
}
/// The journal export, CSV and JSON, is every posted entry line by line
/// (no drafts), as the ledger holds them. Kills: export_journal's
/// The journal export, CSV and JSON, over every date, is every posted
/// entry line by line (no drafts), as the ledger holds them. Kills: export_journal's
/// json/csv switch.
#[test]
fn journal_exports_hold_every_posted_line() {
@@ -1106,7 +1106,10 @@ fn journal_exports_hold_every_posted_line() {
let mut m = Mcp::start(&dir, "1000000.00");
for report in ["journal", "general_journal"] {
let csv = m.export_text(json!({"format": "csv", "report": report}));
// Every date: the journal covers a period (rules.md B-30).
let csv = m.export_text(
json!({"format": "csv", "report": report, "start": "0001-01-01", "end": "9999-12-31"}),
);
let rows = csv_rows(
&csv,
&[
@@ -1148,9 +1151,10 @@ fn journal_exports_hold_every_posted_line() {
}
}
let j: Value =
serde_json::from_str(&m.export_text(json!({"format": "json", "report": "journal"})))
.unwrap();
let j: Value = serde_json::from_str(&m.export_text(
json!({"format": "json", "report": "journal", "start": "0001-01-01", "end": "9999-12-31"}),
))
.unwrap();
let got = j.as_array().unwrap();
assert_eq!(got.len(), posted.len(), "a JSON entry per posted entry");
for (g, e) in got.iter().zip(&posted) {
@@ -1292,6 +1296,17 @@ fn ledger_tax_and_equity_exports_equal_the_core_reports() {
]);
}
}
// Book amounts only: Form 4797 figures the tax gain.
for l in &ts.form_4797 {
want.push(vec![
"form_4797".into(),
l.category.clone(),
l.label.clone(),
l.accounts.join("; "),
l.book.to_string(),
String::new(),
]);
}
for u in &ts.uncategorized {
want.push(vec![
"uncategorized".into(),
@@ -2287,11 +2302,15 @@ fn show_writes_the_viewer_command_for_each_target() {
}
);
// A split, or one total column compared (the viewer compares only a
// report in one total column: defect 15, options a target ignores are
// refused).
for (columns, compare) in [
("month", "prior_year"),
("quarter", "prior_period"),
("year", "prior_year"),
("total", "prior_period"),
(Some("month"), None),
(Some("quarter"), None),
(Some("year"), None),
(Some("total"), Some("prior_period")),
(None, Some("prior_year")),
] {
let out = m.ok(
"books.show",
@@ -2304,16 +2323,25 @@ fn show_writes_the_viewer_command_for_each_target() {
report: "profit_and_loss".into(),
start: Some("2025-01-01".into()),
end: Some("2025-12-31".into()),
columns: Some(columns.into()),
compare: Some(compare.into()),
columns: columns.map(Into::into),
compare: compare.map(Into::into),
}
);
if columns != "total" {
assert!(
out["shown"].as_str().unwrap().contains(columns),
"shown names the {columns} columns: {out}"
);
let shown = out["shown"].as_str().unwrap();
assert!(shown.contains("2025-01-01 to 2025-12-31"), "{out}");
if let Some(c) = columns.filter(|c| *c != "total") {
assert!(shown.contains(&format!("by {c}")), "{out}");
}
if let Some(c) = compare {
assert!(shown.contains(&c.replace('_', " ")), "{out}");
}
}
for (columns, compare) in [("month", "prior_year"), ("quarter", "prior_period")] {
let e = m.refuse(
"books.show",
json!({"report": "profit_and_loss", "columns": columns, "compare": compare}),
);
assert!(e.contains("one total column"), "{e}");
}
m.refuse(
"books.show",
@@ -2370,6 +2398,250 @@ fn show_writes_the_viewer_command_for_each_target() {
m.refuse("books.show", json!({}));
}
/// The command `books.show` last wrote, or None before any.
fn last_show(m: &Mcp) -> Option<ledger_store::ops::ViewerCommand> {
let books = m.run.parent().unwrap().to_path_buf();
ledger_store::ops::read(&data_dir(&books))
}
/// Defect 15, repros 1.1.1 and 1.1.2: a start after the end, given, is
/// refused for a report and for an account, and nothing reaches the
/// viewer (the last command it was sent stays the last). A start equal to
/// the end is a one-day period.
#[test]
fn show_refuses_start_after_end_defect_15() {
let dir = test_dir("show-inverted");
drop(new_store(&dir));
let mut m = Mcp::start(&dir, "1000000.00");
m.ok("books.show", json!({"page": "overview"}));
let before = last_show(&m).unwrap();
for args in [
json!({"report": "profit_and_loss", "start": "2026-12-31", "end": "2026-01-01"}),
json!({"account": "1000", "start": "2026-12-31", "end": "2026-01-01"}),
json!({"report": "cash_flow", "start": "2026-01-02", "end": "2026-01-01"}),
json!({"report": "balance_sheet", "columns": "month", "start": "2026-03-01", "end": "2026-02-28"}),
] {
let e = m.refuse("books.show", args.clone());
assert!(e.contains("is before start"), "{args}: {e}");
assert_eq!(last_show(&m), Some(before.clone()), "{args}");
}
let out = m.ok(
"books.show",
json!({"report": "profit_and_loss", "start": "2026-03-01", "end": "2026-03-01"}),
);
assert!(
out["shown"]
.as_str()
.unwrap()
.contains("2026-03-01 to 2026-03-01"),
"{out}"
);
}
/// Defect 15, repro 1.1.3: a start after today with no end (the end an
/// omitted end means) is refused, saying the end was defaulted; nothing
/// reaches the viewer.
#[test]
fn show_refuses_a_derived_inverted_range_defect_15() {
let dir = test_dir("show-derived");
drop(new_store(&dir));
let mut m = Mcp::start(&dir, "1000000.00");
m.ok("books.show", json!({"page": "journal"}));
let before = last_show(&m).unwrap();
for args in [
json!({"account": "1000", "start": "9999-12-31"}),
json!({"report": "general_ledger", "start": "9999-01-01"}),
] {
let e = m.refuse("books.show", args.clone());
assert!(
e.contains("defaults to today") && e.contains("pass end"),
"{args}: {e}"
);
assert_eq!(last_show(&m), Some(before.clone()), "{args}");
}
}
/// Defect 16, repro 1.1.4: books.show sends the dates it names, resolved
/// by the one default (rules.md B-30): only an end covers that end's
/// fiscal year to it (2025-01-01 to 2025-06-30, never today's year's
/// start); no dates cover today's fiscal year to date (checked against
/// the clock read before and after, R14); a report as of a day says "as
/// of", vendor_1099 "for" its year; and the viewer gets the same pair.
#[test]
fn show_sends_the_dates_it_names_defect_16() {
let dir = test_dir("show-dates");
drop(new_store(&dir));
let mut m = Mcp::start(&dir, "1000000.00");
let target = |m: &Mcp| match last_show(m).unwrap().target {
ViewerTarget::Report { start, end, .. } | ViewerTarget::Account { start, end, .. } => {
(start.unwrap(), end.unwrap())
}
other => panic!("not dated: {other:?}"),
};
for (args, shown, dates) in [
(
json!({"report": "profit_and_loss", "end": "2025-06-30"}),
"the profit and loss report, 2025-01-01 to 2025-06-30",
("2025-01-01", "2025-06-30"),
),
(
json!({"account": "1000", "end": "2025-06-30"}),
"the register of 1000 Bank, 2025-01-01 to 2025-06-30",
("2025-01-01", "2025-06-30"),
),
(
json!({"report": "profit_and_loss", "start": "2025-03-01", "end": "2025-03-31"}),
"the profit and loss report, 2025-03-01 to 2025-03-31",
("2025-03-01", "2025-03-31"),
),
(
json!({"report": "trial_balance", "end": "2025-06-30"}),
"the trial balance report, as of 2025-06-30",
("2025-01-01", "2025-06-30"),
),
(
json!({"report": "balance_sheet", "end": "2025-06-30"}),
"the balance sheet report, as of 2025-06-30",
("2025-01-01", "2025-06-30"),
),
(
json!({"report": "balance_sheet", "end": "2025-06-30", "columns": "quarter"}),
"the balance sheet report, 2025-01-01 to 2025-06-30, by quarter",
("2025-01-01", "2025-06-30"),
),
(
json!({"report": "fixed_asset_register", "end": "2024-12-31"}),
"the fixed asset register report, as of 2024-12-31",
("2024-01-01", "2024-12-31"),
),
(
json!({"report": "ar_aging", "end": "2025-02-15"}),
"the ar aging report, as of 2025-02-15",
("2025-01-01", "2025-02-15"),
),
(
json!({"report": "vendor_1099", "end": "2025-12-31"}),
"the vendor 1099 report, for 2025",
("2025-01-01", "2025-12-31"),
),
] {
let out = m.ok("books.show", args.clone());
assert_eq!(out["shown"], shown, "{args}");
assert_eq!(
target(&m),
(dates.0.to_string(), dates.1.to_string()),
"{args}"
);
}
// No dates: today's fiscal year to date, whichever day the call saw.
let before = ledger_core::settings::today_in(None);
let out = m.ok("books.show", json!({"report": "profit_and_loss"}));
let after = ledger_core::settings::today_in(None);
let (s, e) = target(&m);
let ok = [before, after].iter().any(|t| {
let (fy, _) = ledger_core::settings::Company::default().fiscal_year_containing(*t);
(s.clone(), e.clone()) == (fy.to_string(), t.to_string())
&& out["shown"] == format!("the profit and loss report, {fy} to {t}")
});
assert!(ok, "{s} to {e}: {out}");
}
/// Defect 15 (settled: books.show refuses options its target ignores):
/// dates, columns or a comparison for an entry, a page or any other
/// non-dated target; columns or a comparison for an account; columns for
/// a report that doesn't split; a comparison for one that doesn't compare;
/// a start for a report as of a day, or for the 1099 worksheet. Each names
/// what takes the option, and nothing reaches the viewer.
#[test]
fn show_refuses_options_its_target_ignores_defect_15() {
let dir = test_dir("show-options");
let mut s = new_store(&dir);
let e = post(
&mut s,
"2025-01-02",
"Seed",
&[("1000", Dr, "1.00"), ("4000", Cr, "1.00")],
);
drop(s);
let mut m = Mcp::start(&dir, "1000000.00");
m.ok("books.show", json!({"page": "overview"}));
let before = last_show(&m).unwrap();
for (args, says) in [
(
json!({"entry_id": e, "start": "2025-01-01"}),
"for an account or a report",
),
(
json!({"page": "reports", "end": "2025-01-31"}),
"for an account or a report",
),
(
json!({"search": "x", "columns": "month"}),
"for an account or a report",
),
(
json!({"entry_id": e, "start": "2025-01-01", "compare": "prior_year"}),
"start and compare are",
),
(
json!({"account": "1000", "columns": "month"}),
"takes only start and end",
),
(
json!({"account": "1000", "compare": "prior_year"}),
"takes only start and end",
),
(
json!({"report": "trial_balance", "columns": "total"}),
"columns is for profit_and_loss, balance_sheet and cash_flow",
),
(
json!({"report": "tax_summary", "columns": "month"}),
"columns is for",
),
(
json!({"report": "general_ledger", "compare": "prior_year"}),
"compare is for profit_and_loss, balance_sheet, cash_flow and tax_summary",
),
(
json!({"report": "trial_balance", "start": "2025-01-01", "end": "2025-03-31"}),
"is as of a day: pass end",
),
(
json!({"report": "balance_sheet", "start": "2025-01-01", "end": "2025-03-31"}),
"in one total column is as of a day",
),
(
json!({"report": "vendor_1099", "start": "2025-01-01"}),
"is for a calendar year",
),
// A split past the viewer's 240 columns, which it shows as the
// total: 312 months.
(
json!({"report": "profit_and_loss", "start": "2000-01-01", "end": "2025-12-31", "columns": "month"}),
"is 312 columns, and the viewer splits a report into at most 240",
),
(
json!({"report": "balance_sheet", "start": "0001-01-01", "end": "2025-12-31", "columns": "year"}),
"at most 240",
),
] {
let err = m.refuse("books.show", args.clone());
assert!(err.contains(says), "{args}: {err}");
assert_eq!(last_show(&m), Some(before.clone()), "{args}");
}
// What a balance sheet takes a start for: a split, or the prior period;
// and 240 months is a split the viewer shows.
for args in [
json!({"report": "balance_sheet", "start": "2025-01-01", "end": "2025-03-31", "columns": "month"}),
json!({"report": "balance_sheet", "start": "2025-01-01", "end": "2025-03-31", "compare": "prior_period"}),
json!({"report": "profit_and_loss", "start": "2006-01-01", "end": "2025-12-31", "columns": "month"}),
json!({"report": "profit_and_loss", "start": "0001-01-01", "end": "2025-12-31", "columns": "total"}),
] {
m.ok("books.show", args);
}
}
// ------------------------------------------------- resources
/// Each advertised resource reads back as the matching tool's result, and
+4
View File
@@ -746,6 +746,10 @@ fn happy_path(name: &str) -> Mcp {
"books.renumber_account",
json!({"account": "7000", "code": "7100"}),
);
m.ok(
"books.rename_account",
json!({"account": "7100", "name": "Gain or Loss on Sale", "reason": "clearer"}),
);
m.ok("books.settings", json!({}));
m.ok("books.roles", json!({}));
m.ok(
+451
View File
@@ -0,0 +1,451 @@
//! Tax categories at account creation, and `policy.require_tax_category`
//! (P3, defect 8; rules.md B-32), through the real server over stdio.
//!
//! ```text
//! GOAL: every revenue and expense account maps to a line of the return,
//! or the human hears that it doesn't; a company that refuses
//! uncategorized accounts gets exactly that, never a refused
//! invoice.
//! SOURCE: rules.md B-32; plan/served.md P3 defect 8 and "Settled in
//! design" (warn by default, headers exempt).
//! ```
//!
//! Fixtures are fictional (Fernhill Labs LLC).
use serde_json::{Value, json};
use std::io::{BufRead, BufReader, Write};
use std::process::{Child, ChildStdin, Command, Stdio};
struct Mcp {
stdin: ChildStdin,
stdout: BufReader<std::process::ChildStdout>,
next_id: u64,
child: Child,
}
impl Drop for Mcp {
fn drop(&mut self) {
let _ = self.child.kill();
let _ = self.child.wait();
}
}
impl Mcp {
/// A server on books that keep their own settings, initialized.
fn spawn(data_dir: &std::path::Path) -> Self {
let mut child = Command::new(env!("CARGO_BIN_EXE_ledger-mcp"))
.arg("--data-dir")
.arg(data_dir)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.expect("spawn ledger-mcp");
let stdin = child.stdin.take().unwrap();
let stdout = BufReader::new(child.stdout.take().unwrap());
let mut m = Self {
stdin,
stdout,
next_id: 1,
child,
};
m.request(
"initialize",
json!({"protocolVersion": "2025-06-18", "capabilities": {},
"clientInfo": {"name": "spec-tax-policy", "version": "0"}}),
);
let msg = json!({"jsonrpc": "2.0", "method": "notifications/initialized", "params": {}});
writeln!(m.stdin, "{msg}").unwrap();
m.stdin.flush().unwrap();
m
}
fn request(&mut self, method: &str, params: Value) -> Value {
let id = self.next_id;
self.next_id += 1;
let msg = json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params});
writeln!(self.stdin, "{msg}").unwrap();
self.stdin.flush().unwrap();
let mut line = String::new();
self.stdout
.read_line(&mut line)
.expect("read response line");
let resp: Value = serde_json::from_str(&line).expect("parse response");
assert_eq!(resp["id"], id, "id mismatch in {resp}");
assert!(resp.get("error").is_none(), "{method}: {}", resp["error"]);
resp
}
fn result(&mut self, tool: &str, args: Value) -> Value {
let resp = self.request("tools/call", json!({"name": tool, "arguments": args}));
resp["result"].clone()
}
/// Call a tool; panic on a refusal. Returns structuredContent.
fn call(&mut self, tool: &str, args: Value) -> Value {
let result = self.result(tool, args);
assert_ne!(
result.get("isError"),
Some(&json!(true)),
"{tool}: {}",
result["content"]
);
result.get("structuredContent").cloned().unwrap_or_else(|| {
serde_json::from_str(result["content"][0]["text"].as_str().unwrap()).unwrap()
})
}
/// Call a tool expecting a refusal; returns its text.
fn call_err(&mut self, tool: &str, args: Value) -> String {
let result = self.result(tool, args);
assert_eq!(
result.get("isError"),
Some(&json!(true)),
"{tool}: {result}"
);
result["content"][0]["text"].as_str().unwrap().to_string()
}
fn events(&mut self) -> u64 {
self.call("books.verify", json!({"blobs": false}))["events"]
.as_u64()
.unwrap()
}
fn set_policy(&mut self, value: Value) {
self.call(
"books.set_setting",
json!({"changes": [{"key": "policy.require_tax_category", "value": value}],
"decided_by": "Tobin Harlow", "decided_on": "2026-10-20"}),
);
}
}
/// A test's dir, removed when the test ends, pass or panic.
struct TestDir(std::path::PathBuf);
impl Drop for TestDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
fn test_dir(name: &str) -> TestDir {
let dir = std::env::temp_dir().join(format!("ledger-mcp-tax-{}-{name}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
TestDir(dir)
}
fn add(m: &mut Mcp, args: Value) -> Value {
m.call("books.add_account", args)
}
fn chart_codes(m: &mut Mcp) -> Vec<String> {
m.call("books.chart", json!({}))["accounts"]
.as_array()
.unwrap()
.iter()
.map(|a| a["code"].as_str().unwrap().to_string())
.collect()
}
/// Defect 8, repro 1.4.1: books.add_account of a revenue or expense
/// account without a tax category creates it and warns, naming it and
/// the tools to call; an asset, liability, equity or header account, or
/// one given its category, has no warning. A category that doesn't fit
/// the account or the return is refused, and a deductible percent needs
/// a category.
#[test]
fn add_account_warns_without_tax_category_defect_8() {
let dir = test_dir("warn");
let mut m = Mcp::spawn(&dir.0.join("books-data"));
let out = add(
&mut m,
json!({"code": "6150", "name": "Software", "account_type": "expense"}),
);
assert!(out["account_id"].as_u64().is_some());
assert_eq!(
out["warnings"],
json!([
"6150 Software has no tax category: the tax summary will list it as uncategorized. Propose one from books.tax_categories and set it with books.set_tax_category once the human agrees."
])
);
let out = add(
&mut m,
json!({"code": "4000", "name": "Sales", "account_type": "revenue"}),
);
assert_eq!(out["warnings"].as_array().unwrap().len(), 1, "{out}");
for args in [
json!({"code": "1000", "name": "Business Checking", "account_type": "asset"}),
json!({"code": "2000", "name": "Card", "account_type": "liability"}),
json!({"code": "3000", "name": "Capital", "account_type": "equity"}),
json!({"code": "6000", "name": "Operating", "account_type": "expense", "placeholder": true}),
json!({"code": "6160", "name": "Hosting", "account_type": "expense", "tax_category": "other_deductions"}),
json!({"code": "6170", "name": "Meals", "account_type": "expense", "tax_category": "meals", "deductible_percent": "50"}),
] {
let out = add(&mut m, args.clone());
assert!(out.get("warnings").is_none(), "{args}: {out}");
}
let chart = m.call("books.chart", json!({}));
let meals = chart["accounts"]
.as_array()
.unwrap()
.iter()
.find(|a| a["code"] == "6170")
.unwrap()
.clone();
assert_eq!(meals["tax_category"], "meals");
assert_eq!(meals["deductible_percent"], "50");
let before = m.events();
for (args, says) in [
(
json!({"code": "4100", "name": "Fees", "account_type": "revenue", "tax_category": "meals"}),
"expense",
),
(
json!({"code": "6180", "name": "X", "account_type": "expense", "tax_category": "no_such"}),
"unknown tax category",
),
(
json!({"code": "6190", "name": "Y", "account_type": "expense", "deductible_percent": "50"}),
"needs a tax_category",
),
(
json!({"code": "6195", "name": "Z", "account_type": "expense", "tax_category": "other_deductions", "deductible_percent": "150"}),
"between 0 and 100",
),
] {
let err = m.call_err("books.add_account", args.clone());
assert!(err.contains(says), "{args}: {err}");
}
assert_eq!(m.events(), before, "refusals write nothing");
// Needs attention's list, for the agent too.
let digest = m.call("books.digest", json!({"since": "2026-01-01"}));
assert_eq!(
digest["uncategorized_accounts"],
json!(["4000 Sales", "6150 Software"])
);
}
/// Defect 8 under `policy.require_tax_category = refuse`: books.add_account
/// without a category is refused and the chart is unchanged; with one it
/// is created (account.added and account.tax_set); import_chart_csv with
/// one uncategorized expense row creates nothing and says why; clearing a
/// needed category is refused; a bill on books without 6999 still posts,
/// its result naming the account it created, which Needs attention (the
/// digest) lists. Back to warn, the same account is created with a
/// warning.
#[test]
fn require_tax_category_refuses_defect_8() {
let dir = test_dir("refuse");
let mut m = Mcp::spawn(&dir.0.join("books-data"));
add(
&mut m,
json!({"code": "1000", "name": "Business Checking", "account_type": "asset"}),
);
add(
&mut m,
json!({"code": "6150", "name": "Software", "account_type": "expense", "tax_category": "other_deductions"}),
);
m.set_policy(json!("refuse"));
let settings = m.call("books.settings", json!({}));
assert!(
settings.to_string().contains("policy.require_tax_category"),
"{settings}"
);
let before = m.events();
let codes = chart_codes(&mut m);
let err = m.call_err(
"books.add_account",
json!({"code": "6160", "name": "Hosting", "account_type": "expense"}),
);
assert!(
err.contains("policy.require_tax_category is refuse")
&& err.contains("6160 Hosting")
&& err.contains("books.tax_categories"),
"{err}"
);
assert_eq!(chart_codes(&mut m), codes);
assert_eq!(m.events(), before);
let out = add(
&mut m,
json!({"code": "6160", "name": "Hosting", "account_type": "expense", "tax_category": "other_deductions"}),
);
assert!(out.get("warnings").is_none(), "{out}");
assert_eq!(m.events(), before + 2, "account.added and account.tax_set");
// A header is exempt.
add(
&mut m,
json!({"code": "6000", "name": "Operating", "account_type": "expense", "placeholder": true}),
);
// The import: refused whole, saying why.
let before = m.events();
let err = m.call_err(
"books.import_chart_csv",
json!({"csv_text": "code,name,type,parent_code,tax_category\n6200,Rent,expense,,rent_property\n6210,Misc,expense,,\n"}),
);
assert!(
err.contains("6210") && err.contains("fifth column") && err.contains("never a header"),
"{err}"
);
assert!(!chart_codes(&mut m).contains(&"6200".to_string()));
assert_eq!(m.events(), before);
// Clearing a needed category.
let err = m.call_err(
"books.set_tax_category",
json!({"account": "6150", "category": null}),
);
assert!(err.contains("keeps a tax category"), "{err}");
m.call(
"books.set_tax_category",
json!({"account": "6150", "category": "supplies"}),
);
// A bill creates General Expenses 6999 without a category: never
// refused, named in the result's warnings and the digest.
let vendor = m.call("books.add_vendor", json!({"name": "Coastline Hosting"}));
let bill = m.call(
"books.create_bill",
json!({"party_id": vendor["id"], "date": "2026-02-01", "due_date": "2026-03-01",
"lines": [{"description": "hosting", "amount": "42.00"}]}),
);
assert_eq!(
bill["warnings"],
json!([
"6999 General Expenses has no tax category: the tax summary will list it as uncategorized. Propose one from books.tax_categories and set it with books.set_tax_category once the human agrees."
])
);
let digest = m.call("books.digest", json!({"since": "2026-01-01"}));
assert_eq!(
digest["uncategorized_accounts"],
json!(["6999 General Expenses"])
);
// A second bill creates nothing, and warns of nothing.
let again = m.call(
"books.create_bill",
json!({"party_id": vendor["id"], "date": "2026-02-02", "due_date": "2026-03-02",
"lines": [{"description": "hosting", "amount": "1.00"}]}),
);
assert!(again.get("warnings").is_none(), "{again}");
// Back to warn (cleared): created, with the warning.
m.set_policy(Value::Null);
let out = add(
&mut m,
json!({"code": "6210", "name": "Misc", "account_type": "expense"}),
);
assert_eq!(out["warnings"].as_array().unwrap().len(), 1, "{out}");
let out = m.call(
"books.import_chart_csv",
json!({"csv_text": "code,name,type,parent_code\n6220,Postage,expense,\n6230,Fees,revenue,\n1100,Petty Cash,asset,\n"}),
);
assert_eq!(out["count"], 3);
assert_eq!(out["warnings"].as_array().unwrap().len(), 2, "{out}");
}
/// rules.md B-31 for new accounts: a name another account under the same
/// parent has, ignoring capitals (top-level accounts are siblings too),
/// or one holding nine digits in a row, is refused, for books.add_account
/// and books.import_chart_csv alike; under another parent the name is
/// free.
#[test]
fn a_new_account_named_like_a_sibling_is_refused() {
let dir = test_dir("names");
let mut m = Mcp::spawn(&dir.0.join("books-data"));
add(
&mut m,
json!({"code": "6000", "name": "Operating", "account_type": "expense", "placeholder": true}),
);
let ops = m.call("books.chart", json!({}))["accounts"][0]["id"].clone();
add(
&mut m,
json!({"code": "6150", "name": "Software", "account_type": "expense", "parent": ops, "tax_category": "other_deductions"}),
);
let before = m.events();
for (args, says) in [
(
json!({"code": "6151", "name": "SOFTWARE", "account_type": "expense", "parent": ops}),
"6150 Software already has the name",
),
(
json!({"code": "6001", "name": " operating ", "account_type": "expense"}),
"6000 Operating already has the name",
),
(
json!({"code": "1000", "name": "Checking 123456789", "account_type": "asset"}),
"nine digits",
),
] {
let err = m.call_err("books.add_account", args.clone());
assert!(err.contains(says), "{args}: {err}");
}
let err = m.call_err(
"books.import_chart_csv",
json!({"csv_text": "code,name,type,parent_code\n1000,Cash,asset,\n1010,cash,asset,\n"}),
);
assert!(err.contains("1010") && err.contains("1000 Cash"), "{err}");
assert_eq!(m.events(), before);
// Top level: no sibling of the child.
add(
&mut m,
json!({"code": "6200", "name": "Software", "account_type": "expense", "tax_category": "other_deductions"}),
);
}
/// rules.md B-32: a gain on business property (`form_4797_gain_loss`, the
/// disposal account a refusing company must categorize) is exported apart
/// in the tax summary, JSON and CSV alike: at its book amount, with no tax
/// amount, and outside income and both nets (Form 4797 figures it from
/// the tax basis; never Schedule C income, never ordinary income the
/// summary could place).
#[test]
fn a_gain_on_business_property_is_exported_apart() {
let dir = test_dir("form4797");
let mut m = Mcp::spawn(&dir.0.join("books-data"));
for args in [
json!({"code": "1000", "name": "Business Checking", "account_type": "asset"}),
json!({"code": "4000", "name": "Sales", "account_type": "revenue", "tax_category": "gross_receipts"}),
json!({"code": "4700", "name": "Gain on disposal", "account_type": "revenue", "tax_category": "form_4797_gain_loss"}),
] {
add(&mut m, args);
}
for (cr, amount) in [("4000", "10.00"), ("4700", "7.50")] {
let e = m.call(
"books.draft_entry",
json!({"date": "2026-03-02", "description": "x", "no_party_reason": "test"}),
);
let id = e["entry_id"].clone();
for (account, side) in [("1000", "debit"), (cr, "credit")] {
m.call(
"books.add_line",
json!({"entry_id": id, "account": account, "side": side, "amount": amount}),
);
}
m.call(
"books.post_entry",
json!({"entry_id": id, "confirmed": true}),
);
}
let range = |format: &str| json!({"report": "tax_summary", "format": format, "start": "2026-01-01", "end": "2026-12-31"});
let out = m.call("books.export", range("json"));
let j: Value = serde_json::from_str(out["content"].as_str().unwrap()).unwrap();
assert_eq!(
j["form_4797"],
json!([{"category": "form_4797_gain_loss", "label": "Gain or loss on business property (Form 4797)",
"accounts": ["4700 Gain on disposal"], "book": "7.50"}])
);
assert_eq!(j["income"].as_array().unwrap().len(), 1, "{j}");
assert_eq!(j["income"][0]["category"], "gross_receipts");
assert_eq!(j["book_net"], "10.00");
assert_eq!(j["tax_net"], "10.00");
let out = m.call("books.export", range("csv"));
let csv = out["content"].as_str().unwrap();
assert!(
csv.lines().any(|l| l
== "form_4797,form_4797_gain_loss,Gain or loss on business property (Form 4797),4700 Gain on disposal,7.50,"),
"{csv}"
);
assert!(
!csv.lines().any(|l| l.starts_with("income,form_4797")),
"{csv}"
);
}
+2 -2
View File
@@ -35,8 +35,8 @@ pub use books::PackageBooks;
pub use iif::export_iif;
pub use package::{Package, PackageRequest, build_package, package_file_name};
pub use reports::{
Settings, bs_statement, cash_flow_for, cash_flow_statement, member_capitals, pnl_statement,
report_members, report_meta, statement_range, year_earnings,
Settings, as_of_date, bs_statement, cash_flow_for, cash_flow_statement, member_capitals,
pnl_statement, report_members, report_meta, statement_range, year_earnings,
};
pub use text::{csv_escape, money, parse_date, share_text};
pub use tins::{TinSource, tin_kind, tin_type_key, vault_tin};
+7 -5
View File
@@ -129,12 +129,13 @@ pub fn build_package<B: PackageBooks + ?Sized>(
let tins = req.tins;
let mut tin_gaps: Vec<String> = Vec::new();
// Fiscal year to date, per the company's fiscal_year_start.
let (fy_start, _) = company.fiscal_year_containing(as_of);
let (fy_start, as_of_s) = (fy_start.to_string(), as_of.to_string());
// The fiscal year to date: the tax year holding as_of (IRS Pub. 538),
// the fiscal-year fact itself, not "what omitted dates mean" (rules.md
// B-30), so the package's year never follows a default.
let fy = ledger_core::calendar::fiscal_year_start_on(company.fiscal_year_start, as_of);
let (fy_start, as_of_s) = (fy.to_string(), as_of.to_string());
let xlsx = |r: XlsxReport<'_>| report_to_xlsx(&r, &meta).map_err(|e| e.to_string());
let pdf = |st: &ledger_import::Statement| statement_pdf(st, &meta).map_err(|e| e.to_string());
let fy = NaiveDate::parse_from_str(&fy_start, "%Y-%m-%d").map_err(|e| e.to_string())?;
let members = report_members(company);
// One list of each per package (INVARIANT 7).
let cash_codes = if req.cash_accounts.is_empty() {
@@ -154,6 +155,7 @@ pub fn build_package<B: PackageBooks + ?Sized>(
"profit-and-loss",
report::profit_and_loss(&pnl_statement(
ledger,
company,
Some(&fy_start),
Some(&as_of_s),
None,
@@ -163,7 +165,7 @@ pub fn build_package<B: PackageBooks + ?Sized>(
(
"balance-sheet",
report::balance_sheet(
&bs_statement(ledger, Some(&as_of_s), today)?,
&bs_statement(ledger, company, Some(&as_of_s), today)?,
year_earnings(ledger, company, as_of)?,
&members,
),
+38 -36
View File
@@ -8,9 +8,11 @@
//! books.cash_flow, books.export_cpa_package); ASC 230-10-20
//! (financing and investing activities).
//! INVARIANTS:
//! 1. A range defaults its end to `today` and its start to the first
//! posted entry (never after the end); an end before its start is
//! refused.
//! 1. A range's omitted dates are the one default (rules.md B-30,
//! `ledger_core::calendar::PeriodDefaults`): an omitted end is
//! `today`, an omitted start the first day of the fiscal year that
//! holds the end; a start after the end, given or derived, is
//! refused, never swapped. An omitted as_of is `today`.
//! 2. The cash flow statement needs at least one cash account, and
//! every code it is given must be in the chart; account lists a call
//! leaves empty default to the settings' roles. It foots, or it is
@@ -46,68 +48,67 @@ pub struct Settings<'a> {
pub investing_accounts: &'a [String],
}
/// Resolve a statement's date range: `end` defaults to `today` and `start`
/// to the first posted entry (where "from the beginning" really begins, so
/// no statement says "0001-01-01"), or to `end` for empty books. An end
/// before its start is refused (INVARIANT 1).
/// Resolve a statement's date range (INVARIANT 1): the dates as given,
/// each omitted one the books' default for `company` on `today`
/// (rules.md B-30).
pub fn statement_range(
ledger: &Ledger,
company: &Company,
start: Option<&str>,
end: Option<&str>,
today: NaiveDate,
) -> Result<(NaiveDate, NaiveDate), String> {
let end = match end {
Some(s) => parse_date(s)?,
None => today,
};
let start = match start {
Some(s) => parse_date(s)?,
None => ledger
.posted_entries()
.map(|e| e.date())
.min()
.map_or(end, |first| first.min(end)),
};
if end < start {
return Err("end is before start".to_string());
}
Ok((start, end))
let start = start.map(parse_date).transpose()?;
let end = end.map(parse_date).transpose()?;
ledger_core::calendar::period_defaults(company, today)
.statement(start, end)
.map_err(|e| e.to_string())
}
/// Resolve a point-in-time report's day (INVARIANT 1): as given, else
/// `today`.
pub fn as_of_date(
company: &Company,
as_of: Option<&str>,
today: NaiveDate,
) -> Result<NaiveDate, String> {
let given = as_of.map(parse_date).transpose()?;
Ok(ledger_core::calendar::period_defaults(company, today).as_of(given))
}
/// Profit and loss for a range (see [`statement_range`]), optionally one
/// class.
pub fn pnl_statement(
ledger: &Ledger,
company: &Company,
start: Option<&str>,
end: Option<&str>,
class_id: Option<ledger_core::ClassId>,
today: NaiveDate,
) -> Result<ledger_core::ProfitAndLossStatement, String> {
let (start, end) = statement_range(ledger, start, end, today)?;
let (start, end) = statement_range(company, start, end, today)?;
ledger_core::profit_and_loss_for_period(ledger, start, end, class_id).map_err(|e| e.to_string())
}
/// Balance sheet as of a date (default `today`).
/// Balance sheet as of a day (see [`as_of_date`]).
pub fn bs_statement(
ledger: &Ledger,
company: &Company,
as_of: Option<&str>,
today: NaiveDate,
) -> Result<ledger_core::BalanceSheetStatement, String> {
let as_of = match as_of {
Some(s) => parse_date(s)?,
None => today,
};
let as_of = as_of_date(company, as_of, today)?;
ledger_core::balance_sheet_as_of(ledger, as_of).map_err(|e| e.to_string())
}
/// Build the cash flow statement from account codes, as the exports need it:
/// `start` defaults to the beginning of time, `end` to `today`, and at
/// least one cash account is required (the statement reconciles to them).
/// A member whose capital account the chart lacks is left unnamed (equity
/// is then named by account), never a refusal of the statement.
/// the range as [`statement_range`] resolves it, and at least one cash
/// account (the statement reconciles to them). A member whose capital
/// account the chart lacks is left unnamed (equity is then named by
/// account), never a refusal of the statement.
#[allow(clippy::too_many_arguments)]
pub fn cash_flow_statement(
ledger: &Ledger,
company: &Company,
start: Option<&str>,
end: Option<&str>,
cash_codes: &[String],
@@ -137,7 +138,7 @@ pub fn cash_flow_statement(
}
let financing_accounts = resolve(financing_codes, "financing")?;
let investing_accounts = resolve(investing_codes, "investing")?;
let (start, end) = statement_range(ledger, start, end, today)?;
let (start, end) = statement_range(company, start, end, today)?;
let chart = ledger.chart();
let members = members
.iter()
@@ -190,6 +191,7 @@ pub fn cash_flow_for(
};
cash_flow_statement(
ledger,
settings.company,
start,
end,
&or(cash, settings.cash_accounts),
@@ -274,7 +276,7 @@ pub fn year_earnings(
company: &Company,
as_of: NaiveDate,
) -> Result<ledger_import::YearEarnings, String> {
let (fy_start, _) = company.fiscal_year_containing(as_of);
let fy_start = ledger_core::calendar::fiscal_year_start_on(company.fiscal_year_start, as_of);
let net_income_ytd = ledger_core::profit_and_loss_for_period(ledger, fy_start, as_of, None)
.map_err(|e| e.to_string())?
.net_income();
+36 -7
View File
@@ -18,6 +18,12 @@
//! whitespace). A newer format adds its fields inside `payload`. So this
//! version can always follow the chain one line past the first line it
//! can't read, and say whether that line is intact.
//!
//! A line an earlier version can't read is announced by a feature
//! ([`KNOWN_FEATURES`]): the writer declares it (`journal.format_set`,
//! the features in force and the new one) just before the first line that
//! needs it, so the earlier reader stops at the declaration, and books
//! that never use the feature stay readable by it.
use crate::error::StoreError;
use crate::journal::{JournalLine, ReadLine};
@@ -26,13 +32,18 @@ use serde_json::{Value, json};
/// The newest journal format this version reads and writes.
pub const SUPPORTED_JOURNAL_FORMAT: u32 = 2;
/// Optional journal features this version reads. Each is a field or value
/// on an event type an older version knows, which that version would
/// misread silently; a writer declares the feature (`journal.format_set`)
/// before its first such line, so the older version stops there instead
/// (rules.md B-27). Books that never use one stay readable by versions
/// before it.
pub const KNOWN_FEATURES: &[&str] = &[MEMBERS_CUMULATIVE];
/// Optional journal features this version reads. Each marks lines an
/// older version can't read: a new event type (which it would refuse as
/// unknown), or a field or value on an event type it knows (which it
/// would misread silently). A writer declares the feature
/// (`journal.format_set`) before its first such line, so the older
/// version stops there instead (rules.md B-27). Books that never use one
/// stay readable by versions before it.
pub const KNOWN_FEATURES: &[&str] = &[
MEMBERS_CUMULATIVE,
FEATURE_ACCOUNT_RENAMED,
FEATURE_REQUIRE_TAX_CATEGORY,
];
/// Feature: `period.closed` closes to members with `"rounding":
/// "cumulative_since"` and `since_seq`, the count of members closes
@@ -42,6 +53,23 @@ pub const KNOWN_FEATURES: &[&str] = &[MEMBERS_CUMULATIVE];
/// rule, which can split it differently.
pub const MEMBERS_CUMULATIVE: &str = "members_cumulative";
/// The feature declared before the first `account.renamed` (P3, defect
/// 15).
pub const FEATURE_ACCOUNT_RENAMED: &str = "account_renamed";
/// The feature declared before the first `settings.set` naming
/// `policy.require_tax_category` (P3, defect 8).
pub const FEATURE_REQUIRE_TAX_CATEGORY: &str = "require_tax_category";
/// The feature a `settings.set` naming `key` needs declared first: a key
/// an earlier version doesn't know.
pub(crate) fn setting_feature(key: &str) -> Option<&'static str> {
match key {
"policy.require_tax_category" => Some(FEATURE_REQUIRE_TAX_CATEGORY),
_ => None,
}
}
/// The line type that declares the journal format.
pub const FORMAT_SET: &str = "journal.format_set";
@@ -49,6 +77,7 @@ pub const FORMAT_SET: &str = "journal.format_set";
/// projection arm in `store.rs` (`tests/format.rs` checks both ways).
pub const EVENT_TYPES: &[&str] = &[
"account.added",
"account.renamed",
"account.renumbered",
"account.tax_set",
"asset.acquisition_linked",
+33 -6
View File
@@ -36,6 +36,28 @@ pub const VIEWER_LOCK: &str = "viewer.lock";
/// The most show requests kept.
pub const KEPT: usize = 32;
/// The most columns the viewer splits a report into: past it, the
/// viewer shows the report in one total column and says why, so
/// `books.show` refuses such a split (it would be an option the viewer
/// ignores).
pub const MAX_REPORT_COLUMNS: i64 = 240;
/// How many columns splitting `start..=end` into periods of
/// `months_per_column` months makes (each started period a column),
/// counted from the months, before building any: a range across chrono's
/// calendar is millions of months. The one count `books.show` and the
/// viewer share.
pub fn report_columns(
start: chrono::NaiveDate,
end: chrono::NaiveDate,
months_per_column: u32,
) -> i64 {
use chrono::Datelike;
let months = (i64::from(end.year()) - i64::from(start.year())) * 12 + i64::from(end.month())
- i64::from(start.month());
months / i64::from(months_per_column.max(1)) + 1
}
/// Where to go.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "show", rename_all = "snake_case")]
@@ -51,24 +73,29 @@ pub enum ViewerTarget {
/// Entry id.
entry_id: u64,
},
/// An account's register, optionally for a date range.
/// An account's register, for a date range.
Account {
/// Account code.
code: String,
/// First date, YYYY-MM-DD.
/// First date, YYYY-MM-DD. `books.show` always sends both dates,
/// resolved (rules.md B-30); `None` only from an earlier writer,
/// which the viewer resolves by the same rule.
start: Option<String>,
/// Last date, YYYY-MM-DD.
/// Last date, YYYY-MM-DD (see `start`).
end: Option<String>,
},
/// A report, optionally for a date range.
/// A report, for a date range.
Report {
/// profit_and_loss, balance_sheet, cash_flow, members_equity,
/// trial_balance, general_ledger, tax_summary, vendor_1099,
/// ar_aging, ap_aging, fixed_asset_register.
report: String,
/// First date, YYYY-MM-DD.
/// First date, YYYY-MM-DD. `books.show` always sends both dates,
/// resolved (rules.md B-30); `None` only from an earlier writer,
/// which the viewer resolves by the same rule.
start: Option<String>,
/// Last date, YYYY-MM-DD.
/// Last date, YYYY-MM-DD (see `start`): the as-of day of a report
/// as of a day.
end: Option<String>,
/// Split into columns: total, month, quarter, or year.
#[serde(default)]
+186 -17
View File
@@ -584,24 +584,36 @@ impl Store {
}
/// Declare a journal feature (crate::format::KNOWN_FEATURES) before the
/// first event that needs it: a field or value on a known event type
/// that an older version would otherwise misread silently. Declared
/// with the format in force (at least 2) and every feature already in
/// force; nothing is journaled when it is already declared. Books that
/// never use the feature stay readable by older versions.
/// first line that needs it: a new event type, or a field or value on
/// a known event type that an older version would otherwise misread
/// silently. Declared with the format in force (at least format 2,
/// crate::meta::FORMAT) and every feature already in force; nothing is
/// journaled when it is already declared. Books that never use the
/// feature stay readable by older versions.
///
/// ```text
/// GOAL: an older wanderbooks stops at the declaration (NewerJournal)
/// instead of misreading a line it thinks it knows.
/// instead of misreading, or failing on, a line it can't read.
/// SOURCE: rules.md B-27; plan/served.md 5.1 (format gate).
/// ```
fn ensure_feature(&mut self, feature: &str) -> Result<(), StoreError> {
if self.format.features.iter().any(|f| f == feature) {
self.ensure_features(&[feature])
}
/// [`Self::ensure_feature`] for several at once: one declaration
/// naming every one not yet in force, or none when all are.
fn ensure_features(&mut self, needed: &[&str]) -> Result<(), StoreError> {
let mut features: Vec<String> = self.format.features.clone();
let before = features.len();
for f in needed {
if !features.iter().any(|g| g == f) {
features.push(f.to_string());
}
}
if features.len() == before {
return Ok(());
}
let format = self.format.format.max(crate::meta::FORMAT);
let mut features = self.format.features.clone();
features.push(feature.to_string());
let features: Vec<&str> = features.iter().map(String::as_str).collect();
self.declare_journal_format(format, &features)
}
@@ -642,6 +654,14 @@ impl Store {
if changes.is_empty() && !(adopt && !self.ledger.settings().is_journaled()) {
return Ok(changes);
}
// A key an older version doesn't know needs its feature declared
// first (crate::format::setting_feature), in the one declaration
// that also brings the books to format 2.
let needed: Vec<&str> = changes
.iter()
.filter_map(|c| crate::format::setting_feature(&c.key))
.collect();
self.ensure_features(&needed)?;
self.ensure_format()?;
let line =
self.append_journal("settings.set", crate::meta::settings_set(&changes, source))?;
@@ -827,7 +847,8 @@ impl Store {
self.journal.append(line_type, payload)
}
/// Add an account to the chart.
/// Add an account to the chart, with no tax category (see
/// [`Self::add_account_with_tax`]).
pub fn add_account(
&mut self,
code: &str,
@@ -836,7 +857,73 @@ impl Store {
parent: Option<AccountId>,
placeholder: bool,
) -> Result<AccountId, StoreError> {
self.rehearse(|l| l.add_account(code, name, account_type, parent, placeholder))?;
self.add_account_with_tax(code, name, account_type, parent, placeholder, None)
}
/// Add an account to the chart (`Ledger::add_account`) with, when
/// `tax` is given, its tax category and deductible percent:
/// `account.added`, then `account.tax_set`, the two events
/// `books.import_chart_csv` has always written.
///
/// ```text
/// GOAL: an account created with the line of the return it maps to.
/// SOURCE: rules.md B-32 (policy.require_tax_category).
/// INVARIANTS:
/// 1. Rehearsed whole first: a refusal (of the account, or of its
/// category) journals nothing.
/// 2. Under policy.require_tax_category = refuse, a revenue or
/// expense account that isn't a header and has no category is
/// refused (TaxCategoryRequired), journaling nothing.
/// 3. No new event and no new field: a power cut between the two
/// appends leaves an account without a category, the warn
/// state, which Needs attention lists; never a damaged journal.
/// 4. The name is journaled trimmed, as `Ledger::add_account` keeps
/// it and a rename journals one, so live equals replay.
/// ```
pub fn add_account_with_tax(
&mut self,
code: &str,
name: &str,
account_type: AccountType,
parent: Option<AccountId>,
placeholder: bool,
tax: Option<(&str, Option<Decimal>)>,
) -> Result<AccountId, StoreError> {
let name = name.trim();
let (category, pct) = match tax {
Some((c, p)) => (Some(c), p),
None => (None, None),
};
self.rehearse(|l| {
let id = l.add_account(code, name, account_type, parent, placeholder)?;
if category.is_some() {
l.set_account_tax(id, category, pct)?;
}
Ok(id)
})?;
self.ledger
.check_tax_category_required(code, name, account_type, placeholder, category)?;
let id =
self.journal_account_added(code, name, account_type, parent, placeholder, |l| {
l.add_account(code, name, account_type, parent, placeholder)
})?;
if category.is_some() {
self.journal_account_tax(id, category, pct)?;
}
Ok(id)
}
/// Journal `account.added` and apply it with `add` (the command the
/// caller rehearsed).
fn journal_account_added(
&mut self,
code: &str,
name: &str,
account_type: AccountType,
parent: Option<AccountId>,
placeholder: bool,
add: impl FnOnce(&mut Ledger) -> Result<AccountId, LedgerError>,
) -> Result<AccountId, StoreError> {
// Pure pre-validation replicating ChartOfAccounts::add, so the
// journal write below cannot be followed by a failed mutation.
let chart = self.ledger.chart();
@@ -864,9 +951,7 @@ impl Store {
"currency": self.base_currency.code(),
}),
)?;
let live = self
.ledger
.add_account(code, name, account_type, parent, placeholder)?;
let live = add(&mut self.ledger)?;
debug_assert_eq!(live, id, "id allocator diverged from journal");
apply_line_to_sqlite(&self.conn, &line)?;
Ok(live)
@@ -1970,7 +2055,10 @@ impl Store {
/// code), creating it at its code through the journaled
/// account path when missing. The ledger's internal ensure_account
/// then no-ops, which keeps replay deterministic: every account
/// creation is an `account.added` event.
/// creation is an `account.added` event. The engine's account
/// (`Ledger::add_control_account`): never refused for its name or a
/// missing tax category (rules.md B-32), so an invoice, a bill or a
/// payroll run is never refused over a chart label.
fn ensure_control_account(
&mut self,
c: &ledger_core::ControlAccount,
@@ -1978,7 +2066,10 @@ impl Store {
if let Some(id) = self.ledger.role_account(c) {
return Ok(id);
}
self.add_account(c.code, c.name, c.account_type, None, false)
self.rehearse(|l| l.add_control_account(c))?;
self.journal_account_added(c.code, c.name, c.account_type, None, false, |l| {
l.add_control_account(c)
})
}
/// Journal an auto-posted two-line entry (debit one account, credit
@@ -2854,6 +2945,58 @@ impl Store {
Ok(())
}
/// Give an account a new name (see `Ledger::rename_account`), with an
/// optional reason. Journaled as `account.renamed`, keyed by the
/// account's id (a code can change; an id can't), with its code, the
/// new name, the name it had and the reason. The first rename declares
/// the journal feature `account_renamed`
/// ([`crate::format::FEATURE_ACCOUNT_RENAMED`]), so an earlier version
/// stops at the declaration.
///
/// ```text
/// GOAL: rename an account, keeping the name it had in the journal.
/// SOURCE: rules.md B-31; Rev. Proc. 98-25 (docs/spec/sources/
/// irs-records.md): a report printed under the old name can
/// be tied to the new one.
/// INVARIANTS:
/// 1. Only the account's name changes (Ledger::rename_account
/// INVARIANT 1): every entry, line, hash and earlier journal line
/// stays as it was.
/// 2. Rehearsed and checked first: a refusal journals nothing, not
/// even the feature's declaration.
/// ```
pub fn rename_account(
&mut self,
account: AccountId,
name: &str,
reason: Option<&str>,
) -> Result<(), StoreError> {
self.rehearse(|l| l.rename_account(account, name))?;
self.ledger.check_rename_account(account, name)?;
let reason = reason.map(Ledger::check_rename_reason).transpose()?;
let a = self
.ledger
.chart()
.get(account)
.ok_or(LedgerError::UnknownAccount(account))?;
let (code, previous) = (a.code.clone(), a.name.clone());
let name = name.trim();
self.ensure_feature(crate::format::FEATURE_ACCOUNT_RENAMED)?;
let line = self.append_journal(
"account.renamed",
json!({
"account_id": account,
"code": code,
"name": name,
"previous": previous,
"reason": reason,
}),
)?;
self.ledger.rename_account(account, name)?;
apply_line_to_sqlite(&self.conn, &line)?;
Ok(())
}
/// Assign a control role an account, or (`None`) return it to its code
/// (see `Ledger::assign_role`). Journaled as `role.assigned`, with the
/// account that filled the role before.
@@ -2874,7 +3017,10 @@ impl Store {
}
/// Set (or clear) an account's tax category and deductible percent.
/// Journaled as `account.tax_set`.
/// Journaled as `account.tax_set`. Under
/// `policy.require_tax_category = refuse`, an account that needs a
/// category is never left without one (rules.md B-32;
/// `Ledger::check_tax_category_kept`).
pub fn set_account_tax(
&mut self,
account: AccountId,
@@ -2882,6 +3028,17 @@ impl Store {
deductible_percent: Option<Decimal>,
) -> Result<(), StoreError> {
self.rehearse(|l| l.set_account_tax(account, category, deductible_percent))?;
self.ledger.check_tax_category_kept(account, category)?;
self.journal_account_tax(account, category, deductible_percent)
}
/// Journal `account.tax_set` and apply it (the policy is the caller's).
fn journal_account_tax(
&mut self,
account: AccountId,
category: Option<&str>,
deductible_percent: Option<Decimal>,
) -> Result<(), StoreError> {
self.ledger
.check_account_tax(account, category, deductible_percent)?;
let line = self.append_journal(
@@ -4585,6 +4742,12 @@ pub(crate) fn replay_line_into_ledger(
"account.renumbered" => {
ledger.renumber_account(get_u64(p, "account_id")?, get_str(p, "code")?)?;
}
"account.renamed" => {
// Only that the account exists: the name rules are the
// command's (rules.md B-28). `code`, `previous` and `reason`
// are the record, not state.
ledger.replay_rename_account(get_u64(p, "account_id")?, get_str(p, "name")?)?;
}
"role.assigned" => {
let account = match p.get("account_id") {
None | Some(Value::Null) => None,
@@ -5535,6 +5698,12 @@ fn apply_line_rows(conn: &Connection, line: &JournalLine) -> Result<(), StoreErr
params![get_str(p, "code")?, get_u64(p, "account_id")? as i64],
)?;
}
"account.renamed" => {
conn.execute(
"UPDATE accounts SET name = ? WHERE id = ?",
params![get_str(p, "name")?, get_u64(p, "account_id")? as i64],
)?;
}
"account.tax_set" => {
conn.execute(
"UPDATE accounts SET tax_category = ?, deductible_percent = ? WHERE id = ?",
@@ -0,0 +1,107 @@
[
{
"account_type": "Asset",
"code": "1000",
"currency": [
85,
83,
68
],
"deductible_percent": null,
"id": 1,
"name": "Bank",
"parent": null,
"placeholder": false,
"tax_category": null
},
{
"account_type": "Equity",
"code": "3000",
"currency": [
85,
83,
68
],
"deductible_percent": null,
"id": 2,
"name": "Capital",
"parent": null,
"placeholder": false,
"tax_category": null
},
{
"account_type": "Expense",
"code": "6000",
"currency": [
85,
83,
68
],
"deductible_percent": null,
"id": 3,
"name": "Operating expenses",
"parent": null,
"placeholder": true,
"tax_category": null
},
{
"account_type": "Expense",
"code": "6150",
"currency": [
85,
83,
68
],
"deductible_percent": null,
"id": 4,
"name": "Software",
"parent": 3,
"placeholder": false,
"tax_category": "other_deductions"
},
{
"account_type": "Expense",
"code": "6160",
"currency": [
85,
83,
68
],
"deductible_percent": null,
"id": 5,
"name": "Hosting",
"parent": 3,
"placeholder": false,
"tax_category": null
},
{
"account_type": "Revenue",
"code": "4000",
"currency": [
85,
83,
68
],
"deductible_percent": null,
"id": 6,
"name": "Sales",
"parent": null,
"placeholder": false,
"tax_category": "gross_receipts"
},
{
"account_type": "Expense",
"code": "6170",
"currency": [
85,
83,
68
],
"deductible_percent": null,
"id": 7,
"name": "Domains",
"parent": 3,
"placeholder": false,
"tax_category": "other_deductions"
}
]
@@ -0,0 +1,154 @@
{
"assets": [],
"bills": [],
"entries": [
{
"date": "2026-01-05",
"description": "fixture",
"id": 1,
"lines": [
{
"account": 1,
"amount": {
"amount": "5000.00",
"currency": [
85,
83,
68
]
},
"side": "Debit"
},
{
"account": 2,
"amount": {
"amount": "5000.00",
"currency": [
85,
83,
68
]
},
"side": "Credit"
}
],
"reversal_of": null,
"status": "Posted"
},
{
"date": "2026-02-03",
"description": "fixture",
"id": 2,
"lines": [
{
"account": 4,
"amount": {
"amount": "49.99",
"currency": [
85,
83,
68
]
},
"side": "Debit"
},
{
"account": 1,
"amount": {
"amount": "49.99",
"currency": [
85,
83,
68
]
},
"side": "Credit"
}
],
"reversal_of": null,
"status": "Posted"
},
{
"date": "2026-03-09",
"description": "fixture",
"id": 3,
"lines": [
{
"account": 4,
"amount": {
"amount": "49.99",
"currency": [
85,
83,
68
]
},
"side": "Debit"
},
{
"account": 1,
"amount": {
"amount": "49.99",
"currency": [
85,
83,
68
]
},
"side": "Credit"
}
],
"reversal_of": null,
"status": "Posted"
}
],
"entry_parties": [],
"invoices": [],
"payroll_runs": [],
"periods": [],
"recurring": [],
"schedules": [],
"snapshot": {
"Ok": {
"balance_sheet": {
"assets": "4900.02",
"balanced": true,
"current_earnings": "-99.98",
"equity": "4900.02",
"liabilities": "0"
},
"profit_and_loss": {
"expenses": "99.98",
"net_income": "-99.98",
"revenue": "0"
},
"trial_balance": {
"lines": [
{
"code": "1000",
"credit": "0",
"debit": "4900.02",
"name": "Bank"
},
{
"code": "3000",
"credit": "5000",
"debit": "0",
"name": "Capital"
},
{
"code": "6150",
"credit": "0",
"debit": "99.98",
"name": "Software"
}
],
"total_credits": "5000",
"total_debits": "5000"
}
}
},
"verify": {
"Ok": null
}
}
@@ -0,0 +1,30 @@
{"payload":{"base_currency":"USD"},"prev":"0000000000000000000000000000000000000000000000000000000000000000","seq":1,"ts":"2026-10-21T02:31:59Z","type":"store.created"}
{"payload":{"account_type":"Asset","code":"1000","currency":"USD","id":1,"name":"Bank","parent":null,"placeholder":false},"prev":"b94228016ddcf3ce47c1682e75a6d7e78b63aa5894a2503701d990d55f7b99e1","seq":2,"ts":"2026-10-21T02:31:59Z","type":"account.added"}
{"payload":{"account_type":"Equity","code":"3000","currency":"USD","id":2,"name":"Capital","parent":null,"placeholder":false},"prev":"41e78ca2067b2531e40aa27b89632ed1d29ea2f4c47ab05f4ea0fe7ed448358a","seq":3,"ts":"2026-10-21T02:31:59Z","type":"account.added"}
{"payload":{"account_type":"Expense","code":"6000","currency":"USD","id":3,"name":"Operating","parent":null,"placeholder":true},"prev":"7144fbc2d1ddca0f84b1b40c4be7c2ed20dfd3eaa5e85d82d21ee8e76f03d568","seq":4,"ts":"2026-10-21T02:31:59Z","type":"account.added"}
{"payload":{"account_type":"Expense","code":"6150","currency":"USD","id":4,"name":"Software","parent":3,"placeholder":false},"prev":"e35fee15325262d429dea1bb6dd0c2f462ac3fc476714d2a93cd328c506f0dca","seq":5,"ts":"2026-10-21T02:31:59Z","type":"account.added"}
{"payload":{"account_type":"Expense","code":"6160","currency":"USD","id":5,"name":"Hosting","parent":3,"placeholder":false},"prev":"48dbed424f0aa33a85bb9b9b1b9c2904e7c2f853c6d261bd14624b178cdf14f3","seq":6,"ts":"2026-10-21T02:31:59Z","type":"account.added"}
{"payload":{"account_type":"Revenue","code":"4000","currency":"USD","id":6,"name":"Sales","parent":null,"placeholder":false},"prev":"3565bf2bd6033efa6addb4bc757e21612b154ba5aac19a675977238d713f522f","seq":7,"ts":"2026-10-21T02:31:59Z","type":"account.added"}
{"payload":{"account_id":6,"deductible_percent":null,"tax_category":"gross_receipts"},"prev":"c668734a18c316009542e1b0f81c6f41b0b1bb67acc80a224931bd0f5f22b255","seq":8,"ts":"2026-10-21T02:31:59Z","type":"account.tax_set"}
{"payload":{"features":["require_tax_category"],"format":2},"prev":"fe53bfcaeb90665ff374ce22173e9719786601649aabfd527d4a1cd080386dc3","seq":9,"ts":"2026-10-21T02:31:59Z","type":"journal.format_set"}
{"payload":{"changes":[{"key":"policy.require_tax_category","value":"refuse"}],"source":{"decided_by":"Owner","decided_on":"2026-10-20"}},"prev":"3fd65f0c9fceecf686ae855e8a36565c701b65029a48a9919623e79156dfc19f","seq":10,"ts":"2026-10-21T02:31:59Z","type":"settings.set"}
{"payload":{"account_type":"Expense","code":"6170","currency":"USD","id":7,"name":"Domains","parent":3,"placeholder":false},"prev":"8349d624d6c4e5d5cb6e68c7e457be09c95e094f51660acdc8c9911552df2caf","seq":11,"ts":"2026-10-21T02:31:59Z","type":"account.added"}
{"payload":{"account_id":7,"deductible_percent":null,"tax_category":"other_deductions"},"prev":"6f0a5105551325ff8c46a0e6ae5db60a74a813396c2021b5b42c2162d39c9deb","seq":12,"ts":"2026-10-21T02:31:59Z","type":"account.tax_set"}
{"payload":{"date":"2026-01-05","description":"fixture","id":1},"prev":"11ada13dda80e0a91a1bfa8b87b96fd1e818b5e3e5c52960e098ae8c3cd32675","seq":13,"ts":"2026-10-21T02:31:59Z","type":"entry.created"}
{"payload":{"account":1,"amount":"5000.00","currency":"USD","id":1,"side":"debit"},"prev":"83871fcd54722182038b10357f628f37fb2d2562b9eb9f793f7a36d865c60f73","seq":14,"ts":"2026-10-21T02:31:59Z","type":"entry.line_added"}
{"payload":{"account":2,"amount":"5000.00","currency":"USD","id":1,"side":"credit"},"prev":"5cbc319bf98bc3b490572af050aa42e5f36e9eb21ea02d333cdcdaedbdd61b53","seq":15,"ts":"2026-10-21T02:31:59Z","type":"entry.line_added"}
{"payload":{"date":"2026-01-05","description":"fixture","id":1,"lines":[{"account":1,"amount":"5000.00","currency":"USD","side":"debit"},{"account":2,"amount":"5000.00","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"dfec8a0b1b77bdd9de9993d8f5cd5408275c49480d7ba562d5033e606d0cab0f","seq":16,"ts":"2026-10-21T02:31:59Z","type":"entry.posted"}
{"payload":{"date":"2026-02-03","description":"fixture","id":2},"prev":"94895f542d8b6eff47ab2e78b4b1983205e601b6dfafa3e47e87cce869876c94","seq":17,"ts":"2026-10-21T02:31:59Z","type":"entry.created"}
{"payload":{"account":4,"amount":"49.99","currency":"USD","id":2,"side":"debit"},"prev":"70b7d2bd0ba00224ce8bad014c6ecbc0b9921b45dfc48cdb1c44a16fe722951c","seq":18,"ts":"2026-10-21T02:31:59Z","type":"entry.line_added"}
{"payload":{"account":1,"amount":"49.99","currency":"USD","id":2,"side":"credit"},"prev":"7145687d599c654a7402f37a189cb96aaf2aac03463a48519a4c8b1f5b8ec0cc","seq":19,"ts":"2026-10-21T02:31:59Z","type":"entry.line_added"}
{"payload":{"date":"2026-02-03","description":"fixture","id":2,"lines":[{"account":4,"amount":"49.99","currency":"USD","side":"debit"},{"account":1,"amount":"49.99","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"104f0938e13f9811c8c4963145a415947740dc3e4efc9dfc2120661494a82fee","seq":20,"ts":"2026-10-21T02:31:59Z","type":"entry.posted"}
{"payload":{"date":"2026-03-09","description":"fixture","id":3},"prev":"6dd47d7450a5e451545e8fbca6b9a13d4d898f8571e9ba93dfa5e4b6b6ccd473","seq":21,"ts":"2026-10-21T02:31:59Z","type":"entry.created"}
{"payload":{"account":4,"amount":"49.99","currency":"USD","id":3,"side":"debit"},"prev":"dc00c733f073a6c1ef74b2461df69cb2642981c73f86efdc2d5513e4674766ca","seq":22,"ts":"2026-10-21T02:31:59Z","type":"entry.line_added"}
{"payload":{"account":1,"amount":"49.99","currency":"USD","id":3,"side":"credit"},"prev":"5065fceb3a44ccb628ea44b509c4c98642445b2fa99585761e0dfc38816cffdd","seq":23,"ts":"2026-10-21T02:31:59Z","type":"entry.line_added"}
{"payload":{"date":"2026-03-09","description":"fixture","id":3,"lines":[{"account":4,"amount":"49.99","currency":"USD","side":"debit"},{"account":1,"amount":"49.99","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"79d9e4751f740203c4a14cc25d7a352ac7e576e6c31723d82844e7b3c7f1e192","seq":24,"ts":"2026-10-21T02:31:59Z","type":"entry.posted"}
{"payload":{"features":["account_renamed","require_tax_category"],"format":2},"prev":"561f49d94956a7e4c6192116a22e9152e516f1cbe01591ec6f0b7e674cd3d7c9","seq":25,"ts":"2026-10-21T02:31:59Z","type":"journal.format_set"}
{"payload":{"account_id":4,"code":"6150","name":"Software subscriptions","previous":"Software","reason":"owner asked 2026-10-20"},"prev":"f8cc150e9ce15a0cfb8ecf52c4dcfb72703d0e93fb3d9c4eb08ee3ad54f4a9b3","seq":26,"ts":"2026-10-21T02:31:59Z","type":"account.renamed"}
{"payload":{"account_id":3,"code":"6000","name":"Operating expenses","previous":"Operating","reason":null},"prev":"1311f98abb25afb140df36046a53d146d5a800f5bd54605f15a904b26794a61c","seq":27,"ts":"2026-10-21T02:31:59Z","type":"account.renamed"}
{"payload":{"account_id":4,"deductible_percent":null,"tax_category":"other_deductions"},"prev":"b2a4b494604d991a0ebb99b0d69a14f2bdd11876b693810a4896ff55eecb0145","seq":28,"ts":"2026-10-21T02:31:59Z","type":"account.tax_set"}
{"payload":{"account_id":4,"code":"6150","name":"Software","previous":"Software subscriptions","reason":"back to the short name"},"prev":"3058d2fb9b9b0d94253126e083e23ded7f51ffb6b5ffc64fedeb42a0ca753749","seq":29,"ts":"2026-10-21T02:31:59Z","type":"account.renamed"}
{"payload":{"changes":[{"key":"policy.require_tax_category","value":null}],"source":{"decided_by":"Owner","decided_on":"2026-10-20"}},"prev":"f86c03b68da3205f73d131c2032ea918614b99e2ae016cebc86af7e614112bb7","seq":30,"ts":"2026-10-21T02:31:59Z","type":"settings.set"}
+106
View File
@@ -883,3 +883,109 @@ fn read_journal_and_format_gives_every_line_and_the_format() {
}
);
}
/// P3's journal extensions (rules.md B-27; plan D-39): each is announced
/// by a feature this version knows, declared once, just before the first
/// line that needs it, with the features in force kept: `account_renamed`
/// before the first `account.renamed`, `require_tax_category` before the
/// first `settings.set` naming `policy.require_tax_category`. A reader
/// whose features don't include it (every earlier version: none) stops at
/// the declaration as newer, never at the line, and never calls it
/// corrupt; books that never use either stay format 1 or 2 with no
/// feature.
#[test]
fn p3_features_are_declared_once_before_the_first_line_that_needs_them() {
for f in ["account_renamed", "require_tax_category"] {
assert!(KNOWN_FEATURES.contains(&f), "{f}");
}
let dir = test_dir("p3-features");
books(&dir);
let mut store = Store::open(&dir).unwrap();
assert!(store.journal_format().features.is_empty());
store.rename_account(1, "Business Checking", None).unwrap();
store.rename_account(1, "Checking", None).unwrap();
store
.change_settings(
&[ledger_core::settings::Change {
key: "policy.require_tax_category".into(),
value: Some(json!("refuse")),
}],
&ledger_core::settings::Source {
decided_by: "Owner".into(),
decided_on: NaiveDate::from_ymd_opt(2026, 10, 20).unwrap(),
citation: None,
note: None,
},
)
.unwrap();
assert_eq!(
store.journal_format().features,
["account_renamed", "require_tax_category"]
);
drop(store);
let text = std::fs::read_to_string(dir.join(JOURNAL_FILE)).unwrap();
let lines: Vec<serde_json::Value> = text
.lines()
.map(|l| serde_json::from_str(l).unwrap())
.collect();
let kinds: Vec<(&str, serde_json::Value)> = lines
.iter()
.map(|l| (l["type"].as_str().unwrap(), l["payload"].clone()))
.filter(|(t, _)| matches!(*t, FORMAT_SET | "account.renamed" | "settings.set"))
.collect();
assert_eq!(kinds[0].0, FORMAT_SET);
assert_eq!(
kinds[0].1,
json!({"format": 2, "features": ["account_renamed"]})
);
assert_eq!(kinds[1].0, "account.renamed");
assert_eq!(kinds[2].0, "account.renamed");
assert_eq!(kinds[3].0, FORMAT_SET);
assert_eq!(
kinds[3].1,
json!({"format": 2, "features": ["account_renamed", "require_tax_category"]})
);
assert_eq!(kinds[4].0, "settings.set");
assert_eq!(kinds.len(), 5);
// An earlier reader knows no feature (P2: KNOWN_FEATURES was empty),
// so the gate stops it at the first declaration, before the rename:
// the gate's refusal of a feature it doesn't know is pinned by
// an_unknown_feature_before_a_rename_is_newer_at_the_declaration.
// This version opens them, in the writer and the viewer.
let store = Store::open(&dir).unwrap();
store.verify().unwrap();
assert_eq!(store.ledger().chart().get(1).unwrap().name, "Checking");
drop(store);
let snap = Snapshot::load(&dir).unwrap();
assert_eq!(snap.ledger().chart().get(1).unwrap().name, "Checking");
}
/// A feature this version doesn't know, declared before an account.renamed
/// (as a later version would), is newer at the declaration: the line after
/// it is never read.
#[test]
fn an_unknown_feature_before_a_rename_is_newer_at_the_declaration() {
let dir = test_dir("p3-unknown-feature");
books(&dir);
let seqs = append(
&dir,
&[
(
FORMAT_SET,
json!({"format": 2, "features": ["account_renamed", "later"]}),
),
(
"account.renamed",
json!({"account_id": 1, "code": "1000", "name": "x", "previous": "Cash", "reason": null}),
),
],
);
let _ = std::fs::remove_file(dir.join(DB_FILE));
match Store::open(&dir) {
Err(StoreError::NewerJournal { seq, need, .. }) => {
assert_eq!(seq, seqs[0]);
assert_eq!(need, "journal features later");
}
other => panic!("expected NewerJournal, got {:?}", other.map(|_| ())),
}
}
+803 -3
View File
@@ -975,6 +975,11 @@ mod refusals {
SetVendor1099(u64, bool),
SetAccountTax(u64, u8),
Renumber(u64, u8),
// P3 (defects 8, 15): renames, accounts added with a category,
// and the tax category policy turned on and off.
Rename(u64, u8),
AddAccountTax(u8, u8, u8, bool),
SetTaxPolicy(bool),
// The rest of the command surface (2026-10-13).
AttachReceipt(Option<u64>, u8),
FileDocument(u8),
@@ -1049,6 +1054,10 @@ mod refusals {
(0u64..4, any::<bool>()).prop_map(|(v, t)| Op::SetVendor1099(v, t)),
(0u64..10, 0u8..4).prop_map(|(a, k)| Op::SetAccountTax(a, k)),
(0u64..10, 0u8..9).prop_map(|(a, c)| Op::Renumber(a, c)),
(0u64..12, 0u8..8).prop_map(|(a, n)| Op::Rename(a, n)),
(0u8..8, 0u8..5, 0u8..4, any::<bool>())
.prop_map(|(c, t, k, h)| Op::AddAccountTax(c, t, k, h)),
any::<bool>().prop_map(Op::SetTaxPolicy),
(prop::option::of(id.clone()), 0u8..4).prop_map(|(e, b)| Op::AttachReceipt(e, b)),
(0u8..4).prop_map(Op::FileDocument),
(0u64..6, id.clone()).prop_map(|(a, e)| Op::LinkReceipt(a, e)),
@@ -1138,8 +1147,56 @@ mod refusals {
}]
};
match *op {
// Named for the code: a sibling's name is refused (B-31), and
// a code is the first refusal.
Op::AddAccount(c, t, p, h) => store
.add_account(CODES[c as usize], "acct", TYPES[t as usize], p, h)
.add_account(
CODES[c as usize],
&format!("Account {}", CODES[c as usize]),
TYPES[t as usize],
p,
h,
)
.map(drop),
// A setup account's own name, another's (in other capitals),
// a blank, nine digits, and fresh names.
Op::Rename(a, n) => store.rename_account(
a,
[
"Sales",
"sales",
"CASH",
"",
"Card 123456789",
"Renamed",
"Office",
"Rent ",
][n as usize],
(n % 2 == 0).then_some("owner asked"),
),
Op::AddAccountTax(c, t, k, h) => store
.add_account_with_tax(
CODES[c as usize],
&format!("Taxed {}", CODES[c as usize]),
TYPES[t as usize],
None,
h,
TAX[k as usize].map(|cat| (cat, None)),
)
.map(drop),
Op::SetTaxPolicy(refuse) => store
.change_settings(
&[ledger_core::settings::Change {
key: "policy.require_tax_category".into(),
value: Some(serde_json::json!(if refuse { "refuse" } else { "warn" })),
}],
&ledger_core::settings::Source {
decided_by: "Owner".into(),
decided_on: day(0),
citation: None,
note: None,
},
)
.map(drop),
Op::CreateEntry(d) => store.create_entry(day(d), "e").map(drop),
Op::AddLine(e, a, d, c) => {
@@ -1468,8 +1525,14 @@ mod refusals {
}
}
// The converse law: an accepted command is on the record. Only
// a batch run may find nothing to do.
if result.is_ok() && !matches!(op, Op::RunDepreciation(..) | Op::RunRecurring(..)) {
// a batch run may find nothing to do, and a setting changed to
// the value it has changes nothing.
if result.is_ok()
&& !matches!(
op,
Op::RunDepreciation(..) | Op::RunRecurring(..) | Op::SetTaxPolicy(..)
)
{
let after = std::fs::read_to_string(&journal).unwrap();
prop_assert!(
after.len() > before.len(),
@@ -2776,3 +2839,740 @@ fn a_database_with_damaged_pages_is_rebuilt() {
.count();
assert_eq!(kept, 1);
}
// ---------------------------------------------------------------------------
// P3: renaming an account, and the tax category policy (defects 15 and 8;
// rules.md B-31, B-32).
// ---------------------------------------------------------------------------
/// Every row of every projection table but the journal's own (`events`,
/// `meta`), as text, with `accounts.name` blanked: what a rename may
/// change is that one column.
fn projection_but_names(dir: &std::path::Path) -> Vec<(String, Vec<String>)> {
let conn = rusqlite::Connection::open(dir.join(ledger_store::DB_FILE)).unwrap();
let mut tables: Vec<String> = conn
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name")
.unwrap()
.query_map([], |r| r.get(0))
.unwrap()
.map(Result::unwrap)
.collect();
// The journal's own rows (every line, and its head) grow with every
// command: what a rename adds there is checked from the journal.
tables.retain(|t| !t.starts_with("sqlite_") && t != "events" && t != "meta");
tables
.into_iter()
.map(|t| {
let mut stmt = conn.prepare(&format!("SELECT * FROM \"{t}\"")).unwrap();
let names: Vec<String> = stmt.column_names().iter().map(|c| c.to_string()).collect();
let mut rows: Vec<String> = stmt
.query_map([], |r| {
Ok((0..names.len())
.map(|i| {
if t == "accounts" && names[i] == "name" {
"-".to_string()
} else {
format!("{:?}", r.get_ref(i).unwrap())
}
})
.collect::<Vec<_>>()
.join("|"))
})
.unwrap()
.map(Result::unwrap)
.collect();
rows.sort();
(t, rows)
})
.collect()
}
/// Every figure of the books with account names left out: the trial
/// balance, the P&L and balance sheet (whole and for January), and every
/// entry's lines.
fn figures_but_names(l: &ledger_core::Ledger) -> serde_json::Value {
use ledger_core::reports;
let jan = |d: u32| NaiveDate::from_ymd_opt(2026, 1, d).unwrap();
let lines = |v: &[ledger_core::StatementLine]| {
v.iter()
.map(|x| (x.code.clone(), x.amount.normalize().to_string()))
.collect::<Vec<_>>()
};
let tb = reports::trial_balance(l);
let pl = reports::profit_and_loss_for_period(l, jan(1), jan(31), None).unwrap();
let bs = reports::balance_sheet_as_of(l, jan(31)).unwrap();
serde_json::json!({
"tb": tb.lines.iter().map(|x| (x.code.clone(), x.debit.normalize().to_string(), x.credit.normalize().to_string())).collect::<Vec<_>>(),
"tb_totals": (tb.total_debits.normalize().to_string(), tb.total_credits.normalize().to_string()),
"pl": (lines(&pl.revenue), lines(&pl.expenses), pl.net_income().normalize().to_string()),
"bs": (lines(&bs.assets), lines(&bs.liabilities), lines(&bs.equity), bs.total_assets.normalize().to_string()),
"entries": (1..l.next_entry_id()).filter_map(|id| l.get_entry(id)).collect::<Vec<_>>(),
})
}
/// rename_account (repro 1.2; rules.md B-31): a rename changes the
/// account's name and nothing else: no figure, entry or line, no earlier
/// journal line, no projection row but `accounts.name`; the journal gains
/// one `account.renamed` line (keyed by id, with the old name and the
/// reason) after the feature's declaration; a refused rename (unknown,
/// blank, unchanged, a sibling's name, nine digits, a bad reason) appends
/// nothing; the name survives a reopen, a rebuild and the viewer's read.
#[test]
fn rename_account_changes_only_the_name_defect_15() {
let dir = test_dir("rename-only-name");
let mut store = build_books(&dir);
let journal = dir.join(ledger_store::JOURNAL_FILE);
let text = std::fs::read_to_string(&journal).unwrap();
let figures = figures_but_names(store.ledger());
let rows = projection_but_names(&dir);
let rent = store.ledger().chart().find_by_code("6000").unwrap().id;
for (id, name, reason) in [
(999, "Office Rent", None),
(rent, "", None),
(rent, " Rent Expense ", None),
(rent, "cash", None),
(rent, "Lease 123456789", None),
(rent, "Office Rent", Some("")),
(rent, "Office Rent", Some("lease no. 123-45-6789")),
] {
assert!(
store.rename_account(id, name, reason).is_err(),
"{name:?} {reason:?}"
);
assert_eq!(std::fs::read_to_string(&journal).unwrap(), text, "{name:?}");
assert_eq!(projection_but_names(&dir), rows, "{name:?}");
}
let (seq, _) = store.head();
store
.rename_account(rent, " Office Rent ", Some("owner asked 2026-10-21"))
.unwrap();
let after = std::fs::read_to_string(&journal).unwrap();
// Every earlier line kept, byte for byte; two lines appended: the
// feature's declaration, then the rename.
assert!(after.starts_with(&text));
let added: Vec<serde_json::Value> = after[text.len()..]
.lines()
.map(|l| serde_json::from_str(l).unwrap())
.collect();
assert_eq!(added.len(), 2);
assert_eq!(added[0]["type"], "journal.format_set");
assert_eq!(
added[0]["payload"],
serde_json::json!({"format": 2, "features": ["account_renamed"]})
);
assert_eq!(added[1]["type"], "account.renamed");
assert_eq!(
added[1]["payload"],
serde_json::json!({
"account_id": rent, "code": "6000", "name": "Office Rent",
"previous": "Rent Expense", "reason": "owner asked 2026-10-21"
})
);
assert_eq!(store.head().0, seq + 2);
assert_eq!(
store.ledger().chart().get(rent).unwrap().name,
"Office Rent"
);
assert_eq!(figures_but_names(store.ledger()), figures);
assert_eq!(projection_but_names(&dir), rows);
// The one column a rename changes, read from the projection itself
// (projection_but_names blanks it).
let db_name = |dir: &std::path::Path| -> String {
rusqlite::Connection::open(dir.join(ledger_store::DB_FILE))
.unwrap()
.query_row(
"SELECT name FROM accounts WHERE id = ?",
[rent as i64],
|r| r.get(0),
)
.unwrap()
};
assert_eq!(db_name(&dir), "Office Rent");
store.verify().unwrap();
// A second rename declares nothing more.
store.rename_account(rent, "Rent", None).unwrap();
let again = std::fs::read_to_string(&journal).unwrap();
let last: serde_json::Value = serde_json::from_str(again.lines().last().unwrap()).unwrap();
assert_eq!(again.lines().count(), after.lines().count() + 1);
assert_eq!(last["payload"]["previous"], "Office Rent");
assert_eq!(last["payload"]["reason"], serde_json::Value::Null);
drop(store);
let reopened = Store::open(&dir).unwrap();
reopened.verify().unwrap();
assert_eq!(reopened.ledger().chart().get(rent).unwrap().name, "Rent");
assert_eq!(figures_but_names(reopened.ledger()), figures);
drop(reopened);
let _ = std::fs::remove_file(dir.join(ledger_store::DB_FILE));
let rebuilt = Store::open(&dir).unwrap();
assert_eq!(rebuilt.ledger().chart().get(rent).unwrap().name, "Rent");
assert_eq!(projection_but_names(&dir), rows);
assert_eq!(db_name(&dir), "Rent");
drop(rebuilt);
let snap = ledger_store::Snapshot::load(&dir).unwrap();
assert_eq!(snap.ledger().chart().get(rent).unwrap().name, "Rent");
}
/// rules.md B-28's pair for renames (review R5): the command refuses a
/// blank name, a sibling's or nine digits; replay takes a journaled line
/// holding any of them (a later version may tighten the rules, never lock
/// out books it wrote), and refuses only an account that doesn't exist.
#[test]
fn a_journaled_rename_replays_past_the_command_rules() {
use serde_json::json;
for (name, opens) in [
("", true),
("Cash", true),
("Lease 123456789", true),
("Rent Expense", true),
] {
let dir = test_dir("rename-replay");
let store = build_books(&dir);
let rent = store.ledger().chart().find_by_code("6000").unwrap().id;
drop(store);
let mut w = JournalWriter::open(&dir.join(ledger_store::JOURNAL_FILE)).unwrap();
w.append(
"journal.format_set",
json!({"format": 2, "features": ["account_renamed"]}),
)
.unwrap();
w.append(
"account.renamed",
json!({"account_id": rent, "code": "6000", "name": name, "previous": "Rent Expense", "reason": null}),
)
.unwrap();
drop(w);
let _ = std::fs::remove_file(dir.join(ledger_store::DB_FILE));
let store = Store::open(&dir);
assert_eq!(store.is_ok(), opens, "{name:?}");
let store = store.unwrap();
store.verify().unwrap();
assert_eq!(store.ledger().chart().get(rent).unwrap().name, name);
}
// An account that doesn't exist: the line can't be replayed.
let dir = test_dir("rename-replay-unknown");
drop(build_books(&dir));
let mut w = JournalWriter::open(&dir.join(ledger_store::JOURNAL_FILE)).unwrap();
w.append(
"journal.format_set",
json!({"format": 2, "features": ["account_renamed"]}),
)
.unwrap();
w.append(
"account.renamed",
json!({"account_id": 99, "code": "9", "name": "x", "previous": "y", "reason": null}),
)
.unwrap();
drop(w);
let _ = std::fs::remove_file(dir.join(ledger_store::DB_FILE));
assert!(Store::open(&dir).is_err());
}
fn tax_policy(store: &mut Store, value: Option<&str>) {
store
.change_settings(
&[ledger_core::settings::Change {
key: "policy.require_tax_category".into(),
value: value.map(|v| serde_json::json!(v)),
}],
&ledger_core::settings::Source {
decided_by: "Owner".into(),
decided_on: NaiveDate::from_ymd_opt(2026, 10, 20).unwrap(),
citation: None,
note: None,
},
)
.unwrap();
}
/// add_account_with_tax and the policy (rules.md B-32): under warn an
/// account is created without a category; under refuse a revenue or
/// expense account that isn't a header is refused without one, journaling
/// nothing, and created with one as account.added then account.tax_set;
/// a needed category can't be cleared; the accounts the engine creates
/// for a control role are never refused. The first setting of the key
/// declares the feature require_tax_category, once.
#[test]
fn the_tax_category_policy_refuses_only_what_it_says() {
let dir = test_dir("tax-policy");
let mut store = build_books(&dir);
let journal = dir.join(ledger_store::JOURNAL_FILE);
store
.add_account("6150", "Software", AccountType::Expense, None, false)
.unwrap();
let before = std::fs::read_to_string(&journal).unwrap();
tax_policy(&mut store, Some("refuse"));
let declared = std::fs::read_to_string(&journal).unwrap();
let new: Vec<serde_json::Value> = declared[before.len()..]
.lines()
.map(|l| serde_json::from_str(l).unwrap())
.collect();
assert_eq!(new[0]["type"], "journal.format_set");
assert_eq!(
new[0]["payload"],
serde_json::json!({"format": 2, "features": ["require_tax_category"]})
);
assert_eq!(new.last().unwrap()["type"], "settings.set");
for (code, t, header, tax, refused) in [
("6160", AccountType::Expense, false, None, true),
("4100", AccountType::Revenue, false, None, true),
("6170", AccountType::Expense, true, None, false),
("1100", AccountType::Asset, false, None, false),
("2100", AccountType::Liability, false, None, false),
("3100", AccountType::Equity, false, None, false),
(
"6180",
AccountType::Expense,
false,
Some(("bogus", None)),
true,
),
(
"4200",
AccountType::Revenue,
false,
Some(("supplies", None)),
true,
),
] {
let text = std::fs::read_to_string(&journal).unwrap();
let got = store.add_account_with_tax(code, &format!("Acct {code}"), t, None, header, tax);
assert_eq!(got.is_err(), refused, "{code} {got:?}");
if refused {
assert_eq!(std::fs::read_to_string(&journal).unwrap(), text, "{code}");
assert!(store.ledger().chart().find_by_code(code).is_none());
}
}
let err = store
.add_account("6160", "Hosting", AccountType::Expense, None, false)
.unwrap_err()
.to_string();
assert!(
err.contains("policy.require_tax_category is refuse"),
"{err}"
);
assert!(err.contains("6160 Hosting"), "{err}");
// With a category: two lines, account.added then account.tax_set.
let text = std::fs::read_to_string(&journal).unwrap();
let id = store
.add_account_with_tax(
"6160",
"Hosting",
AccountType::Expense,
None,
false,
Some(("other_deductions", Some(Decimal::from(80)))),
)
.unwrap();
let added: Vec<serde_json::Value> = std::fs::read_to_string(&journal).unwrap()[text.len()..]
.lines()
.map(|l| serde_json::from_str(l).unwrap())
.collect();
assert_eq!(
added
.iter()
.map(|l| l["type"].as_str().unwrap())
.collect::<Vec<_>>(),
["account.added", "account.tax_set"]
);
let a = store.ledger().chart().get(id).unwrap();
assert_eq!(a.tax_category.as_deref(), Some("other_deductions"));
assert_eq!(a.deductible_percent, Some(Decimal::from(80)));
// A needed category is never cleared; a header's or an asset's is no
// concern; an existing uncategorized account is left as it was.
let text = std::fs::read_to_string(&journal).unwrap();
assert!(store.set_account_tax(id, None, None).is_err());
let software = store.ledger().chart().find_by_code("6150").unwrap().id;
assert!(store.set_account_tax(software, None, None).is_err());
assert_eq!(std::fs::read_to_string(&journal).unwrap(), text);
assert!(
store
.ledger()
.chart()
.get(software)
.unwrap()
.tax_category
.is_none()
);
store
.set_account_tax(software, Some("other_deductions"), None)
.unwrap();
// The engine's own accounts: a bill makes 6999 General Expenses with
// no category, under refuse too.
let v = store.add_vendor("Coastline Hosting").unwrap();
store
.create_bill(
v,
NaiveDate::from_ymd_opt(2026, 2, 1).unwrap(),
NaiveDate::from_ymd_opt(2026, 3, 1).unwrap(),
vec![ledger_core::DocumentLine {
description: "hosting".into(),
amount: Decimal::new(4_200, 2),
}],
)
.unwrap();
let general = store.ledger().chart().find_by_code("6999").unwrap();
assert!(general.tax_category.is_none());
// Back to warn: no second declaration, and the account is created.
let text = std::fs::read_to_string(&journal).unwrap();
tax_policy(&mut store, Some("warn"));
let lines: Vec<serde_json::Value> = std::fs::read_to_string(&journal).unwrap()[text.len()..]
.lines()
.map(|l| serde_json::from_str(l).unwrap())
.collect();
assert_eq!(lines.len(), 1);
assert_eq!(lines[0]["type"], "settings.set");
store
.add_account("6190", "Domains", AccountType::Expense, None, false)
.unwrap();
store.verify().unwrap();
drop(store);
Store::open(&dir).unwrap().verify().unwrap();
}
/// rules.md B-28's pair for the policy (review R5): the policy is the
/// commands', never replay's. A journal that sets refuse and then clears
/// a category, or adds an uncategorized expense account, replays.
#[test]
fn a_journaled_clear_replays_under_refuse() {
use serde_json::json;
let dir = test_dir("tax-policy-replay");
let mut store = build_books(&dir);
let rent = store.ledger().chart().find_by_code("6000").unwrap().id;
store
.set_account_tax(rent, Some("rent_property"), None)
.unwrap();
tax_policy(&mut store, Some("refuse"));
let next = store.ledger().next_account_id();
drop(store);
let mut w = JournalWriter::open(&dir.join(ledger_store::JOURNAL_FILE)).unwrap();
w.append(
"account.tax_set",
json!({"account_id": rent, "tax_category": null, "deductible_percent": null}),
)
.unwrap();
w.append(
"account.added",
json!({"id": next, "code": "6150", "name": "Software", "account_type": "Expense",
"parent": null, "placeholder": false, "currency": "USD"}),
)
.unwrap();
drop(w);
let _ = std::fs::remove_file(dir.join(ledger_store::DB_FILE));
let store = Store::open(&dir).unwrap();
store.verify().unwrap();
assert!(
store
.ledger()
.chart()
.get(rent)
.unwrap()
.tax_category
.is_none()
);
assert!(store.ledger().chart().find_by_code("6150").is_some());
assert_eq!(
store.ledger().settings().require_tax_category(),
ledger_core::settings::TaxCategoryPolicy::Refuse
);
}
mod p3_properties {
use super::*;
use proptest::prelude::*;
const NAMES: [&str; 8] = [
"Office",
"office",
"Cash",
"",
"Lease 123456789",
"Rent Expense",
"Studio",
"Tools",
];
proptest! {
#![proptest_config(ProptestConfig {
cases: std::env::var("STORE_CASES").ok().and_then(|v| v.parse().ok()).unwrap_or(64),
failure_persistence: None,
..ProptestConfig::default()
})]
/// rules.md B-31 (the engine-side counterpart to the Lean note,
/// docs/verification.md 13a): on books with postings, any sequence
/// of renames changes no figure (the ledger's statements, without
/// names), no entry and no earlier journal line; live equals
/// replay; the projection equals a fresh one. The sets picked by
/// name (cash, card accounts) are presentation, outside this law.
#[test]
fn prop_rename_changes_no_figure(
renames in prop::collection::vec((1u64..6, 0usize..NAMES.len()), 1..12),
seed in any::<u64>(),
) {
let dir = test_dir(&format!("prop-rename-{seed}"));
let mut store = build_books(&dir);
let journal = dir.join(ledger_store::JOURNAL_FILE);
let figures = figures_but_names(store.ledger());
let rows = projection_but_names(&dir);
let mut text = std::fs::read_to_string(&journal).unwrap();
for (account, n) in renames {
let ok = store.rename_account(account, NAMES[n], None).is_ok();
let now = std::fs::read_to_string(&journal).unwrap();
prop_assert!(now.starts_with(&text));
prop_assert_eq!(ok, now.len() > text.len());
text = now;
prop_assert_eq!(&figures_but_names(store.ledger()), &figures);
prop_assert_eq!(&projection_but_names(&dir), &rows);
}
prop_assert!(store.verify().is_ok());
let live = store.ledger().clone();
drop(store);
let reopened = Store::open(&dir).unwrap();
prop_assert!(live.differs_from(reopened.ledger()).is_empty());
}
/// rules.md B-32: add_account_with_tax is refused exactly when the
/// policy is refuse, the account needs a category (revenue or
/// expense, not a header) and none is given, or when the category
/// doesn't fit; a refusal journals nothing.
#[test]
fn prop_tax_policy_refuses_exactly(
refuse in any::<bool>(),
t in 0usize..5,
header in any::<bool>(),
cat in 0usize..4,
seed in any::<u64>(),
) {
let types = [
AccountType::Asset,
AccountType::Liability,
AccountType::Equity,
AccountType::Revenue,
AccountType::Expense,
];
let cats = [None, Some("other_income"), Some("supplies"), Some("bogus")];
let dir = test_dir(&format!("prop-tax-{seed}"));
let mut store = build_books(&dir);
tax_policy(&mut store, Some(if refuse { "refuse" } else { "warn" }));
let journal = dir.join(ledger_store::JOURNAL_FILE);
let text = std::fs::read_to_string(&journal).unwrap();
let t = types[t];
let needs = matches!(t, AccountType::Revenue | AccountType::Expense) && !header;
let fits = match cats[cat] {
None => true,
Some("other_income") => matches!(t, AccountType::Revenue | AccountType::Expense),
Some("supplies") => t == AccountType::Expense,
_ => false,
};
let want_refused = !fits || (refuse && needs && cats[cat].is_none());
let got = store.add_account_with_tax(
"7000", "Prop", t, None, header, cats[cat].map(|c| (c, None)),
);
prop_assert_eq!(got.is_err(), want_refused, "{:?}", got);
let after = std::fs::read_to_string(&journal).unwrap();
if want_refused {
prop_assert_eq!(after, text);
} else {
let lines = after[text.len()..].lines().count();
prop_assert_eq!(lines, if cats[cat].is_some() { 2 } else { 1 });
}
}
}
}
/// Each account as the books hold it, by id: what chart.json in the
/// p3_rename fixture records (expected.json's figures carry no names).
fn chart_of(l: &ledger_core::Ledger) -> serde_json::Value {
let mut accounts: Vec<_> = l.chart().all().collect();
accounts.sort_by_key(|a| a.id);
serde_json::json!(accounts)
}
fn p3_fixture() -> PathBuf {
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/old-books/p3_rename")
}
/// How the p3_rename fixture was written (2026-10-20, P3): through the
/// Store API of the version that introduced `account.renamed` and
/// `policy.require_tax_category`, with their journal features, a header
/// and its children, refused commands between them. Run once with
/// `--ignored`; the journal it wrote is frozen in the fixture, and later
/// versions must open it to the same figures
/// (books_an_earlier_version_wrote_open_to_its_figures) and names
/// (books_written_under_p3_keep_their_names).
#[test]
#[ignore]
fn write_p3_rename_fixture() {
let dir = test_dir("p3-fixture");
let mut s = Store::create(&dir, Currency::USD).unwrap();
let day = |m: u32, d: u32| NaiveDate::from_ymd_opt(2026, m, d).unwrap();
let bank = s
.add_account("1000", "Bank", AccountType::Asset, None, false)
.unwrap();
let capital = s
.add_account("3000", "Capital", AccountType::Equity, None, false)
.unwrap();
let ops = s
.add_account("6000", "Operating", AccountType::Expense, None, true)
.unwrap();
let software = s
.add_account("6150", "Software", AccountType::Expense, Some(ops), false)
.unwrap();
s.add_account("6160", "Hosting", AccountType::Expense, Some(ops), false)
.unwrap();
s.add_account_with_tax(
"4000",
"Sales",
AccountType::Revenue,
None,
false,
Some(("gross_receipts", None)),
)
.unwrap();
tax_policy(&mut s, Some("refuse"));
assert!(
s.add_account("6170", "Domains", AccountType::Expense, Some(ops), false)
.is_err()
);
s.add_account_with_tax(
"6170",
"Domains",
AccountType::Expense,
Some(ops),
false,
Some(("other_deductions", None)),
)
.unwrap();
for (m, d, dr, cr, cents) in [
(1u32, 5u32, bank, capital, 500_000i64),
(2, 3, software, bank, 4_999),
(3, 9, software, bank, 4_999),
] {
let e = s.create_entry(day(m, d), "fixture").unwrap();
let money = Money::new(Decimal::new(cents, 2), Currency::USD);
s.add_line(e, dr, Side::Debit, money).unwrap();
s.add_line(e, cr, Side::Credit, money).unwrap();
s.post_entry(e).unwrap();
}
assert!(s.rename_account(software, "hosting", None).is_err());
s.rename_account(
software,
"Software subscriptions",
Some("owner asked 2026-10-20"),
)
.unwrap();
s.rename_account(ops, "Operating expenses", None).unwrap();
assert!(s.set_account_tax(software, None, None).is_err());
s.set_account_tax(software, Some("other_deductions"), None)
.unwrap();
s.rename_account(software, "Software", Some("back to the short name"))
.unwrap();
tax_policy(&mut s, None);
s.verify().unwrap();
let out = p3_fixture();
let _ = std::fs::remove_dir_all(&out);
std::fs::create_dir_all(&out).unwrap();
std::fs::copy(
dir.join(ledger_store::JOURNAL_FILE),
out.join(ledger_store::JOURNAL_FILE),
)
.unwrap();
let mut figures = old_books_figures(s.ledger());
figures["verify"] = serde_json::json!({"Ok": null});
std::fs::write(
out.join("expected.json"),
serde_json::to_string_pretty(&figures).unwrap() + "\n",
)
.unwrap();
std::fs::write(
out.join("chart.json"),
serde_json::to_string_pretty(&chart_of(s.ledger())).unwrap() + "\n",
)
.unwrap();
}
/// Books written under P3 (fixture p3_rename): every account's name,
/// code, parent and tax category, as journaled through renames and the
/// tax category policy, read back the same by the writer, a rebuild and
/// the viewer; and the features it declared are the ones this version
/// knows.
#[test]
fn books_written_under_p3_keep_their_names() {
let fixture = p3_fixture();
let want: serde_json::Value =
serde_json::from_slice(&std::fs::read(fixture.join("chart.json")).unwrap()).unwrap();
let dir = old_books_copy(&fixture, "p3-names");
let store = Store::open(&dir).unwrap();
store.verify().unwrap();
assert_eq!(chart_of(store.ledger()), want);
assert_eq!(
store.journal_format().features,
["account_renamed", "require_tax_category"]
);
let names: Vec<&str> = want
.as_array()
.unwrap()
.iter()
.map(|a| a["name"].as_str().unwrap())
.collect();
assert_eq!(
names,
[
"Bank",
"Capital",
"Operating expenses",
"Software",
"Hosting",
"Sales",
"Domains"
]
);
drop(store);
let _ = std::fs::remove_file(dir.join(ledger_store::DB_FILE));
let rebuilt = Store::open(&dir).unwrap();
assert_eq!(chart_of(rebuilt.ledger()), want);
drop(rebuilt);
let snap = ledger_store::Snapshot::load(&dir).unwrap();
assert_eq!(chart_of(snap.ledger()), want);
}
/// rules.md B-31: a new account's name is journaled trimmed, as a rename
/// journals one, so the live books, a reopen and the projection all read
/// the same name; the command's text rules refuse a name a report can't
/// print on one line, journaling nothing.
#[test]
fn a_new_accounts_name_is_journaled_trimmed() {
let dir = test_dir("add-trimmed");
let mut store = build_books(&dir);
let journal = dir.join(ledger_store::JOURNAL_FILE);
let text = std::fs::read_to_string(&journal).unwrap();
for name in ["Two\nlines", &"N".repeat(201)] {
assert!(
store
.add_account("6150", name, AccountType::Expense, None, false)
.is_err(),
"{name:?}"
);
assert_eq!(std::fs::read_to_string(&journal).unwrap(), text);
}
let id = store
.add_account("6150", " Hosting\t", AccountType::Expense, None, false)
.unwrap();
let after = std::fs::read_to_string(&journal).unwrap();
let line: serde_json::Value = serde_json::from_str(after.lines().last().unwrap()).unwrap();
assert_eq!(line["type"], "account.added");
assert_eq!(line["payload"]["name"], "Hosting");
assert_eq!(store.ledger().chart().get(id).unwrap().name, "Hosting");
let live = store.ledger().clone();
drop(store);
let _ = std::fs::remove_file(dir.join(ledger_store::DB_FILE));
let reopened = Store::open(&dir).unwrap();
assert!(live.differs_from(reopened.ledger()).is_empty());
let conn = rusqlite::Connection::open(dir.join(ledger_store::DB_FILE)).unwrap();
let name: String = conn
.query_row("SELECT name FROM accounts WHERE id = ?", [id as i64], |r| {
r.get(0)
})
.unwrap();
assert_eq!(name, "Hosting");
}