Files
wanderbooks/crates/ledger-gui/src/pages/assets.rs
T
swaitsandClaude Opus 5.5 321b94562a
check / check (push) Canceled after 0s
Projects: classes as entities, and the sidebar fits short windows
A project is a class with facts (description, status, kind, party,
dates, links, notes) in one project.set event behind the format
feature projects. Four tools (books.add_project, update_project,
projects, project); documents and decisions link to projects; B-37:
on-spec costs are expensed as incurred, repayment is revenue, and the
unrecovered cost is never below zero (Kani). Viewer: a Projects list
and a project page with its figures, by month and by account. The
sidebar measures its footer instead of reserving a fixed 172 px.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
2026-10-22 03:50:52 -06:00

1666 lines
67 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Fixed assets: the register (everything the company owns as a fixed
//! asset, reconciled to the books) and one asset completely (its label,
//! its schedule month by month, the entries and documents behind it).
//! Every number comes from `ledger-core` (`fixed_asset_register`,
//! `depreciation_schedule`, `Asset::basis_figures`), what is posted too
//! (`Ledger::accumulated_posted`, its runs). Book value is what the books
//! carry (cost less the depreciation posted), never the schedule's figure,
//! which sits beside it as "scheduled" (B-24). Book value and the federal
//! tax figures sit side by side and are never confused: book cost is what
//! was paid or, for a member's contribution, its fair market value; the
//! §723 basis, the depreciable basis and the §704(c) built-in gain follow
//! the basis rule (docs/spec/rules.md B-25).
use super::page;
use crate::app::Shell;
use crate::books::Books;
use crate::fmt;
use crate::nav::{ReportKind, Route};
use crate::theme::{self, pal};
use crate::widgets;
use chrono::{Datelike, NaiveDate};
use egui::{Color32, CornerRadius, Margin, RichText, Sense, Ui, vec2};
use egui_phosphor::regular as icon;
use ledger_core::{
Asset, AssetId, AssetRegister, AssetRegisterRow, AssetStatus, BasisRule, Sec704cMethod,
};
use rust_decimal::Decimal;
/// The register's date (None: today), kept across navigation.
#[derive(Default)]
pub struct State {
pub as_of: Option<NaiveDate>,
}
/// Something the register says about the books.
#[derive(Clone, Debug, PartialEq)]
pub struct Notice {
/// A problem (warning colors) or just so (muted).
pub problem: bool,
pub text: String,
}
/// What the register says about the books on its date: depreciation not
/// yet posted, cost the register doesn't account for, or that they agree.
///
/// ```text
/// GOAL: the register and the general ledger agree, and when they don't,
/// say how and who fixes it.
/// SOURCE: ledger_core::fixed_asset_register (unposted_depreciation,
/// unregistered_cost); Books::depreciation_due (a month's
/// depreciation is due when the month ends).
/// INVARIANTS:
/// 1. unposted_depreciation() > 0: a problem naming the amount due
/// through the last finished month (when any) and asking the
/// bookkeeper to run depreciation; the rest of it (the month under
/// way) a muted note that it posts when the month ends.
/// 2. unposted_depreciation() < 0, unregistered_cost() != 0: a problem
/// each, with the exact amount.
/// 3. Both zero: one note that the register agrees with the books.
/// 4. unattributed_accumulated() != 0: a problem (accumulated
/// depreciation no asset's runs account for), with the exact amount.
/// 5. missing_basis > 0: a problem, last, from [`missing_tax_notice`].
/// ```
pub fn notices(b: &Books, r: &AssetRegister) -> Vec<Notice> {
let mut out = Vec::new();
let unposted = r.unposted_depreciation();
if unposted > Decimal::ZERO {
let due = match b.depreciation_due(r.as_of) {
Ok(due) => due,
Err(e) => {
out.push(Notice {
problem: true,
text: format!("Depreciation due can't be computed: {e}."),
});
return out;
}
};
let due_amount = due.amount.clamp(Decimal::ZERO, unposted);
if due_amount > Decimal::ZERO {
out.push(Notice {
problem: true,
text: format!(
"Depreciation not yet posted through {}: {} — ask the bookkeeper to run depreciation{}.",
fmt::date(due.through),
fmt::dollars(due_amount),
if due.periods.is_empty() {
String::new()
} else {
format!(" for {}", due.periods.join(", "))
}
),
});
}
let accruing = unposted - due_amount;
if accruing > Decimal::ZERO {
out.push(Notice {
problem: false,
text: format!(
"{} more accrues in {} through {}; it's posted when the month ends.",
fmt::dollars(accruing),
r.as_of.format("%B"),
fmt::date(r.as_of)
),
});
}
} else if unposted < Decimal::ZERO {
out.push(Notice {
problem: true,
text: format!(
"The books carry {} more accumulated depreciation than the schedules through {} — ask the bookkeeper to check the depreciation entries.",
fmt::dollars(-unposted),
fmt::date(r.as_of)
),
});
}
let unregistered = r.unregistered_cost();
if unregistered > Decimal::ZERO {
out.push(Notice {
problem: true,
text: format!(
"{} in the asset cost accounts isn't in the register — ask the bookkeeper to register it with books.register_asset.",
fmt::dollars(unregistered)
),
});
} else if unregistered < Decimal::ZERO {
out.push(Notice {
problem: true,
text: format!(
"{} of registered cost isn't in the books' asset cost accounts — ask the bookkeeper to check the purchase entries.",
fmt::dollars(-unregistered)
),
});
}
let unattributed = r.unattributed_accumulated();
if !unattributed.is_zero() {
out.push(Notice {
problem: true,
text: format!(
"{} of accumulated depreciation in the books isn't from any asset's depreciation runs — ask the bookkeeper to find the entries behind it.",
fmt::dollars(unattributed)
),
});
}
if unposted.is_zero() && unregistered.is_zero() && unattributed.is_zero() {
out.push(Notice {
problem: false,
text: format!(
"{} The register agrees with the books: cost and accumulated depreciation match the general ledger to the cent.",
icon::CHECK
),
});
}
if let Some(text) = missing_tax_notice(r.missing_basis) {
out.push(Notice {
problem: true,
text,
});
}
out
}
/// "Tax basis not recorded for 2 assets — ask the bookkeeper to record it
/// (books.set_asset_tax_basis).", or `None` when every asset held has one.
pub fn missing_tax_notice(missing: usize) -> Option<String> {
(missing > 0).then(|| {
format!(
"Tax basis not recorded for {} — ask the bookkeeper to record {} (books.set_asset_tax_basis): bought by the company, contributed by a member from business use, or contributed from personal use.",
fmt::count(missing, "asset", "assets"),
if missing == 1 { "it" } else { "each" }
)
})
}
/// A basis rule for a sentence: "purchase", "carryover", "personal-use
/// conversion".
pub fn rule_words(r: BasisRule) -> &'static str {
match r {
BasisRule::Purchase => "purchase",
BasisRule::Carryover723 => "carryover",
BasisRule::PersonalUseConversion => "personal-use conversion",
}
}
/// What a rule means, in plain words, with the law it comes from.
pub fn rule_explained(r: BasisRule) -> &'static str {
match r {
BasisRule::Purchase => {
"Bought by the company: its tax basis is what it paid (IRC §1012(a))."
}
BasisRule::Carryover723 => {
"Contributed by a member from business use: the company's basis is the member's adjusted basis — cost plus improvements, less depreciation allowed or allowable (IRC §723, §1016(a)) — and tax depreciation starts from it."
}
BasisRule::PersonalUseConversion => {
"Contributed by a member from personal use: the company's basis is the member's adjusted basis (IRC §723); tax depreciation starts from the lesser of its fair market value and that basis (Treas. Reg. §1.168(i)-4(b))."
}
}
}
/// Book value against the tax figures, in one sentence, for an asset
/// under `rule` (or none recorded).
pub fn book_vs_tax(rule: Option<BasisRule>) -> &'static str {
match rule {
Some(BasisRule::Purchase) => {
"Book cost and tax basis are both what the company paid; tax depreciation starts from the tax basis — the CPA's."
}
Some(BasisRule::Carryover723 | BasisRule::PersonalUseConversion) => {
"Book cost is the fair market value at contribution, and book value that cost less the depreciation posted; the tax figures are at contribution, and tax depreciation starts from the depreciable basis — the CPA's."
}
None => {
"No tax basis is recorded: book value is cost less the depreciation posted; the tax figures follow once the basis is recorded."
}
}
}
/// An asset's basis facts and tax figures as the viewer shows them: every
/// figure is ledger-core's (`Asset::basis_figures`), never re-derived.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct TaxFacts {
/// The rule, if recorded.
pub rule: Option<BasisRule>,
/// The contributing member's adjusted basis (a contribution only).
pub contributor_adjusted_basis: Option<Decimal>,
/// The value at contribution (a contribution only).
pub fmv_at_contribution: Option<Decimal>,
/// Whether that value was recorded (false: read as the book cost).
pub fmv_recorded: bool,
/// The §723 basis (a contribution only).
pub tax_basis_723: Option<Decimal>,
/// The depreciable tax basis, if recorded.
pub depreciable_basis: Option<Decimal>,
/// Built-in gain (+) or loss (−) at contribution, if any.
pub built_in_gain_704c: Option<Decimal>,
/// The §704(c) method, if chosen.
pub sec704c_method: Option<Sec704cMethod>,
}
impl TaxFacts {
/// An asset's.
pub fn of(a: &Asset) -> Self {
let f = a.basis_figures().ok().flatten();
TaxFacts {
rule: a.basis.as_ref().map(|b| b.rule),
contributor_adjusted_basis: a.basis.as_ref().and_then(|b| b.contributor_adjusted_basis),
fmv_at_contribution: a.fmv_at_contribution(),
fmv_recorded: a.fmv_is_recorded(),
tax_basis_723: f.and_then(|f| f.tax_basis_723),
depreciable_basis: f.map(|f| f.depreciable_basis),
built_in_gain_704c: f.and_then(|f| f.built_in_gain_704c),
sec704c_method: a.basis.as_ref().and_then(|b| b.sec704c_method),
}
}
/// A register row's.
pub fn of_row(x: &AssetRegisterRow) -> Self {
TaxFacts {
rule: x.basis_rule,
contributor_adjusted_basis: x.contributor_adjusted_basis,
fmv_at_contribution: x.fmv_at_contribution,
fmv_recorded: x.fmv_recorded,
tax_basis_723: x.tax_basis_723,
depreciable_basis: x.depreciable_basis,
built_in_gain_704c: x.built_in_gain_704c,
sec704c_method: x.sec704c_method,
}
}
/// "Carryover · §723 basis $900.00", or "Tax basis not recorded": the
/// line under an asset in the register.
pub fn summary(&self) -> String {
match self.rule {
None => "Tax basis not recorded".into(),
Some(r) => {
let mut s = ledger_import::asset_register::rule_label(r).to_string();
if let Some(b) = self.tax_basis_723 {
s.push_str(&format!(" · §723 basis {}", fmt::dollars(b)));
}
s
}
}
}
/// Everything, with the rule's citation, for a tooltip: "Depreciable
/// basis $900.00: Carryover (IRC §723); §723 basis $1,000.00; value at
/// contribution $1,100.00; built-in gain $100.00." or "Tax basis not
/// recorded."
pub fn detail(&self) -> String {
let (Some(r), Some(t)) = (self.rule, self.depreciable_basis) else {
return "Tax basis not recorded.".into();
};
let mut s = format!(
"Depreciable basis {}: {}",
fmt::dollars(t),
ledger_import::asset_register::rule_cited(r)
);
if let Some(b) = self.tax_basis_723 {
s.push_str(&format!("; §723 basis {}", fmt::dollars(b)));
}
if let Some(v) = self.fmv_at_contribution {
s.push_str(&format!("; value at contribution {}", fmt::dollars(v)));
if !self.fmv_recorded {
s.push_str(" (the book cost: not recorded)");
}
}
if let Some(g) = self.built_in_gain_704c {
let (label, amount) = gain_or_loss(g);
s.push_str(&format!(
"; {} {}",
label.to_lowercase(),
fmt::dollars(amount)
));
}
if let Some(m) = self.sec704c_method {
s.push_str(&format!(
"; §704(c) method {}",
ledger_import::asset_register::method_cited(m)
));
}
s.push('.');
s
}
}
/// "Built-in gain" or "Built-in loss", and the amount as a positive
/// figure, for a built-in gain (+) or loss (−).
pub fn gain_or_loss(g: Decimal) -> (&'static str, Decimal) {
if g < Decimal::ZERO {
("Built-in loss", -g)
} else {
("Built-in gain", g)
}
}
/// The status column: "Active", or "Disposed Mar 3, 2026 for $400.00".
pub fn status_text(disposed: Option<(NaiveDate, Decimal)>) -> String {
match disposed {
None => "Active".into(),
Some((d, proceeds)) => format!("Disposed {} for {}", fmt::date(d), fmt::dollars(proceeds)),
}
}
pub(crate) fn notice_box(ui: &mut Ui, n: &Notice) {
let p = pal(ui.ctx());
let (fg, bg) = if n.problem {
(p.warning, p.warning_soft)
} else {
(p.muted, p.hover)
};
egui::Frame::new()
.fill(bg)
.corner_radius(CornerRadius::same(8))
.inner_margin(Margin::symmetric(12, 8))
.show(ui, |ui| {
ui.set_width(ui.available_width());
ui.horizontal_top(|ui| {
if n.problem {
ui.label(RichText::new(icon::WARNING).color(fg));
}
ui.add(
egui::Label::new(RichText::new(&n.text).font(theme::regular(13.0)).color(fg))
.wrap(),
);
});
});
ui.add_space(6.0);
}
/// One painted table row: `cells` are (text, width, right-aligned,
/// color); the cell whose width is 0 takes what's left. A second line
/// (`sub`) goes under the flexible cell.
pub(crate) fn row(
ui: &mut Ui,
w: f32,
cells: &[(String, f32, bool, Color32)],
sub: Option<(&str, Color32)>,
strong: bool,
click: bool,
) -> egui::Response {
let p = pal(ui.ctx());
let h = if sub.is_some() { 40.0 } else { 28.0 };
let (r, resp) = ui.allocate_exact_size(
vec2(w, h),
if click {
Sense::click()
} else {
Sense::hover()
},
);
if click {
// Named by its main cell, for assistive tech (and the tests).
let name = cells
.iter()
.find(|c| c.1 == 0.0)
.or(cells.first())
.map(|c| c.0.clone())
.unwrap_or_default();
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, &name));
}
if click && resp.hovered() {
ui.painter().rect_filled(r, CornerRadius::same(4), p.hover);
ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand);
}
let fixed: f32 = cells.iter().map(|c| c.1).sum();
let flex = (w - fixed).max(40.0);
let painter = ui.painter_at(r);
let font = if strong {
theme::semibold(13.0)
} else {
theme::regular(13.0)
};
let y = if sub.is_some() {
r.center().y - 7.0
} else {
r.center().y
};
let flex_at = cells.iter().position(|c| c.1 == 0.0);
let mut x = r.left();
for (i, (text, cw, right, color)) in cells.iter().enumerate() {
let cw = if *cw == 0.0 { flex } else { *cw };
if *right {
painter.text(
egui::pos2(x + cw - 6.0, y),
egui::Align2::RIGHT_CENTER,
text,
font.clone(),
*color,
);
} else {
widgets::clipped_text(
&painter,
egui::pos2(x + 6.0, y),
text,
font.clone(),
*color,
cw - 12.0,
);
}
if flex_at == Some(i)
&& let Some((s, c)) = sub
{
// The second line may run under the next cell when that one
// is text (its own line is above).
let room = cw
+ cells
.get(i + 1)
.filter(|n| !n.2 && n.1 > 0.0)
.map_or(0.0, |n| n.1);
widgets::clipped_text(
&painter,
egui::pos2(x + 6.0, r.center().y + 9.0),
s,
theme::regular(11.5),
c,
room - 12.0,
);
}
x += cw;
}
resp
}
fn date_controls(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) -> NaiveDate {
let p = pal(ui.ctx());
let as_of = shell.state.assets.as_of.unwrap_or(b.today);
ui.horizontal_wrapped(|ui| {
ui.label(
RichText::new(format!("{} As of", icon::CALENDAR_BLANK)).font(theme::semibold(13.5)),
);
let picked = widgets::date_picker(ui, "assets_as_of", as_of);
if picked != as_of {
shell.state.assets.as_of = Some(picked);
}
if shell.state.assets.as_of.is_some() && ui.button("Today").clicked() {
shell.state.assets.as_of = None;
}
if ui
.button(format!("{} As a report", icon::FILE_TEXT))
.on_hover_text(
"The same register on the Reports page, to save as a PDF or copy for a spreadsheet",
)
.clicked()
{
shell.state.reports.range = crate::pages::reports::Range::Custom(
b.kpis.first_entry.unwrap_or(as_of).min(as_of),
as_of,
);
shell.go(Route::Reports(ReportKind::FixedAssetRegister));
}
ui.label(
RichText::new(format!("{} basis", b.settings.company.basis))
.font(theme::regular(12.5))
.color(p.muted),
);
});
shell.state.assets.as_of.unwrap_or(b.today)
}
/// The fixed asset register page.
pub fn show_register(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
let p = pal(ui.ctx());
page(ui, "assets", |ui| {
widgets::page_header(
ui,
"Fixed assets",
"Everything the company owns as a fixed asset: its label, what it cost, how much has been depreciated, and what it's worth on the books, reconciled to the general ledger.",
);
if b.ledger.registered_assets().next().is_none() {
widgets::card(ui, |ui| {
widgets::empty(
ui,
icon::PACKAGE,
"No fixed assets",
"Equipment the company capitalizes appears here once the bookkeeper registers it (books.register_asset).",
)
});
return;
}
let as_of = date_controls(ui, b, shell);
ui.add_space(12.0);
let r = match b.asset_register(as_of) {
Ok(r) => r,
Err(e) => {
widgets::card(ui, |ui| {
widgets::empty(
ui,
icon::PACKAGE,
"The register can't be computed",
&format!("{e}. Ask the bookkeeper to find the record behind it."),
)
});
return;
}
};
super::grid(ui, 4, 200.0, 4, |i, ui| {
let (label, value, how, sub) = match i {
0 => (
"Cost",
r.total_cost,
"What the assets still held cost (for a member's contribution, its fair market value then), together.",
None,
),
1 => (
"Depreciation posted",
r.total_accumulated_posted,
"The book depreciation the books carry for them on the date: their depreciation runs, less any reversed.",
(r.total_accumulated_scheduled != r.total_accumulated_posted).then(|| {
(
format!("{} scheduled", fmt::dollars(r.total_accumulated_scheduled)),
p.muted,
)
}),
),
2 => (
"Book value",
r.total_book_value,
"Cost less the depreciation posted: what the books carry.",
None,
),
_ => (
"Depreciable tax basis",
r.total_depreciable_basis,
"The federal depreciable basis of the assets held that have one recorded, at contribution or purchase: what tax depreciation (the CPA's) starts from.",
(r.missing_basis > 0).then(|| {
(
format!(
"{} not recorded",
fmt::count(r.missing_basis, "asset", "assets")
),
p.warning,
)
}),
),
};
widgets::kpi(
ui,
widgets::Kpi {
label,
value: fmt::dollars(value),
sub,
spark: None,
how,
},
);
});
for n in notices(b, &r) {
notice_box(ui, &n);
}
ui.add_space(8.0);
let mut go = None;
widgets::card(ui, |ui| {
if r.rows.is_empty() {
widgets::empty(
ui,
icon::PACKAGE,
"Nothing acquired yet",
&format!("No asset was acquired by {}.", fmt::date(as_of)),
);
return;
}
let amt = widgets::AMOUNT_MIN + 24.0;
// The depreciable basis's heading is the longest.
let wide = amt + 36.0;
let fixed = 110.0 + 104.0 + amt * 4.0 + wide + 190.0;
let w = ui.available_width().max(fixed + 200.0);
widgets::min_width(ui, "asset_register", w, |ui| {
let head = |t: &str, cw: f32, right: bool| (t.to_string(), cw, right, p.muted);
row(
ui,
w,
&[
head("Tag", 110.0, false),
head("Description", 0.0, false),
head("In service", 104.0, false),
head("Cost", amt, true),
head("Posted", amt, true),
head("Book value", amt, true),
head("Scheduled", amt, true),
head("Depreciable basis", wide, true),
head("Status", 190.0, false),
],
None,
true,
false,
);
widgets::rule(ui);
for x in &r.rows {
let disposed = x.disposed.is_some();
let text = if disposed { p.muted } else { p.text };
let money = |d: Decimal| {
if disposed {
String::new()
} else {
fmt::money(d)
}
};
let tax = TaxFacts::of_row(x);
let sub = match &x.serial {
Some(s) => format!("Serial {s} · {}", tax.summary()),
None => tax.summary(),
};
let dates = if x.acquired_date != x.in_service_date {
format!(
"Acquired {}, in service {}.",
fmt::date(x.acquired_date),
fmt::date(x.in_service_date)
)
} else {
format!("Acquired and in service {}.", fmt::date(x.in_service_date))
};
let (tax_text, tax_color) = match tax.depreciable_basis {
_ if disposed => (String::new(), text),
Some(t) => (fmt::money(t), text),
None => ("not recorded".to_string(), p.warning),
};
let resp = row(
ui,
w,
&[
(
x.tag.clone().unwrap_or_else(|| "no tag".into()),
110.0,
false,
if x.tag.is_some() { text } else { p.warning },
),
(x.description.clone(), 0.0, false, text),
(fmt::date(x.in_service_date), 104.0, false, text),
(money(x.cost), amt, true, text),
(money(x.accumulated_posted), amt, true, text),
(money(x.book_value), amt, true, text),
(money(x.accumulated_scheduled), amt, true, p.muted),
(tax_text, wide, true, tax_color),
(status_text(x.disposed), 190.0, false, p.muted),
],
Some((sub.as_str(), p.faint)),
false,
true,
);
let resp = resp.on_hover_text(if disposed {
format!(
"Open this asset (it cost {}; it left the books when disposed). {dates} {}",
fmt::dollars(x.cost),
tax.detail()
)
} else {
format!("Open this asset. {dates} {}", tax.detail())
});
resp.context_menu(|ui| {
if let Some(t) = &x.tag {
widgets::copy_item(ui, "Copy tag", t);
}
if let Some(s) = &x.serial {
widgets::copy_item(ui, "Copy serial number", s);
}
widgets::copy_item(ui, "Copy book value", &x.book_value.to_string());
if let Some(t) = x.depreciable_basis {
widgets::copy_item(ui, "Copy depreciable basis", &t.to_string());
}
});
if resp.clicked() {
go = Some(x.asset_id);
}
}
widgets::rule(ui);
row(
ui,
w,
&[
("Total held".into(), 110.0, false, p.text),
(String::new(), 0.0, false, p.text),
(String::new(), 104.0, false, p.text),
(fmt::money(r.total_cost), amt, true, p.text),
(fmt::money(r.total_accumulated_posted), amt, true, p.text),
(fmt::money(r.total_book_value), amt, true, p.text),
(
fmt::money(r.total_accumulated_scheduled),
amt,
true,
p.muted,
),
(fmt::money(r.total_depreciable_basis), wide, true, p.text),
(String::new(), 190.0, false, p.text),
],
None,
true,
false,
);
});
});
ui.add_space(4.0);
ui.add(
egui::Label::new(
RichText::new(
"Book value is cost less the depreciation posted (what the books carry); scheduled is what the schedule says is due by the date. Cost is what the company paid or, for a member's contribution, its fair market value then; the tax figures follow its rule (under each asset), at contribution or purchase, and can differ. Tax depreciation starts from the depreciable basis — the CPA's.",
)
.font(theme::regular(11.5))
.color(p.faint),
)
.wrap(),
);
ui.add_space(14.0);
reconciliation(ui, b, &r, shell);
if let Some(id) = go {
shell.go(Route::Asset(id));
}
});
}
/// Register against the general ledger, line by line.
fn reconciliation(ui: &mut Ui, b: &Books, r: &AssetRegister, shell: &mut Shell<'_>) {
let p = pal(ui.ctx());
let held: Vec<_> = r
.rows
.iter()
.filter_map(|x| b.ledger.get_asset(x.asset_id))
.collect();
let mut cost_accts: Vec<u64> = held.iter().map(|a| a.cost_account).collect();
cost_accts.sort_unstable();
cost_accts.dedup();
let mut accum_accts: Vec<u64> = held.iter().map(|a| a.accum_depr_account).collect();
accum_accts.sort_unstable();
accum_accts.dedup();
let mut go = None;
widgets::titled_card(
ui,
"Reconciliation to the books",
|_| {},
|ui| {
let amt = widgets::AMOUNT_MIN + 40.0;
let w = ui.available_width().max(260.0 + amt);
widgets::min_width(ui, "asset_recon", w, |ui| {
let line = |ui: &mut Ui, label: &str, v: Decimal, strong: bool, color| {
let resp = row(
ui,
w,
&[
(label.to_string(), 0.0, false, p.text),
(fmt::money(v), amt, true, color),
],
None,
strong,
false,
);
resp.context_menu(|ui| widgets::copy_item(ui, "Copy amount", &v.to_string()));
};
let diff_color = |d: Decimal| if d.is_zero() { p.positive } else { p.warning };
line(ui, "Cost in the register", r.total_cost, false, p.text);
line(ui, "Cost in the books", r.ledger_cost, false, p.text);
let u = r.unregistered_cost();
line(
ui,
"Difference (not in the register)",
u,
true,
diff_color(u),
);
ui.add_space(8.0);
line(
ui,
"Accumulated depreciation by schedule",
r.total_accumulated_scheduled,
false,
p.text,
);
line(
ui,
"Accumulated depreciation in the books",
r.ledger_accumulated,
false,
p.text,
);
let d = r.unposted_depreciation();
// Only the month under way: not a problem, just not yet.
let accruing = d > Decimal::ZERO
&& b.depreciation_due(r.as_of)
.is_ok_and(|due| due.amount <= Decimal::ZERO);
let color = if accruing { p.muted } else { diff_color(d) };
line(ui, "Difference (not yet posted)", d, true, color);
ui.add_space(8.0);
line(
ui,
"Accumulated depreciation from the assets' runs",
r.total_accumulated_posted,
false,
p.text,
);
let u = r.unattributed_accumulated();
line(
ui,
"Difference (in the books, not from runs)",
u,
true,
diff_color(u),
);
});
ui.add_space(8.0);
ui.horizontal_wrapped(|ui| {
ui.label(
RichText::new("Accounts:")
.font(theme::regular(12.0))
.color(p.muted),
);
for id in cost_accts.iter().chain(&accum_accts) {
if widgets::link(
ui,
RichText::new(b.account_label(*id))
.font(theme::regular(12.0))
.color(p.accent),
)
.clicked()
{
go = Some(*id);
}
}
});
},
);
if let Some(id) = go {
shell.state.accounts.range = crate::pages::accounts::RegisterRange::Default;
shell.go(Route::Account(id));
}
}
/// How one month of an asset's schedule stands in the books.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum MonthStatus {
/// Posted, by this entry.
Posted(ledger_core::EntryId),
/// The month has ended, nothing is posted for it, and a run for it
/// would post something.
Due,
/// The month has ended with no run of its own, and nothing is due for
/// it: a later run caught it up, its run was reversed after it ended
/// (the reversal's month catches it up), it rounds to nothing, or the
/// asset's disposal took it.
Covered,
/// The month under way.
ThisMonth,
/// A month still to come.
Scheduled,
}
/// Each month of the asset's full schedule (to the end of its life, or
/// its disposal) with how it stands on `today`.
///
/// ```text
/// INVARIANTS:
/// 1. The lines are ledger_core::depreciation_schedule(asset, the end of
/// time), unchanged and in order: for a disposed asset, through its
/// disposal day.
/// 2. A month with a run in effect (asset.depreciation_posted, not
/// reversed) is Posted(its entry); otherwise, once it has ended, Due
/// when a run for it would post something (ledger_core::run_amount,
/// over what the books carry, every run and reversal in its own
/// month) and Covered when not (a later run caught it up, its run was
/// reversed after it ended, or it rounds to nothing); ThisMonth when
/// today is in it, Scheduled after. A disposed asset's months are
/// never Due: its disposal took the schedule through its day.
/// 3. A schedule the books can't compute (an asset an earlier version
/// took) is refused, never a panic: the page says why.
/// ```
pub fn schedule(
b: &Books,
id: AssetId,
) -> Result<Vec<(ledger_core::ScheduleLine, MonthStatus)>, ledger_core::LedgerError> {
let Some(a) = b.ledger.get_asset(id) else {
return Ok(Vec::new());
};
let trail = b.asset_trail(id);
let this_month = b.today.format("%Y-%m").to_string();
// What a run would post for each month ended before this one, in one
// pass (a long life has many).
let ended = b
.today
.with_day(1)
.and_then(|d| d.pred_opt())
.map_or((i32::MIN, 1), |d| (d.year(), d.month()));
let disposed = matches!(a.status, AssetStatus::Disposed { .. });
let due: std::collections::HashMap<(i32, u32), Decimal> = if disposed {
Default::default()
} else {
ledger_core::run_amounts(a, ended, &trail.effects)?
.into_iter()
.map(|(y, m, r)| ((y, m), r.amount))
.collect()
};
Ok(ledger_core::depreciation_schedule(a, NaiveDate::MAX)?
.into_iter()
.map(|l| {
let st = match trail.depreciation.get(&l.period) {
Some((e, _)) => MonthStatus::Posted(*e),
None if disposed => MonthStatus::Covered,
None if l.period < this_month => {
let owed = crate::books::period_month(&l.period)
.and_then(|ym| due.get(&ym).copied())
.unwrap_or(l.amount);
if owed.is_zero() {
MonthStatus::Covered
} else {
MonthStatus::Due
}
}
None if l.period == this_month => MonthStatus::ThisMonth,
None => MonthStatus::Scheduled,
};
(l, st)
})
.collect())
}
/// Depreciation for finished months that the books don't carry yet: what
/// a run of the last month before today's would post
/// (ledger_core::run_amounts), every skipped or reversed month caught up.
/// Zero for a disposed asset (its disposal took the schedule through its
/// day) or one with nothing due.
pub fn unposted(b: &Books, id: AssetId) -> Result<Decimal, ledger_core::LedgerError> {
let Some(a) = b
.ledger
.get_asset(id)
.filter(|a| a.status == AssetStatus::Active)
else {
return Ok(Decimal::ZERO);
};
let ended = b
.today
.with_day(1)
.and_then(|d| d.pred_opt())
.map_or((i32::MIN, 1), |d| (d.year(), d.month()));
Ok(
ledger_core::run_amounts(a, ended, &b.asset_trail(id).effects)?
.last()
.map_or(Decimal::ZERO, |(_, _, r)| r.amount),
)
}
/// One asset, completely.
pub fn show_asset(ui: &mut Ui, b: &Books, id: AssetId, shell: &mut Shell<'_>) {
let p = pal(ui.ctx());
let Some(a) = b.ledger.get_asset(id) else {
page(ui, "asset", |ui| {
widgets::empty(
ui,
icon::PACKAGE,
"No such asset",
"It isn't in these books.",
);
});
return;
};
let trail = b.asset_trail(id);
// Err: the register's totals can't be computed (a contributor basis an
// earlier version journaled, past exact arithmetic); said, never guessed.
let today = b
.asset_register(b.today)
.map(|r| r.rows.into_iter().find(|r| r.asset_id == id));
let mut go: Option<Route> = None;
page(ui, "asset", |ui| {
ui.horizontal(|ui| {
if widgets::link(
ui,
RichText::new(format!("{} Fixed assets", icon::ARROW_LEFT))
.font(theme::regular(12.5))
.color(p.muted),
)
.clicked()
{
go = Some(Route::Assets);
}
widgets::copyable(
ui,
RichText::new(format!("· Asset {id}"))
.font(theme::regular(12.5))
.color(p.muted),
&id.to_string(),
);
});
ui.add_space(4.0);
match &a.tag {
Some(tag) => {
widgets::copyable(
ui,
RichText::new(tag).font(theme::bold(30.0)).monospace(),
tag,
);
}
None => {
widgets::pill(ui, "No tag yet", p.warning, p.warning_soft)
.on_hover_text("Ask the bookkeeper to label it (books.label_asset).");
}
}
ui.label(RichText::new(&a.description).font(theme::bold(22.0)));
ui.add_space(6.0);
ui.horizontal_wrapped(|ui| {
match &a.status {
AssetStatus::Active => widgets::pill(ui, "Active", p.positive, p.positive_soft),
AssetStatus::Disposed { date, proceeds } => widgets::pill(
ui,
&status_text(Some((*date, proceeds.amount))),
p.muted,
p.hover,
),
};
widgets::pill(ui, "Straight line", p.muted, p.hover);
});
ui.add_space(14.0);
super::columns(ui, 820.0, &[1.0, 1.0], |col, ui| {
if col == 0 {
widgets::titled_card(
ui,
"Identity",
|_| {},
|ui| {
facts(ui, |ui, k| {
k(ui, "Tag");
match &a.tag {
Some(t) => {
widgets::copyable(ui, RichText::new(t).monospace(), t);
}
None => {
ui.label(RichText::new("none").color(p.warning));
}
}
ui.end_row();
k(ui, "Serial number");
match &a.serial {
Some(s) => {
widgets::copyable(ui, RichText::new(s).monospace(), s);
}
None => {
ui.label(RichText::new("not recorded").color(p.muted));
}
}
ui.end_row();
k(ui, "Asset id");
widgets::copyable(ui, RichText::new(id.to_string()), &id.to_string());
ui.end_row();
for (label, acct) in [
("Cost account", a.cost_account),
("Accumulated depreciation", a.accum_depr_account),
("Depreciation expense", a.expense_account),
] {
k(ui, label);
if widgets::link_truncated(
ui,
RichText::new(b.account_label(acct)).color(p.accent),
)
.clicked()
{
shell.state.accounts.range =
crate::pages::accounts::RegisterRange::Default;
go = Some(Route::Account(acct));
}
ui.end_row();
}
});
},
);
} else {
widgets::titled_card(
ui,
"Cost and depreciation",
|_| {},
|ui| {
let end = a
.in_service_date
.checked_add_months(chrono::Months::new(a.useful_life_months))
.and_then(|d| d.pred_opt());
facts(ui, |ui, k| {
let money = |ui: &mut Ui, d: Decimal, strong: bool| {
let t = fmt::dollars(d);
let rt = if strong {
RichText::new(&t).font(theme::semibold(14.0))
} else {
RichText::new(&t)
};
widgets::copyable(ui, rt, &d.to_string());
};
if let Some(acquired) = b.ledger.asset_acquired_date(a.id)
&& acquired != a.in_service_date
{
k(ui, "Acquired");
ui.label(fmt::date(acquired));
ui.end_row();
}
k(ui, "In service");
ui.label(fmt::date(a.in_service_date));
ui.end_row();
k(ui, "Cost");
money(ui, a.cost.amount, false);
ui.end_row();
k(ui, "Salvage value");
money(ui, a.salvage_value.amount, false);
ui.end_row();
k(ui, "Useful life");
ui.label(match end {
Some(e) => format!(
"{} · through {}",
fmt::count(a.useful_life_months as usize, "month", "months"),
fmt::date(e)
),
None => {
fmt::count(a.useful_life_months as usize, "month", "months")
}
});
ui.end_row();
match &today {
Ok(Some(r)) => {
k(ui, &format!("Posted, {}", fmt::date_short(b.today)));
money(ui, r.accumulated_posted, false);
ui.end_row();
k(ui, &format!("Scheduled, {}", fmt::date_short(b.today)));
money(ui, r.accumulated_scheduled, false);
ui.end_row();
k(ui, "Book value today");
money(ui, r.book_value, true);
ui.end_row();
}
Ok(None) => {}
Err(e) => {
k(ui, "Book value today");
ui.label(format!("can't be computed: {e}"));
ui.end_row();
}
}
if let AssetStatus::Disposed { date, proceeds } = &a.status {
k(ui, "Disposed");
ui.label(fmt::date(*date));
ui.end_row();
k(ui, "Proceeds");
money(ui, proceeds.amount, false);
ui.end_row();
}
});
},
);
}
});
tax_card(ui, a);
ui.add_space(14.0);
// The schedule.
let (lines, schedule_error) = match schedule(b, id) {
Ok(lines) => (lines, None),
Err(e) => (Vec::new(), Some(e)),
};
if let Some(e) = &schedule_error {
notice_box(
ui,
&Notice {
problem: true,
text: format!("Its depreciation schedule can't be computed: {e}."),
},
);
}
let due = unposted(b, id).unwrap_or(Decimal::ZERO);
if due > Decimal::ZERO {
notice_box(
ui,
&Notice {
problem: true,
text: format!(
"{} of depreciation for finished months isn't posted — ask the bookkeeper to run depreciation.",
fmt::dollars(due)
),
},
);
}
widgets::titled_card(
ui,
"Depreciation schedule",
|ui| {
ui.label(
RichText::new(format!(
"{} · {} posted",
fmt::count(lines.len(), "month", "months"),
lines
.iter()
.filter(|(_, s)| matches!(s, MonthStatus::Posted(_)))
.count()
))
.font(theme::regular(12.0))
.color(p.muted),
);
},
|ui| {
if lines.is_empty() {
let why = if schedule_error.is_some() {
"The schedule can't be computed (above)."
} else {
"Nothing to depreciate: its salvage value is its cost."
};
ui.label(RichText::new(why).color(p.muted));
return;
}
let amt = widgets::AMOUNT_MIN + 16.0;
let w = ui.available_width().max(100.0 + amt * 3.0 + 200.0);
widgets::min_width(ui, "asset_schedule", w, |ui| {
let head = |t: &str, cw: f32, right: bool| (t.to_string(), cw, right, p.muted);
row(
ui,
w,
&[
head("Month", 100.0, false),
head("Amount", amt, true),
head("Accumulated", amt, true),
// The schedule's cost less accumulated, never
// what the books carry (that is above).
head("Remaining", amt, true),
head("", 24.0, false),
head("In the books", 0.0, false),
],
None,
true,
false,
);
widgets::rule(ui);
for (l, st) in &lines {
let month =
NaiveDate::parse_from_str(&format!("{}-01", l.period), "%Y-%m-%d")
.map(fmt::month)
.unwrap_or_else(|_| l.period.clone());
let (status, color) = match st {
MonthStatus::Posted(e) => (format!("Posted · entry #{e}"), p.positive),
MonthStatus::Due => ("Not posted".to_string(), p.warning),
MonthStatus::Covered if l.amount.is_zero() => {
("Rounds to nothing".to_string(), p.muted)
}
MonthStatus::Covered if a.status != AssetStatus::Active => {
("Taken by the disposal".to_string(), p.muted)
}
MonthStatus::Covered => ("Nothing due".to_string(), p.muted),
MonthStatus::ThisMonth => ("This month".to_string(), p.muted),
MonthStatus::Scheduled => ("Scheduled".to_string(), p.faint),
};
let text = if matches!(st, MonthStatus::Scheduled) {
p.muted
} else {
p.text
};
let resp = row(
ui,
w,
&[
(month, 100.0, false, text),
(fmt::money(l.amount), amt, true, text),
(fmt::money(l.accumulated), amt, true, text),
(fmt::money(l.book_value), amt, true, text),
(String::new(), 24.0, false, text),
(status, 0.0, false, color),
],
None,
false,
matches!(st, MonthStatus::Posted(_)),
);
resp.context_menu(|ui| {
widgets::copy_item(ui, "Copy amount", &l.amount.to_string());
widgets::copy_item(ui, "Copy book value", &l.book_value.to_string());
});
if let MonthStatus::Posted(e) = st
&& resp.on_hover_text("Open the depreciation entry").clicked()
{
go = Some(Route::Entry(*e));
}
}
});
},
);
ui.add_space(14.0);
// Entries and documents.
let entries = trail.entries();
widgets::titled_card(
ui,
"Entries",
|_| {},
|ui| {
if entries.is_empty() {
ui.label(
RichText::new("No entry in the books is about it yet.").color(p.muted),
);
}
for e in &entries {
let Some(info) = b.entry(*e) else {
continue;
};
let role = if Some(*e) == trail.purchase_entry {
"Purchase"
} else if Some(*e) == trail.disposal_entry {
"Disposal"
} else {
"Depreciation"
};
ui.horizontal(|ui| {
ui.add_sized(
vec2(96.0, 18.0),
egui::Label::new(
RichText::new(role)
.font(theme::regular(12.5))
.color(p.muted),
),
);
if widgets::link_truncated(
ui,
RichText::new(format!(
"{} · #{} {} · {}",
fmt::date(info.date),
info.id,
info.description,
fmt::dollars_or(info.amount)
))
.font(theme::regular(13.0)),
)
.clicked()
{
go = Some(Route::Entry(*e));
}
});
}
if trail.purchase_entry.is_some() {
ui.add_space(4.0);
ui.label(
RichText::new(if a.acquired_by.is_some() {
"The purchase is the entry linked to this asset."
} else {
"The purchase is matched by its cost account, amount, and date."
})
.font(theme::regular(11.5))
.color(p.faint),
);
}
},
);
ui.add_space(14.0);
let mut subjects = vec![ledger_store::Subject::Asset(id)];
subjects.extend(entries.iter().map(|e| ledger_store::Subject::Entry(*e)));
super::documents::documents_card_with(
ui,
b,
"Receipts and documents",
&subjects,
&[ledger_store::Subject::Asset(id)],
"None linked to it or its entries (photos, the warranty, the manual; depreciation needs none: the schedule is its evidence).",
&mut go,
);
ui.add_space(14.0);
// Its own events.
widgets::titled_card(
ui,
"History",
|_| {},
|ui| {
for &i in &trail.events {
let line = &b.journal[i];
let ev = super::journal::describe_any(b, line);
ui.horizontal(|ui| {
ui.label(RichText::new(ev.icon).color(p.muted));
if widgets::link_truncated(
ui,
RichText::new(&ev.text).font(theme::regular(12.5)),
)
.clicked()
{
go = Some(Route::JournalEvent(line.seq));
}
ui.label(
RichText::new(format!(
"event {} · {}",
line.seq,
fmt::timestamp(&line.ts)
))
.font(theme::regular(11.0))
.color(p.faint),
);
});
}
},
);
});
if let Some(r) = go {
shell.go(r);
}
}
/// The asset's federal tax basis beside its book figures.
fn tax_card(ui: &mut Ui, a: &Asset) {
let p = pal(ui.ctx());
let t = TaxFacts::of(a);
widgets::titled_card(
ui,
"Tax basis",
|ui| {
ui.label(
RichText::new("Federal")
.font(theme::regular(12.0))
.color(p.muted),
);
},
|ui| {
let Some(rule) = t.rule else {
ui.label(
RichText::new("Not recorded")
.color(p.warning)
.font(theme::semibold(14.0)),
);
ui.add(
egui::Label::new(
RichText::new(
"Ask the bookkeeper to record it (books.set_asset_tax_basis): was it bought by the company, contributed by a member from business use, or converted from personal use? For a contribution, book cost is the fair market value at contribution; tax depreciation starts from the depreciable basis — the CPA's.",
)
.font(theme::regular(12.5))
.color(p.muted),
)
.wrap(),
);
return;
};
facts(ui, |ui, k| {
let money = |ui: &mut Ui, d: Decimal, strong: bool| {
let s = fmt::dollars(d);
let rt = if strong {
RichText::new(&s).font(theme::semibold(14.0))
} else {
RichText::new(&s)
};
widgets::copyable(ui, rt, &d.to_string());
};
k(ui, "Rule");
ui.label(ledger_import::asset_register::rule_label(rule));
ui.end_row();
k(ui, "Book cost");
money(ui, a.cost.amount, false);
ui.end_row();
if let Some(v) = t.fmv_at_contribution {
k(ui, "Value at contribution");
ui.horizontal(|ui| {
money(ui, v, false);
if !t.fmv_recorded {
ui.label(
RichText::new(
"from book cost, recorded before the value was asked",
)
.font(theme::regular(12.0))
.color(p.warning),
);
}
});
ui.end_row();
}
if let Some(c) = t.contributor_adjusted_basis {
k(ui, "Contributor's adjusted basis");
money(ui, c, false);
ui.end_row();
}
if let Some(b) = t.tax_basis_723 {
k(ui, "§723 basis");
money(ui, b, false);
ui.end_row();
}
if let Some(tb) = t.depreciable_basis {
k(ui, "Depreciable basis");
money(ui, tb, true);
ui.end_row();
}
if let Some(g) = t.built_in_gain_704c {
let (label, amount) = gain_or_loss(g);
k(ui, &format!("{label} §704(c)"));
money(ui, amount, false);
ui.end_row();
}
if rule.is_contribution() {
k(ui, "§704(c) method");
match t.sec704c_method {
Some(m) => {
ui.label(ledger_import::asset_register::method_cited(m));
}
None => {
ui.label(RichText::new("not chosen").color(p.warning));
}
}
ui.end_row();
}
if let Some(src) = a.basis.as_ref().and_then(|b| b.source.as_ref()) {
k(ui, "Facts from");
ui.label(ledger_import::asset_register::source_text(src));
ui.end_row();
}
});
ui.add_space(6.0);
let mut why = rule_explained(rule).to_string();
if t.built_in_gain_704c.is_some_and(|g| g < Decimal::ZERO) {
why.push_str(" A built-in loss counts only for the contributing member; for the others the basis is treated as the value at contribution (IRC §704(c)(1)(C)).");
} else if t.built_in_gain_704c.is_some_and(|g| g > Decimal::ZERO) {
why.push_str(" IRC §704(c) has the company take the built-in gain into account for the contributing member.");
}
for line in [why.as_str(), book_vs_tax(Some(rule))] {
ui.add(
egui::Label::new(
RichText::new(line)
.font(theme::regular(12.5))
.color(p.muted),
)
.wrap(),
);
}
},
);
}
/// A two-column grid of facts; `add` gets the label painter.
pub(crate) fn facts(ui: &mut Ui, add: impl FnOnce(&mut Ui, &dyn Fn(&mut Ui, &str))) {
let p = pal(ui.ctx());
egui::Grid::new(ui.next_auto_id())
.num_columns(2)
.spacing(vec2(20.0, 6.0))
.show(ui, |ui| {
let k = |ui: &mut Ui, s: &str| {
ui.label(RichText::new(s).font(theme::regular(12.5)).color(p.muted));
};
add(ui, &k);
});
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_support::{Build, Painted, beside, cents, d, find, render, today};
use ledger_core::{Currency, Money, NewAsset};
/// Two assets bought 2026-01-01 for $1,200.00 over 12 months and
/// $2,400.00 over 24 (both $100.00 a month), no depreciation posted.
fn two_assets(tag: &str) -> (crate::test_support::TempDir, Books, Vec<AssetId>) {
let mut b = Build::new(tag);
let mut add =
|code: &str, name: &str, ty| b.s.add_account(code, name, ty, None, false).unwrap();
let equipment = add("1500", "Equipment", ledger_core::AccountType::Asset);
let accum = add(
"1510",
"Accumulated Depreciation",
ledger_core::AccountType::Asset,
);
let depreciation = add("6100", "Depreciation", ledger_core::AccountType::Expense);
let (cash, capital) = (b.cash, b.capital);
b.post(d(2026, 1, 1), cash, capital, 1_000_000);
b.post(d(2026, 1, 1), equipment, cash, 360_000);
let ids = [(120_000, 12), (240_000, 24)]
.into_iter()
.map(|(cost, months)| {
b.s.register_asset(NewAsset {
description: format!("Thing {months}"),
in_service_date: d(2026, 1, 1),
cost: Money::new(cents(cost), Currency::USD),
salvage_value: Money::new(Decimal::ZERO, Currency::USD),
useful_life_months: months,
cost_account: equipment,
accum_depr_account: accum,
expense_account: depreciation,
})
.unwrap()
})
.collect();
let (dir, books) = b.books();
(dir, books, ids)
}
fn register_on(
tag: &str,
as_of: Option<NaiveDate>,
) -> (Vec<Painted>, Color32, Color32, Color32) {
let (_dir, books, _) = two_assets(tag);
let (texts, p) = render(books, Route::Assets, |s| s.assets.as_of = as_of);
(texts, p.warning, p.muted, p.positive)
}
/// The register on its date, reconciled to the books: its cost, and
/// the depreciation the schedules have accrued but the books don't
/// carry: a problem when some is due through a finished month, just
/// accruing when only the month under way has any, agreeing when none.
#[test]
fn the_register_reconciles_to_the_books_on_its_date() {
let (texts, warning, muted, positive) = register_on("register-today", None);
find(&texts, "Fixed assets");
find(&texts, "Reconciliation to the books");
assert_eq!(
beside(&texts, "Cost in the register").text,
fmt::money(cents(360_000))
);
// Due through September: a problem.
assert_eq!(beside(&texts, "Difference (not yet posted)").color, warning);
// January 15th: nothing due through December, half a month accruing.
let (texts, _, muted2, _) = register_on("register-jan", Some(d(2026, 1, 15)));
assert_eq!(muted2, muted);
assert_eq!(beside(&texts, "Difference (not yet posted)").color, muted);
// Before the purchase: nothing to post, the books agree.
let (texts, _, _, _) = register_on("register-before", Some(d(2025, 12, 31)));
let none = beside(&texts, "Difference (not yet posted)");
assert_eq!(none.text, fmt::money(Decimal::ZERO));
assert_eq!(none.color, positive);
}
/// One asset's page shows its own book value today, not another's:
/// what the books carry (cost less posted: nothing is posted, so its
/// cost), with the schedule's figure beside it, never as book value.
#[test]
fn an_assets_page_shows_its_own_book_value() {
for i in 0..2 {
let (_dir, books, ids) = two_assets(&format!("asset-page-{i}"));
let a = books.ledger.get_asset(ids[i]).unwrap().clone();
let scheduled = ledger_core::accumulated_scheduled_through(&a, today()).unwrap();
assert!(scheduled > Decimal::ZERO, "the schedule has run ahead");
let (texts, _) = render(books, Route::Asset(ids[i]), |_| {});
assert_eq!(
beside(&texts, "Book value today").text,
fmt::dollars(a.cost.amount),
"asset {i}"
);
let label = format!("Scheduled, {}", fmt::date_short(today()));
assert_eq!(
beside(&texts, &label).text,
fmt::dollars(scheduled),
"asset {i}"
);
}
}
}