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

+1 -1
View File
@@ -147,7 +147,7 @@ fn mcp_end_to_end_over_stdio() {
// Tool listing: the narrow tools; chart is read-only.
let list = mcp.request("tools/list", json!({}));
let tools = list["result"]["tools"].as_array().unwrap();
assert_eq!(tools.len(), 72, "expected 72 tools, got {}", tools.len());
assert_eq!(tools.len(), 73, "expected 73 tools, got {}", tools.len());
let chart = tools.iter().find(|t| t["name"] == "books.chart").unwrap();
assert_eq!(chart["annotations"]["readOnlyHint"], true);
assert!(
+328
View File
@@ -889,3 +889,331 @@ fn export_fixed_asset_register_as_of_leaves_out_later_assets() {
assert!(text.contains("EARLY-1"));
assert!(!text.contains("LATE-1"), "{text}");
}
// ------------------------------------------------- tax basis (rules.md B-25)
//
// From the `books.set_asset_tax_basis` description and
// `SetAssetTaxBasisParams` / `TaxBasisOut` docs, `RegisterAssetParams`
// (`tax_basis_rule`, `contributor_basis`), `AssetSummary::tax`, and the
// fixed_asset_register export. Cost: the book cost (IRC §1012);
// carryover: the member's adjusted basis (IRC §723);
// personal_use_conversion: the lesser of fair market value (the book
// cost) and the member's basis (Treas. Reg. §1.168(i)-4(b)); built-in
// gain = fair market value less the member's basis (IRC §704(c)).
/// A decimal-string field, compared by value; `None` for null.
fn dec(v: &Value, key: &str) -> Option<String> {
match &v[key] {
Value::Null => None,
Value::String(s) => Some(canon(s)),
other => panic!("{key} is not a decimal string or null: {other}"),
}
}
fn tax_basis_lines(m: &Mcp) -> usize {
m.journal_types()
.iter()
.filter(|t| *t == "asset.tax_basis_set")
.count()
}
/// books.set_asset_tax_basis by tag and by id; the output reports rule,
/// contributor basis, tax basis, and built-in gain; each call journals.
#[test]
fn set_asset_tax_basis_by_tag_or_id_reports_the_basis() {
let mut m = books("tax-set");
let (a, t) = m.register_ok(json!({}));
assert_eq!(m.asset(a)["tax"], Value::Null, "not recorded yet");
// By tag (any case): carryover of the member's $900 basis.
let r = m.ok(
"books.set_asset_tax_basis",
json!({"asset": t.to_ascii_lowercase(), "rule": "carryover", "contributor_basis": "900.00"}),
);
assert_eq!(r["rule"], "carryover");
assert_eq!(dec(&r, "contributor_basis").as_deref(), Some("900"));
assert_eq!(dec(&r, "tax_basis").as_deref(), Some("900"));
assert_eq!(
dec(&r, "built_in_gain").as_deref(),
Some("300"),
"1200 − 900"
);
assert_eq!(tax_basis_lines(&m), 1);
// By id: personal-use conversion, member paid $2,000 for a $1,200 laptop.
let r = m.ok(
"books.set_asset_tax_basis",
json!({"asset": a.to_string(), "rule": "personal_use_conversion", "contributor_basis": "2000.00"}),
);
assert_eq!(r["rule"], "personal_use_conversion");
assert_eq!(dec(&r, "contributor_basis").as_deref(), Some("2000"));
assert_eq!(dec(&r, "tax_basis").as_deref(), Some("1200"), "the lesser");
assert_eq!(
dec(&r, "built_in_gain").as_deref(),
Some("-800"),
"a built-in loss"
);
assert_eq!(tax_basis_lines(&m), 2);
// Boundary: value equals basis.
let r = m.ok(
"books.set_asset_tax_basis",
json!({"asset": t, "rule": "personal_use_conversion", "contributor_basis": "1200.00"}),
);
assert_eq!(dec(&r, "tax_basis").as_deref(), Some("1200"));
assert_eq!(dec(&r, "built_in_gain").as_deref(), Some("0"));
// Cost: the book cost, no contributor basis, no built-in gain.
let r = m.ok(
"books.set_asset_tax_basis",
json!({"asset": t, "rule": "cost"}),
);
assert_eq!(r["rule"], "cost");
assert_eq!(r["contributor_basis"], Value::Null);
assert_eq!(dec(&r, "tax_basis").as_deref(), Some("1200"));
assert_eq!(r["built_in_gain"], Value::Null);
assert_eq!(tax_basis_lines(&m), 4);
// list_assets shows the latest record.
let tax = m.asset(a)["tax"].clone();
assert_eq!(tax["rule"], "cost");
assert_eq!(dec(&tax, "tax_basis").as_deref(), Some("1200"));
}
/// Refusals: unknown asset, bad rule, cost with a basis, a contribution
/// without one, negative, finer than cents, not a decimal string. Each
/// leaves the journal byte-identical (the harness checks) and the record.
#[test]
fn set_asset_tax_basis_refusals_change_nothing() {
let mut m = books("tax-refuse");
let (a, t) = m.register_ok(json!({}));
m.ok(
"books.set_asset_tax_basis",
json!({"asset": t, "rule": "carryover", "contributor_basis": "500.00"}),
);
let before = m.asset(a)["tax"].clone();
for args in [
json!({"asset": "NOPE-1", "rule": "cost"}),
json!({"asset": "999", "rule": "cost"}),
json!({"asset": t, "rule": "fmv"}),
json!({"asset": t, "rule": "Cost"}),
json!({"asset": t, "rule": ""}),
json!({"asset": t, "rule": "cost", "contributor_basis": "100.00"}),
json!({"asset": t, "rule": "carryover"}),
json!({"asset": t, "rule": "personal_use_conversion"}),
json!({"asset": t, "rule": "carryover", "contributor_basis": "-1.00"}),
json!({"asset": t, "rule": "carryover", "contributor_basis": "1.001"}),
json!({"asset": t, "rule": "carryover", "contributor_basis": "lots"}),
json!({"asset": t, "rule": "carryover", "contributor_basis": "1,000.00"}),
] {
m.refuse("books.set_asset_tax_basis", args);
}
assert_eq!(m.asset(a)["tax"], before);
assert_eq!(tax_basis_lines(&m), 1);
}
/// register_asset with tax_basis_rule / contributor_basis records it at
/// registration; without them, list_assets shows tax null.
#[test]
fn register_asset_records_a_tax_basis() {
let mut m = books("tax-register");
let (a, _) = m.register_ok(json!({"tax_basis_rule": "cost"}));
let (b, _) = m.register_ok(json!({"tax_basis_rule": "personal_use_conversion",
"contributor_basis": "1500.00"}));
let (c, _) = m.register_ok(json!({"tax_basis_rule": "carryover",
"contributor_basis": "400.00"}));
let (n, _) = m.register_ok(json!({}));
let ta = m.asset(a)["tax"].clone();
assert_eq!(ta["rule"], "cost");
assert_eq!(ta["contributor_basis"], Value::Null);
assert_eq!(dec(&ta, "tax_basis").as_deref(), Some("1200"));
assert_eq!(ta["built_in_gain"], Value::Null);
let tb = m.asset(b)["tax"].clone();
assert_eq!(tb["rule"], "personal_use_conversion");
assert_eq!(dec(&tb, "contributor_basis").as_deref(), Some("1500"));
assert_eq!(dec(&tb, "tax_basis").as_deref(), Some("1200"));
assert_eq!(dec(&tb, "built_in_gain").as_deref(), Some("-300"));
let tc = m.asset(c)["tax"].clone();
assert_eq!(tc["rule"], "carryover");
assert_eq!(dec(&tc, "tax_basis").as_deref(), Some("400"));
assert_eq!(dec(&tc, "built_in_gain").as_deref(), Some("800"));
assert_eq!(m.asset(n)["tax"], Value::Null);
}
/// contributor_basis without tax_basis_rule, or a bad rule, is refused
/// and registers nothing.
#[test]
fn register_asset_refuses_a_bad_tax_basis() {
let mut m = books("tax-register-bad");
m.register_ok(json!({}));
for extra in [
json!({"contributor_basis": "500.00"}),
json!({"tax_basis_rule": "market_value"}),
json!({"tax_basis_rule": "carryover", "contributor_basis": "five"}),
] {
let out = m.register(extra.clone());
assert!(out.refused(), "{extra}: {out:?}");
}
assert_eq!(m.assets().len(), 1, "nothing registered");
}
/// A record the engine refuses (Ledger::set_asset_tax_basis INVARIANTS
/// 1–2) given at registration is a refused call, so (server invariant 2)
/// it registers nothing either.
#[test]
fn register_asset_with_a_basis_the_engine_refuses_registers_nothing() {
let mut m = books("tax-register-engine");
for extra in [
json!({"tax_basis_rule": "carryover"}),
json!({"tax_basis_rule": "personal_use_conversion"}),
json!({"tax_basis_rule": "cost", "contributor_basis": "100.00"}),
json!({"tax_basis_rule": "carryover", "contributor_basis": "-1.00"}),
json!({"tax_basis_rule": "carryover", "contributor_basis": "1.001"}),
] {
let out = m.register(extra.clone());
assert!(out.refused(), "{extra}: {out:?}");
}
assert!(m.assets().is_empty(), "nothing registered");
}
/// Every object anywhere in `v` that has `key`.
fn objects_with<'a>(v: &'a Value, key: &str, out: &mut Vec<&'a Value>) {
match v {
Value::Object(m) => {
if m.contains_key(key) {
out.push(v);
}
m.values().for_each(|c| objects_with(c, key, out));
}
Value::Array(a) => a.iter().for_each(|c| objects_with(c, key, out)),
_ => {}
}
}
/// The fixed_asset_register export carries each asset's tax basis rule,
/// contributor basis, tax basis, and built-in gain; JSON also the total
/// tax basis of held assets that have one and how many held assets have
/// none. A disposed asset is out of the totals.
#[test]
fn export_fixed_asset_register_carries_tax_basis() {
let mut m = books("tax-export");
m.post(
"2026-01-01",
&[("1000", "debit", "9000.00"), ("3000", "credit", "9000.00")],
);
m.register_ok(json!({"tag": "BOUGHT-1", "tax_basis_rule": "cost"}));
m.register_ok(json!({"tag": "CARRY-1", "tax_basis_rule": "carryover",
"contributor_basis": "650.25"}));
m.register_ok(
json!({"tag": "CONV-1", "tax_basis_rule": "personal_use_conversion",
"contributor_basis": "2000.00"}),
);
m.register_ok(json!({"tag": "NONE-1"}));
let (_, sold) = m.register_ok(json!({"tag": "SOLD-1", "tax_basis_rule": "cost"}));
let doc = m.file_bill_of_sale();
m.ok(
"books.dispose_asset",
json!({"tag": sold, "date": "2026-03-31", "proceeds": "100.00",
"proceeds_account": "1000", "gain_loss_account": "7100",
"confirmed": true, "receipt_attachment_ids": [doc]}),
);
let export = |m: &mut Mcp, format: &str| -> String {
m.read_only(
"books.export",
json!({"report": "fixed_asset_register", "format": format, "as_of": "2026-06-30"}),
)["content"]
.as_str()
.unwrap()
.to_string()
};
// JSON.
let j: Value = serde_json::from_str(&export(&mut m, "json")).unwrap();
let mut rows = Vec::new();
objects_with(&j, "tax_basis_rule", &mut rows);
assert_eq!(rows.len(), 5, "{j}");
let row = |tag: &str| -> Value {
(*rows
.iter()
.find(|r| r["tag"] == tag)
.unwrap_or_else(|| panic!("{tag} row")))
.clone()
};
let r = row("BOUGHT-1");
assert_eq!(r["tax_basis_rule"], "cost");
assert_eq!(r["contributor_basis"], Value::Null);
assert_eq!(dec(&r, "tax_basis").as_deref(), Some("1200"));
assert_eq!(r["built_in_gain"], Value::Null);
let r = row("CARRY-1");
assert_eq!(r["tax_basis_rule"], "carryover");
assert_eq!(dec(&r, "contributor_basis").as_deref(), Some("650.25"));
assert_eq!(dec(&r, "tax_basis").as_deref(), Some("650.25"));
assert_eq!(dec(&r, "built_in_gain").as_deref(), Some("549.75"));
let r = row("CONV-1");
assert_eq!(r["tax_basis_rule"], "personal_use_conversion");
assert_eq!(dec(&r, "contributor_basis").as_deref(), Some("2000"));
assert_eq!(dec(&r, "tax_basis").as_deref(), Some("1200"));
assert_eq!(dec(&r, "built_in_gain").as_deref(), Some("-800"));
let r = row("NONE-1");
assert_eq!(r["tax_basis_rule"], Value::Null);
assert_eq!(r["tax_basis"], Value::Null);
let r = row("SOLD-1");
assert_eq!(r["tax_basis_rule"], "cost");
// Held with a basis: 1200 + 650.25 + 1200; SOLD-1 out, NONE-1 missing.
assert_eq!(the_value(&j, "total_tax_basis"), "3050.25");
let mut missing = Vec::new();
find_key(&j, "missing_tax_basis", &mut missing);
assert_eq!(missing, vec![&json!(1)]);
// CSV: the four tax columns, by header.
let csv = export(&mut m, "csv");
let mut lines = csv.lines();
let header: Vec<String> = lines
.next()
.unwrap()
.split(',')
.map(|h| h.trim().to_ascii_lowercase())
.collect();
let col = |name: &str| {
header
.iter()
.position(|h| h == name)
.unwrap_or_else(|| panic!("{name} column in {header:?}"))
};
let (c_tag, c_rule, c_basis, c_tax, c_gain) = (
col("tag"),
col("tax_basis_rule"),
col("contributor_basis"),
col("tax_basis"),
col("built_in_gain"),
);
let body: Vec<Vec<String>> = lines
.map(|l| l.split(',').map(str::to_string).collect())
.collect();
let csv_row = |tag: &str| {
body.iter()
.find(|r| r.get(c_tag).map(String::as_str) == Some(tag))
.unwrap_or_else(|| panic!("{tag} in {csv}"))
.clone()
};
let r = csv_row("CARRY-1");
assert_eq!(r[c_rule], "carryover");
assert_eq!(canon(&r[c_basis]), "650.25");
assert_eq!(canon(&r[c_tax]), "650.25");
assert_eq!(canon(&r[c_gain]), "549.75");
let r = csv_row("CONV-1");
assert_eq!(r[c_rule], "personal_use_conversion");
assert_eq!(canon(&r[c_tax]), "1200");
assert_eq!(canon(&r[c_gain]), "-800");
let r = csv_row("BOUGHT-1");
assert_eq!(r[c_rule], "cost");
assert_eq!(r[c_basis], "");
assert_eq!(canon(&r[c_tax]), "1200");
assert_eq!(r[c_gain], "");
let r = csv_row("NONE-1");
assert_eq!((r[c_rule].as_str(), r[c_tax].as_str()), ("", ""));
// The TOTAL row's tax basis column is the total tax basis.
let total = csv_row("TOTAL");
assert_eq!(canon(&total[c_tax]), "3050.25");
}
+4
View File
@@ -792,6 +792,10 @@ fn happy_path(name: &str) -> Mcp {
"books.label_asset",
json!({"asset": "S-0001", "tag": "LAPTOP-1", "serial": "SN 123"}),
);
m.ok(
"books.set_asset_tax_basis",
json!({"asset": "LAPTOP-1", "rule": "personal_use_conversion", "contributor_basis": "3200.00"}),
);
m.ok("books.asset_labels", json!({}));
m.ok(
"books.asset_labels",