Fixed assets carry federal tax basis beside book value; tests sweep stale temp dirs
check / check (push) Canceled after 0s

- core: TaxBasisRule (cost IRC 1012, carryover IRC 723, personal-use conversion Treas. Reg. 1.168(i)-4(b)), TaxBasis with the contributor's adjusted basis, Asset::tax_basis and ::built_in_gain (IRC 704(c)); the register gains tax rule, contributor basis, tax basis, built-in gain, total tax basis, missing count (rules.md B-25; sources vendored in docs/spec/sources/irs-partnership-basis.md)
- store: asset.tax_basis_set journaled, replayed, projected (schema v10)
- mcp: books.set_asset_tax_basis; register_asset takes tax_basis_rule and contributor_basis, checked before anything registers (found by the spec-blind suite); list_assets and the register export (json, csv) carry tax basis; instructions say to record it for every asset
- import: the register's XLSX and PDF gain the federal tax basis table with citations
- viewer: tax basis on the Assets page, the asset page (a Tax basis card), the report, the journal, and Needs attention for assets without one
- just: check and the mutation recipes sweep this user's test book dirs older than an hour (killed runs had left 23 GB in RAM-backed /tmp)
- docs: the parties, attachments, TIN vault, completeness engine, and updates designs (docs/parties-and-attachments.md)
- tests: spec-blind tax basis suites in core, store, MCP; register PDF/XLSX and viewer tests

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
This commit is contained in:
swaitsandClaude Opus 5.5 committed 2026-10-15 13:30:09 -06:00
1 parent 49c49aef33
commit 6e6f9d65b2
30 files changed
+3240 -93

No files matched your search

