P3: rename accounts, one set of period defaults, tax categories warn or refuse (defects 15, 16, 8)
check / check (push) Canceled after 0s
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:
69 files changed
+8063
-570
No files matched your search
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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| {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
Reference in new issue
Block a user