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

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