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

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