Fixed assets carry federal tax basis beside book value; tests sweep stale temp dirs
check / check (push) Canceled after 0s
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:
1 parent
49c49aef33
commit
6e6f9d65b2
30 files changed
+3240
-93
No files matched your search
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user