P3: assets' posted and scheduled depreciation, their bases, and the K-1 on the tax basis (defect 13, D-41)
check / check (push) Canceled after 0s

Book value is cost less what the books posted; the schedule's figure is
apart. An asset's acquired date comes from its entry. Contributed
property keeps its section 723 carryover basis apart from its depreciable
basis (the lower of value and adjusted basis), with its 704(c) built-in
gain and method. The K-1 worksheet reports tax-basis capital (Form 1065
Item L) beside the book figures, leaving open what only the CPA knows.

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 14:04:12 -06:00
1 parent 3a9aa92315
commit 2f999c6050
81 files changed
+9391 -2604

No files matched your search

@@ -4,3 +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.
+4 -2
View File
@@ -3,6 +3,8 @@
- Register with books.register_asset, inspect with books.asset_schedule and books.list_assets, post monthly depreciation with books.run_depreciation (exempt from the receipt policy; the schedule is the evidence), and dispose with books.dispose_asset (preview first; the bill of sale is the receipt).
- Depreciation is straight-line, book basis only; tax depreciation stays with the CPA.
- It accrues by the day: a partial first month, the final month and a disposal month are prorated by days (the disposal day is held), and the lifetime total is exactly cost minus salvage. A month skipped is caught up by the next run (books.run_depreciation says how much under caught_up; a material miss is a restatement for the CPA); a disposal dated inside a month already run credits the extra days back; one dated before a later month's run is refused until that run is reversed.
- 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 federal tax basis beside its book value (books.set_asset_tax_basis, or tax_basis_rule on books.register_asset): cost for a purchase; for a member's contribution, the member's adjusted basis (what they paid, less depreciation they took) with rule personal_use_conversion (their personal property: tax basis is the lesser of value and their basis) or carryover (their business property). Book value stays fair market value.
- books.export fixed_asset_register is the register for the CPA; books.show asset opens one in the viewer.
- 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.
- 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.
+407 -106
View File
@@ -42,8 +42,7 @@ use chrono::NaiveDate;
use ledger_config::Company;
use ledger_core::{
AccountId, AccountType, AssetStatus, Currency, DocumentLine, DocumentStatus, JournalEntry,
Ledger, Money, NewAsset, PeriodStatus, Side, accumulated_depreciation, book_value,
depreciation_schedule, reports,
Ledger, Money, NewAsset, PeriodStatus, Side, depreciation_schedule, reports,
};
use ledger_import::ReportMeta;
use ledger_package::{csv_escape, form_1099_label, money, parse_date, tin_kind, vault_tin};
@@ -1438,26 +1437,143 @@ fn settings_problems(ledger: &Ledger, cfg: &Settings) -> Vec<String> {
out
}
/// A tax basis record from a tool's rule name and contributor basis.
fn parse_tax_basis(rule: &str, basis: Option<&str>) -> Result<ledger_core::TaxBasis, String> {
let rule = ledger_core::TaxBasisRule::parse(rule).ok_or_else(|| {
format!("tax basis rule {rule:?}: expected cost, carryover, or personal_use_conversion")
})?;
Ok(ledger_core::TaxBasis {
rule,
contributor_basis: basis.map(parse_amount).transpose()?,
})
/// A parameter and its deprecated alias (one release): the value given,
/// noting when it came by the alias; both given and different is refused.
fn aliased<'a>(
name: &str,
value: Option<&'a str>,
alias: &str,
old: Option<&'a str>,
notes: &mut Vec<String>,
) -> Result<Option<&'a str>, String> {
match (value, old) {
(Some(v), Some(o)) if v != o => Err(format!(
"{name} {v:?} and its old name {alias} {o:?} disagree: give {name} only"
)),
(Some(v), _) => Ok(Some(v)),
(None, Some(o)) => {
notes.push(format!("{alias} is now {name}"));
Ok(Some(o))
}
(None, None) => Ok(None),
}
}
/// An asset's tax basis for a tool's output.
fn tax_out(a: &ledger_core::Asset) -> Option<TaxBasisOut> {
let t = a.tax?;
Some(TaxBasisOut {
rule: t.rule.key().to_string(),
contributor_basis: t.contributor_basis.map(money),
tax_basis: a.tax_basis().map(money),
built_in_gain: a.built_in_gain().map(money),
})
/// 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> {
if let Some(r) = ledger_core::BasisRule::parse(rule) {
return Ok(r);
}
match ledger_core::BasisRule::parse_legacy(rule) {
Some(r) => {
notes.push(format!("the rule {rule:?} is now {:?}", r.key()));
Ok(r)
}
None => Err(format!(
"basis rule {rule:?}: expected purchase, carryover_723, or personal_use_conversion"
)),
}
}
/// A basis record from a tool's facts, checked (AssetBasis::check). A
/// contribution's value at contribution is required: it is a fact the
/// human gives, never assumed from the cost.
fn parse_asset_basis(
rule: &str,
adjusted: Option<&str>,
fmv: Option<&str>,
method: Option<&str>,
source: Option<&BasisSourceIn>,
notes: &mut Vec<String>,
) -> Result<ledger_core::AssetBasis, String> {
let rule = parse_basis_rule(rule, notes)?;
if rule.is_contribution() && fmv.is_none() {
return Err(format!(
"a contribution ({}) needs fmv_at_contribution: its fair market value when contributed (ask the human; the books' amount in the contribution entry, if that is what it was worth)",
rule.key()
));
}
let basis = ledger_core::AssetBasis {
rule,
contributor_adjusted_basis: adjusted.map(parse_amount).transpose()?,
fmv_at_contribution: fmv.map(parse_amount).transpose()?,
sec704c_method: method
.map(|m| {
ledger_core::Sec704cMethod::parse(m).ok_or_else(|| {
format!(
"sec704c_method {m:?}: expected traditional, traditional_with_curative, or remedial"
)
})
})
.transpose()?,
source: source
.map(|s| -> Result<_, String> {
Ok(ledger_core::settings::Source {
decided_by: s.decided_by.clone(),
decided_on: match &s.decided_on {
Some(d) => parse_date(d)?,
None => today(),
},
citation: s.citation.clone(),
note: s.note.clone(),
})
})
.transpose()?,
};
basis
.check()
.map_err(|why| format!("bad tax basis: {why}"))?;
Ok(basis)
}
/// An asset's basis facts and figures for a tool's output.
fn basis_out(a: &ledger_core::Asset) -> Result<Option<AssetBasisOut>, String> {
let Some(b) = &a.basis else {
return Ok(None);
};
let Some(f) = a.basis_figures().map_err(|e| e.to_string())? else {
return Ok(None);
};
use ledger_core::BasisRule;
let loss = f
.built_in_gain_704c
.filter(|g| g.is_sign_negative() && !g.is_zero());
Ok(Some(AssetBasisOut {
rule: b.rule.key().to_string(),
contributor_adjusted_basis: b.contributor_adjusted_basis.map(money),
fmv_at_contribution: a.fmv_at_contribution().map(money),
fmv_recorded: a.fmv_is_recorded(),
tax_basis_723: f.tax_basis_723.map(money),
depreciable_basis: money(f.depreciable_basis),
built_in_gain_704c: f.built_in_gain_704c.map(money),
sec704c_1c_note: loss.map(|l| {
format!(
"a built-in loss of {}: it counts only for the contributing member(s); for the others the basis is treated as the value at contribution (IRC §704(c)(1)(C))",
money(-l)
)
}),
sec704c_method: b.sec704c_method.map(|m| m.key().to_string()),
source: b.source.as_ref().map(|s| BasisSourceOut {
decided_by: s.decided_by.clone(),
decided_on: s.decided_on.to_string(),
citation: s.citation.clone(),
note: s.note.clone(),
}),
sources: BasisSourcesOut {
tax_basis_723: b.rule.is_contribution().then(|| "IRC §723".to_string()),
depreciable_basis: match b.rule {
BasisRule::Purchase => "IRC §1012",
BasisRule::Carryover723 => "IRC §723",
BasisRule::PersonalUseConversion => "Treas. Reg. §1.168(i)-4(b)",
}
.to_string(),
built_in_gain_704c: b
.rule
.is_contribution()
.then(|| "Treas. Reg. §1.704-3(a)(3)(ii)".to_string()),
},
}))
}
/// An asset named by id or by tag (exactly one).
@@ -5493,7 +5609,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 when it was registered wrong, with the reason; omitted fields keep their values. Refused once any depreciation for it is posted (correct with entries then). Its tag, serial, links, and tax basis record stay; 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 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.",
annotations(
read_only_hint = false,
destructive_hint = true,
@@ -5505,6 +5621,14 @@ impl BooksServer {
params: Parameters<CorrectAssetParams>,
) -> Result<Json<AssetSummaryOne>, String> {
let p = params.0;
let mut notes = Vec::new();
let in_service = aliased(
"in_service_date",
p.in_service_date.as_deref(),
"in_service",
p.in_service.as_deref(),
&mut notes,
)?;
let mut store = self.lock()?;
let id = resolve_asset_ref(&store, &p.asset)?;
let a = store.ledger().get_asset(id).expect("resolved").clone();
@@ -5519,30 +5643,36 @@ impl BooksServer {
None => a.salvage_value,
},
useful_life_months: p.useful_life_months.unwrap_or(a.useful_life_months),
purchase_date: match &p.in_service {
in_service_date: match in_service {
Some(d) => parse_date(d)?,
None => a.purchase_date,
None => a.in_service_date,
},
};
store
.correct_asset(id, fix, &p.reason)
.map_err(|e| e.to_string())?;
let a = store.ledger().get_asset(id).expect("resolved");
let ledger = store.ledger();
let a = ledger.get_asset(id).expect("resolved");
Ok(Json(AssetSummaryOne {
asset_id: id,
tag: a.tag.clone(),
cost: money(a.cost.amount),
salvage_value: money(a.salvage_value.amount),
useful_life_months: a.useful_life_months,
in_service: a.purchase_date.to_string(),
tax: tax_out(a),
in_service_date: a.in_service_date.to_string(),
acquired_date: ledger
.asset_acquired_date(id)
.expect("known asset")
.to_string(),
basis: basis_out(a)?,
notes,
}))
}
#[tool(
name = "books.set_asset_tax_basis",
title = "Set an asset's tax basis",
description = "Record a fixed asset's federal tax basis beside its book value: rule cost (the company bought it: basis is its cost, IRC §1012), carryover (a member contributed business property: the member's adjusted basis, IRC §723), or personal_use_conversion (a member contributed personal-use property: the lesser of fair market value, which is the book cost, and the member's adjusted basis, Treas. Reg. §1.168(i)-4(b)). Contributions take the member's adjusted basis (what they paid, less any depreciation they took). Reports the tax basis and the built-in gain or loss (IRC §704(c)). Journaled; book figures don't change. Every asset should have one: the register and the CPA package show it.",
description = "Record how the company got a fixed asset and its tax figures beside book value. rule: purchase (basis is cost, IRC §1012); carryover_723 (a member's business property: their adjusted basis carries over, IRC §723); personal_use_conversion (a member's personal property: §723 basis likewise, depreciable basis the lesser of value and that basis, Treas. Reg. §1.168(i)-4(b)). A contribution needs contributor_adjusted_basis and fmv_at_contribution (ask the human; never assume the cost); built_in_gain_704c = value − §723 basis (Treas. Reg. §1.704-3(a)(3)(ii)), a loss counting only for the contributor. sec704c_method once chosen; basis_source: who says so. Journaled; book figures don't change.",
annotations(
read_only_hint = false,
destructive_hint = false,
@@ -5552,16 +5682,35 @@ impl BooksServer {
async fn set_asset_tax_basis(
&self,
params: Parameters<SetAssetTaxBasisParams>,
) -> Result<Json<TaxBasisOut>, String> {
) -> Result<Json<SetAssetTaxBasisOut>, String> {
let p = params.0;
let mut notes = Vec::new();
let adjusted = aliased(
"contributor_adjusted_basis",
p.contributor_adjusted_basis.as_deref(),
"contributor_basis",
p.contributor_basis.as_deref(),
&mut notes,
)?;
let basis = parse_asset_basis(
&p.rule,
adjusted,
p.fmv_at_contribution.as_deref(),
p.sec704c_method.as_deref(),
p.basis_source.as_ref(),
&mut notes,
)?;
let mut store = self.lock()?;
let id = resolve_asset_ref(&store, &p.asset)?;
let tax = parse_tax_basis(&p.rule, p.contributor_basis.as_deref())?;
store
.set_asset_tax_basis(id, tax)
.set_asset_basis(id, basis)
.map_err(|e| e.to_string())?;
let a = store.ledger().get_asset(id).expect("resolved");
Ok(Json(tax_out(a).expect("just set")))
Ok(Json(SetAssetTaxBasisOut {
asset_id: id,
basis: basis_out(a)?.ok_or("the basis just set has no figures")?,
notes,
}))
}
#[tool(
@@ -6087,7 +6236,7 @@ impl BooksServer {
#[tool(
name = "books.register_asset",
title = "Register a fixed asset",
description = "Register a fixed asset (equipment, vehicles, buildings). Links three accounts: cost (asset), accumulated depreciation (asset, contra), depreciation expense. Depreciation is straight-line, book basis only — tax depreciation (MACRS, Section 179) stays with the CPA.",
description = "Register a fixed asset (equipment, vehicles, buildings). Links three accounts: cost (asset), accumulated depreciation (asset, contra), depreciation expense. in_service_date (ready and available for its use) starts depreciation; the acquisition_entry (its purchase or contribution, dated on or before that) gives the acquired date, which puts it on the register. Its tax basis (basis_rule and the contribution facts) as books.set_asset_tax_basis takes it. Depreciation is straight-line, book basis only — tax depreciation (MACRS, Section 179) stays with the CPA.",
annotations(
read_only_hint = false,
destructive_hint = false,
@@ -6099,6 +6248,17 @@ impl BooksServer {
params: Parameters<RegisterAssetParams>,
) -> Result<Json<RegisterAssetOut>, String> {
let p = params.0;
let mut notes = Vec::new();
let in_service_date = parse_date(
aliased(
"in_service_date",
p.in_service_date.as_deref(),
"purchase_date",
p.purchase_date.as_deref(),
&mut notes,
)?
.ok_or("in_service_date is required: the day it was placed in service")?,
)?;
let mut store = self.lock()?;
let base = store.ledger().base_currency();
let cost_account = resolve_account(&store, &p.cost_account)?;
@@ -6133,35 +6293,95 @@ impl BooksServer {
"entry {entry} can't be the acquisition: it must be posted and debit the cost account"
));
}
let reversed = (1..store.ledger().next_entry_id())
.filter_map(|id| store.ledger().get_entry(id))
.any(|x| x.reversal_of() == Some(entry));
if e.reversal_of().is_some() || reversed {
return Err(format!(
"entry {entry} can't be the acquisition: it is a reversal or has been reversed (link the entry that stands)"
));
}
if e.date() > in_service_date {
return Err(format!(
"entry {entry} can't be the acquisition: it is dated {}, after the in-service date {in_service_date} (an asset is acquired before it serves: correct the in-service date, or the entry)",
e.date()
));
}
}
// Checked before registering, so a bad tax basis registers nothing
// (found by the spec-blind MCP suite, 2026-10-15).
let tax = match &p.tax_basis_rule {
Some(rule) => {
let tax = parse_tax_basis(rule, p.contributor_basis.as_deref())?;
tax.check().map_err(|why| format!("bad tax basis: {why}"))?;
Some(tax)
}
None if p.contributor_basis.is_some() => {
return Err("contributor_basis needs tax_basis_rule".into());
let rule = aliased(
"basis_rule",
p.basis_rule.as_deref(),
"tax_basis_rule",
p.tax_basis_rule.as_deref(),
&mut notes,
)?;
let adjusted = aliased(
"contributor_adjusted_basis",
p.contributor_adjusted_basis.as_deref(),
"contributor_basis",
p.contributor_basis.as_deref(),
&mut notes,
)?;
let basis = match rule {
Some(rule) => Some(parse_asset_basis(
rule,
adjusted,
p.fmv_at_contribution.as_deref(),
p.sec704c_method.as_deref(),
p.basis_source.as_ref(),
&mut notes,
)?),
None if adjusted.is_some()
|| p.fmv_at_contribution.is_some()
|| p.sec704c_method.is_some()
|| p.basis_source.is_some() =>
{
return Err("the basis facts need basis_rule".into());
}
None => None,
};
let asset_id = store
.register_asset(NewAsset {
description: p.description,
purchase_date: parse_date(&p.purchase_date)?,
cost: Money::new(parse_amount(&p.cost)?, base),
salvage_value: Money::new(
parse_amount(p.salvage_value.as_deref().unwrap_or("0"))?,
base,
),
useful_life_months: p.useful_life_months,
cost_account,
accum_depr_account,
expense_account,
})
.map_err(|e| e.to_string())?;
let new = NewAsset {
description: p.description,
in_service_date,
cost: Money::new(parse_amount(&p.cost)?, base),
salvage_value: Money::new(
parse_amount(p.salvage_value.as_deref().unwrap_or("0"))?,
base,
),
useful_life_months: p.useful_life_months,
cost_account,
accum_depr_account,
expense_account,
};
// One registration is several commands: rehearse them all on a
// copy first, and ask for the journal feature the basis needs
// (B-27), so a refusal anywhere (the tag, the serial, the link, the
// basis, books waiting for their format upgrade) journals nothing.
{
let mut copy = store.ledger().clone();
let id = copy
.register_asset(new.clone())
.map_err(|e| e.to_string())?;
copy.set_asset_tag(id, &tag).map_err(|e| e.to_string())?;
if let Some(serial) = &p.serial {
copy.set_asset_serial(id, Some(serial))
.map_err(|e| e.to_string())?;
}
if let Some(entry) = p.acquisition_entry {
copy.set_asset_acquisition(id, entry)
.map_err(|e| e.to_string())?;
}
if let Some(basis) = &basis {
copy.set_asset_basis(id, basis.clone())
.map_err(|e| e.to_string())?;
store
.ensure_feature(ledger_store::format::ASSET_BASIS)
.map_err(|e| e.to_string())?;
}
}
let asset_id = store.register_asset(new).map_err(|e| e.to_string())?;
store
.set_asset_tag(asset_id, &tag)
.map_err(|e| e.to_string())?;
@@ -6175,12 +6395,24 @@ impl BooksServer {
.set_asset_acquisition(asset_id, entry)
.map_err(|e| e.to_string())?;
}
if let Some(tax) = tax {
if let Some(basis) = basis {
store
.set_asset_tax_basis(asset_id, tax)
.set_asset_basis(asset_id, basis)
.map_err(|e| e.to_string())?;
}
Ok(Json(RegisterAssetOut { asset_id, tag }))
let ledger = store.ledger();
let a = ledger.get_asset(asset_id).expect("registered");
Ok(Json(RegisterAssetOut {
asset_id,
tag,
in_service_date: a.in_service_date.to_string(),
acquired_date: ledger
.asset_acquired_date(asset_id)
.expect("registered")
.to_string(),
basis: basis_out(a)?,
notes,
}))
}
#[tool(
@@ -6234,45 +6466,72 @@ impl BooksServer {
#[tool(
name = "books.list_assets",
title = "List fixed assets",
description = "List all registered fixed assets with accumulated depreciation by schedule and book value as of today (a disposed asset's through its disposal day). An asset whose schedule can't be computed lists with null figures and schedule_error.",
description = "List every registered fixed asset as of a date (as_of, default today): in-service and acquired dates, cost, and per asset accumulated_posted (what the books carry: runs posted, less reversals), accumulated_scheduled_through (what the schedule says is due by as_of), unposted_depreciation (the difference: run books.run_depreciation for it) and book_value (cost less posted: what the books carry); held says whether the books hold it on as_of. With its basis facts and tax figures. An asset whose figures can't be computed (one an earlier version took) lists with null figures and schedule_error.",
annotations(read_only_hint = true, open_world_hint = false)
)]
async fn list_assets(&self) -> Result<Json<ListAssetsOut>, String> {
async fn list_assets(
&self,
params: Parameters<ListAssetsParams>,
) -> Result<Json<ListAssetsOut>, String> {
let p = params.0;
let as_of = match &p.as_of {
Some(d) => parse_date(d)?,
None => today(),
};
let store = self.lock()?;
let today = today();
let assets = store
.ledger()
let ledger = store.ledger();
// The register's rows (one derivation, reports::register_row): an
// asset acquired by as_of has one; a later one has no figures yet.
let assets = ledger
.registered_assets()
.map(|a| {
// One asset whose schedule can't be computed (an earlier
// version's) lists with why, never failing the whole list.
let figures = accumulated_depreciation(a, today)
.and_then(|acc| Ok((acc, book_value(a, today)?)));
let (accumulated, book_value, schedule_error) = match figures {
Ok((acc, bv)) => (Some(money(acc)), Some(money(bv)), None),
Err(e) => (None, None, Some(e.to_string())),
let acquired = ledger.asset_acquired_date(a.id).expect("registered");
let disposed =
matches!(a.status, AssetStatus::Disposed { date, .. } if date <= as_of);
// One asset whose figures can't be computed (one an earlier
// version took) lists with why, never failing the whole
// list.
let row = ledger
.accumulated_posted(a.id, as_of)
.map(|posted| std::collections::BTreeMap::from([(a.id, posted)]))
.and_then(|posted| reports::register_row(ledger, a, as_of, &posted));
let (row, schedule_error) = match row {
Ok(row) => (row, None),
Err(e) => (None, Some(e.to_string())),
};
AssetSummary {
let row = row.as_ref();
let figure =
|f: fn(&ledger_core::AssetRegisterRow) -> Decimal| row.map(|r| money(f(r)));
Ok(AssetSummary {
asset_id: a.id,
tag: a.tag.clone(),
serial: a.serial.clone(),
acquisition_entry: a.acquired_by,
tax: tax_out(a),
basis: basis_out(a)?,
description: a.description.clone(),
purchase_date: a.purchase_date.to_string(),
in_service_date: a.in_service_date.to_string(),
acquired_date: acquired.to_string(),
cost: money(a.cost.amount),
useful_life_months: a.useful_life_months,
status: match a.status {
AssetStatus::Active => "active".to_string(),
AssetStatus::Disposed { .. } => "disposed".to_string(),
},
accumulated,
book_value,
held: acquired <= as_of && !disposed,
accumulated_posted: figure(|r| r.accumulated_posted),
accumulated_scheduled_through: figure(|r| r.accumulated_scheduled),
unposted_depreciation: figure(|r| {
r.accumulated_scheduled - r.accumulated_posted
}),
book_value: figure(|r| r.book_value),
schedule_error,
}
})
})
.collect();
Ok(Json(ListAssetsOut { assets }))
.collect::<Result<_, String>>()?;
Ok(Json(ListAssetsOut {
as_of: as_of.to_string(),
assets,
}))
}
#[tool(
@@ -6322,11 +6581,11 @@ impl BooksServer {
// failing the whole batch.
if p.asset_id.is_none()
&& let Some(asset) = store.ledger().get_asset(id)
&& asset.purchase_date > period_end
&& asset.in_service_date > period_end
{
skipped.push(skip(format!(
"placed in service {}, after {}",
asset.purchase_date, p.period
asset.in_service_date, p.period
)));
continue;
}
@@ -8715,8 +8974,9 @@ impl BooksServer {
/// 2. With members, k1-members.csv has one row per [[company.members]]
/// member: name, party, share, capital account, TIN and type, and
/// the K-1 worksheet's capital figures (ledger_core::k1) for the
/// fiscal year to date. The 1099 worksheet adds each payee's party,
/// last four, TIN, and type.
/// fiscal year to date; k1-tax-basis-capital.csv the same capital on
/// the tax basis beside them (ledger_core::tax_capital). The 1099
/// worksheet adds each payee's party, last four, TIN, and type.
/// 3. A locked vault or a missing TIN never fails the package: the
/// row says why (vault_tin), the README lists it, and so does
/// tin_gaps (members, and 1099 payees that are reportable).
@@ -8725,7 +8985,7 @@ impl BooksServer {
#[tool(
name = "books.export_cpa_package",
title = "CPA handoff package",
description = "Build the CPA handoff package as a zip: the profit and loss, balance sheet, cash flow (direct method, with its reconciliation and noncash schedules), statement of changes in members' equity (multi-member LLCs), tax summary, and trial balance, each as PDF and XLSX; the fixed asset register as PDF and XLSX when an asset was bought by as_of; the general ledger (by account) and general journal (entry by entry) as XLSX; the journal through as_of as IIF (imports into QuickBooks Desktop; plain ASCII within its limits: warnings says what is written differently from the books, and so does the README); the 1099 worksheet as CSV (payments from January 1 through as_of); with members, the K-1 worksheet (k1-members.csv: per member their party, share, capital account, TIN, and beginning capital, contributions in cash and in property, share of income, distributions, and ending capital for the fiscal year to date); every receipt behind the year's entries, filed on them or linked to them (receipts/, with an index.csv tying each file to its entry) and the company's documents (documents/), unless include_receipts is false; and a README with the journal's verified hash. By default (include_tins) the K-1 and 1099 worksheets carry every member's and payee's full TIN from the TIN vault; include_tins: false, last fours only. The zip is handed over: run its download command to save it in a folder (books served by `wanderbooks mcp`; over stdio it comes back inline). Never file it into the books. A locked vault or a missing TIN doesn't stop the package: tin_gaps lists each one (collect them with books.set_tin or books.set_w9 and export again). cash_accounts (e.g. [\"1000\"]) is required: the cash flow statement reconciles to them. as_of defaults to today; period reports cover the fiscal year to date.",
description = "Build the CPA handoff package as a zip: the profit and loss, balance sheet, cash flow (direct method, with its reconciliation and noncash schedules), statement of changes in members' equity (multi-member LLCs), tax summary, and trial balance, each as PDF and XLSX; the fixed asset register as PDF and XLSX when an asset was acquired by as_of; the general ledger (by account) and general journal (entry by entry) as XLSX; the journal through as_of as IIF (imports into QuickBooks Desktop; plain ASCII within its limits: warnings says what is written differently from the books, and so does the README); the 1099 worksheet as CSV (payments from January 1 through as_of); with members, the K-1 worksheet (k1-members.csv: per member their party, share, capital account, TIN, and beginning capital, contributions in cash and in property, share of income, distributions, and ending capital for the fiscal year to date) and its tax-basis capital beside the book figures (k1-tax-basis-capital.csv: Schedule K-1 Item L on the tax basis, property at its §723 basis, with the reconciliation; what only the CPA can supply, like tax depreciation, left open and named); every receipt behind the year's entries, filed on them or linked to them (receipts/, with an index.csv tying each file to its entry) and the company's documents (documents/), unless include_receipts is false; and a README with the journal's verified hash. By default (include_tins) the K-1 and 1099 worksheets carry every member's and payee's full TIN from the TIN vault; include_tins: false, last fours only. The zip is handed over: run its download command to save it in a folder (books served by `wanderbooks mcp`; over stdio it comes back inline). Never file it into the books. A locked vault or a missing TIN doesn't stop the package: tin_gaps lists each one (collect them with books.set_tin or books.set_w9 and export again). cash_accounts (e.g. [\"1000\"]) is required: the cash flow statement reconciles to them. as_of defaults to today; period reports cover the fiscal year to date.",
annotations(
read_only_hint = false,
destructive_hint = false,
@@ -8921,68 +9181,109 @@ impl BooksServer {
Some((d, p)) => format!("Disposed {d}, proceeds {}", money(p)),
None => "Active".to_string(),
};
let source = |row: &ledger_core::AssetRegisterRow| {
row.basis_source.as_ref().map(|s| {
let mut t = format!("{}, {}", s.decided_by, s.decided_on);
if let Some(c) = &s.citation {
t.push_str(&format!(": {c}"));
}
t
})
};
if format == "json" {
return serde_json::to_string_pretty(&serde_json::json!({
"as_of": as_of.to_string(),
"tax_figures_are": "at contribution (or purchase): the CPA adjusts them for tax depreciation, and the §704(c) gain or loss as it is reduced after (Treas. Reg. §1.704-3(a)(3)(ii))",
"assets": r.rows.iter().map(|a| serde_json::json!({
"asset_id": a.asset_id,
"tag": a.tag,
"description": a.description,
"serial": a.serial,
"in_service": a.in_service.to_string(),
"acquired_date": a.acquired_date.to_string(),
"in_service_date": a.in_service_date.to_string(),
"cost": money(a.cost),
"salvage": money(a.salvage),
"useful_life_months": a.useful_life_months,
"accumulated": money(a.accumulated),
"accumulated_scheduled": money(a.accumulated_scheduled),
"accumulated_posted": money(a.accumulated_posted),
"book_value": money(a.book_value),
"status": status(a),
"proceeds": a.disposed.map(|(_, p)| money(p)),
"tax_basis_rule": a.tax_rule.map(|r| r.key()),
"contributor_basis": a.contributor_basis.map(money),
"tax_basis": a.tax_basis.map(money),
"built_in_gain": a.built_in_gain.map(money),
"basis_rule": a.basis_rule.map(|r| r.key()),
"contributor_adjusted_basis": a.contributor_adjusted_basis.map(money),
"fmv_at_contribution": a.fmv_at_contribution.map(money),
"fmv_recorded": a.fmv_recorded,
"tax_basis_723": a.tax_basis_723.map(money),
"depreciable_basis": a.depreciable_basis.map(money),
"built_in_gain_704c": a.built_in_gain_704c.map(money),
"sec704c_1c": a.sec704c_1c,
"sec704c_method": a.sec704c_method.map(|m| m.key()),
"basis_source": source(a),
})).collect::<Vec<_>>(),
"total_tax_basis": money(r.total_tax_basis),
"missing_tax_basis": r.missing_tax_basis,
"total_cost": money(r.total_cost),
"total_accumulated": money(r.total_accumulated),
"total_accumulated_scheduled": money(r.total_accumulated_scheduled),
"total_accumulated_posted": money(r.total_accumulated_posted),
"total_book_value": money(r.total_book_value),
"total_depreciable_basis": money(r.total_depreciable_basis),
"total_tax_basis_723": money(r.total_tax_basis_723),
"missing_basis": r.missing_basis,
"ledger_cost": money(r.ledger_cost),
"ledger_accumulated": money(r.ledger_accumulated),
"unposted_depreciation": money(r.unposted_depreciation()),
"unattributed_accumulated": money(r.unattributed_accumulated()),
"unregistered_cost": money(r.unregistered_cost()),
}))
.map_err(|e| e.to_string());
}
let mut csv = String::from(
"tag,description,serial,in_service,cost,salvage,useful_life_months,accumulated,book_value,status,proceeds,tax_basis_rule,contributor_basis,tax_basis,built_in_gain\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_method,basis_source\n",
);
for a in &r.rows {
csv.push_str(&format!(
"{},{},{},{},{},{},{},{},{},{},{},{},{},{},{}\n",
let cells = [
csv_escape(a.tag.as_deref().unwrap_or("")),
csv_escape(&a.description),
csv_escape(a.serial.as_deref().unwrap_or("")),
a.in_service,
a.acquired_date.to_string(),
a.in_service_date.to_string(),
money(a.cost),
money(a.salvage),
a.useful_life_months,
money(a.accumulated),
a.useful_life_months.to_string(),
money(a.accumulated_scheduled),
money(a.accumulated_posted),
money(a.book_value),
status(a),
a.disposed.map(|(_, p)| money(p)).unwrap_or_default(),
a.tax_rule.map(|r| r.key()).unwrap_or_default(),
a.contributor_basis.map(money).unwrap_or_default(),
a.tax_basis.map(money).unwrap_or_default(),
a.built_in_gain.map(money).unwrap_or_default(),
));
a.basis_rule
.map(|r| r.key())
.unwrap_or_default()
.to_string(),
a.contributor_adjusted_basis.map(money).unwrap_or_default(),
a.fmv_at_contribution.map(money).unwrap_or_default(),
if a.basis_rule.is_some_and(|r| r.is_contribution()) {
a.fmv_recorded.to_string()
} else {
String::new()
},
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(),
a.sec704c_method
.map(|m| m.key())
.unwrap_or_default()
.to_string(),
csv_escape(&source(a).unwrap_or_default()),
];
csv.push_str(&cells.join(","));
csv.push('\n');
}
csv.push_str(&format!(
"TOTAL,,,,{},,,{},{},,,,,{},",
"TOTAL,,,,,{},,,{},{},{},,,,,,,{},{},,,",
money(r.total_cost),
money(r.total_accumulated),
money(r.total_accumulated_scheduled),
money(r.total_accumulated_posted),
money(r.total_book_value),
money(r.total_tax_basis)
money(r.total_tax_basis_723),
money(r.total_depreciable_basis)
));
Ok(csv)
}
+227 -45
View File
@@ -2213,8 +2213,16 @@ pub struct ExportOut {
pub struct RegisterAssetParams {
/// What it is, e.g. "2023 Sprinter van".
pub description: String,
/// Purchase date, YYYY-MM-DD. Depreciation starts here.
pub purchase_date: String,
/// The day it was placed in service (ready and available for its use),
/// YYYY-MM-DD: depreciation starts here. Required (or its alias
/// purchase_date).
#[serde(default)]
pub in_service_date: Option<String>,
/// Deprecated alias of in_service_date (it always meant the in-service
/// date), accepted for one release. Giving both with different dates is
/// refused.
#[serde(default)]
pub purchase_date: Option<String>,
/// Capitalized cost, decimal string, e.g. "60000.00".
pub cost: String,
/// Expected residual value, decimal string. Defaults to "0".
@@ -2234,14 +2242,54 @@ pub struct RegisterAssetParams {
/// The manufacturer's serial number, if known.
pub serial: Option<String>,
/// The posted entry that put it on the books (its purchase, or a
/// member's contribution of it); it must debit the cost account.
/// member's contribution of it): it must debit the cost account and be
/// dated on or before the in-service date. Its date is the asset's
/// acquired date, which puts it on the register.
pub acquisition_entry: Option<u64>,
/// The federal tax basis rule (see books.set_asset_tax_basis): cost,
/// carryover, or personal_use_conversion. Omit to record it later.
/// How the company got it (see books.set_asset_tax_basis): purchase,
/// carryover_723, or personal_use_conversion. Omit to record it later.
#[serde(default)]
pub basis_rule: Option<String>,
/// Deprecated alias of basis_rule, with the old names (cost,
/// carryover, personal_use_conversion), for one release.
#[serde(default)]
pub tax_basis_rule: Option<String>,
/// The contributing member's adjusted basis, for carryover and
/// personal_use_conversion.
/// The contributing member's adjusted basis at contribution, for
/// carryover_723 and personal_use_conversion.
#[serde(default)]
pub contributor_adjusted_basis: Option<String>,
/// Deprecated alias of contributor_adjusted_basis, for one release.
#[serde(default)]
pub contributor_basis: Option<String>,
/// Fair market value at contribution, for carryover_723 and
/// personal_use_conversion (required: ask the human).
#[serde(default)]
pub fmv_at_contribution: Option<String>,
/// The §704(c) method chosen for a contribution: traditional,
/// traditional_with_curative, or remedial.
#[serde(default)]
pub sec704c_method: Option<String>,
/// Who gave the basis facts, when, and on what (e.g. the entry that
/// records the value).
#[serde(default)]
pub basis_source: Option<BasisSourceIn>,
}
/// Who gave an asset's basis facts, and when: the shape every tool that
/// records a decision takes (books.set_setting's source).
#[derive(Debug, Deserialize, JsonSchema)]
pub struct BasisSourceIn {
/// Who gave them: a person's name, as the human says.
pub decided_by: String,
/// When, YYYY-MM-DD. Default: today.
#[serde(default)]
pub decided_on: Option<String>,
/// The document or entry they rest on, if any ("entry 14").
#[serde(default)]
pub citation: Option<String>,
/// Anything else worth keeping with them.
#[serde(default)]
pub note: Option<String>,
}
/// Parameters for `books.correct_asset`.
@@ -2255,7 +2303,12 @@ pub struct CorrectAssetParams {
pub salvage_value: Option<String>,
/// Corrected useful life in months. Omit to keep it.
pub useful_life_months: Option<u32>,
/// Corrected in-service date, YYYY-MM-DD. Omit to keep it.
/// Corrected in-service date, YYYY-MM-DD (never before its acquisition
/// entry's date). Omit to keep it.
#[serde(default)]
pub in_service_date: Option<String>,
/// Deprecated alias of in_service_date, for one release.
#[serde(default)]
pub in_service: Option<String>,
/// Why (required): what was wrong.
pub reason: String,
@@ -2275,10 +2328,16 @@ pub struct AssetSummaryOne {
/// Useful life in months.
pub useful_life_months: u32,
/// In-service date, YYYY-MM-DD.
pub in_service: String,
/// Its federal tax basis (follows the corrected cost under
/// personal_use_conversion and cost).
pub tax: Option<TaxBasisOut>,
pub in_service_date: String,
/// Acquired date (its acquisition entry's), YYYY-MM-DD.
pub acquired_date: String,
/// Its basis facts and tax figures (a purchase's depreciable basis
/// follows the corrected cost; a contribution's figures stay as
/// contributed).
pub basis: Option<AssetBasisOut>,
/// Anything the call did beyond the correction (an alias used).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub notes: Vec<String>,
}
/// Parameters for `books.set_asset_tax_basis`.
@@ -2286,31 +2345,119 @@ pub struct AssetSummaryOne {
pub struct SetAssetTaxBasisParams {
/// The asset: its id or tag.
pub asset: String,
/// "cost" (the company bought it: basis is its cost), "carryover" (a
/// member contributed business property: the member's adjusted basis,
/// IRC §723), or "personal_use_conversion" (a member contributed
/// personal-use property: the lesser of fair market value and the
/// member's adjusted basis, Treas. Reg. §1.168(i)-4(b)).
/// How the company got it: "purchase" (bought: basis is cost, IRC
/// §1012), "carryover_723" (a member contributed business property:
/// the member's adjusted basis carries over, IRC §723), or
/// "personal_use_conversion" (a member contributed personal-use
/// property: §723 basis as for carryover, depreciable basis the lesser
/// of fair market value and that basis, Treas. Reg. §1.168(i)-4(b)).
/// The old names cost and carryover are aliases for one release.
pub rule: String,
/// The contributing member's adjusted basis at contribution, decimal
/// string (what they paid, less any depreciation they took). Required
/// for carryover and personal_use_conversion; omit for cost.
/// string: cost plus improvements, less depreciation allowed or
/// allowable (IRC §1016(a)). Required for a contribution; omit for a
/// purchase.
#[serde(default)]
pub contributor_adjusted_basis: Option<String>,
/// Deprecated alias of contributor_adjusted_basis, for one release.
#[serde(default)]
pub contributor_basis: Option<String>,
/// Fair market value at contribution, decimal string. Required for a
/// contribution: ask the human (the books' amount in the contribution
/// entry, when that is what it was worth).
#[serde(default)]
pub fmv_at_contribution: Option<String>,
/// The §704(c) method chosen for a contribution: traditional,
/// traditional_with_curative, or remedial (Treas. Reg. §1.704-3(b)-(d)).
#[serde(default)]
pub sec704c_method: Option<String>,
/// Who gave the facts, when, and on what (e.g. the contribution
/// entry).
#[serde(default)]
pub basis_source: Option<BasisSourceIn>,
}
/// An asset's federal tax basis, as tools report it.
/// Where each basis figure's rule comes from.
#[derive(Debug, Serialize, JsonSchema)]
pub struct TaxBasisOut {
/// The rule: cost, carryover, or personal_use_conversion.
pub struct BasisSourcesOut {
/// The §723 basis's: "IRC §723" (contributions).
#[serde(skip_serializing_if = "Option::is_none")]
pub tax_basis_723: Option<String>,
/// The depreciable basis's: "IRC §1012" (purchase), "IRC §723"
/// (carryover), or "Treas. Reg. §1.168(i)-4(b)" (personal use).
pub depreciable_basis: String,
/// The built-in gain's: "Treas. Reg. §1.704-3(a)(3)(ii)"
/// (contributions).
#[serde(skip_serializing_if = "Option::is_none")]
pub built_in_gain_704c: Option<String>,
}
/// Who gave an asset's basis facts, as tools show it.
#[derive(Debug, Serialize, JsonSchema)]
pub struct BasisSourceOut {
/// Who gave them.
pub decided_by: String,
/// When, YYYY-MM-DD.
pub decided_on: String,
/// What they rest on, if anything.
#[serde(skip_serializing_if = "Option::is_none")]
pub citation: Option<String>,
/// Anything kept with them.
#[serde(skip_serializing_if = "Option::is_none")]
pub note: Option<String>,
}
/// An asset's basis facts and tax figures, as tools report them. The
/// figures are at contribution (or purchase): the CPA adjusts them for tax
/// depreciation and, for the §704(c) gain or loss, as it is reduced after.
#[derive(Debug, Serialize, JsonSchema)]
pub struct AssetBasisOut {
/// How the company got it: purchase, carryover_723, or
/// personal_use_conversion.
pub rule: String,
/// The contributing member's adjusted basis, decimal string, if a
/// contribution.
pub contributor_basis: Option<String>,
/// The depreciable federal tax basis, decimal string.
pub tax_basis: Option<String>,
/// Built-in gain (+) or loss (−) at contribution (fair market value less
/// the contributor's basis), IRC §704(c), decimal string.
pub built_in_gain: Option<String>,
/// The contributing member's adjusted basis at contribution, decimal
/// string (a contribution).
pub contributor_adjusted_basis: Option<String>,
/// Fair market value at contribution, decimal string (a contribution):
/// recorded, or the book cost for a record made before the value was
/// a fact (fmv_recorded false).
pub fmv_at_contribution: Option<String>,
/// Whether fmv_at_contribution was recorded as a fact.
pub fmv_recorded: bool,
/// The company's basis in contributed property at contribution (IRC
/// §723: the member's adjusted basis), decimal string.
pub tax_basis_723: Option<String>,
/// The basis tax depreciation starts from, decimal string.
pub depreciable_basis: String,
/// Fair market value less the §723 basis at contribution, IRC §704(c)
/// (positive a built-in gain, negative a built-in loss), decimal string.
/// What remains later is the CPA's (Treas. Reg. §1.704-3(a)(3)(ii)).
pub built_in_gain_704c: Option<String>,
/// For a built-in loss: it counts only for the contributing member(s);
/// for the others the basis is treated as the value (IRC
/// §704(c)(1)(C)).
#[serde(skip_serializing_if = "Option::is_none")]
pub sec704c_1c_note: Option<String>,
/// The §704(c) method chosen: traditional, traditional_with_curative,
/// or remedial; null until chosen.
pub sec704c_method: Option<String>,
/// Who gave the facts, when, and on what.
#[serde(skip_serializing_if = "Option::is_none")]
pub source: Option<BasisSourceOut>,
/// The authority for each figure.
pub sources: BasisSourcesOut,
}
/// Result of `books.set_asset_tax_basis`.
#[derive(Debug, Serialize, JsonSchema)]
pub struct SetAssetTaxBasisOut {
/// The asset.
pub asset_id: u64,
/// Its basis facts and figures now.
pub basis: AssetBasisOut,
/// Anything to know (an alias used).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub notes: Vec<String>,
}
/// Result of `books.register_asset`.
@@ -2320,6 +2467,17 @@ pub struct RegisterAssetOut {
pub asset_id: u64,
/// Its tag (given or generated).
pub tag: String,
/// In-service date, YYYY-MM-DD.
pub in_service_date: String,
/// Acquired date, YYYY-MM-DD: the acquisition entry's, or the
/// in-service date with none linked.
pub acquired_date: String,
/// Its basis facts and figures, if given.
#[serde(skip_serializing_if = "Option::is_none")]
pub basis: Option<AssetBasisOut>,
/// Anything to know (an alias used).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub notes: Vec<String>,
}
/// Parameters for `books.label_asset`.
@@ -2389,7 +2547,9 @@ pub struct ScheduleLineOut {
pub amount: String,
/// Accumulated depreciation through this month, decimal string.
pub accumulated: String,
/// Book value after this month, decimal string.
/// Cost less the schedule's accumulated through this month, decimal
/// string: the schedule's figure; what the books carry is
/// books.list_assets' book_value.
pub book_value: String,
}
@@ -2424,7 +2584,7 @@ pub struct AssetScheduleOut {
pub schedule: Vec<ScheduleLineOut>,
}
/// One asset in `books.list_assets`.
/// One asset in `books.list_assets`, as of the date asked.
#[derive(Debug, Serialize, JsonSchema)]
pub struct AssetSummary {
/// Asset id.
@@ -2435,38 +2595,60 @@ pub struct AssetSummary {
pub serial: Option<String>,
/// The entry that acquired it, if linked.
pub acquisition_entry: Option<u64>,
/// Its federal tax basis, if recorded.
pub tax: Option<TaxBasisOut>,
/// Its basis facts and tax figures, if recorded.
pub basis: Option<AssetBasisOut>,
/// Asset description.
pub description: String,
/// Purchase date, YYYY-MM-DD.
pub purchase_date: String,
/// In-service date, YYYY-MM-DD: depreciation starts here.
pub in_service_date: String,
/// Acquired date, YYYY-MM-DD: on the books from here.
pub acquired_date: String,
/// Cost, decimal string.
pub cost: String,
/// Useful life in months.
pub useful_life_months: u32,
/// "active" or "disposed".
pub status: String,
/// Accumulated depreciation by its schedule through today (a disposed
/// asset's through its disposal day), decimal string. Null when the
/// schedule can't be computed (see `schedule_error`).
pub accumulated: Option<String>,
/// Book value today (cost less `accumulated`), decimal string. Null
/// when the schedule can't be computed.
/// Whether the books hold it on as_of: acquired by then and not yet
/// disposed.
pub held: bool,
/// Accumulated depreciation the books carry on as_of (runs posted, less
/// reversals), decimal string; "0.00" once disposed; null before it
/// was acquired.
pub accumulated_posted: Option<String>,
/// Accumulated depreciation the schedule says is due through as_of,
/// decimal string; "0.00" once disposed; null before it was acquired.
pub accumulated_scheduled_through: Option<String>,
/// Scheduled less posted: run books.run_depreciation for it. Decimal
/// string; null before it was acquired.
pub unposted_depreciation: Option<String>,
/// What the books carry: cost less accumulated_posted ("0.00" once
/// disposed), decimal string; null before it was acquired.
pub book_value: Option<String>,
/// Why `accumulated` and `book_value` are null, when they are: an
/// asset an earlier version took whose schedule the books can't
/// compute. It lists, and the rest of the register with it.
/// Why the figures are null for an asset acquired by as_of: one an
/// earlier version took whose schedule the books can't compute. It
/// lists, and the rest of the register with it.
pub schedule_error: Option<String>,
}
/// Result of `books.list_assets`.
#[derive(Debug, Serialize, JsonSchema)]
pub struct ListAssetsOut {
/// The date the figures are as of, YYYY-MM-DD.
pub as_of: String,
/// All registered assets.
pub assets: Vec<AssetSummary>,
}
/// Parameters for `books.list_assets`.
#[derive(Debug, Default, Deserialize, JsonSchema)]
pub struct ListAssetsParams {
/// The date the figures are as of, YYYY-MM-DD. Default: today (the
/// company's time zone).
#[serde(default)]
pub as_of: Option<String>,
}
/// Parameters for `books.run_depreciation`.
#[derive(Debug, Deserialize, JsonSchema)]
pub struct RunDepreciationParams {
+5 -2
View File
@@ -677,6 +677,7 @@ fn string_for(name: &str, node: &Value, p: &Places) -> BoxedStrategy<Value> {
"ss_",
"medicare",
"deductions",
"fmv",
];
if n.contains("period") && !n.contains("period_") {
likely.extend(pick(period_like));
@@ -992,11 +993,13 @@ fn inputs_that_once_panicked_are_refused_or_answered() {
json!({"description": "y", "purchase_date": "2026-01-01", "cost": "100.00",
"useful_life_months": 12, "cost_account": "1500",
"accum_depr_account": "1501", "expense_account": "6100",
"tax_basis_rule": "carryover", "contributor_basis": MAX}),
"tax_basis_rule": "carryover", "contributor_basis": MAX,
"fmv_at_contribution": MAX}),
),
(
"books.set_asset_tax_basis",
json!({"asset": "1", "rule": "carryover", "contributor_basis": MAX}),
json!({"asset": "1", "rule": "carryover", "contributor_basis": MAX,
"fmv_at_contribution": MAX}),
),
(
"books.export",
+3 -3
View File
@@ -562,7 +562,7 @@ fn rich_books(s: &mut Store) -> Rich {
let asset = s
.register_asset(NewAsset {
description: "Laptop".into(),
purchase_date: d("2025-03-20"),
in_service_date: d("2025-03-20"),
cost: usd("3000.00"),
salvage_value: usd("0.00"),
useful_life_months: 36,
@@ -1905,7 +1905,7 @@ fn dispose_asset_previews_equal_the_store_and_gate_receipts_on_debits() {
let reg = |s: &mut Store, desc: &str| {
s.register_asset(NewAsset {
description: desc.into(),
purchase_date: d("2025-01-01"),
in_service_date: d("2025-01-01"),
cost: usd("1200.00"),
salvage_value: usd("0.00"),
useful_life_months: 12,
@@ -2030,7 +2030,7 @@ fn run_depreciation_catches_up_and_skips_what_has_nothing_due() {
let reg = |s: &mut Store, desc: &str, date: &str, months: u32| {
s.register_asset(NewAsset {
description: desc.into(),
purchase_date: d(date),
in_service_date: d(date),
cost: usd("600.00"),
salvage_value: usd("0.00"),
useful_life_months: months,
+510 -212
View File
@@ -323,7 +323,7 @@ impl Mcp {
/// Register a $1,200, 12-month laptop from 2026-01-01 with `extra` args
/// (tag, serial, description).
fn register(&mut self, extra: Value) -> Out {
let mut args = json!({"description": "Laptop", "purchase_date": "2026-01-01",
let mut args = json!({"description": "Laptop", "in_service_date": "2026-01-01",
"cost": "1200.00", "salvage_value": "0.00", "useful_life_months": 12,
"cost_account": "1500", "accum_depr_account": "1510", "expense_account": "6500"});
for (k, v) in extra.as_object().unwrap() {
@@ -798,7 +798,7 @@ fn asset_labels_refuses_an_asset_without_a_tag() {
let usd = |a: &str| Money::new(a.parse().unwrap(), Currency::USD);
s.register_asset(NewAsset {
description: "Old laptop".into(),
purchase_date: chrono::NaiveDate::from_ymd_opt(2026, 1, 1).unwrap(),
in_service_date: chrono::NaiveDate::from_ymd_opt(2026, 1, 1).unwrap(),
cost: usd("1200.00"),
salvage_value: usd("0.00"),
useful_life_months: 12,
@@ -909,7 +909,7 @@ fn export_fixed_asset_register_reconciles_to_the_books() {
fn export_fixed_asset_register_as_of_leaves_out_later_assets() {
let mut m = books("export-later");
m.register_ok(json!({"tag": "EARLY-1"}));
m.register_ok(json!({"tag": "LATE-1", "purchase_date": "2026-06-01"}));
m.register_ok(json!({"tag": "LATE-1", "in_service_date": "2026-06-01"}));
let r = m.read_only(
"books.export",
json!({"report": "fixed_asset_register", "format": "json", "as_of": "2026-03-31"}),
@@ -921,14 +921,14 @@ fn export_fixed_asset_register_as_of_leaves_out_later_assets() {
// ------------------------------------------------- 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)).
// From the `books.set_asset_tax_basis` and `books.register_asset`
// descriptions, `SetAssetTaxBasisParams` / `AssetBasisOut` /
// `RegisterAssetParams` docs, and the fixed_asset_register export.
// Purchase: the book cost (IRC §1012); carryover_723: the member's
// adjusted basis (IRC §723); personal_use_conversion: §723 basis as for
// carryover, depreciable basis the lesser of fair market value and that
// basis (Treas. Reg. §1.168(i)-4(b)); built_in_gain_704c = value less the
// §723 basis at contribution (Treas. Reg. §1.704-3(a)(3)(ii)).
/// A decimal-string field, compared by value; `None` for null.
fn dec(v: &Value, key: &str) -> Option<String> {
@@ -939,146 +939,252 @@ fn dec(v: &Value, key: &str) -> Option<String> {
}
}
fn tax_basis_lines(m: &Mcp) -> usize {
fn basis_lines(m: &Mcp) -> usize {
m.journal_types()
.iter()
.filter(|t| *t == "asset.tax_basis_set")
.filter(|t| *t == "asset.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.
/// books.set_asset_tax_basis by tag and by id; the output names each
/// figure for what it is, with its authority; each call journals an
/// asset.basis_set; the old rule names are aliases, noted.
#[test]
fn set_asset_tax_basis_by_tag_or_id_reports_the_basis() {
fn set_asset_tax_basis_by_tag_or_id_reports_each_figure() {
let mut m = books("tax-set");
let (a, t) = m.register_ok(json!({}));
assert_eq!(m.asset(a)["tax"], Value::Null, "not recorded yet");
assert_eq!(m.asset(a)["basis"], Value::Null, "not recorded yet");
// By tag (any case): carryover of the member's $900 basis.
// By tag (any case): the member's $900 basis in a $1,200 laptop from
// business use.
let r = m.ok(
"books.set_asset_tax_basis",
json!({"asset": t.to_ascii_lowercase(), "rule": "carryover", "contributor_basis": "900.00"}),
json!({"asset": t.to_ascii_lowercase(), "rule": "carryover_723",
"contributor_adjusted_basis": "900.00", "fmv_at_contribution": "1200.00",
"sec704c_method": "traditional",
"basis_source": {"decided_by": "Morgan Ellis", "decided_on": "2026-10-20",
"citation": "entry 1"}}),
);
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!(r["asset_id"], json!(a));
assert!(r.get("notes").is_none(), "{r}");
let b = &r["basis"];
assert_eq!(b["rule"], "carryover_723");
assert_eq!(dec(b, "contributor_adjusted_basis").as_deref(), Some("900"));
assert_eq!(dec(b, "fmv_at_contribution").as_deref(), Some("1200"));
assert_eq!(b["fmv_recorded"], true);
assert_eq!(dec(b, "tax_basis_723").as_deref(), Some("900"));
assert_eq!(dec(b, "depreciable_basis").as_deref(), Some("900"));
assert_eq!(dec(b, "built_in_gain_704c").as_deref(), Some("300"));
assert!(b.get("sec704c_1c_note").is_none(), "a gain has no note");
assert_eq!(b["sec704c_method"], "traditional");
assert_eq!(b["source"]["decided_by"], "Morgan Ellis");
assert_eq!(b["sources"]["tax_basis_723"], "IRC §723");
assert_eq!(b["sources"]["depreciable_basis"], "IRC §723");
assert_eq!(
dec(&r, "built_in_gain").as_deref(),
Some("300"),
"1200 − 900"
b["sources"]["built_in_gain_704c"],
"Treas. Reg. §1.704-3(a)(3)(ii)"
);
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!(basis_lines(&m), 1);
assert_eq!(
dec(&r, "built_in_gain").as_deref(),
Some("-800"),
"a built-in loss"
m.journal_types()
.iter()
.filter(|t| *t == "journal.format_set")
.count(),
1,
"the feature declared once"
);
assert_eq!(tax_basis_lines(&m), 2);
// Boundary: value equals basis.
// By id: personal use, the member's $2,000 laptop worth $1,200.
let r = m.ok(
"books.set_asset_tax_basis",
json!({"asset": t, "rule": "personal_use_conversion", "contributor_basis": "1200.00"}),
json!({"asset": a.to_string(), "rule": "personal_use_conversion",
"contributor_adjusted_basis": "2000.00", "fmv_at_contribution": "1200.00"}),
);
assert_eq!(dec(&r, "tax_basis").as_deref(), Some("1200"));
assert_eq!(dec(&r, "built_in_gain").as_deref(), Some("0"));
let b = &r["basis"];
assert_eq!(dec(b, "tax_basis_723").as_deref(), Some("2000"));
assert_eq!(
dec(b, "depreciable_basis").as_deref(),
Some("1200"),
"the lesser"
);
assert_eq!(dec(b, "built_in_gain_704c").as_deref(), Some("-800"));
assert!(
b["sec704c_1c_note"]
.as_str()
.unwrap()
.contains("IRC §704(c)(1)(C)"),
"{b}"
);
assert_eq!(
b["sources"]["depreciable_basis"],
"Treas. Reg. §1.168(i)-4(b)"
);
assert_eq!(b["sec704c_method"], Value::Null);
// Cost: the book cost, no contributor basis, no built-in gain.
// A purchase: the book cost, nothing contributed.
let r = m.ok(
"books.set_asset_tax_basis",
json!({"asset": t, "rule": "purchase"}),
);
let b = &r["basis"];
assert_eq!(b["rule"], "purchase");
assert_eq!(b["contributor_adjusted_basis"], Value::Null);
assert_eq!(b["tax_basis_723"], Value::Null);
assert_eq!(dec(b, "depreciable_basis").as_deref(), Some("1200"));
assert_eq!(b["built_in_gain_704c"], Value::Null);
assert_eq!(b["sources"]["depreciable_basis"], "IRC §1012");
assert_eq!(basis_lines(&m), 3);
// list_assets shows the latest record.
let basis = m.asset(a)["basis"].clone();
assert_eq!(basis["rule"], "purchase");
}
/// The old names (rule cost, carryover; contributor_basis) are aliases for
/// one release, noted in the result; a name and its alias that disagree
/// are refused.
#[test]
fn basis_rule_legacy_names_are_aliases() {
let mut m = books("tax-alias");
let (_, t) = m.register_ok(json!({}));
let r = m.ok(
"books.set_asset_tax_basis",
json!({"asset": t, "rule": "carryover", "contributor_basis": "900.00",
"fmv_at_contribution": "1200.00"}),
);
assert_eq!(r["basis"]["rule"], "carryover_723");
let notes = r["notes"].to_string();
assert!(
notes.contains("contributor_basis is now contributor_adjusted_basis"),
"{notes}"
);
assert!(
notes.contains("\\\"carryover\\\" is now \\\"carryover_723\\\""),
"{notes}"
);
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"));
assert_eq!(r["basis"]["rule"], "purchase");
let why = m.refuse(
"books.set_asset_tax_basis",
json!({"asset": t, "rule": "carryover_723", "contributor_adjusted_basis": "1.00",
"contributor_basis": "2.00", "fmv_at_contribution": "3.00"}),
);
assert!(why.contains("disagree"), "{why}");
let (_, t2) = m.register_ok(json!({"tax_basis_rule": "carryover",
"contributor_basis": "400.00", "fmv_at_contribution": "1200.00"}));
assert_eq!(m.assets().len(), 2);
let _ = t2;
}
/// 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.
/// 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]
fn fmv_is_required_for_a_contribution() {
let mut m = books("tax-fmv");
let (_, t) = m.register_ok(json!({}));
for rule in ["carryover_723", "personal_use_conversion"] {
let why = m.refuse(
"books.set_asset_tax_basis",
json!({"asset": t, "rule": rule, "contributor_adjusted_basis": "900.00"}),
);
assert!(why.contains("fmv_at_contribution"), "{why}");
let out = m.register(json!({"basis_rule": rule, "contributor_adjusted_basis": "1.00"}));
assert!(out.refused(), "{out:?}");
}
assert_eq!(m.assets().len(), 1, "nothing registered");
assert_eq!(basis_lines(&m), 0);
}
/// Refusals: unknown asset, bad rule or method, a purchase with
/// contribution facts, a contribution without its basis, 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"}),
json!({"asset": t, "rule": "carryover_723", "contributor_adjusted_basis": "500.00",
"fmv_at_contribution": "1200.00"}),
);
let before = m.asset(a)["tax"].clone();
let before = m.asset(a)["basis"].clone();
let c = |extra: Value| {
let mut v = json!({"asset": t, "rule": "carryover_723",
"contributor_adjusted_basis": "500.00", "fmv_at_contribution": "1200.00"});
for (k, x) in extra.as_object().unwrap() {
v[k] = x.clone();
}
v
};
for args in [
json!({"asset": "NOPE-1", "rule": "cost"}),
json!({"asset": "999", "rule": "cost"}),
json!({"asset": "NOPE-1", "rule": "purchase"}),
json!({"asset": "999", "rule": "purchase"}),
json!({"asset": t, "rule": "fmv"}),
json!({"asset": t, "rule": "Cost"}),
json!({"asset": t, "rule": "Purchase"}),
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"}),
json!({"asset": t, "rule": "purchase", "contributor_adjusted_basis": "100.00"}),
json!({"asset": t, "rule": "purchase", "fmv_at_contribution": "100.00"}),
json!({"asset": t, "rule": "purchase", "sec704c_method": "traditional"}),
json!({"asset": t, "rule": "carryover_723", "fmv_at_contribution": "1.00"}),
c(json!({"contributor_adjusted_basis": "-1.00"})),
c(json!({"contributor_adjusted_basis": "1.001"})),
c(json!({"fmv_at_contribution": "lots"})),
c(json!({"fmv_at_contribution": "1,000.00"})),
c(json!({"sec704c_method": "ceiling"})),
c(json!({"basis_source": {"decided_by": " "}})),
] {
m.refuse("books.set_asset_tax_basis", args);
}
assert_eq!(m.asset(a)["tax"], before);
assert_eq!(tax_basis_lines(&m), 1);
assert_eq!(m.asset(a)["basis"], before);
assert_eq!(basis_lines(&m), 1);
}
/// register_asset with tax_basis_rule / contributor_basis records it at
/// registration; without them, list_assets shows tax null.
/// register_asset with basis_rule and its facts records it at
/// registration; without, list_assets shows basis null.
#[test]
fn register_asset_records_a_tax_basis() {
fn register_asset_records_a_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 (a, _) = m.register_ok(json!({"basis_rule": "purchase"}));
let (b, _) = m.register_ok(json!({"basis_rule": "personal_use_conversion",
"contributor_adjusted_basis": "1500.00", "fmv_at_contribution": "1200.00"}));
let (c, _) = m.register_ok(json!({"basis_rule": "carryover_723",
"contributor_adjusted_basis": "400.00", "fmv_at_contribution": "1200.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);
let ta = m.asset(a)["basis"].clone();
assert_eq!(ta["rule"], "purchase");
assert_eq!(dec(&ta, "depreciable_basis").as_deref(), Some("1200"));
let tb = m.asset(b)["basis"].clone();
assert_eq!(dec(&tb, "tax_basis_723").as_deref(), Some("1500"));
assert_eq!(dec(&tb, "depreciable_basis").as_deref(), Some("1200"));
assert_eq!(dec(&tb, "built_in_gain_704c").as_deref(), Some("-300"));
let tc = m.asset(c)["basis"].clone();
assert_eq!(dec(&tc, "depreciable_basis").as_deref(), Some("400"));
assert_eq!(dec(&tc, "built_in_gain_704c").as_deref(), Some("800"));
assert_eq!(m.asset(n)["basis"], Value::Null);
}
/// contributor_basis without tax_basis_rule, or a bad rule, is refused
/// and registers nothing.
/// A basis fact without basis_rule, a bad rule, or a record the engine
/// refuses, given at registration, is a refused call: it registers
/// nothing (server invariant 2).
#[test]
fn register_asset_refuses_a_bad_tax_basis() {
fn register_asset_refuses_a_bad_basis_and_registers_nothing() {
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"}),
json!({"contributor_adjusted_basis": "500.00"}),
json!({"fmv_at_contribution": "500.00"}),
json!({"basis_rule": "market_value"}),
json!({"basis_rule": "carryover_723", "contributor_adjusted_basis": "five",
"fmv_at_contribution": "1.00"}),
json!({"basis_rule": "carryover_723", "fmv_at_contribution": "1.00"}),
json!({"basis_rule": "purchase", "contributor_adjusted_basis": "100.00"}),
json!({"basis_rule": "carryover_723", "contributor_adjusted_basis": "-1.00",
"fmv_at_contribution": "1.00"}),
json!({"basis_rule": "purchase", "tax_basis_rule": "carryover"}),
] {
let out = m.register(extra.clone());
assert!(out.refused(), "{extra}: {out:?}");
@@ -1086,59 +1192,187 @@ fn register_asset_refuses_a_bad_tax_basis() {
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.
// ------------------------------------------- the dates (rules.md B-15)
/// purchase_date is an alias for in_service_date for one release, noted.
#[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");
fn purchase_date_is_an_alias_for_in_service_date() {
let mut m = books("alias-date");
let r = match m.register(json!({"in_service_date": null, "purchase_date": "2026-02-01"})) {
Out::Ok(v) => v,
other => panic!("{other:?}"),
};
assert_eq!(r["in_service_date"], "2026-02-01");
assert_eq!(r["acquired_date"], "2026-02-01");
assert!(
r["notes"]
.to_string()
.contains("purchase_date is now in_service_date"),
"{r}"
);
let a = m.asset(r["asset_id"].as_u64().unwrap());
assert_eq!(a["in_service_date"], "2026-02-01");
assert!(a.get("purchase_date").is_none(), "{a}");
}
/// 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)),
_ => {}
}
/// Both given with different dates: refused, nothing registered; neither:
/// refused.
#[test]
fn purchase_date_and_a_different_in_service_date_are_refused() {
let mut m = books("alias-differ");
let out = m.register(json!({"in_service_date": "2026-01-01", "purchase_date": "2026-02-01"}));
assert!(out.refused(), "{out:?}");
let out = m.register(json!({"in_service_date": null}));
assert!(out.refused(), "{out:?}");
assert!(m.assets().is_empty());
m.register_ok(json!({"in_service_date": "2026-01-01", "purchase_date": "2026-01-01"}));
}
/// 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.
/// The acquired date is the acquisition entry's (an asset bought before it
/// is in service); an entry dated after the in-service date is refused,
/// registering nothing.
#[test]
fn export_fixed_asset_register_carries_tax_basis() {
let mut m = books("tax-export");
fn acquired_date_comes_from_the_acquisition_entry() {
let mut m = books("acquired");
m.post(
"2026-01-10",
&[("1500", "debit", "1200.00"), ("1000", "credit", "1200.00")],
);
let entry = 1;
let r = match m.register(json!({"in_service_date": "2026-02-01", "acquisition_entry": entry})) {
Out::Ok(v) => v,
other => panic!("{other:?}"),
};
assert_eq!(r["acquired_date"], "2026-01-10");
assert_eq!(r["in_service_date"], "2026-02-01");
let a = m.asset(r["asset_id"].as_u64().unwrap());
assert_eq!(a["acquired_date"], "2026-01-10");
let why = match m.register(json!({"in_service_date": "2026-01-05", "acquisition_entry": entry}))
{
Out::Ok(v) => panic!("registered {v}"),
other => other.err_text(),
};
assert!(why.contains("after the in-service date"), "{why}");
assert_eq!(m.assets().len(), 1);
// correct_asset: never before the acquisition entry; in_service the
// old name of in_service_date.
let id = r["asset_id"].as_u64().unwrap();
let why = m.refuse(
"books.correct_asset",
json!({"asset": id.to_string(), "in_service_date": "2026-01-09", "reason": "typo"}),
);
assert!(why.contains("before it was acquired"), "{why}");
let c = m.ok(
"books.correct_asset",
json!({"asset": id.to_string(), "in_service": "2026-01-15", "reason": "typo"}),
);
assert_eq!(c["in_service_date"], "2026-01-15");
assert_eq!(c["acquired_date"], "2026-01-10");
assert!(
c["notes"]
.to_string()
.contains("in_service is now in_service_date")
);
}
// ------------------------------- posted and scheduled (rules.md B-24)
/// Named regression 1 (the brief's reproduction 1, its figures made up): a
/// $1,440.00 laptop, 36 months, in service 2026-05-15, nothing run. As of
/// 2026-05-20 the schedule has 1,440/36 × 6/31 = 7.74 due, the books carry
/// nothing: posted 0.00, scheduled 7.74, unposted 7.74, book value
/// 1,440.00 (the tool called 1,432.26 book value before P3). May's run is
/// dated 05-31: as of 05-20 still 0.00 posted; as of 05-31 posted =
/// scheduled = 1,440/36 × 17/31 = 21.94.
#[test]
fn list_assets_book_value_is_what_the_books_carry() {
let mut m = books("book-value");
let (id, _) = m.register_ok(json!({"in_service_date": "2026-05-15", "cost": "1440.00",
"useful_life_months": 36}));
let at = |m: &mut Mcp, as_of: &str| -> Value {
let r = m.read_only("books.list_assets", json!({"as_of": as_of}));
assert_eq!(r["as_of"], as_of);
r["assets"]
.as_array()
.unwrap()
.iter()
.find(|a| a["asset_id"] == json!(id))
.unwrap()
.clone()
};
let a = at(&mut m, "2026-05-20");
assert_eq!(a["held"], true);
assert_eq!(dec(&a, "accumulated_posted").as_deref(), Some("0"));
assert_eq!(
dec(&a, "accumulated_scheduled_through").as_deref(),
Some("7.74")
);
assert_eq!(dec(&a, "unposted_depreciation").as_deref(), Some("7.74"));
assert_eq!(dec(&a, "book_value").as_deref(), Some("1440"));
assert!(a.get("accumulated").is_none(), "{a}");
m.ok(
"books.run_depreciation",
json!({"period": "2026-05", "asset_id": id}),
);
let a = at(&mut m, "2026-05-20");
assert_eq!(dec(&a, "accumulated_posted").as_deref(), Some("0"));
let a = at(&mut m, "2026-05-31");
assert_eq!(dec(&a, "accumulated_posted").as_deref(), Some("21.94"));
assert_eq!(
dec(&a, "accumulated_scheduled_through").as_deref(),
Some("21.94")
);
assert_eq!(dec(&a, "unposted_depreciation").as_deref(), Some("0"));
assert_eq!(dec(&a, "book_value").as_deref(), Some("1418.06"));
// Before it was acquired: not held, no figures.
let a = at(&mut m, "2026-05-14");
assert_eq!(a["held"], false);
assert_eq!(a["book_value"], Value::Null);
assert_eq!(a["accumulated_posted"], Value::Null);
}
/// A run reversed is no longer posted (named regression 2, its posted
/// figure): the books' accumulated depreciation and book value go back,
/// the scheduled figure stays, and the difference is due again.
#[test]
fn a_reversed_run_is_not_posted() {
let mut m = books("reversed-run");
let (id, _) = m.register_ok(json!({}));
let run = m.ok(
"books.run_depreciation",
json!({"period": "2026-01", "asset_id": id}),
);
let entry = run["runs"][0]["entry_id"].as_u64().unwrap();
let at = |m: &mut Mcp| -> Value {
m.read_only("books.list_assets", json!({"as_of": "2026-01-31"}))["assets"][0].clone()
};
assert_eq!(
dec(&at(&mut m), "accumulated_posted").as_deref(),
Some("100")
);
m.ok(
"books.reverse_entry",
json!({"entry_id": entry, "date": "2026-01-31", "description": "undo", "confirmed": true}),
);
let a = at(&mut m);
assert_eq!(dec(&a, "accumulated_posted").as_deref(), Some("0"));
assert_eq!(
dec(&a, "accumulated_scheduled_through").as_deref(),
Some("100")
);
assert_eq!(dec(&a, "unposted_depreciation").as_deref(), Some("100"));
assert_eq!(dec(&a, "book_value").as_deref(), Some("1200"));
}
/// A disposed asset is not held: every figure 0.00 once it left the books.
#[test]
fn a_disposed_asset_has_left_the_books() {
let mut m = books("disposed-list");
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 (id, sold) = m.register_ok(json!({}));
let doc = m.file_bill_of_sale();
m.ok(
"books.dispose_asset",
@@ -1146,7 +1380,57 @@ fn export_fixed_asset_register_carries_tax_basis() {
"proceeds_account": "1000", "gain_loss_account": "7100",
"confirmed": true, "receipt_attachment_ids": [doc]}),
);
let a = m.asset(id);
assert_eq!(a["status"], "disposed");
assert_eq!(a["held"], false);
for k in [
"accumulated_posted",
"accumulated_scheduled_through",
"unposted_depreciation",
"book_value",
] {
assert_eq!(dec(&a, k).as_deref(), Some("0"), "{k}");
}
}
/// The fixed_asset_register export carries, per asset, its acquired and
/// in-service dates, accumulated depreciation scheduled and posted, book
/// value (cost less posted), and the basis facts and figures; JSON the
/// totals and the three differences. A disposed asset is out of the
/// totals. By hand, as of 2026-06-30: held with a basis, depreciable
/// BOUGHT-1 1,200.00, CARRY-1 650.25 and CONV-1 1,200.00, together
/// 3,050.25; §723 650.25 and 2,000.00, together 2,650.25; BOUGHT-1 ran
/// January: posted 100.00 of 600.00 scheduled.
#[test]
fn export_fixed_asset_register_carries_posted_scheduled_and_704c() {
let mut m = books("tax-export");
m.post(
"2026-01-01",
&[("1000", "debit", "9000.00"), ("3000", "credit", "9000.00")],
);
let (bought, _) = m.register_ok(json!({"tag": "BOUGHT-1", "basis_rule": "purchase"}));
m.register_ok(json!({"tag": "CARRY-1", "basis_rule": "carryover_723",
"contributor_adjusted_basis": "650.25", "fmv_at_contribution": "1200.00",
"sec704c_method": "traditional"}));
m.register_ok(
json!({"tag": "CONV-1", "basis_rule": "personal_use_conversion",
"contributor_adjusted_basis": "2000.00", "fmv_at_contribution": "1200.00",
"basis_source": {"decided_by": "Tobin Harlow", "decided_on": "2026-10-20",
"citation": "entry 9"}}),
);
m.register_ok(json!({"tag": "NONE-1"}));
let (_, sold) = m.register_ok(json!({"tag": "SOLD-1", "basis_rule": "purchase"}));
m.ok(
"books.run_depreciation",
json!({"period": "2026-01", "asset_id": bought}),
);
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",
@@ -1156,95 +1440,101 @@ fn export_fixed_asset_register_carries_tax_basis() {
.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);
let rows = j["assets"].as_array().unwrap();
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 row = |tag: &str| rows.iter().find(|r| r["tag"] == tag).unwrap().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);
assert_eq!(r["basis_rule"], "purchase");
assert_eq!(dec(&r, "accumulated_scheduled").as_deref(), Some("600"));
assert_eq!(dec(&r, "accumulated_posted").as_deref(), Some("100"));
assert_eq!(dec(&r, "book_value").as_deref(), Some("1100"));
assert_eq!(dec(&r, "depreciable_basis").as_deref(), Some("1200"));
assert_eq!(r["acquired_date"], "2026-01-01");
assert_eq!(r["in_service_date"], "2026-01-01");
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"));
assert_eq!(dec(&r, "tax_basis_723").as_deref(), Some("650.25"));
assert_eq!(dec(&r, "built_in_gain_704c").as_deref(), Some("549.75"));
assert_eq!(r["sec704c_method"], "traditional");
assert_eq!(r["fmv_recorded"], true);
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)]);
assert_eq!(dec(&r, "depreciable_basis").as_deref(), Some("1200"));
assert_eq!(dec(&r, "built_in_gain_704c").as_deref(), Some("-800"));
assert_eq!(r["sec704c_1c"], true);
assert_eq!(r["basis_source"], "Tobin Harlow, 2026-10-20: entry 9");
assert_eq!(row("NONE-1")["basis_rule"], Value::Null);
assert_eq!(the_value(&j, "total_depreciable_basis"), "3050.25");
assert_eq!(the_value(&j, "total_tax_basis_723"), "2650.25");
assert_eq!(the_value(&j, "total_accumulated_posted"), "100");
assert_eq!(the_value(&j, "unattributed_accumulated"), "0");
assert_eq!(j["missing_basis"], json!(1));
assert!(
j["tax_figures_are"]
.as_str()
.unwrap()
.contains("at contribution")
);
// CSV: the four tax columns, by header.
// CSV: 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())
.map(str::to_string)
.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"),
assert_eq!(
header,
[
"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"
]
);
let col = |name: &str| header.iter().position(|h| h == name).unwrap();
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))
.find(|r| r.first().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");
assert_eq!(r[col("basis_rule")], "carryover_723");
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");
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.
assert_eq!(canon(&r[col("accumulated_posted")]), "100");
assert_eq!(canon(&r[col("book_value")]), "1100");
assert_eq!(r[col("contributor_adjusted_basis")], "");
let total = csv_row("TOTAL");
assert_eq!(canon(&total[c_tax]), "3050.25");
assert_eq!(total.len(), header.len());
assert_eq!(canon(&total[col("depreciable_basis")]), "3050.25");
assert_eq!(canon(&total[col("tax_basis_723")]), "2650.25");
assert_eq!(canon(&total[col("accumulated_posted")]), "100");
}
// ------------------------------------- P3: defect 22 and runs, over MCP
@@ -1279,9 +1569,10 @@ fn dispose_args(x: u64, date: &str, proceeds: &str) -> Value {
/// Defect 22, reproduction B over MCP: January through May run, then a
/// disposal on 2026-06-15 for 7,000.00. Hand check: June held 15 of 30
/// days, 500.00 (the final depreciation); accumulated 5,500.00, book value
/// 6,500.00, gain 500.00. Afterwards books.asset_schedule ends there and
/// books.list_assets says the same. Before P3 the schedule showed June
/// 7,000.00, accumulated 12,000.00, book value 0.00.
/// 6,500.00, gain 500.00. Afterwards books.asset_schedule ends there, and
/// books.list_assets lists it as gone: not held, every figure 0.00 (it
/// left the books with its disposal; rules.md B-15 rule 8). Before P3 the
/// schedule showed June 7,000.00, accumulated 12,000.00, book value 0.00.
#[test]
fn a_disposed_asset_lists_what_it_depreciated_through_its_disposal() {
let (mut m, x) = asset_x("disposed-figures");
@@ -1313,8 +1604,15 @@ fn a_disposed_asset_lists_what_it_depreciated_through_its_disposal() {
assert_eq!(canon(jun["accumulated"].as_str().unwrap()), "5500");
assert_eq!(canon(jun["book_value"].as_str().unwrap()), "6500");
let listed = m.asset(x);
assert_eq!(canon(listed["accumulated"].as_str().unwrap()), "5500");
assert_eq!(canon(listed["book_value"].as_str().unwrap()), "6500");
assert_eq!(listed["held"], false);
for figure in [
"accumulated_posted",
"accumulated_scheduled_through",
"unposted_depreciation",
"book_value",
] {
assert_eq!(canon(listed[figure].as_str().unwrap()), "0", "{figure}");
}
assert_eq!(listed["schedule_error"], Value::Null);
}
+1 -1
View File
@@ -1013,7 +1013,7 @@ fn happy_path(name: &str) -> Mcp {
);
m.ok(
"books.set_asset_tax_basis",
json!({"asset": "LAPTOP-1", "rule": "personal_use_conversion", "contributor_basis": "3200.00"}),
json!({"asset": "LAPTOP-1", "rule": "personal_use_conversion", "contributor_basis": "3200.00", "fmv_at_contribution": "3000.00"}),
);
m.ok(
"books.correct_asset",