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

+242 -13
View File
@@ -28,11 +28,28 @@
//! 5. Header and footer as on every statement: company, "Fixed Asset
//! Register", "As of DATE"; basis, prepared on, journal seq.
//! 6. PDF: deterministic bytes; every text is data, never markup.
//! 7. Tax basis beside book value: every line carries its tax basis
//! rule (in words: [`rule_label`], [`rule_cited`]), the contributor's
//! basis, the tax basis, and the built-in gain (loss), each the
//! register row's own (ledger_core::Asset::tax_basis,
//! Asset::built_in_gain), never re-derived here; a line with none
//! recorded says "Not recorded" and leaves the amounts blank.
//! 8. The tax basis total is the register's total_tax_basis; when
//! missing_tax_basis > 0 a line says "Tax basis not recorded for N
//! asset(s)" (see [`missing_line`]).
//! 9. The notes ([`notes`]) say that book value is fair market value at
//! contribution and tax basis follows its rule, and cite each rule
//! (IRC §1012(a), §723, Treas. Reg. §1.168(i)-4(b), IRC §704(c)).
//! The PDF marks each rule in the tax table with its note's number.
//! ```
//!
//! SOURCE (7-9): docs/spec/rules.md B-25;
//! docs/spec/sources/irs-partnership-basis.md.
use crate::pdf::{INTER, PdfError};
use crate::report::{ReportMeta, amount_parts, format_amount, long_date};
use crate::xlsx::{Formats, XlsxError, footer, header, number};
use ledger_core::TaxBasisRule;
use ledger_core::reports::{AssetRegister, AssetRegisterRow};
use rust_decimal::Decimal;
use rust_xlsxwriter::{Format, FormatAlign, Workbook};
@@ -58,9 +75,135 @@ pub const COLUMNS: [&str; 10] = [
"Status",
];
/// The PDF's tax basis table's headings, left to right.
pub const TAX_COLUMNS: [&str; 7] = [
"Tag",
"Description",
"Tax basis rule",
"Book cost",
"Contributor's basis",
"Tax basis",
"Built-in gain (loss)",
];
/// The XLSX headings, left to right: the book columns, the tax columns,
/// then the status.
pub const XLSX_COLUMNS: [&str; 14] = [
"Tag",
"Description",
"Serial",
"In service",
"Cost",
"Salvage",
"Life (months)",
"Accumulated depreciation",
"Book value",
"Tax basis rule",
"Contributor's basis",
"Tax basis",
"Built-in gain (loss)",
"Status",
];
/// The line shown when there are no assets.
pub const EMPTY: &str = "No fixed assets";
/// The tax table's title.
pub const TAX_TITLE: &str = "Federal tax basis";
/// The rule cell of an asset with no tax basis recorded.
pub const NOT_RECORDED: &str = "Not recorded";
/// A rule in short words, for the table: "Cost", "Carryover",
/// "Personal-use conversion".
pub fn rule_label(r: TaxBasisRule) -> &'static str {
match r {
TaxBasisRule::Cost => "Cost",
TaxBasisRule::Carryover => "Carryover",
TaxBasisRule::PersonalUseConversion => "Personal-use conversion",
}
}
/// A rule in words with its citation: "Cost", "Carryover (IRC §723)",
/// "Personal-use conversion (Treas. Reg. §1.168(i)-4(b))".
pub fn rule_cited(r: TaxBasisRule) -> &'static str {
match r {
TaxBasisRule::Cost => "Cost",
TaxBasisRule::Carryover => "Carryover (IRC §723)",
TaxBasisRule::PersonalUseConversion => {
"Personal-use conversion (Treas. Reg. §1.168(i)-4(b))"
}
}
}
/// The number of a rule's note in [`notes`].
pub fn rule_mark(r: TaxBasisRule) -> &'static str {
match r {
TaxBasisRule::Cost => "1",
TaxBasisRule::Carryover => "2",
TaxBasisRule::PersonalUseConversion => "3",
}
}
/// One note under the register.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Note {
/// Its number, as the tax table marks it, or empty for a general note.
pub mark: String,
/// What it says.
pub text: String,
}
/// The notes (INVARIANT 9): book value against tax basis, then each
/// rule's citation and the built-in gain (loss).
pub fn notes() -> Vec<Note> {
let n = |mark: &str, text: &str| Note {
mark: mark.to_string(),
text: text.to_string(),
};
vec![
n(
"",
"Book value is the fair market value at contribution for property a member contributed, \
and the price paid for property the company bought; tax basis follows its rule and can \
differ. Depreciation here is book depreciation; tax depreciation starts from the tax \
basis and is the CPA's.",
),
n(
rule_mark(TaxBasisRule::Cost),
"Cost: property the company bought has a basis of its cost (IRC §1012(a)).",
),
n(
rule_mark(TaxBasisRule::Carryover),
"Carryover (IRC §723): property a member contributes keeps the member's adjusted \
basis at contribution (their cost less any depreciation they took).",
),
n(
rule_mark(TaxBasisRule::PersonalUseConversion),
"Personal-use conversion (Treas. Reg. §1.168(i)-4(b)): property converted from \
personal use depreciates from the lesser of its fair market value and the member's \
adjusted basis at conversion.",
),
n(
"4",
"Built-in gain (loss): fair market value at contribution less the contributor's basis \
(IRC §704(c)(1)(A)); a built-in loss is taken into account only for the contributing \
member (IRC §704(c)(1)(C)).",
),
]
}
/// "Tax basis not recorded for 1 asset" / "... for 2 assets", or `None`
/// when every asset held has one.
pub fn missing_line(missing: usize) -> Option<String> {
(missing > 0).then(|| {
format!(
"Tax basis not recorded for {missing} {}",
if missing == 1 { "asset" } else { "assets" }
)
})
}
/// One asset's line.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct AssetLine {
@@ -86,6 +229,14 @@ pub struct AssetLine {
pub status: String,
/// Disposed by the date (shown, not in the totals).
pub disposed: bool,
/// The tax basis rule, if recorded.
pub tax_rule: Option<TaxBasisRule>,
/// The contributing member's adjusted basis (a contribution only).
pub contributor_basis: Option<Decimal>,
/// The depreciable federal tax basis, if recorded.
pub tax_basis: Option<Decimal>,
/// Built-in gain (+) or loss (−) at contribution, if any.
pub built_in_gain: Option<Decimal>,
}
/// Which register column a reconciliation amount sits under.
@@ -122,6 +273,12 @@ pub struct RegisterLayout {
/// Total cost, accumulated depreciation, and book value of the assets
/// held.
pub totals: [Decimal; 3],
/// Total tax basis of the assets held that have one.
pub tax_total: Decimal,
/// [`missing_line`] of the register's missing_tax_basis.
pub missing: Option<String>,
/// [`notes`].
pub notes: Vec<Note>,
/// Cost then accumulated depreciation: per register, per books,
/// difference.
pub reconciliation: Vec<ReconLine>,
@@ -162,9 +319,16 @@ pub fn layout(r: &AssetRegister) -> RegisterLayout {
book_value: a.book_value,
status: status(a),
disposed: a.disposed.is_some(),
tax_rule: a.tax_rule,
contributor_basis: a.contributor_basis,
tax_basis: a.tax_basis,
built_in_gain: a.built_in_gain,
})
.collect(),
totals: [r.total_cost, r.total_accumulated, r.total_book_value],
tax_total: r.total_tax_basis,
missing: missing_line(r.missing_tax_basis),
notes: notes(),
reconciliation: vec![
recon("Cost per register", r.total_cost, ReconColumn::Cost, false),
recon("Cost per books", r.ledger_cost, ReconColumn::Cost, false),
@@ -199,20 +363,25 @@ pub fn layout(r: &AssetRegister) -> RegisterLayout {
/// The fixed asset register as XLSX bytes.
///
/// Layout as every statement (crate::xlsx INVARIANT 4): company, title,
/// and date on rows 1-3, headings on row 5, one row per asset from row 6,
/// amounts as numbers in the accounting format; then the totals row
/// (double rule), an empty row, "Reconciliation to the books", and its
/// lines with amounts under Cost and Accumulated depreciation.
/// and date on rows 1-3, headings ([`XLSX_COLUMNS`]) on row 5, one row
/// per asset from row 6, amounts as numbers in the accounting format, the
/// tax rule in words with its citation ([`rule_cited`]); then the totals
/// row (double rule) with the tax basis total under Tax basis, the
/// missing tax basis line when there is one, an empty row, "Reconciliation
/// to the books" and its lines with amounts under Cost and Accumulated
/// depreciation, an empty row, and "Notes" with [`notes`].
pub fn asset_register_xlsx(r: &AssetRegister, meta: &ReportMeta) -> Result<Vec<u8>, XlsxError> {
const AMOUNTS: [u16; 7] = [4, 5, 7, 8, 10, 11, 12];
const TOTALS: [u16; 4] = [4, 7, 8, 11];
let l = layout(r);
let mut workbook = Workbook::new();
let sheet = workbook.add_worksheet();
let f = Formats::new();
sheet.set_name(TITLE)?;
let width = COLUMNS.len() as u16;
let width = XLSX_COLUMNS.len() as u16;
let mut row = header(sheet, &f, meta, &l.title, &l.period, width)?;
for (i, h) in COLUMNS.iter().enumerate() {
let fmt = if (4..=8).contains(&i) {
for (i, h) in XLSX_COLUMNS.iter().enumerate() {
let fmt = if AMOUNTS.contains(&(i as u16)) || i == 6 {
&f.header_right
} else {
&f.header
@@ -226,6 +395,7 @@ pub fn asset_register_xlsx(r: &AssetRegister, meta: &ReportMeta) -> Result<Vec<u
}
let quiet = Format::new().set_italic().set_font_color("#666666");
let quiet_plain = f.plain.clone().set_italic().set_font_color("#666666");
let missing = Format::new().set_italic().set_font_color("#9A5B00");
for a in &l.lines {
let (text, amount) = if a.disposed {
(&quiet, &quiet_plain)
@@ -246,17 +416,43 @@ pub fn asset_register_xlsx(r: &AssetRegister, meta: &ReportMeta) -> Result<Vec<u
)?;
number(sheet, row, 7, a.accumulated, amount)?;
number(sheet, row, 8, a.book_value, amount)?;
sheet.write_string_with_format(row, 9, &a.status, text)?;
match a.tax_rule {
Some(rule) => sheet.write_string_with_format(row, 9, rule_cited(rule), text)?,
None => sheet.write_string_with_format(
row,
9,
NOT_RECORDED,
if a.disposed { text } else { &missing },
)?,
};
for (c, v) in [
(10, a.contributor_basis),
(11, a.tax_basis),
(12, a.built_in_gain),
] {
if let Some(v) = v {
number(sheet, row, c, v, amount)?;
}
}
sheet.write_string_with_format(row, 13, &a.status, text)?;
row += 1;
}
sheet.write_string_with_format(row, 0, "Total, assets held", &f.bold)?;
for c in (1..width).filter(|c| ![4, 7, 8].contains(c)) {
for c in (1..width).filter(|c| !TOTALS.contains(c)) {
sheet.write_blank(row, c, &f.dollars_total)?;
}
for (c, d) in [4u16, 7, 8].into_iter().zip(l.totals) {
for (c, d) in TOTALS
.into_iter()
.zip(l.totals.into_iter().chain([l.tax_total]))
{
number(sheet, row, c, d, &f.dollars_total)?;
}
row += 2;
row += 1;
if let Some(m) = &l.missing {
sheet.write_string_with_format(row, 0, m, &f.sub_label)?;
row += 1;
}
row += 1;
sheet.write_string_with_format(row, 0, "Reconciliation to the books", &f.bold)?;
row += 1;
for line in &l.reconciliation {
@@ -276,6 +472,17 @@ pub fn asset_register_xlsx(r: &AssetRegister, meta: &ReportMeta) -> Result<Vec<u
row += 1;
}
}
sheet.write_string_with_format(row, 0, "Notes", &f.bold)?;
row += 1;
for n in &l.notes {
let text = if n.mark.is_empty() {
n.text.clone()
} else {
format!("{}. {}", n.mark, n.text)
};
sheet.write_string_with_format(row, 0, text, &f.line_label)?;
row += 1;
}
for (c, w) in [
(0, 12.0),
(1, 40.0),
@@ -286,11 +493,15 @@ pub fn asset_register_xlsx(r: &AssetRegister, meta: &ReportMeta) -> Result<Vec<u
(6, 9.0),
(7, 16.0),
(8, 14.0),
(9, 34.0),
(9, 30.0),
(10, 14.0),
(11, 14.0),
(12, 14.0),
(13, 34.0),
] {
sheet.set_column_width(c, w)?;
}
footer(sheet, &f, meta, row.saturating_sub(1), width)?;
footer(sheet, &f, meta, row, width)?;
Ok(workbook.save_to_buffer()?)
}
@@ -311,6 +522,10 @@ fn amount(d: Decimal) -> serde_json::Value {
serde_json::json!({ "digits": p.digits, "negative": p.negative })
}
fn maybe(d: Option<Decimal>) -> serde_json::Value {
d.map_or(serde_json::Value::Null, amount)
}
/// The PDF template's input: the layout, every amount split for
/// alignment.
pub fn template_data(r: &AssetRegister, meta: &ReportMeta) -> serde_json::Value {
@@ -321,6 +536,8 @@ pub fn template_data(r: &AssetRegister, meta: &ReportMeta) -> serde_json::Value
"period": l.period,
"footer": meta.footer(),
"columns": COLUMNS,
"tax_title": TAX_TITLE,
"tax_columns": TAX_COLUMNS,
"empty": EMPTY,
"lines": l.lines.iter().map(|a| serde_json::json!({
"tag": a.tag,
@@ -334,8 +551,20 @@ pub fn template_data(r: &AssetRegister, meta: &ReportMeta) -> serde_json::Value
"book_value": amount(a.book_value),
"status": a.status,
"disposed": a.disposed,
"rule": a.tax_rule.map_or(NOT_RECORDED, rule_label),
"rule_mark": a.tax_rule.map_or("", rule_mark),
"recorded": a.tax_rule.is_some(),
"contributor_basis": maybe(a.contributor_basis),
"tax_basis": maybe(a.tax_basis),
"built_in_gain": maybe(a.built_in_gain),
})).collect::<Vec<_>>(),
"totals": l.totals.iter().map(|d| amount(*d)).collect::<Vec<_>>(),
"tax_total": amount(l.tax_total),
"missing": l.missing,
"notes": l.notes.iter().map(|n| serde_json::json!({
"mark": n.mark,
"text": n.text,
})).collect::<Vec<_>>(),
"reconciliation": l.reconciliation.iter().map(|x| serde_json::json!({
"label": x.label,
"amount": amount(x.amount),
@@ -1,15 +1,21 @@
// The fixed asset register: every asset, its cost, depreciation, and book
// value, then the totals and the reconciliation to the general ledger.
// value, then the totals and the reconciliation to the general ledger,
// then each asset's federal tax basis beside its book cost, and the notes
// that cite the tax basis rules.
//
// Everything arrives as data in sys.inputs.data (JSON), laid out by
// ledger-import::asset_register, with amounts already split into exact
// parts. The template only typesets: no arithmetic, and no text is ever
// parsed as markup.
//
// Shape: { company, title, period, footer, columns: [10 headings], empty,
// Shape: { company, title, period, footer, columns: [10 headings],
// tax_title, tax_columns: [7 headings], empty,
// lines: [{ tag, description, serial, in_service, cost, salvage,
// life, accumulated, book_value, status, disposed }],
// totals: [cost, accumulated, book_value],
// life, accumulated, book_value, status, disposed,
// rule, rule_mark, recorded, contributor_basis|null,
// tax_basis|null, built_in_gain|null }],
// totals: [cost, accumulated, book_value], tax_total,
// missing: text|null, notes: [{ mark, text }],
// reconciliation: [{ label, amount, difference }] }
// where every amount is { digits: "1,234.56", negative }.
@@ -121,8 +127,8 @@
),
)
#v(14pt)
#block(breakable: false, {
// The reconciliation to the books, drawn beside the notes at the end.
#let reconciliation = block(breakable: false, {
text(weight: "semibold", "Reconciliation to the books")
v(2pt)
table(
@@ -147,3 +153,86 @@
.flatten(),
)
})
// Federal tax basis, asset by asset, beside the book cost it may differ
// from; each rule marked with the number of the note that cites it.
#let tax_numeric = (3, 4, 5, 6)
#let tcols = d.tax_columns.len()
#let maybe(x) = if x == none { [] } else { amount(x) }
#v(12pt)
#text(weight: "semibold", d.tax_title)
#v(2pt)
#table(
columns: (auto, 1fr, auto, 7.4em, 7.4em, 7.4em, 7.4em),
stroke: none,
inset: (x: 3.5pt, y: 3.4pt),
align: (col, row) => (if col in tax_numeric { right } else { left }) + (if row == 0 { bottom } else { horizon }),
table.header(
..d.tax_columns.enumerate().map(((i, c)) => table.cell(
stroke: (top: firm, bottom: hair),
text(6.5pt, weight: "bold", tracking: 0.04em, {
upper(c)
if i == tcols - 1 { super("4") }
}),
)),
),
..if d.lines.len() == 0 {
(table.cell(colspan: tcols, emph(d.empty)),)
} else {
d
.lines
.map(l => {
let t(s) = if l.disposed { text(fill: quiet, s) } else { s }
let rule = if l.recorded {
[#l.rule#super(l.rule_mark)]
} else if l.disposed {
emph(l.rule)
} else {
text(fill: rgb("#9a5b00"), emph(l.rule))
}
(
t(l.tag),
t(l.description),
t(rule),
t(amount(l.cost)),
t(maybe(l.contributor_basis)),
t(maybe(l.tax_basis)),
t(maybe(l.built_in_gain)),
)
})
.flatten()
},
..{
let s = (top: firm)
let blank = table.cell(stroke: s, [])
(
table.cell(stroke: s, colspan: 5, text(weight: "bold", "Total tax basis, assets held")),
table.cell(stroke: s, text(weight: "bold", amount(d.tax_total, currency: true))),
blank,
)
},
table.cell(
colspan: tcols,
inset: (x: 0pt, y: 0pt),
stack(spacing: 1.6pt, line(length: 100%, stroke: firm), line(length: 100%, stroke: firm)),
),
)
#if d.missing != none {
v(4pt)
text(fill: rgb("#9a5b00"), emph(d.missing))
}
// Notes: book value against tax basis, and each rule's citation.
#let notes = {
set text(7.5pt, fill: luma(70))
set par(leading: 0.5em)
text(weight: "semibold", fill: ink, "Notes")
block(above: 6pt, d.notes.at(0).text)
for n in d.notes.slice(1) {
block(above: 4pt, grid(columns: (1.2em, 1fr), super(n.mark), n.text))
}
}
// The reconciliation and the notes, side by side.
#v(12pt)
#grid(columns: (auto, 1fr), column-gutter: 32pt, reconciliation, pad(top: 1pt, notes))
+139 -8
View File
@@ -4,8 +4,10 @@
//! INVARIANT it checks.
use chrono::NaiveDate;
use ledger_core::{AssetRegister, AssetRegisterRow};
use ledger_import::asset_register::{layout, typeset_asset_register};
use ledger_core::{AssetRegister, AssetRegisterRow, TaxBasisRule};
use ledger_import::asset_register::{
layout, missing_line, notes, rule_cited, rule_label, typeset_asset_register,
};
use ledger_import::report::ReportMeta;
use ledger_import::{PdfError, asset_register_pdf};
use rust_decimal::Decimal;
@@ -41,14 +43,43 @@ fn row(id: u64, tag: &str, desc: &str, cost: i64, acc: i64) -> AssetRegisterRow
accumulated: cents(acc),
book_value: cents(cost - acc),
disposed: None,
tax_rule: None,
contributor_basis: None,
tax_basis: None,
built_in_gain: None,
}
}
/// Bought: tax basis is cost.
fn bought(mut r: AssetRegisterRow) -> AssetRegisterRow {
r.tax_rule = Some(TaxBasisRule::Cost);
r.tax_basis = Some(r.cost);
r
}
/// Contributed under `rule` with the member's basis `basis` (cents); tax
/// basis and built-in gain as ledger_core::Asset computes them.
fn contributed(mut r: AssetRegisterRow, rule: TaxBasisRule, basis: i64) -> AssetRegisterRow {
let b = cents(basis);
r.tax_rule = Some(rule);
r.contributor_basis = Some(b);
r.tax_basis = Some(match rule {
TaxBasisRule::PersonalUseConversion => b.min(r.cost),
_ => b,
});
r.built_in_gain = Some(r.cost - b);
r
}
fn register(rows: Vec<AssetRegisterRow>) -> AssetRegister {
let held = || rows.iter().filter(|r| r.disposed.is_none());
let total_cost = held().map(|r| r.cost).sum();
let total_accumulated = held().map(|r| r.accumulated).sum();
let total_tax_basis = held().filter_map(|r| r.tax_basis).sum();
let missing_tax_basis = held().filter(|r| r.tax_basis.is_none()).count();
AssetRegister {
total_tax_basis,
missing_tax_basis,
as_of: d(2026, 10, 15),
total_book_value: total_cost - total_accumulated,
ledger_cost: total_cost,
@@ -73,9 +104,23 @@ fn sample() -> AssetRegister {
laptop.serial = Some("FRANBMCP0A1234".into());
laptop.salvage = cents(10000);
register(vec![
laptop,
row(2, "WL-0002", "MacBook Air", 129900, 32475),
disposed,
bought(laptop),
// A member's business laptop: carryover basis, built-in gain.
contributed(
row(2, "WL-0002", "MacBook Air", 129900, 32475),
TaxBasisRule::Carryover,
90000,
),
bought(disposed),
// A member's personal camera: worth less than its basis, so tax
// basis is the fair market value and there's a built-in loss.
contributed(
row(4, "WL-0004", "Sony A7 IV camera", 165000, 0),
TaxBasisRule::PersonalUseConversion,
191721,
),
// No tax basis recorded yet.
row(5, "WL-0005", "Standing desk", 80000, 0),
])
}
@@ -126,9 +171,9 @@ fn pdf_shows_assets_totals_and_reconciliation() {
"Active",
"Disposed 2026-09-30, proceeds 45.00",
"Total, assets held",
"3,799.00", // cost held: 2,500.00 + 1,299.00
"6,249.00", // cost held: 2,500.00 + 1,299.00 + 1,650.00 + 800.00
"852.53", // accumulated held
"2,946.47", // book value held
"5,396.47", // book value held
"Cost per register",
"Cost per books",
"Difference (unregistered cost)",
@@ -143,7 +188,93 @@ fn pdf_shows_assets_totals_and_reconciliation() {
// The disposed asset's cost (400.00) is shown but in no total.
assert!(t.contains("400.00"));
let l = layout(&sample());
assert_eq!(l.totals, [cents(379900), cents(85253), cents(294647)]);
assert_eq!(l.totals, [cents(624900), cents(85253), cents(539647)]);
}
/// INVARIANTS 7-9: the tax table shows each rule in short words, the
/// contributor's basis, the tax basis, the built-in gain and loss, the
/// register's tax basis total, the missing line, and the notes with
/// their citations.
#[test]
fn pdf_shows_tax_basis() {
let r = sample();
let t = all_text(&r, &meta());
for s in [
"Federal tax basis",
"TAX BASIS RULE",
"CONTRIBUTOR'S BASIS",
"BUILT-IN GAIN (LOSS)",
"Carryover",
"Personal-use conversion",
"900.00", // MacBook: contributor's basis = tax basis
"399.00", // its built-in gain
"1,917.21", // camera: contributor's basis
"1,650.00", // its tax basis: the fair market value
"(267.21)", // its built-in loss
"Not recorded",
"Total tax basis, assets held",
"5,050.00", // 2,500.00 + 900.00 + 1,650.00; the desk has none
"Tax basis not recorded for 1 asset",
"Notes",
"Book value is the fair market value at contribution",
"IRC §1012(a)",
"IRC §723",
"Treas. Reg. §1.168(i)-4(b)",
"IRC §704(c)(1)(C)",
] {
assert!(t.contains(s), "{s:?} missing from {t:?}");
}
let l = layout(&r);
assert_eq!(l.tax_total, r.total_tax_basis);
assert_eq!(l.tax_total, cents(505000));
assert_eq!(
l.missing.as_deref(),
Some("Tax basis not recorded for 1 asset")
);
assert_eq!(l.notes, notes());
// Each line carries its row's tax fields unchanged.
for (line, row) in l.lines.iter().zip(&r.rows) {
assert_eq!(line.tax_rule, row.tax_rule);
assert_eq!(line.contributor_basis, row.contributor_basis);
assert_eq!(line.tax_basis, row.tax_basis);
assert_eq!(line.built_in_gain, row.built_in_gain);
}
}
/// INVARIANT 8: no missing line when every asset held has a tax basis
/// (a disposed one without doesn't count), and the plural reads right.
#[test]
fn missing_line_only_when_missing() {
let mut gone = row(1, "WL-0001", "Old monitor", 40000, 0);
gone.book_value = Decimal::ZERO;
gone.disposed = Some((d(2026, 9, 30), cents(4500)));
let r = register(vec![bought(row(2, "WL-0002", "Desk", 50000, 0)), gone]);
assert_eq!(r.missing_tax_basis, 0);
assert_eq!(layout(&r).missing, None);
assert!(!all_text(&r, &meta()).contains("Tax basis not recorded"));
assert_eq!(missing_line(0), None);
assert_eq!(
missing_line(2).as_deref(),
Some("Tax basis not recorded for 2 assets")
);
}
/// INVARIANT 7: the rules in words, short and with their citations.
#[test]
fn rule_words() {
use TaxBasisRule::*;
assert_eq!(
[Cost, Carryover, PersonalUseConversion].map(rule_label),
["Cost", "Carryover", "Personal-use conversion"]
);
assert_eq!(
[Cost, Carryover, PersonalUseConversion].map(rule_cited),
[
"Cost",
"Carryover (IRC §723)",
"Personal-use conversion (Treas. Reg. §1.168(i)-4(b))"
]
);
}
/// INVARIANT 4: an empty register renders a "No fixed assets" line, its
+106 -21
View File
@@ -3011,7 +3011,10 @@ fn tax_summary_optional_sections() {
/// A register as ledger_core::fixed_asset_register would return it: two
/// assets held, one disposed (shown, in no total), and books that differ
/// from the register on both counts.
/// from the register on both counts. Tax basis: the laptop a member
/// converted from personal use (worth less than their basis: tax basis is
/// its value, a built-in loss), the desk with none recorded, the monitor
/// a carryover with a built-in gain (disposed, in no total).
fn asset_register_fixture() -> ledger_core::AssetRegister {
use ledger_core::AssetRegisterRow;
let row = |id, tag: Option<&str>, desc: &str, serial: Option<&str>, cost, acc, disposed| {
@@ -3027,19 +3030,35 @@ fn asset_register_fixture() -> ledger_core::AssetRegister {
accumulated: cents(acc),
book_value: cents(cost - acc),
disposed,
tax_rule: None,
contributor_basis: None,
tax_basis: None,
built_in_gain: None,
}
};
let taxed = |mut r: AssetRegisterRow, rule, basis: Decimal, tax: Decimal| {
r.tax_rule = Some(rule);
r.contributor_basis = Some(basis);
r.tax_basis = Some(tax);
r.built_in_gain = Some(r.cost - basis);
r
};
ledger_core::AssetRegister {
as_of: d(2026, 10, 15),
rows: vec![
row(
1,
Some("WL-0001"),
"Framework Laptop 13",
Some("FRAN123"),
250000,
52778,
None,
taxed(
row(
1,
Some("WL-0001"),
"Framework Laptop 13",
Some("FRAN123"),
250000,
52778,
None,
),
ledger_core::TaxBasisRule::PersonalUseConversion,
cents(280000),
cents(250000),
),
row(2, None, "Desk", None, 120000, 25000, None),
{
@@ -3053,12 +3072,19 @@ fn asset_register_fixture() -> ledger_core::AssetRegister {
Some((d(2026, 9, 30), cents(4500))),
);
r.book_value = Decimal::ZERO;
r
taxed(
r,
ledger_core::TaxBasisRule::Carryover,
cents(30000),
cents(30000),
)
},
],
total_cost: cents(370000),
total_accumulated: cents(77778),
total_book_value: cents(292222),
total_tax_basis: cents(250000),
missing_tax_basis: 1,
ledger_cost: cents(400000),
ledger_accumulated: cents(70000),
}
@@ -3090,7 +3116,7 @@ fn asset_register_xlsx_cells() {
assert_eq!(text_cell(&c, (1, 0)), "Spec Test LLC");
assert_eq!(text_cell(&c, (2, 0)), "Fixed Asset Register");
assert_eq!(text_cell(&c, (3, 0)), "As of October 15, 2026");
let heads: Vec<String> = (0..10).map(|i| text_cell(&c, (5, i))).collect();
let heads: Vec<String> = (0..14).map(|i| text_cell(&c, (5, i))).collect();
assert_eq!(
heads,
[
@@ -3103,6 +3129,10 @@ fn asset_register_xlsx_cells() {
"LIFE (MONTHS)",
"ACCUMULATED DEPRECIATION",
"BOOK VALUE",
"TAX BASIS RULE",
"CONTRIBUTOR'S BASIS",
"TAX BASIS",
"BUILT-IN GAIN (LOSS)",
"STATUS"
]
);
@@ -3115,31 +3145,78 @@ fn asset_register_xlsx_cells() {
assert_eq!(number_cell(&c, (6, 6)), dec("36"));
assert_eq!(number_cell(&c, (6, 7)), dec("527.78"));
assert_eq!(number_cell(&c, (6, 8)), dec("1972.22"));
assert_eq!(text_cell(&c, (6, 9)), "Active");
// Tax basis (INVARIANT 7): rule in words with its citation, the
// contributor's basis, tax basis, built-in loss as a negative.
assert_eq!(
text_cell(&c, (6, 9)),
"Personal-use conversion (Treas. Reg. §1.168(i)-4(b))"
);
assert_eq!(number_cell(&c, (6, 10)), dec("2800.00"));
assert_eq!(number_cell(&c, (6, 11)), dec("2500.00"));
assert_eq!(number_cell(&c, (6, 12)), dec("-300.00"));
assert_eq!(text_cell(&c, (6, 13)), "Active");
// The desk: no tag, no serial, no tax basis recorded.
assert_eq!(text_cell(&c, (7, 1)), "Desk");
assert_eq!(number_cell(&c, (7, 4)), dec("1200.00"));
assert_eq!(number_cell(&c, (7, 7)), dec("250.00"));
assert_eq!(number_cell(&c, (7, 8)), dec("950.00"));
assert_eq!(text_cell(&c, (7, 9)), "Not recorded");
for col in 10..=12 {
assert!(
!c.contains_key(&(7, col)),
"desk has no tax amount in {col}"
);
}
assert_eq!(text_cell(&c, (7, 13)), "Active");
assert_eq!(text_cell(&c, (8, 0)), "WL-0003");
assert_eq!(text_cell(&c, (8, 9)), "Disposed 2026-09-30, proceeds 45.00");
assert_eq!(text_cell(&c, (8, 9)), "Carryover (IRC §723)");
assert_eq!(number_cell(&c, (8, 10)), dec("300.00"));
assert_eq!(number_cell(&c, (8, 11)), dec("300.00"));
assert_eq!(number_cell(&c, (8, 12)), dec("100.00"));
assert_eq!(
text_cell(&c, (8, 13)),
"Disposed 2026-09-30, proceeds 45.00"
);
assert_eq!(number_cell(&c, (8, 8)), Decimal::ZERO);
// Totals: the register's (held assets only), not a re-sum of the rows.
assert_eq!(text_cell(&c, (9, 0)), "Total, assets held");
assert_eq!(number_cell(&c, (9, 4)), dec("3700.00"));
assert_eq!(number_cell(&c, (9, 7)), dec("777.78"));
assert_eq!(number_cell(&c, (9, 8)), dec("2922.22"));
assert!(!c.contains_key(&(9, 5)), "no salvage total");
assert_eq!(number_cell(&c, (9, 11)), dec("2500.00"));
for col in [5, 10, 12] {
assert!(!c.contains_key(&(9, col)), "no total in {col}");
}
// INVARIANT 8: the missing tax basis line.
assert_eq!(text_cell(&c, (10, 0)), "Tax basis not recorded for 1 asset");
// Reconciliation.
assert_eq!(text_cell(&c, (11, 0)), "Reconciliation to the books");
assert_eq!(text_cell(&c, (12, 0)), "Reconciliation to the books");
let recon = [
(12, "Cost per register", 4, "3700.00"),
(13, "Cost per books", 4, "4000.00"),
(14, "Difference (unregistered cost)", 4, "300.00"),
(16, "Accumulated depreciation per schedule", 7, "777.78"),
(17, "Accumulated depreciation per books", 7, "700.00"),
(18, "Difference (not yet posted)", 7, "77.78"),
(13, "Cost per register", 4, "3700.00"),
(14, "Cost per books", 4, "4000.00"),
(15, "Difference (unregistered cost)", 4, "300.00"),
(17, "Accumulated depreciation per schedule", 7, "777.78"),
(18, "Accumulated depreciation per books", 7, "700.00"),
(19, "Difference (not yet posted)", 7, "77.78"),
];
for (row, label, col, amt) in recon {
assert_eq!(text_cell(&c, (row, 0)), label);
assert_eq!(number_cell(&c, (row, col)), dec(amt), "{label}");
}
// INVARIANT 9: the notes, numbered as the PDF marks them.
assert_eq!(text_cell(&c, (21, 0)), "Notes");
let notes = ledger_import::asset_register::notes();
for (i, n) in notes.iter().enumerate() {
let want = if n.mark.is_empty() {
n.text.clone()
} else {
format!("{}. {}", n.mark, n.text)
};
assert_eq!(text_cell(&c, (22 + i as u32, 0)), want);
}
assert!(
text_cell(&c, (22, 0)).starts_with("Book value is the fair market value at contribution")
);
let footer = c
.values()
.any(|v| matches!(v, Cell::Text(t) if t.contains("Journal seq 42")));
@@ -3156,10 +3233,18 @@ fn asset_register_xlsx_empty() {
total_cost: Decimal::ZERO,
total_accumulated: Decimal::ZERO,
total_book_value: Decimal::ZERO,
total_tax_basis: Decimal::ZERO,
missing_tax_basis: 0,
ledger_cost: cents(99900),
ledger_accumulated: Decimal::ZERO,
};
let c = sheet_cells(&ledger_import::asset_register_xlsx(&r, &meta()).unwrap());
assert_eq!(number_cell(&c, (7, 11)), Decimal::ZERO);
assert!(
!c.values()
.any(|v| matches!(v, Cell::Text(t) if t.starts_with("Tax basis not recorded"))),
"nothing missing"
);
assert_eq!(text_cell(&c, (6, 0)), "No fixed assets");
assert_eq!(text_cell(&c, (7, 0)), "Total, assets held");
assert_eq!(number_cell(&c, (7, 4)), Decimal::ZERO);