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

+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);
}