P3 review: asset bases and the tax-basis K-1

Schema 13 is as shipped and the asset columns are schema 14 (shipped
migrations pinned by hash); the tax-basis K-1 leaves net income open (only
the return has it) and takes a contributed prepaid plan at its book amount
as its section 723 basis; a disposal of contributed property names its
704(c) gain or loss; aliases compare by meaning; fixture amounts made up.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
This commit is contained in:
swaitsandClaude Opus 5.5 committed 2026-10-21 17:43:44 -06:00
1 parent 2ae93fb2d6
commit 6288ead747
24 files changed
+860 -127

No files matched your search

+1 -1
View File
@@ -4,4 +4,4 @@
- Never file the package, or a file from it, into the books.
- tin_gaps lists each member or payee whose TIN it lacks and why: collect them (books.set_tin, books.set_w9) and export again.
- Everything in it is as of its as_of: the 1099 worksheet counts the year's payments through that day, and general-journal.iif (the QuickBooks Desktop file) every posted entry from the first through it, closing entries left out. That file is plain ASCII within QuickBooks Desktop's limits: warnings (and the README) say what it writes differently from the books ("Café" as "Cafe", a name cut to 31 characters, a subaccount of another type at the top level, the memos grouped); tell the human. It names no customer or vendor: the README lists the entries on accounts receivable or payable, where QuickBooks may ask the CPA for one.
- With members, the K-1 worksheet is two files: k1-members.csv (each member's capital on the books, with their TIN) and k1-tax-basis-capital.csv (the same capital on the tax basis, as Schedule K-1 Item L reports it: property contributed at the member's adjusted basis, IRC §723, and the reconciliation from book to tax capital). A line the books can't know (tax depreciation, the basis of property no asset records) is left open with what the books have: tell the human the CPA supplies it; never fill it in.
- With members, the K-1 worksheet is two files: k1-members.csv (each member's capital on the books, with their TIN) and k1-tax-basis-capital.csv (the same capital on the tax basis, as Schedule K-1 Item L reports it: property contributed at the member's adjusted basis, IRC §723, a prepaid contract handed over at its book amount for the CPA to confirm, and the reconciliation from book to tax capital). A line the books can't know (the share of income as figured for tax, always; tax depreciation; a disposal's tax gain; the basis of property no asset records) is left open with what the books have: tell the human the CPA supplies it; never fill it in.
+1 -1
View File
@@ -6,5 +6,5 @@
- Two dates: the in-service date (in_service_date: the day it is ready and available for its use) starts depreciation; the acquired date is the date of the entry that put it on the books (its purchase or a member's contribution: give it as acquisition_entry, dated on or before the in-service date), and puts it on the register. Something bought before it is used is on the register from the purchase and depreciates from the day it serves.
- Two accumulated figures, never confused: what the books carry (accumulated_posted: its depreciation runs, less any reversed) and what the schedule says is due by a date (accumulated_scheduled_through). Book value is cost less what the books carry; the difference is depreciation not yet run.
- Every asset gets a tag for the label on the thing itself (books.register_asset generates the next one, like FA-0001, unless the human has their own); record the manufacturer's serial number too, change either with books.label_asset, and print label sheets with books.asset_labels (give the human the PDF; print at 100% on Avery 5160/8160). Asset tools take the tag in place of the id.
- Record every asset's tax basis beside its book value (books.set_asset_tax_basis, or basis_rule on books.register_asset): purchase for something bought (basis is its cost); for a member's contribution, carryover_723 (their business property) or personal_use_conversion (their personal property), with the member's adjusted basis (cost plus improvements, less depreciation allowed or allowable) and its fair market value when contributed: ask the human for both and who says so (basis_source), never assume the value is the cost. Three figures follow, each named for what it is: the §723 basis (the member's adjusted basis), the depreciable basis (that basis; for personal use, the lesser of it and the value), and the §704(c) built-in gain or loss (value less §723 basis; a loss counts only for the contributing member). They are at contribution; tax depreciation, and what remains of the gain or loss later, are the CPA's. Record the §704(c) method (traditional, traditional_with_curative, remedial) once chosen with the CPA. Book value stays fair market value.
- Record every asset's tax basis beside its book value (books.set_asset_tax_basis, or basis_rule on books.register_asset): purchase for something bought (basis is its cost); for a member's contribution, carryover_723 (their business property) or personal_use_conversion (their personal property), with the member's adjusted basis (cost plus improvements, less depreciation allowed or allowable) and its fair market value when contributed: ask the human for both and who says so (basis_source), never assume the value is the cost. Three figures follow, each named for what it is: the §723 basis (the member's adjusted basis), the depreciable basis (that basis; for personal use, the lesser of it and the value), and the §704(c) built-in gain or loss (value less §723 basis; a loss counts only for the contributing member). They are at contribution; tax depreciation, and what remains of the gain or loss later, are the CPA's. Record the §704(c) method (traditional, traditional_with_curative, remedial) once chosen with the CPA. The book cost of a contribution is its fair market value at contribution; book value is that cost less the depreciation posted.
- books.export fixed_asset_register is the register for the CPA (posted and scheduled, every tax figure, reconciled to the books); the CPA package's k1-tax-basis-capital.csv carries each member's capital on the tax basis beside the book figures, with what only the CPA can supply left open. books.show asset opens one in the viewer.
+55 -5
View File
@@ -1438,16 +1438,19 @@ fn settings_problems(ledger: &Ledger, cfg: &Settings) -> Vec<String> {
}
/// A parameter and its deprecated alias (one release): the value given,
/// noting when it came by the alias; both given and different is refused.
/// noting when it came by the alias; both given and meaning different
/// values (by `same`: an amount, a day or a rule however written) is
/// refused.
fn aliased<'a>(
name: &str,
value: Option<&'a str>,
alias: &str,
old: Option<&'a str>,
notes: &mut Vec<String>,
same: fn(&str, &str) -> bool,
) -> Result<Option<&'a str>, String> {
match (value, old) {
(Some(v), Some(o)) if v != o => Err(format!(
(Some(v), Some(o)) if !same(v, o) => Err(format!(
"{name} {v:?} and its old name {alias} {o:?} disagree: give {name} only"
)),
(Some(v), _) => Ok(Some(v)),
@@ -1459,6 +1462,35 @@ fn aliased<'a>(
}
}
/// Two amounts given for one figure mean the same (`"100"` and
/// `"100.00"`); text that isn't an amount compares as text.
fn same_amount(a: &str, b: &str) -> bool {
match (a.trim().parse::<Decimal>(), b.trim().parse::<Decimal>()) {
(Ok(x), Ok(y)) => x == y,
_ => a == b,
}
}
/// Two days given for one date mean the same.
fn same_day(a: &str, b: &str) -> bool {
match (parse_date(a), parse_date(b)) {
(Ok(x), Ok(y)) => x == y,
_ => a == b,
}
}
/// Two basis rules mean the same, by wire or pre-P3 name
/// (`"carryover"` and `"carryover_723"`).
fn same_rule(a: &str, b: &str) -> bool {
let rule = |r: &str| {
ledger_core::BasisRule::parse(r).or_else(|| ledger_core::BasisRule::parse_legacy(r))
};
match (rule(a), rule(b)) {
(Some(x), Some(y)) => x == y,
_ => a == b,
}
}
/// A basis rule from a tool: its wire name, or its pre-P3 name (an alias
/// for one release, noted).
fn parse_basis_rule(rule: &str, notes: &mut Vec<String>) -> Result<ledger_core::BasisRule, String> {
@@ -5609,7 +5641,7 @@ impl BooksServer {
#[tool(
name = "books.correct_asset",
title = "Correct a fixed asset",
description = "Correct a registered fixed asset's cost, salvage value, useful life, or in-service date (never before its acquisition entry's date) when it was registered wrong, with the reason; omitted fields keep their values. Refused while the books carry depreciation for it (a run posted and not reversed: correct with entries then). Its tag, serial, links, and basis record stay (a contribution's value and built-in gain are as contributed); correct the entry that put it on the books too, so the books and the register agree (books.export fixed_asset_register shows any difference). Journaled with the old figures.",
description = "Correct a registered fixed asset's cost, salvage value, useful life, or in-service date (never before its acquisition entry's date) when it was registered wrong, with the reason; omitted fields keep their values. Refused while the books carry depreciation for it (a run posted and not reversed: correct with entries then). Its tag, serial, links and basis record stay (a recorded value at contribution never moves; a pre-P3 basis reads it as the book cost, so its built-in gain moves, noted); correct the entry that put it on the books too (books.export fixed_asset_register shows any difference). Journaled with the old figures.",
annotations(
read_only_hint = false,
destructive_hint = true,
@@ -5628,6 +5660,7 @@ impl BooksServer {
"in_service",
p.in_service.as_deref(),
&mut notes,
same_day,
)?;
let mut store = self.lock()?;
let id = resolve_asset_ref(&store, &p.asset)?;
@@ -5648,11 +5681,19 @@ impl BooksServer {
None => a.in_service_date,
},
};
let before = a.built_in_gain_704c();
store
.correct_asset(id, fix, &p.reason)
.map_err(|e| e.to_string())?;
let ledger = store.ledger();
let a = ledger.get_asset(id).expect("resolved");
if a.built_in_gain_704c() != before {
notes.push(format!(
"its basis was recorded before P3 with no value at contribution, which reads as the book cost, so its §704(c) built-in gain moved with the cost ({} to {}): record the value at contribution with books.set_asset_tax_basis",
before.map_or("none".into(), money),
a.built_in_gain_704c().map_or("none".into(), money)
));
}
Ok(Json(AssetSummaryOne {
asset_id: id,
tag: a.tag.clone(),
@@ -5691,6 +5732,7 @@ impl BooksServer {
"contributor_basis",
p.contributor_basis.as_deref(),
&mut notes,
same_amount,
)?;
let basis = parse_asset_basis(
&p.rule,
@@ -6256,6 +6298,7 @@ impl BooksServer {
"purchase_date",
p.purchase_date.as_deref(),
&mut notes,
same_day,
)?
.ok_or("in_service_date is required: the day it was placed in service")?,
)?;
@@ -6316,6 +6359,7 @@ impl BooksServer {
"tax_basis_rule",
p.tax_basis_rule.as_deref(),
&mut notes,
same_rule,
)?;
let adjusted = aliased(
"contributor_adjusted_basis",
@@ -6323,6 +6367,7 @@ impl BooksServer {
"contributor_basis",
p.contributor_basis.as_deref(),
&mut notes,
same_amount,
)?;
let basis = match rule {
Some(rule) => Some(parse_asset_basis(
@@ -9235,7 +9280,7 @@ impl BooksServer {
.map_err(|e| e.to_string());
}
let mut csv = String::from(
"tag,description,serial,acquired,in_service,cost,salvage,useful_life_months,accumulated_scheduled,accumulated_posted,book_value,status,proceeds,basis_rule,contributor_adjusted_basis,fmv_at_contribution,fmv_recorded,tax_basis_723,depreciable_basis,built_in_gain_704c,sec704c_method,basis_source\n",
"tag,description,serial,acquired,in_service,cost,salvage,useful_life_months,accumulated_scheduled,accumulated_posted,book_value,status,proceeds,basis_rule,contributor_adjusted_basis,fmv_at_contribution,fmv_recorded,tax_basis_723,depreciable_basis,built_in_gain_704c,sec704c_1c,sec704c_method,basis_source\n",
);
for a in &r.rows {
let cells = [
@@ -9266,6 +9311,11 @@ impl BooksServer {
a.tax_basis_723.map(money).unwrap_or_default(),
a.depreciable_basis.map(money).unwrap_or_default(),
a.built_in_gain_704c.map(money).unwrap_or_default(),
// IRC §704(c)(1)(C): a built-in loss counts only for the
// contributor; blank for a purchase or no basis.
a.built_in_gain_704c
.map(|g| (g < Decimal::ZERO).to_string())
.unwrap_or_default(),
a.sec704c_method
.map(|m| m.key())
.unwrap_or_default()
@@ -9276,7 +9326,7 @@ impl BooksServer {
csv.push('\n');
}
csv.push_str(&format!(
"TOTAL,,,,,{},,,{},{},{},,,,,,,{},{},,,",
"TOTAL,,,,,{},,,{},{},{},,,,,,,{},{},,,,",
money(r.total_cost),
money(r.total_accumulated_scheduled),
money(r.total_accumulated_posted),
+87
View File
@@ -1079,6 +1079,34 @@ fn basis_rule_legacy_names_are_aliases() {
let _ = t2;
}
/// A name and its alias that mean the same, however written, agree (P3
/// review, 2026-10-21: they were compared as text, so "100" and "100.00",
/// or carryover and carryover_723, were refused as disagreeing).
#[test]
fn a_name_and_its_alias_that_mean_the_same_agree() {
let mut m = books("alias-same");
let (_, t) = m.register_ok(json!({}));
let r = m.ok(
"books.set_asset_tax_basis",
json!({"asset": t, "rule": "carryover_723", "contributor_adjusted_basis": "900",
"contributor_basis": "900.00", "fmv_at_contribution": "1200.00"}),
);
assert_eq!(r["basis"]["contributor_adjusted_basis"], "900.00", "{r}");
m.register_ok(
json!({"basis_rule": "carryover_723", "tax_basis_rule": "carryover",
"contributor_adjusted_basis": "400.00", "contributor_basis": "400",
"fmv_at_contribution": "1200.00"}),
);
m.register_ok(json!({"basis_rule": "purchase", "tax_basis_rule": "cost"}));
assert_eq!(m.assets().len(), 3);
let why = m.refuse(
"books.set_asset_tax_basis",
json!({"asset": t, "rule": "carryover_723", "contributor_adjusted_basis": "900.00",
"contributor_basis": "900.01", "fmv_at_contribution": "1200.00"}),
);
assert!(why.contains("disagree"), "{why}");
}
/// A contribution's fair market value is a fact the human gives, never
/// assumed from the cost (review R11): without it the call is refused.
#[test]
@@ -1507,6 +1535,7 @@ fn export_fixed_asset_register_carries_posted_scheduled_and_704c() {
"tax_basis_723",
"depreciable_basis",
"built_in_gain_704c",
"sec704c_1c",
"sec704c_method",
"basis_source"
]
@@ -1526,7 +1555,14 @@ fn export_fixed_asset_register_carries_posted_scheduled_and_704c() {
assert_eq!(canon(&r[col("tax_basis_723")]), "650.25");
assert_eq!(canon(&r[col("built_in_gain_704c")]), "549.75");
assert_eq!(r[col("fmv_recorded")], "true");
assert_eq!(r[col("sec704c_1c")], "false", "a built-in gain");
assert_eq!(
csv_row("CONV-1")[col("sec704c_1c")],
"true",
"a built-in loss"
);
let r = csv_row("BOUGHT-1");
assert_eq!(r[col("sec704c_1c")], "", "a purchase");
assert_eq!(canon(&r[col("accumulated_posted")]), "100");
assert_eq!(canon(&r[col("book_value")]), "1100");
assert_eq!(r[col("contributor_adjusted_basis")], "");
@@ -1731,3 +1767,54 @@ fn a_skipped_month_is_caught_up_and_said() {
);
assert!(err.contains("can't be reversed"), "{err}");
}
/// A basis recorded before P3 has no value at contribution and reads it as
/// the book cost, so correcting the cost moves its built-in gain: the
/// result says so (P3 review, 2026-10-21: the tool text said the built-in
/// gain was "as contributed" whatever the record). By hand: cost 1,200.00,
/// basis 400.00, gain 800.00; cost corrected to 1,100.00, gain 700.00.
#[test]
fn correcting_a_legacy_contributions_cost_says_its_built_in_gain_moved() {
let mut m = books("legacy-correct");
let (id, t) = m.register_ok(json!({}));
let dir = m.books_dir.take().unwrap();
let data = m.data.clone();
drop(m);
let mut w = ledger_store::JournalWriter::open(&data.join("journal.jsonl")).unwrap();
w.append(
"asset.tax_basis_set",
json!({"asset_id": id, "rule": "carryover", "contributor_basis": "400.00"}),
)
.unwrap();
drop(w);
let toml = dir.join("wanderbooks.toml");
let mut m = Mcp::start(&dir, &toml);
m.books_dir = Some(dir);
assert_eq!(m.asset(id)["basis"]["built_in_gain_704c"], "800.00");
let r = m.ok(
"books.correct_asset",
json!({"asset": t, "cost": "1100.00", "reason": "invoice misread"}),
);
assert_eq!(r["basis"]["built_in_gain_704c"], "700.00", "{r}");
let notes = r["notes"].to_string();
assert!(
notes.contains("moved with the cost (800.00 to 700.00)"),
"{notes}"
);
// A recorded value never moves, and says nothing.
m.ok(
"books.set_asset_tax_basis",
json!({"asset": t, "rule": "carryover_723", "contributor_adjusted_basis": "400.00",
"fmv_at_contribution": "1100.00"}),
);
let r = m.ok(
"books.correct_asset",
json!({"asset": t, "cost": "1000.00", "reason": "invoice misread again"}),
);
assert_eq!(r["basis"]["built_in_gain_704c"], "700.00", "{r}");
assert!(
r.get("notes")
.is_none_or(|n| n.as_array().is_some_and(|a| a.is_empty())),
"{r}"
);
}