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),