+28 -3
View File
@@ -1,8 +1,8 @@
//! What the books need: drafts left unposted, missing receipts, schedules
//! and recurring entries due, open past periods, uncategorized tax lines,
//! 1099 vendors without a W-9, overdue bills and invoices, money owed to
//! owners, fixed asset depreciation not posted and assets without a tag,
//! and legal rules (like the 1099 threshold) due for an update. Each item says what's wrong, where to look, and the prompt that
//! owners, fixed asset depreciation not posted, assets without a tag or a
//! tax basis, and legal rules (like the 1099 threshold) due for an update. Each item says what's wrong, where to look, and the prompt that
//! asks the agent to fix it (the GUI never changes the books itself).
use crate::books::{Attention, Books, Severity};
@@ -36,7 +36,9 @@ use rust_decimal::Decimal;
/// and not posted (Books::depreciation_due amount > 0: Todo, one
/// item); the asset register and the books' asset cost accounts
/// disagreeing today (unregistered_cost != 0: Problem); an active
/// asset in service with no tag (Todo, one per asset).
/// asset in service with no tag (Todo, one per asset); an active
/// asset in service with no tax basis (Asset::tax_basis is None:
/// Todo, one per asset, its prompt naming books.set_asset_tax_basis).
/// 3. Nothing else: books with none of those yield no items.
/// 4. Every item's prompt starts "In wanderbooks," or names
/// wanderbooks, and its amounts are the books' exact amounts.
@@ -384,6 +386,29 @@ pub fn find(b: &Books) -> Vec<Attention> {
});
}
for a in b.ledger.registered_assets().filter(|a| {
a.tax_basis().is_none()
&& a.status == ledger_core::AssetStatus::Active
&& a.purchase_date <= today
}) {
let name = a.tag.clone().unwrap_or_else(|| format!("asset {}", a.id));
out.push(Attention {
severity: Severity::Todo,
title: format!("No tax basis: {}", b.asset_label(a.id)),
detail: format!(
"Book cost {}, in service {}. The CPA needs its federal tax basis beside its book value.",
fmt::dollars(a.cost.amount),
fmt::date(a.purchase_date)
),
target: Some(Route::Asset(a.id)),
prompt: format!(
"In wanderbooks, record the tax basis of {name} (\"{}\", book cost {}) with books.set_asset_tax_basis. Ask me how the company got it: bought (cost), contributed by a member from business use (carryover, IRC §723: the member's adjusted basis), or converted by a member from personal use (personal-use conversion, Treas. Reg. §1.168(i)-4(b): the member's adjusted basis; the tax basis is the lesser of that and its fair market value). Then show me the asset.",
a.description,
fmt::dollars(a.cost.amount)
),
});
}
use ledger_store::DiskCheck;
let verify_prompt = "In wanderbooks, run books.verify and tell me what it found.".to_string();
match &b.integrity.disk {
+8
View File
@@ -379,6 +379,14 @@ pub fn build(dir: &Path, today: NaiveDate) -> Result<(), StoreError> {
// The label on the laptop, and its serial number.
s.set_asset_tag(asset, "JS-0001")?;
s.set_asset_serial(asset, Some("C02FK3XJQ6L4"))?;
// Bought by the company, so its federal tax basis is its cost.
s.set_asset_tax_basis(
asset,
ledger_core::TaxBasis {
rule: ledger_core::TaxBasisRule::Cost,
contributor_basis: None,
},
)?;
// Prepaid insurance, spread over a year.
let ins_date = day(start + Months::new(2), 15);
+315 -15
View File
@@ -2,7 +2,11 @@
//! 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`); what is posted comes from the journal.
//! `depreciation_schedule`, `Asset::tax_basis`, `Asset::built_in_gain`);
//! what is posted comes from the journal. Book value and federal tax
//! basis sit side by side and are never confused: book cost is what was
//! paid or, for a member's contribution, its fair market value; tax basis
//! follows its rule (docs/spec/rules.md B-25).
use super::page;
use crate::app::Shell;
@@ -14,7 +18,7 @@ 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::{AssetId, AssetRegister, AssetStatus};
use ledger_core::{Asset, AssetId, AssetRegister, AssetRegisterRow, AssetStatus, TaxBasisRule};
use rust_decimal::Decimal;
/// The register's date (None: today), kept across navigation.
@@ -48,6 +52,8 @@ pub struct Notice {
/// 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. missing_tax_basis > 0: a problem, last, from
/// [`missing_tax_notice`].
/// ```
pub fn notices(b: &Books, r: &AssetRegister) -> Vec<Notice> {
let mut out = Vec::new();
@@ -119,9 +125,154 @@ pub fn notices(b: &Books, r: &AssetRegister) -> Vec<Notice> {
),
});
}
if let Some(text) = missing_tax_notice(r.missing_tax_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 converted from personal use.",
fmt::count(missing, "asset", "assets"),
if missing == 1 { "it" } else { "each" }
)
})
}
/// A tax basis rule for a sentence: "cost", "carryover", "personal-use
/// conversion".
pub fn rule_words(r: TaxBasisRule) -> &'static str {
match r {
TaxBasisRule::Cost => "cost",
TaxBasisRule::Carryover => "carryover",
TaxBasisRule::PersonalUseConversion => "personal-use conversion",
}
}
/// What a rule means, in plain words, with the law it comes from.
pub fn rule_explained(r: TaxBasisRule) -> &'static str {
match r {
TaxBasisRule::Cost => {
"Bought by the company: its tax basis is what it paid (IRC §1012(a))."
}
TaxBasisRule::Carryover => {
"Contributed by a member from business use: it keeps the member's adjusted basis — what they paid less any depreciation they took (IRC §723)."
}
TaxBasisRule::PersonalUseConversion => {
"Contributed by a member from personal use: it depreciates from the lesser of its fair market value and the member's adjusted basis (Treas. Reg. §1.168(i)-4(b))."
}
}
}
/// Book value against tax basis, in one sentence, for an asset under
/// `rule` (or none recorded).
pub fn book_vs_tax(rule: Option<TaxBasisRule>) -> &'static str {
match rule {
Some(TaxBasisRule::Cost) => {
"Book cost and tax basis are both what the company paid; tax depreciation starts from the tax basis — the CPA's."
}
_ => {
"Book value is the fair market value at contribution; tax depreciation starts from the tax basis — the CPA's."
}
}
}
/// An asset's federal tax basis as the viewer shows it: every figure is
/// ledger-core's (`Asset::tax_basis`, `Asset::built_in_gain`), never
/// re-derived.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct TaxFacts {
/// The rule, if recorded.
pub rule: Option<TaxBasisRule>,
/// The contributing member's adjusted basis (a contribution only).
pub contributor_basis: Option<Decimal>,
/// The depreciable tax basis, if recorded.
pub tax_basis: Option<Decimal>,
/// Built-in gain (+) or loss (−) at contribution, if any.
pub built_in_gain: Option<Decimal>,
}
impl TaxFacts {
/// An asset's.
pub fn of(a: &Asset) -> Self {
TaxFacts {
rule: a.tax.map(|t| t.rule),
contributor_basis: a.tax.and_then(|t| t.contributor_basis),
tax_basis: a.tax_basis(),
built_in_gain: a.built_in_gain(),
}
}
/// A register row's.
pub fn of_row(x: &AssetRegisterRow) -> Self {
TaxFacts {
rule: x.tax_rule,
contributor_basis: x.contributor_basis,
tax_basis: x.tax_basis,
built_in_gain: x.built_in_gain,
}
}
/// "Carryover · contributor's 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.contributor_basis {
s.push_str(&format!(" · contributor's basis {}", fmt::dollars(b)));
}
s
}
}
}
/// Everything, with the rule's citation, for a tooltip: "Tax basis
/// $900.00: Carryover (IRC §723); contributor's basis $1,000.00;
/// built-in gain $100.00." or "Tax basis not recorded."
pub fn detail(&self) -> String {
let (Some(r), Some(t)) = (self.rule, self.tax_basis) else {
return "Tax basis not recorded.".into();
};
let mut s = format!(
"Tax basis {}: {}",
fmt::dollars(t),
ledger_import::asset_register::rule_cited(r)
);
if let Some(b) = self.contributor_basis {
s.push_str(&format!("; contributor's basis {}", fmt::dollars(b)));
}
if let Some(g) = self.built_in_gain {
let (label, amount) = gain_or_loss(g);
s.push_str(&format!(
"; {} {}",
label.to_lowercase(),
fmt::dollars(amount)
));
}
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 {
@@ -219,13 +370,20 @@ fn row(
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,
cw - 12.0,
room - 12.0,
);
}
x += cw;
@@ -301,22 +459,39 @@ pub fn show_register(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
let as_of = date_controls(ui, b, shell);
ui.add_space(12.0);
let r = b.asset_register(as_of);
super::grid(ui, 3, 200.0, 3, |i, ui| {
let (label, value, how) = match i {
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, together.",
"What the assets still held cost (for a member's contribution, its fair market value then), together.",
None,
),
1 => (
"Accumulated depreciation",
r.total_accumulated,
"Their depreciation by schedule through the date.",
"Their book depreciation by schedule through the date.",
None,
),
_ => (
2 => (
"Book value",
r.total_book_value,
"Cost less accumulated depreciation.",
None,
),
_ => (
"Tax basis",
r.total_tax_basis,
"The federal tax basis of the assets held that have one recorded: what tax depreciation (the CPA's) starts from.",
(r.missing_tax_basis > 0).then(|| {
(
format!(
"{} not recorded",
fmt::count(r.missing_tax_basis, "asset", "assets")
),
p.warning,
)
}),
),
};
widgets::kpi(
@@ -324,7 +499,7 @@ pub fn show_register(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
widgets::Kpi {
label,
value: fmt::dollars(value),
sub: None,
sub,
spark: None,
how,
},
@@ -346,7 +521,7 @@ pub fn show_register(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
return;
}
let amt = widgets::AMOUNT_MIN + 24.0;
let fixed = 110.0 + 104.0 + amt * 3.0 + 190.0;
let fixed = 110.0 + 104.0 + amt * 5.0 + 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);
@@ -360,6 +535,8 @@ pub fn show_register(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
head("Cost", amt, true),
head("Accumulated", amt, true),
head("Book value", amt, true),
head("Tax basis", amt, true),
head("Built-in gain", amt, true),
head("Status", 190.0, false),
],
None,
@@ -377,7 +554,16 @@ pub fn show_register(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
fmt::money(d)
}
};
let sub = x.serial.as_ref().map(|s| format!("Serial {s}"));
let tax = TaxFacts::of_row(x);
let sub = match &x.serial {
Some(s) => format!("Serial {s} · {}", tax.summary()),
None => tax.summary(),
};
let (tax_text, tax_color) = match tax.tax_basis {
_ if disposed => (String::new(), text),
Some(t) => (fmt::money(t), text),
None => ("not recorded".to_string(), p.warning),
};
let resp = row(
ui,
w,
@@ -393,19 +579,27 @@ pub fn show_register(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
(money(x.cost), amt, true, text),
(money(x.accumulated), amt, true, text),
(money(x.book_value), amt, true, text),
(tax_text, amt, true, tax_color),
(
x.built_in_gain.map(money).unwrap_or_default(),
amt,
true,
text,
),
(status_text(x.disposed), 190.0, false, p.muted),
],
sub.as_deref().map(|s| (s, p.faint)),
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)",
fmt::dollars(x.cost)
"Open this asset (it cost {}; it left the books when disposed). {}",
fmt::dollars(x.cost),
tax.detail()
)
} else {
"Open this asset".to_string()
format!("Open this asset. {}", tax.detail())
});
resp.context_menu(|ui| {
if let Some(t) = &x.tag {
@@ -415,6 +609,9 @@ pub fn show_register(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
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.tax_basis {
widgets::copy_item(ui, "Copy tax basis", &t.to_string());
}
});
if resp.clicked() {
go = Some(x.asset_id);
@@ -431,6 +628,8 @@ pub fn show_register(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
(fmt::money(r.total_cost), amt, true, p.text),
(fmt::money(r.total_accumulated), amt, true, p.text),
(fmt::money(r.total_book_value), amt, true, p.text),
(fmt::money(r.total_tax_basis), amt, true, p.text),
(String::new(), amt, true, p.text),
(String::new(), 190.0, false, p.text),
],
None,
@@ -439,6 +638,17 @@ pub fn show_register(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
);
});
});
ui.add_space(4.0);
ui.add(
egui::Label::new(
RichText::new(
"Cost is what the company paid or, for a member's contribution, its fair market value then; the tax basis follows its rule (under each asset) and can differ. Tax depreciation starts from the tax 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 {
@@ -777,6 +987,9 @@ pub fn show_asset(ui: &mut Ui, b: &Books, id: AssetId, shell: &mut Shell<'_>) {
}
});
tax_card(ui, a);
ui.add_space(14.0);
// The schedule.
let lines = schedule(b, id);
let due: Decimal = lines
@@ -1044,6 +1257,93 @@ pub fn show_asset(ui: &mut Ui, b: &Books, id: AssetId, shell: &mut Shell<'_>) {
}
}
/// 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? Book value is the fair market value at contribution; tax depreciation starts from the tax 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(c) = t.contributor_basis {
k(ui, "Contributor's basis");
money(ui, c, false);
ui.end_row();
}
if let Some(tb) = t.tax_basis {
k(ui, "Tax basis");
money(ui, tb, true);
ui.end_row();
}
if let Some(g) = t.built_in_gain {
let (label, amount) = gain_or_loss(g);
k(ui, label);
money(ui, amount, false);
ui.end_row();
}
});
ui.add_space(6.0);
let mut why = rule_explained(rule).to_string();
if t.built_in_gain.is_some_and(|g| g < Decimal::ZERO) {
why.push_str(" A built-in loss is taken into account only for the contributing member (IRC §704(c)(1)(C)).");
} else if t.built_in_gain.is_some_and(|g| g > Decimal::ZERO) {
why.push_str(" IRC §704(c) has the partnership 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.
fn facts(ui: &mut Ui, add: impl FnOnce(&mut Ui, &dyn Fn(&mut Ui, &str))) {
let p = pal(ui.ctx());
+44
View File
@@ -53,6 +53,45 @@ fn attachment_name(b: &Books, id: u64) -> String {
.unwrap_or_else(|| format!("attachment {id}"))
}
/// "Tax basis of WL-0001: personal-use conversion, contributor's basis
/// $1,917.21 → tax basis $1,650.00". The tax basis is ledger-core's
/// (`Asset::tax_basis`) for the event's rule and basis on the asset's
/// book cost, which never changes once registered.
fn tax_basis_text(b: &Books, asset_id: Option<u64>, p: &Value) -> String {
let asset = asset_id.and_then(|id| b.ledger.get_asset(id));
let name = match (asset, asset_id) {
(Some(a), _) => a.tag.clone().unwrap_or_else(|| a.description.clone()),
(None, Some(id)) => format!("asset {id}"),
(None, None) => "an asset".to_string(),
};
let rule = ledger_core::TaxBasisRule::parse(s(p, "rule"));
let contributor: Option<rust_decimal::Decimal> = p
.get("contributor_basis")
.and_then(|v| v.as_str())
.and_then(|v| v.parse().ok());
let Some(rule) = rule else {
return format!("Tax basis of {name} recorded");
};
let tax = asset.and_then(|a| {
let mut a = a.clone();
a.tax = Some(ledger_core::TaxBasis {
rule,
contributor_basis: contributor,
});
a.tax_basis()
});
let mut text = format!("Tax basis of {name}: {}", super::assets::rule_words(rule));
if let Some(c) = contributor {
text.push_str(&format!(", contributor's basis {}", fmt::dollars(c)));
}
match (tax, contributor.is_some()) {
(Some(t), true) => text.push_str(&format!(" → tax basis {}", fmt::dollars(t))),
(Some(t), false) => text.push_str(&format!(", tax basis {}", fmt::dollars(t))),
(None, _) => {}
}
text
}
/// A sentence for a meaningful event; `None` for the intermediate steps of
/// building an entry (created, line added), which the posted event covers.
pub fn describe(b: &Books, line: &JournalLine) -> Option<Event> {
@@ -267,6 +306,11 @@ pub fn describe(b: &Books, line: &JournalLine) -> Option<Event> {
};
ev(icon::BARCODE, text, asset_id.map(Route::Asset))
}
"asset.tax_basis_set" => ev(
icon::SCALES,
tax_basis_text(b, asset_id, p),
asset_id.map(Route::Asset),
),
"asset.described" => ev(
icon::PENCIL_SIMPLE,
format!(
+105 -2
View File
@@ -945,14 +945,22 @@ fn aging(rows: Vec<ledger_core::AgingRow>, as_of: NaiveDate) -> Table {
/// The fixed asset register on `as_of`: ledger-core's
/// `fixed_asset_register`, laid out, with its reconciliation to the
/// general ledger and what that says (the Assets page's notices).
/// general ledger and what that says (the Assets page's notices). Each
/// asset's federal tax basis sits beside its book figures: contributor's
/// basis, tax basis, and built-in gain (loss), the register row's own,
/// with its rule (and citation) in the detail; the tax basis total is the
/// register's.
fn asset_register(b: &Books, as_of: NaiveDate) -> Table {
let r = b.asset_register(as_of);
let mut t = Table::new(vec![
"Cost".into(),
"Accumulated".into(),
"Book value".into(),
"Contributor's basis".into(),
"Tax basis".into(),
"Built-in gain (loss)".into(),
]);
let none3 = || vec![Cell::none(), Cell::none(), Cell::none()];
if r.rows.is_empty() {
t.note(format!(
"No fixed asset was in service by {}.",
@@ -975,6 +983,18 @@ fn asset_register(b: &Books, as_of: NaiveDate) -> Table {
if !x.salvage.is_zero() {
detail.push(format!("salvage {}", fmt::dollars(x.salvage)));
}
let tax = super::assets::TaxFacts::of_row(x);
detail.push(match tax.rule {
Some(rule) => format!(
"tax basis: {}",
ledger_import::asset_register::rule_cited(rule)
),
None => "tax basis not recorded".into(),
});
let opt = |v: Option<Decimal>| Cell {
value: v,
drill: None,
};
let cells = match x.disposed {
Some(_) => {
detail.push(format!(
@@ -982,12 +1002,15 @@ fn asset_register(b: &Books, as_of: NaiveDate) -> Table {
super::assets::status_text(x.disposed),
fmt::dollars(x.cost)
));
vec![Cell::none(), Cell::none(), Cell::none()]
[none3(), none3()].concat()
}
None => vec![
Cell::of(x.cost),
Cell::of(x.accumulated),
Cell::of(x.book_value),
opt(tax.contributor_basis),
opt(tax.tax_basis),
opt(tax.built_in_gain),
],
};
t.rows.push(Row {
@@ -1004,6 +1027,9 @@ fn asset_register(b: &Books, as_of: NaiveDate) -> Table {
Cell::of(r.total_cost),
Cell::of(r.total_accumulated),
Cell::of(r.total_book_value),
Cell::none(),
Cell::of(r.total_tax_basis),
Cell::none(),
],
);
t.section("Reconciliation to the books");
@@ -1039,6 +1065,9 @@ fn asset_register(b: &Books, as_of: NaiveDate) -> Table {
drill: accum_drill,
},
Cell::of(r.ledger_cost - r.ledger_accumulated),
Cell::none(),
Cell::none(),
Cell::none(),
],
);
t.row(
@@ -1048,11 +1077,17 @@ fn asset_register(b: &Books, as_of: NaiveDate) -> Table {
Cell::of(-r.unregistered_cost()),
Cell::of(r.unposted_depreciation()),
Cell::of(-r.unregistered_cost() - r.unposted_depreciation()),
Cell::none(),
Cell::none(),
Cell::none(),
],
);
for n in super::assets::notices(b, &r) {
t.note(n.text);
}
t.note(
"Cost is what the company paid or, for a member's contribution, its fair market value then; the tax basis follows its rule and can differ. Built-in gain (loss) is that value less the contributor's basis (IRC §704(c)). Tax depreciation starts from the tax basis — the CPA's.",
);
t
}
@@ -1998,6 +2033,74 @@ fn vendor_1099(ui: &mut Ui, b: &Books, year: i32, shell: &mut Shell<'_>) {
mod tests {
use super::*;
/// The fixed asset register report's tax columns are the register's:
/// each held asset's contributor's basis, tax basis, and built-in gain
/// (ledger-core's Asset::tax_basis and built_in_gain), the total its
/// total_tax_basis, and its rule (cited) in the detail.
#[test]
fn asset_register_report_carries_tax_basis() {
let today = NaiveDate::from_ymd_opt(2026, 10, 12).unwrap();
let dir = std::env::temp_dir().join(format!("ledger-gui-tax-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
crate::demo::build(&dir, today).unwrap();
let b = crate::data::load_once(&crate::data::Source {
data_dir: dir.join("books-data"),
books_toml: Some(dir.join("wanderbooks.toml")),
today: Some(today),
})
.unwrap();
let r = ledger_core::fixed_asset_register(&b.ledger, today);
assert!(!r.rows.is_empty(), "the demo has an asset");
assert_eq!(r.missing_tax_basis, 0, "the demo's asset has a tax basis");
let t = asset_register(&b, today);
assert_eq!(
t.columns,
[
"Cost",
"Accumulated",
"Book value",
"Contributor's basis",
"Tax basis",
"Built-in gain (loss)"
]
);
for x in &r.rows {
let a = b.ledger.get_asset(x.asset_id).unwrap();
let row = t
.rows
.iter()
.find(|row| row.kind == Kind::Line && row.label.contains(&x.description))
.expect("a line per asset");
let v: Vec<Option<Decimal>> = row.cells.iter().map(|c| c.value).collect();
assert_eq!(
v,
[
Some(x.cost),
Some(x.accumulated),
Some(x.book_value),
a.tax.and_then(|t| t.contributor_basis),
a.tax_basis(),
a.built_in_gain(),
]
);
assert!(row.detail.contains("tax basis: Cost"), "{}", row.detail);
}
let total = t.rows.iter().find(|row| row.label == "Total held").unwrap();
assert_eq!(total.cells[4].value, Some(r.total_tax_basis));
assert_eq!(total.cells[4].value, r.rows[0].tax_basis);
assert!(
t.rows
.iter()
.all(|row| row.cells.is_empty() || row.cells.len() == 6)
);
assert!(
t.rows
.iter()
.any(|row| row.kind == Kind::Note && row.label.contains("fair market value"))
);
let _ = std::fs::remove_dir_all(&dir);
}
/// Every table-backed report typesets as a PDF, in its plain, by-month,
/// and compared layouts. Set `PDF_DIR` to keep the files for eyes.
#[test]