Fixed assets carry federal tax basis beside book value; tests sweep stale temp dirs
check / check (push) Canceled after 0s
check / check (push) Canceled after 0s
- core: TaxBasisRule (cost IRC 1012, carryover IRC 723, personal-use conversion Treas. Reg. 1.168(i)-4(b)), TaxBasis with the contributor's adjusted basis, Asset::tax_basis and ::built_in_gain (IRC 704(c)); the register gains tax rule, contributor basis, tax basis, built-in gain, total tax basis, missing count (rules.md B-25; sources vendored in docs/spec/sources/irs-partnership-basis.md) - store: asset.tax_basis_set journaled, replayed, projected (schema v10) - mcp: books.set_asset_tax_basis; register_asset takes tax_basis_rule and contributor_basis, checked before anything registers (found by the spec-blind suite); list_assets and the register export (json, csv) carry tax basis; instructions say to record it for every asset - import: the register's XLSX and PDF gain the federal tax basis table with citations - viewer: tax basis on the Assets page, the asset page (a Tax basis card), the report, the journal, and Needs attention for assets without one - just: check and the mutation recipes sweep this user's test book dirs older than an hour (killed runs had left 23 GB in RAM-backed /tmp) - docs: the parties, attachments, TIN vault, completeness engine, and updates designs (docs/parties-and-attachments.md) - tests: spec-blind tax basis suites in core, store, MCP; register PDF/XLSX and viewer tests Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
This commit is contained in:
1 parent
49c49aef33
commit
6e6f9d65b2
30 files changed
+3240
-93
No files matched your search
@@ -28,11 +28,28 @@
|
||||
//! 5. Header and footer as on every statement: company, "Fixed Asset
|
||||
//! Register", "As of DATE"; basis, prepared on, journal seq.
|
||||
//! 6. PDF: deterministic bytes; every text is data, never markup.
|
||||
//! 7. Tax basis beside book value: every line carries its tax basis
|
||||
//! rule (in words: [`rule_label`], [`rule_cited`]), the contributor's
|
||||
//! basis, the tax basis, and the built-in gain (loss), each the
|
||||
//! register row's own (ledger_core::Asset::tax_basis,
|
||||
//! Asset::built_in_gain), never re-derived here; a line with none
|
||||
//! recorded says "Not recorded" and leaves the amounts blank.
|
||||
//! 8. The tax basis total is the register's total_tax_basis; when
|
||||
//! missing_tax_basis > 0 a line says "Tax basis not recorded for N
|
||||
//! asset(s)" (see [`missing_line`]).
|
||||
//! 9. The notes ([`notes`]) say that book value is fair market value at
|
||||
//! contribution and tax basis follows its rule, and cite each rule
|
||||
//! (IRC §1012(a), §723, Treas. Reg. §1.168(i)-4(b), IRC §704(c)).
|
||||
//! The PDF marks each rule in the tax table with its note's number.
|
||||
//! ```
|
||||
//!
|
||||
//! SOURCE (7-9): docs/spec/rules.md B-25;
|
||||
//! docs/spec/sources/irs-partnership-basis.md.
|
||||
|
||||
use crate::pdf::{INTER, PdfError};
|
||||
use crate::report::{ReportMeta, amount_parts, format_amount, long_date};
|
||||
use crate::xlsx::{Formats, XlsxError, footer, header, number};
|
||||
use ledger_core::TaxBasisRule;
|
||||
use ledger_core::reports::{AssetRegister, AssetRegisterRow};
|
||||
use rust_decimal::Decimal;
|
||||
use rust_xlsxwriter::{Format, FormatAlign, Workbook};
|
||||
@@ -58,9 +75,135 @@ pub const COLUMNS: [&str; 10] = [
|
||||
"Status",
|
||||
];
|
||||
|
||||
/// The PDF's tax basis table's headings, left to right.
|
||||
pub const TAX_COLUMNS: [&str; 7] = [
|
||||
"Tag",
|
||||
"Description",
|
||||
"Tax basis rule",
|
||||
"Book cost",
|
||||
"Contributor's basis",
|
||||
"Tax basis",
|
||||
"Built-in gain (loss)",
|
||||
];
|
||||
|
||||
/// The XLSX headings, left to right: the book columns, the tax columns,
|
||||
/// then the status.
|
||||
pub const XLSX_COLUMNS: [&str; 14] = [
|
||||
"Tag",
|
||||
"Description",
|
||||
"Serial",
|
||||
"In service",
|
||||
"Cost",
|
||||
"Salvage",
|
||||
"Life (months)",
|
||||
"Accumulated depreciation",
|
||||
"Book value",
|
||||
"Tax basis rule",
|
||||
"Contributor's basis",
|
||||
"Tax basis",
|
||||
"Built-in gain (loss)",
|
||||
"Status",
|
||||
];
|
||||
|
||||
/// The line shown when there are no assets.
|
||||
pub const EMPTY: &str = "No fixed assets";
|
||||
|
||||
/// The tax table's title.
|
||||
pub const TAX_TITLE: &str = "Federal tax basis";
|
||||
|
||||
/// The rule cell of an asset with no tax basis recorded.
|
||||
pub const NOT_RECORDED: &str = "Not recorded";
|
||||
|
||||
/// A rule in short words, for the table: "Cost", "Carryover",
|
||||
/// "Personal-use conversion".
|
||||
pub fn rule_label(r: TaxBasisRule) -> &'static str {
|
||||
match r {
|
||||
TaxBasisRule::Cost => "Cost",
|
||||
TaxBasisRule::Carryover => "Carryover",
|
||||
TaxBasisRule::PersonalUseConversion => "Personal-use conversion",
|
||||
}
|
||||
}
|
||||
|
||||
/// A rule in words with its citation: "Cost", "Carryover (IRC §723)",
|
||||
/// "Personal-use conversion (Treas. Reg. §1.168(i)-4(b))".
|
||||
pub fn rule_cited(r: TaxBasisRule) -> &'static str {
|
||||
match r {
|
||||
TaxBasisRule::Cost => "Cost",
|
||||
TaxBasisRule::Carryover => "Carryover (IRC §723)",
|
||||
TaxBasisRule::PersonalUseConversion => {
|
||||
"Personal-use conversion (Treas. Reg. §1.168(i)-4(b))"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The number of a rule's note in [`notes`].
|
||||
pub fn rule_mark(r: TaxBasisRule) -> &'static str {
|
||||
match r {
|
||||
TaxBasisRule::Cost => "1",
|
||||
TaxBasisRule::Carryover => "2",
|
||||
TaxBasisRule::PersonalUseConversion => "3",
|
||||
}
|
||||
}
|
||||
|
||||
/// One note under the register.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Note {
|
||||
/// Its number, as the tax table marks it, or empty for a general note.
|
||||
pub mark: String,
|
||||
/// What it says.
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
/// The notes (INVARIANT 9): book value against tax basis, then each
|
||||
/// rule's citation and the built-in gain (loss).
|
||||
pub fn notes() -> Vec<Note> {
|
||||
let n = |mark: &str, text: &str| Note {
|
||||
mark: mark.to_string(),
|
||||
text: text.to_string(),
|
||||
};
|
||||
vec![
|
||||
n(
|
||||
"",
|
||||
"Book value is the fair market value at contribution for property a member contributed, \
|
||||
and the price paid for property the company bought; tax basis follows its rule and can \
|
||||
differ. Depreciation here is book depreciation; tax depreciation starts from the tax \
|
||||
basis and is the CPA's.",
|
||||
),
|
||||
n(
|
||||
rule_mark(TaxBasisRule::Cost),
|
||||
"Cost: property the company bought has a basis of its cost (IRC §1012(a)).",
|
||||
),
|
||||
n(
|
||||
rule_mark(TaxBasisRule::Carryover),
|
||||
"Carryover (IRC §723): property a member contributes keeps the member's adjusted \
|
||||
basis at contribution (their cost less any depreciation they took).",
|
||||
),
|
||||
n(
|
||||
rule_mark(TaxBasisRule::PersonalUseConversion),
|
||||
"Personal-use conversion (Treas. Reg. §1.168(i)-4(b)): property converted from \
|
||||
personal use depreciates from the lesser of its fair market value and the member's \
|
||||
adjusted basis at conversion.",
|
||||
),
|
||||
n(
|
||||
"4",
|
||||
"Built-in gain (loss): fair market value at contribution less the contributor's basis \
|
||||
(IRC §704(c)(1)(A)); a built-in loss is taken into account only for the contributing \
|
||||
member (IRC §704(c)(1)(C)).",
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
/// "Tax basis not recorded for 1 asset" / "... for 2 assets", or `None`
|
||||
/// when every asset held has one.
|
||||
pub fn missing_line(missing: usize) -> Option<String> {
|
||||
(missing > 0).then(|| {
|
||||
format!(
|
||||
"Tax basis not recorded for {missing} {}",
|
||||
if missing == 1 { "asset" } else { "assets" }
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// One asset's line.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct AssetLine {
|
||||
@@ -86,6 +229,14 @@ pub struct AssetLine {
|
||||
pub status: String,
|
||||
/// Disposed by the date (shown, not in the totals).
|
||||
pub disposed: bool,
|
||||
/// The tax basis rule, if recorded.
|
||||
pub tax_rule: Option<TaxBasisRule>,
|
||||
/// The contributing member's adjusted basis (a contribution only).
|
||||
pub contributor_basis: Option<Decimal>,
|
||||
/// The depreciable federal tax basis, if recorded.
|
||||
pub tax_basis: Option<Decimal>,
|
||||
/// Built-in gain (+) or loss (−) at contribution, if any.
|
||||
pub built_in_gain: Option<Decimal>,
|
||||
}
|
||||
|
||||
/// Which register column a reconciliation amount sits under.
|
||||
@@ -122,6 +273,12 @@ pub struct RegisterLayout {
|
||||
/// Total cost, accumulated depreciation, and book value of the assets
|
||||
/// held.
|
||||
pub totals: [Decimal; 3],
|
||||
/// Total tax basis of the assets held that have one.
|
||||
pub tax_total: Decimal,
|
||||
/// [`missing_line`] of the register's missing_tax_basis.
|
||||
pub missing: Option<String>,
|
||||
/// [`notes`].
|
||||
pub notes: Vec<Note>,
|
||||
/// Cost then accumulated depreciation: per register, per books,
|
||||
/// difference.
|
||||
pub reconciliation: Vec<ReconLine>,
|
||||
@@ -162,9 +319,16 @@ pub fn layout(r: &AssetRegister) -> RegisterLayout {
|
||||
book_value: a.book_value,
|
||||
status: status(a),
|
||||
disposed: a.disposed.is_some(),
|
||||
tax_rule: a.tax_rule,
|
||||
contributor_basis: a.contributor_basis,
|
||||
tax_basis: a.tax_basis,
|
||||
built_in_gain: a.built_in_gain,
|
||||
})
|
||||
.collect(),
|
||||
totals: [r.total_cost, r.total_accumulated, r.total_book_value],
|
||||
tax_total: r.total_tax_basis,
|
||||
missing: missing_line(r.missing_tax_basis),
|
||||
notes: notes(),
|
||||
reconciliation: vec![
|
||||
recon("Cost per register", r.total_cost, ReconColumn::Cost, false),
|
||||
recon("Cost per books", r.ledger_cost, ReconColumn::Cost, false),
|
||||
@@ -199,20 +363,25 @@ pub fn layout(r: &AssetRegister) -> RegisterLayout {
|
||||
/// The fixed asset register as XLSX bytes.
|
||||
///
|
||||
/// Layout as every statement (crate::xlsx INVARIANT 4): company, title,
|
||||
/// and date on rows 1-3, headings on row 5, one row per asset from row 6,
|
||||
/// amounts as numbers in the accounting format; then the totals row
|
||||
/// (double rule), an empty row, "Reconciliation to the books", and its
|
||||
/// lines with amounts under Cost and Accumulated depreciation.
|
||||
/// and date on rows 1-3, headings ([`XLSX_COLUMNS`]) on row 5, one row
|
||||
/// per asset from row 6, amounts as numbers in the accounting format, the
|
||||
/// tax rule in words with its citation ([`rule_cited`]); then the totals
|
||||
/// row (double rule) with the tax basis total under Tax basis, the
|
||||
/// missing tax basis line when there is one, an empty row, "Reconciliation
|
||||
/// to the books" and its lines with amounts under Cost and Accumulated
|
||||
/// depreciation, an empty row, and "Notes" with [`notes`].
|
||||
pub fn asset_register_xlsx(r: &AssetRegister, meta: &ReportMeta) -> Result<Vec<u8>, XlsxError> {
|
||||
const AMOUNTS: [u16; 7] = [4, 5, 7, 8, 10, 11, 12];
|
||||
const TOTALS: [u16; 4] = [4, 7, 8, 11];
|
||||
let l = layout(r);
|
||||
let mut workbook = Workbook::new();
|
||||
let sheet = workbook.add_worksheet();
|
||||
let f = Formats::new();
|
||||
sheet.set_name(TITLE)?;
|
||||
let width = COLUMNS.len() as u16;
|
||||
let width = XLSX_COLUMNS.len() as u16;
|
||||
let mut row = header(sheet, &f, meta, &l.title, &l.period, width)?;
|
||||
for (i, h) in COLUMNS.iter().enumerate() {
|
||||
let fmt = if (4..=8).contains(&i) {
|
||||
for (i, h) in XLSX_COLUMNS.iter().enumerate() {
|
||||
let fmt = if AMOUNTS.contains(&(i as u16)) || i == 6 {
|
||||
&f.header_right
|
||||
} else {
|
||||
&f.header
|
||||
@@ -226,6 +395,7 @@ pub fn asset_register_xlsx(r: &AssetRegister, meta: &ReportMeta) -> Result<Vec<u
|
||||
}
|
||||
let quiet = Format::new().set_italic().set_font_color("#666666");
|
||||
let quiet_plain = f.plain.clone().set_italic().set_font_color("#666666");
|
||||
let missing = Format::new().set_italic().set_font_color("#9A5B00");
|
||||
for a in &l.lines {
|
||||
let (text, amount) = if a.disposed {
|
||||
(&quiet, &quiet_plain)
|
||||
@@ -246,17 +416,43 @@ pub fn asset_register_xlsx(r: &AssetRegister, meta: &ReportMeta) -> Result<Vec<u
|
||||
)?;
|
||||
number(sheet, row, 7, a.accumulated, amount)?;
|
||||
number(sheet, row, 8, a.book_value, amount)?;
|
||||
sheet.write_string_with_format(row, 9, &a.status, text)?;
|
||||
match a.tax_rule {
|
||||
Some(rule) => sheet.write_string_with_format(row, 9, rule_cited(rule), text)?,
|
||||
None => sheet.write_string_with_format(
|
||||
row,
|
||||
9,
|
||||
NOT_RECORDED,
|
||||
if a.disposed { text } else { &missing },
|
||||
)?,
|
||||
};
|
||||
for (c, v) in [
|
||||
(10, a.contributor_basis),
|
||||
(11, a.tax_basis),
|
||||
(12, a.built_in_gain),
|
||||
] {
|
||||
if let Some(v) = v {
|
||||
number(sheet, row, c, v, amount)?;
|
||||
}
|
||||
}
|
||||
sheet.write_string_with_format(row, 13, &a.status, text)?;
|
||||
row += 1;
|
||||
}
|
||||
sheet.write_string_with_format(row, 0, "Total, assets held", &f.bold)?;
|
||||
for c in (1..width).filter(|c| ![4, 7, 8].contains(c)) {
|
||||
for c in (1..width).filter(|c| !TOTALS.contains(c)) {
|
||||
sheet.write_blank(row, c, &f.dollars_total)?;
|
||||
}
|
||||
for (c, d) in [4u16, 7, 8].into_iter().zip(l.totals) {
|
||||
for (c, d) in TOTALS
|
||||
.into_iter()
|
||||
.zip(l.totals.into_iter().chain([l.tax_total]))
|
||||
{
|
||||
number(sheet, row, c, d, &f.dollars_total)?;
|
||||
}
|
||||
row += 2;
|
||||
row += 1;
|
||||
if let Some(m) = &l.missing {
|
||||
sheet.write_string_with_format(row, 0, m, &f.sub_label)?;
|
||||
row += 1;
|
||||
}
|
||||
row += 1;
|
||||
sheet.write_string_with_format(row, 0, "Reconciliation to the books", &f.bold)?;
|
||||
row += 1;
|
||||
for line in &l.reconciliation {
|
||||
@@ -276,6 +472,17 @@ pub fn asset_register_xlsx(r: &AssetRegister, meta: &ReportMeta) -> Result<Vec<u
|
||||
row += 1;
|
||||
}
|
||||
}
|
||||
sheet.write_string_with_format(row, 0, "Notes", &f.bold)?;
|
||||
row += 1;
|
||||
for n in &l.notes {
|
||||
let text = if n.mark.is_empty() {
|
||||
n.text.clone()
|
||||
} else {
|
||||
format!("{}. {}", n.mark, n.text)
|
||||
};
|
||||
sheet.write_string_with_format(row, 0, text, &f.line_label)?;
|
||||
row += 1;
|
||||
}
|
||||
for (c, w) in [
|
||||
(0, 12.0),
|
||||
(1, 40.0),
|
||||
@@ -286,11 +493,15 @@ pub fn asset_register_xlsx(r: &AssetRegister, meta: &ReportMeta) -> Result<Vec<u
|
||||
(6, 9.0),
|
||||
(7, 16.0),
|
||||
(8, 14.0),
|
||||
(9, 34.0),
|
||||
(9, 30.0),
|
||||
(10, 14.0),
|
||||
(11, 14.0),
|
||||
(12, 14.0),
|
||||
(13, 34.0),
|
||||
] {
|
||||
sheet.set_column_width(c, w)?;
|
||||
}
|
||||
footer(sheet, &f, meta, row.saturating_sub(1), width)?;
|
||||
footer(sheet, &f, meta, row, width)?;
|
||||
Ok(workbook.save_to_buffer()?)
|
||||
}
|
||||
|
||||
@@ -311,6 +522,10 @@ fn amount(d: Decimal) -> serde_json::Value {
|
||||
serde_json::json!({ "digits": p.digits, "negative": p.negative })
|
||||
}
|
||||
|
||||
fn maybe(d: Option<Decimal>) -> serde_json::Value {
|
||||
d.map_or(serde_json::Value::Null, amount)
|
||||
}
|
||||
|
||||
/// The PDF template's input: the layout, every amount split for
|
||||
/// alignment.
|
||||
pub fn template_data(r: &AssetRegister, meta: &ReportMeta) -> serde_json::Value {
|
||||
@@ -321,6 +536,8 @@ pub fn template_data(r: &AssetRegister, meta: &ReportMeta) -> serde_json::Value
|
||||
"period": l.period,
|
||||
"footer": meta.footer(),
|
||||
"columns": COLUMNS,
|
||||
"tax_title": TAX_TITLE,
|
||||
"tax_columns": TAX_COLUMNS,
|
||||
"empty": EMPTY,
|
||||
"lines": l.lines.iter().map(|a| serde_json::json!({
|
||||
"tag": a.tag,
|
||||
@@ -334,8 +551,20 @@ pub fn template_data(r: &AssetRegister, meta: &ReportMeta) -> serde_json::Value
|
||||
"book_value": amount(a.book_value),
|
||||
"status": a.status,
|
||||
"disposed": a.disposed,
|
||||
"rule": a.tax_rule.map_or(NOT_RECORDED, rule_label),
|
||||
"rule_mark": a.tax_rule.map_or("", rule_mark),
|
||||
"recorded": a.tax_rule.is_some(),
|
||||
"contributor_basis": maybe(a.contributor_basis),
|
||||
"tax_basis": maybe(a.tax_basis),
|
||||
"built_in_gain": maybe(a.built_in_gain),
|
||||
})).collect::<Vec<_>>(),
|
||||
"totals": l.totals.iter().map(|d| amount(*d)).collect::<Vec<_>>(),
|
||||
"tax_total": amount(l.tax_total),
|
||||
"missing": l.missing,
|
||||
"notes": l.notes.iter().map(|n| serde_json::json!({
|
||||
"mark": n.mark,
|
||||
"text": n.text,
|
||||
})).collect::<Vec<_>>(),
|
||||
"reconciliation": l.reconciliation.iter().map(|x| serde_json::json!({
|
||||
"label": x.label,
|
||||
"amount": amount(x.amount),
|
||||
|
||||
@@ -1,15 +1,21 @@
|
||||
// The fixed asset register: every asset, its cost, depreciation, and book
|
||||
// value, then the totals and the reconciliation to the general ledger.
|
||||
// value, then the totals and the reconciliation to the general ledger,
|
||||
// then each asset's federal tax basis beside its book cost, and the notes
|
||||
// that cite the tax basis rules.
|
||||
//
|
||||
// Everything arrives as data in sys.inputs.data (JSON), laid out by
|
||||
// ledger-import::asset_register, with amounts already split into exact
|
||||
// parts. The template only typesets: no arithmetic, and no text is ever
|
||||
// parsed as markup.
|
||||
//
|
||||
// Shape: { company, title, period, footer, columns: [10 headings], empty,
|
||||
// Shape: { company, title, period, footer, columns: [10 headings],
|
||||
// tax_title, tax_columns: [7 headings], empty,
|
||||
// lines: [{ tag, description, serial, in_service, cost, salvage,
|
||||
// life, accumulated, book_value, status, disposed }],
|
||||
// totals: [cost, accumulated, book_value],
|
||||
// life, accumulated, book_value, status, disposed,
|
||||
// rule, rule_mark, recorded, contributor_basis|null,
|
||||
// tax_basis|null, built_in_gain|null }],
|
||||
// totals: [cost, accumulated, book_value], tax_total,
|
||||
// missing: text|null, notes: [{ mark, text }],
|
||||
// reconciliation: [{ label, amount, difference }] }
|
||||
// where every amount is { digits: "1,234.56", negative }.
|
||||
|
||||
@@ -121,8 +127,8 @@
|
||||
),
|
||||
)
|
||||
|
||||
#v(14pt)
|
||||
#block(breakable: false, {
|
||||
// The reconciliation to the books, drawn beside the notes at the end.
|
||||
#let reconciliation = block(breakable: false, {
|
||||
text(weight: "semibold", "Reconciliation to the books")
|
||||
v(2pt)
|
||||
table(
|
||||
@@ -147,3 +153,86 @@
|
||||
.flatten(),
|
||||
)
|
||||
})
|
||||
|
||||
// Federal tax basis, asset by asset, beside the book cost it may differ
|
||||
// from; each rule marked with the number of the note that cites it.
|
||||
#let tax_numeric = (3, 4, 5, 6)
|
||||
#let tcols = d.tax_columns.len()
|
||||
#let maybe(x) = if x == none { [] } else { amount(x) }
|
||||
#v(12pt)
|
||||
#text(weight: "semibold", d.tax_title)
|
||||
#v(2pt)
|
||||
#table(
|
||||
columns: (auto, 1fr, auto, 7.4em, 7.4em, 7.4em, 7.4em),
|
||||
stroke: none,
|
||||
inset: (x: 3.5pt, y: 3.4pt),
|
||||
align: (col, row) => (if col in tax_numeric { right } else { left }) + (if row == 0 { bottom } else { horizon }),
|
||||
table.header(
|
||||
..d.tax_columns.enumerate().map(((i, c)) => table.cell(
|
||||
stroke: (top: firm, bottom: hair),
|
||||
text(6.5pt, weight: "bold", tracking: 0.04em, {
|
||||
upper(c)
|
||||
if i == tcols - 1 { super("4") }
|
||||
}),
|
||||
)),
|
||||
),
|
||||
..if d.lines.len() == 0 {
|
||||
(table.cell(colspan: tcols, emph(d.empty)),)
|
||||
} else {
|
||||
d
|
||||
.lines
|
||||
.map(l => {
|
||||
let t(s) = if l.disposed { text(fill: quiet, s) } else { s }
|
||||
let rule = if l.recorded {
|
||||
[#l.rule#super(l.rule_mark)]
|
||||
} else if l.disposed {
|
||||
emph(l.rule)
|
||||
} else {
|
||||
text(fill: rgb("#9a5b00"), emph(l.rule))
|
||||
}
|
||||
(
|
||||
t(l.tag),
|
||||
t(l.description),
|
||||
t(rule),
|
||||
t(amount(l.cost)),
|
||||
t(maybe(l.contributor_basis)),
|
||||
t(maybe(l.tax_basis)),
|
||||
t(maybe(l.built_in_gain)),
|
||||
)
|
||||
})
|
||||
.flatten()
|
||||
},
|
||||
..{
|
||||
let s = (top: firm)
|
||||
let blank = table.cell(stroke: s, [])
|
||||
(
|
||||
table.cell(stroke: s, colspan: 5, text(weight: "bold", "Total tax basis, assets held")),
|
||||
table.cell(stroke: s, text(weight: "bold", amount(d.tax_total, currency: true))),
|
||||
blank,
|
||||
)
|
||||
},
|
||||
table.cell(
|
||||
colspan: tcols,
|
||||
inset: (x: 0pt, y: 0pt),
|
||||
stack(spacing: 1.6pt, line(length: 100%, stroke: firm), line(length: 100%, stroke: firm)),
|
||||
),
|
||||
)
|
||||
#if d.missing != none {
|
||||
v(4pt)
|
||||
text(fill: rgb("#9a5b00"), emph(d.missing))
|
||||
}
|
||||
|
||||
// Notes: book value against tax basis, and each rule's citation.
|
||||
#let notes = {
|
||||
set text(7.5pt, fill: luma(70))
|
||||
set par(leading: 0.5em)
|
||||
text(weight: "semibold", fill: ink, "Notes")
|
||||
block(above: 6pt, d.notes.at(0).text)
|
||||
for n in d.notes.slice(1) {
|
||||
block(above: 4pt, grid(columns: (1.2em, 1fr), super(n.mark), n.text))
|
||||
}
|
||||
}
|
||||
|
||||
// The reconciliation and the notes, side by side.
|
||||
#v(12pt)
|
||||
#grid(columns: (auto, 1fr), column-gutter: 32pt, reconciliation, pad(top: 1pt, notes))
|
||||
@@ -4,8 +4,10 @@
|
||||
//! INVARIANT it checks.
|
||||
|
||||
use chrono::NaiveDate;
|
||||
use ledger_core::{AssetRegister, AssetRegisterRow};
|
||||
use ledger_import::asset_register::{layout, typeset_asset_register};
|
||||
use ledger_core::{AssetRegister, AssetRegisterRow, TaxBasisRule};
|
||||
use ledger_import::asset_register::{
|
||||
layout, missing_line, notes, rule_cited, rule_label, typeset_asset_register,
|
||||
};
|
||||
use ledger_import::report::ReportMeta;
|
||||
use ledger_import::{PdfError, asset_register_pdf};
|
||||
use rust_decimal::Decimal;
|
||||
@@ -41,14 +43,43 @@ fn row(id: u64, tag: &str, desc: &str, cost: i64, acc: i64) -> AssetRegisterRow
|
||||
accumulated: cents(acc),
|
||||
book_value: cents(cost - acc),
|
||||
disposed: None,
|
||||
tax_rule: None,
|
||||
contributor_basis: None,
|
||||
tax_basis: None,
|
||||
built_in_gain: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Bought: tax basis is cost.
|
||||
fn bought(mut r: AssetRegisterRow) -> AssetRegisterRow {
|
||||
r.tax_rule = Some(TaxBasisRule::Cost);
|
||||
r.tax_basis = Some(r.cost);
|
||||
r
|
||||
}
|
||||
|
||||
/// Contributed under `rule` with the member's basis `basis` (cents); tax
|
||||
/// basis and built-in gain as ledger_core::Asset computes them.
|
||||
fn contributed(mut r: AssetRegisterRow, rule: TaxBasisRule, basis: i64) -> AssetRegisterRow {
|
||||
let b = cents(basis);
|
||||
r.tax_rule = Some(rule);
|
||||
r.contributor_basis = Some(b);
|
||||
r.tax_basis = Some(match rule {
|
||||
TaxBasisRule::PersonalUseConversion => b.min(r.cost),
|
||||
_ => b,
|
||||
});
|
||||
r.built_in_gain = Some(r.cost - b);
|
||||
r
|
||||
}
|
||||
|
||||
fn register(rows: Vec<AssetRegisterRow>) -> AssetRegister {
|
||||
let held = || rows.iter().filter(|r| r.disposed.is_none());
|
||||
let total_cost = held().map(|r| r.cost).sum();
|
||||
let total_accumulated = held().map(|r| r.accumulated).sum();
|
||||
let total_tax_basis = held().filter_map(|r| r.tax_basis).sum();
|
||||
let missing_tax_basis = held().filter(|r| r.tax_basis.is_none()).count();
|
||||
AssetRegister {
|
||||
total_tax_basis,
|
||||
missing_tax_basis,
|
||||
as_of: d(2026, 10, 15),
|
||||
total_book_value: total_cost - total_accumulated,
|
||||
ledger_cost: total_cost,
|
||||
@@ -73,9 +104,23 @@ fn sample() -> AssetRegister {
|
||||
laptop.serial = Some("FRANBMCP0A1234".into());
|
||||
laptop.salvage = cents(10000);
|
||||
register(vec![
|
||||
laptop,
|
||||
row(2, "WL-0002", "MacBook Air", 129900, 32475),
|
||||
disposed,
|
||||
bought(laptop),
|
||||
// A member's business laptop: carryover basis, built-in gain.
|
||||
contributed(
|
||||
row(2, "WL-0002", "MacBook Air", 129900, 32475),
|
||||
TaxBasisRule::Carryover,
|
||||
90000,
|
||||
),
|
||||
bought(disposed),
|
||||
// A member's personal camera: worth less than its basis, so tax
|
||||
// basis is the fair market value and there's a built-in loss.
|
||||
contributed(
|
||||
row(4, "WL-0004", "Sony A7 IV camera", 165000, 0),
|
||||
TaxBasisRule::PersonalUseConversion,
|
||||
191721,
|
||||
),
|
||||
// No tax basis recorded yet.
|
||||
row(5, "WL-0005", "Standing desk", 80000, 0),
|
||||
])
|
||||
}
|
||||
|
||||
@@ -126,9 +171,9 @@ fn pdf_shows_assets_totals_and_reconciliation() {
|
||||
"Active",
|
||||
"Disposed 2026-09-30, proceeds 45.00",
|
||||
"Total, assets held",
|
||||
"3,799.00", // cost held: 2,500.00 + 1,299.00
|
||||
"6,249.00", // cost held: 2,500.00 + 1,299.00 + 1,650.00 + 800.00
|
||||
"852.53", // accumulated held
|
||||
"2,946.47", // book value held
|
||||
"5,396.47", // book value held
|
||||
"Cost per register",
|
||||
"Cost per books",
|
||||
"Difference (unregistered cost)",
|
||||
@@ -143,7 +188,93 @@ fn pdf_shows_assets_totals_and_reconciliation() {
|
||||
// The disposed asset's cost (400.00) is shown but in no total.
|
||||
assert!(t.contains("400.00"));
|
||||
let l = layout(&sample());
|
||||
assert_eq!(l.totals, [cents(379900), cents(85253), cents(294647)]);
|
||||
assert_eq!(l.totals, [cents(624900), cents(85253), cents(539647)]);
|
||||
}
|
||||
|
||||
/// INVARIANTS 7-9: the tax table shows each rule in short words, the
|
||||
/// contributor's basis, the tax basis, the built-in gain and loss, the
|
||||
/// register's tax basis total, the missing line, and the notes with
|
||||
/// their citations.
|
||||
#[test]
|
||||
fn pdf_shows_tax_basis() {
|
||||
let r = sample();
|
||||
let t = all_text(&r, &meta());
|
||||
for s in [
|
||||
"Federal tax basis",
|
||||
"TAX BASIS RULE",
|
||||
"CONTRIBUTOR'S BASIS",
|
||||
"BUILT-IN GAIN (LOSS)",
|
||||
"Carryover",
|
||||
"Personal-use conversion",
|
||||
"900.00", // MacBook: contributor's basis = tax basis
|
||||
"399.00", // its built-in gain
|
||||
"1,917.21", // camera: contributor's basis
|
||||
"1,650.00", // its tax basis: the fair market value
|
||||
"(267.21)", // its built-in loss
|
||||
"Not recorded",
|
||||
"Total tax basis, assets held",
|
||||
"5,050.00", // 2,500.00 + 900.00 + 1,650.00; the desk has none
|
||||
"Tax basis not recorded for 1 asset",
|
||||
"Notes",
|
||||
"Book value is the fair market value at contribution",
|
||||
"IRC §1012(a)",
|
||||
"IRC §723",
|
||||
"Treas. Reg. §1.168(i)-4(b)",
|
||||
"IRC §704(c)(1)(C)",
|
||||
] {
|
||||
assert!(t.contains(s), "{s:?} missing from {t:?}");
|
||||
}
|
||||
let l = layout(&r);
|
||||
assert_eq!(l.tax_total, r.total_tax_basis);
|
||||
assert_eq!(l.tax_total, cents(505000));
|
||||
assert_eq!(
|
||||
l.missing.as_deref(),
|
||||
Some("Tax basis not recorded for 1 asset")
|
||||
);
|
||||
assert_eq!(l.notes, notes());
|
||||
// Each line carries its row's tax fields unchanged.
|
||||
for (line, row) in l.lines.iter().zip(&r.rows) {
|
||||
assert_eq!(line.tax_rule, row.tax_rule);
|
||||
assert_eq!(line.contributor_basis, row.contributor_basis);
|
||||
assert_eq!(line.tax_basis, row.tax_basis);
|
||||
assert_eq!(line.built_in_gain, row.built_in_gain);
|
||||
}
|
||||
}
|
||||
|
||||
/// INVARIANT 8: no missing line when every asset held has a tax basis
|
||||
/// (a disposed one without doesn't count), and the plural reads right.
|
||||
#[test]
|
||||
fn missing_line_only_when_missing() {
|
||||
let mut gone = row(1, "WL-0001", "Old monitor", 40000, 0);
|
||||
gone.book_value = Decimal::ZERO;
|
||||
gone.disposed = Some((d(2026, 9, 30), cents(4500)));
|
||||
let r = register(vec![bought(row(2, "WL-0002", "Desk", 50000, 0)), gone]);
|
||||
assert_eq!(r.missing_tax_basis, 0);
|
||||
assert_eq!(layout(&r).missing, None);
|
||||
assert!(!all_text(&r, &meta()).contains("Tax basis not recorded"));
|
||||
assert_eq!(missing_line(0), None);
|
||||
assert_eq!(
|
||||
missing_line(2).as_deref(),
|
||||
Some("Tax basis not recorded for 2 assets")
|
||||
);
|
||||
}
|
||||
|
||||
/// INVARIANT 7: the rules in words, short and with their citations.
|
||||
#[test]
|
||||
fn rule_words() {
|
||||
use TaxBasisRule::*;
|
||||
assert_eq!(
|
||||
[Cost, Carryover, PersonalUseConversion].map(rule_label),
|
||||
["Cost", "Carryover", "Personal-use conversion"]
|
||||
);
|
||||
assert_eq!(
|
||||
[Cost, Carryover, PersonalUseConversion].map(rule_cited),
|
||||
[
|
||||
"Cost",
|
||||
"Carryover (IRC §723)",
|
||||
"Personal-use conversion (Treas. Reg. §1.168(i)-4(b))"
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/// INVARIANT 4: an empty register renders a "No fixed assets" line, its
|
||||
|
||||
@@ -3011,7 +3011,10 @@ fn tax_summary_optional_sections() {
|
||||
|
||||
/// A register as ledger_core::fixed_asset_register would return it: two
|
||||
/// assets held, one disposed (shown, in no total), and books that differ
|
||||
/// from the register on both counts.
|
||||
/// from the register on both counts. Tax basis: the laptop a member
|
||||
/// converted from personal use (worth less than their basis: tax basis is
|
||||
/// its value, a built-in loss), the desk with none recorded, the monitor
|
||||
/// a carryover with a built-in gain (disposed, in no total).
|
||||
fn asset_register_fixture() -> ledger_core::AssetRegister {
|
||||
use ledger_core::AssetRegisterRow;
|
||||
let row = |id, tag: Option<&str>, desc: &str, serial: Option<&str>, cost, acc, disposed| {
|
||||
@@ -3027,19 +3030,35 @@ fn asset_register_fixture() -> ledger_core::AssetRegister {
|
||||
accumulated: cents(acc),
|
||||
book_value: cents(cost - acc),
|
||||
disposed,
|
||||
tax_rule: None,
|
||||
contributor_basis: None,
|
||||
tax_basis: None,
|
||||
built_in_gain: None,
|
||||
}
|
||||
};
|
||||
let taxed = |mut r: AssetRegisterRow, rule, basis: Decimal, tax: Decimal| {
|
||||
r.tax_rule = Some(rule);
|
||||
r.contributor_basis = Some(basis);
|
||||
r.tax_basis = Some(tax);
|
||||
r.built_in_gain = Some(r.cost - basis);
|
||||
r
|
||||
};
|
||||
ledger_core::AssetRegister {
|
||||
as_of: d(2026, 10, 15),
|
||||
rows: vec![
|
||||
row(
|
||||
1,
|
||||
Some("WL-0001"),
|
||||
"Framework Laptop 13",
|
||||
Some("FRAN123"),
|
||||
250000,
|
||||
52778,
|
||||
None,
|
||||
taxed(
|
||||
row(
|
||||
1,
|
||||
Some("WL-0001"),
|
||||
"Framework Laptop 13",
|
||||
Some("FRAN123"),
|
||||
250000,
|
||||
52778,
|
||||
None,
|
||||
),
|
||||
ledger_core::TaxBasisRule::PersonalUseConversion,
|
||||
cents(280000),
|
||||
cents(250000),
|
||||
),
|
||||
row(2, None, "Desk", None, 120000, 25000, None),
|
||||
{
|
||||
@@ -3053,12 +3072,19 @@ fn asset_register_fixture() -> ledger_core::AssetRegister {
|
||||
Some((d(2026, 9, 30), cents(4500))),
|
||||
);
|
||||
r.book_value = Decimal::ZERO;
|
||||
r
|
||||
taxed(
|
||||
r,
|
||||
ledger_core::TaxBasisRule::Carryover,
|
||||
cents(30000),
|
||||
cents(30000),
|
||||
)
|
||||
},
|
||||
],
|
||||
total_cost: cents(370000),
|
||||
total_accumulated: cents(77778),
|
||||
total_book_value: cents(292222),
|
||||
total_tax_basis: cents(250000),
|
||||
missing_tax_basis: 1,
|
||||
ledger_cost: cents(400000),
|
||||
ledger_accumulated: cents(70000),
|
||||
}
|
||||
@@ -3090,7 +3116,7 @@ fn asset_register_xlsx_cells() {
|
||||
assert_eq!(text_cell(&c, (1, 0)), "Spec Test LLC");
|
||||
assert_eq!(text_cell(&c, (2, 0)), "Fixed Asset Register");
|
||||
assert_eq!(text_cell(&c, (3, 0)), "As of October 15, 2026");
|
||||
let heads: Vec<String> = (0..10).map(|i| text_cell(&c, (5, i))).collect();
|
||||
let heads: Vec<String> = (0..14).map(|i| text_cell(&c, (5, i))).collect();
|
||||
assert_eq!(
|
||||
heads,
|
||||
[
|
||||
@@ -3103,6 +3129,10 @@ fn asset_register_xlsx_cells() {
|
||||
"LIFE (MONTHS)",
|
||||
"ACCUMULATED DEPRECIATION",
|
||||
"BOOK VALUE",
|
||||
"TAX BASIS RULE",
|
||||
"CONTRIBUTOR'S BASIS",
|
||||
"TAX BASIS",
|
||||
"BUILT-IN GAIN (LOSS)",
|
||||
"STATUS"
|
||||
]
|
||||
);
|
||||
@@ -3115,31 +3145,78 @@ fn asset_register_xlsx_cells() {
|
||||
assert_eq!(number_cell(&c, (6, 6)), dec("36"));
|
||||
assert_eq!(number_cell(&c, (6, 7)), dec("527.78"));
|
||||
assert_eq!(number_cell(&c, (6, 8)), dec("1972.22"));
|
||||
assert_eq!(text_cell(&c, (6, 9)), "Active");
|
||||
// Tax basis (INVARIANT 7): rule in words with its citation, the
|
||||
// contributor's basis, tax basis, built-in loss as a negative.
|
||||
assert_eq!(
|
||||
text_cell(&c, (6, 9)),
|
||||
"Personal-use conversion (Treas. Reg. §1.168(i)-4(b))"
|
||||
);
|
||||
assert_eq!(number_cell(&c, (6, 10)), dec("2800.00"));
|
||||
assert_eq!(number_cell(&c, (6, 11)), dec("2500.00"));
|
||||
assert_eq!(number_cell(&c, (6, 12)), dec("-300.00"));
|
||||
assert_eq!(text_cell(&c, (6, 13)), "Active");
|
||||
// The desk: no tag, no serial, no tax basis recorded.
|
||||
assert_eq!(text_cell(&c, (7, 1)), "Desk");
|
||||
assert_eq!(number_cell(&c, (7, 4)), dec("1200.00"));
|
||||
assert_eq!(number_cell(&c, (7, 7)), dec("250.00"));
|
||||
assert_eq!(number_cell(&c, (7, 8)), dec("950.00"));
|
||||
assert_eq!(text_cell(&c, (7, 9)), "Not recorded");
|
||||
for col in 10..=12 {
|
||||
assert!(
|
||||
!c.contains_key(&(7, col)),
|
||||
"desk has no tax amount in {col}"
|
||||
);
|
||||
}
|
||||
assert_eq!(text_cell(&c, (7, 13)), "Active");
|
||||
assert_eq!(text_cell(&c, (8, 0)), "WL-0003");
|
||||
assert_eq!(text_cell(&c, (8, 9)), "Disposed 2026-09-30, proceeds 45.00");
|
||||
assert_eq!(text_cell(&c, (8, 9)), "Carryover (IRC §723)");
|
||||
assert_eq!(number_cell(&c, (8, 10)), dec("300.00"));
|
||||
assert_eq!(number_cell(&c, (8, 11)), dec("300.00"));
|
||||
assert_eq!(number_cell(&c, (8, 12)), dec("100.00"));
|
||||
assert_eq!(
|
||||
text_cell(&c, (8, 13)),
|
||||
"Disposed 2026-09-30, proceeds 45.00"
|
||||
);
|
||||
assert_eq!(number_cell(&c, (8, 8)), Decimal::ZERO);
|
||||
// Totals: the register's (held assets only), not a re-sum of the rows.
|
||||
assert_eq!(text_cell(&c, (9, 0)), "Total, assets held");
|
||||
assert_eq!(number_cell(&c, (9, 4)), dec("3700.00"));
|
||||
assert_eq!(number_cell(&c, (9, 7)), dec("777.78"));
|
||||
assert_eq!(number_cell(&c, (9, 8)), dec("2922.22"));
|
||||
assert!(!c.contains_key(&(9, 5)), "no salvage total");
|
||||
assert_eq!(number_cell(&c, (9, 11)), dec("2500.00"));
|
||||
for col in [5, 10, 12] {
|
||||
assert!(!c.contains_key(&(9, col)), "no total in {col}");
|
||||
}
|
||||
// INVARIANT 8: the missing tax basis line.
|
||||
assert_eq!(text_cell(&c, (10, 0)), "Tax basis not recorded for 1 asset");
|
||||
// Reconciliation.
|
||||
assert_eq!(text_cell(&c, (11, 0)), "Reconciliation to the books");
|
||||
assert_eq!(text_cell(&c, (12, 0)), "Reconciliation to the books");
|
||||
let recon = [
|
||||
(12, "Cost per register", 4, "3700.00"),
|
||||
(13, "Cost per books", 4, "4000.00"),
|
||||
(14, "Difference (unregistered cost)", 4, "300.00"),
|
||||
(16, "Accumulated depreciation per schedule", 7, "777.78"),
|
||||
(17, "Accumulated depreciation per books", 7, "700.00"),
|
||||
(18, "Difference (not yet posted)", 7, "77.78"),
|
||||
(13, "Cost per register", 4, "3700.00"),
|
||||
(14, "Cost per books", 4, "4000.00"),
|
||||
(15, "Difference (unregistered cost)", 4, "300.00"),
|
||||
(17, "Accumulated depreciation per schedule", 7, "777.78"),
|
||||
(18, "Accumulated depreciation per books", 7, "700.00"),
|
||||
(19, "Difference (not yet posted)", 7, "77.78"),
|
||||
];
|
||||
for (row, label, col, amt) in recon {
|
||||
assert_eq!(text_cell(&c, (row, 0)), label);
|
||||
assert_eq!(number_cell(&c, (row, col)), dec(amt), "{label}");
|
||||
}
|
||||
// INVARIANT 9: the notes, numbered as the PDF marks them.
|
||||
assert_eq!(text_cell(&c, (21, 0)), "Notes");
|
||||
let notes = ledger_import::asset_register::notes();
|
||||
for (i, n) in notes.iter().enumerate() {
|
||||
let want = if n.mark.is_empty() {
|
||||
n.text.clone()
|
||||
} else {
|
||||
format!("{}. {}", n.mark, n.text)
|
||||
};
|
||||
assert_eq!(text_cell(&c, (22 + i as u32, 0)), want);
|
||||
}
|
||||
assert!(
|
||||
text_cell(&c, (22, 0)).starts_with("Book value is the fair market value at contribution")
|
||||
);
|
||||
let footer = c
|
||||
.values()
|
||||
.any(|v| matches!(v, Cell::Text(t) if t.contains("Journal seq 42")));
|
||||
@@ -3156,10 +3233,18 @@ fn asset_register_xlsx_empty() {
|
||||
total_cost: Decimal::ZERO,
|
||||
total_accumulated: Decimal::ZERO,
|
||||
total_book_value: Decimal::ZERO,
|
||||
total_tax_basis: Decimal::ZERO,
|
||||
missing_tax_basis: 0,
|
||||
ledger_cost: cents(99900),
|
||||
ledger_accumulated: Decimal::ZERO,
|
||||
};
|
||||
let c = sheet_cells(&ledger_import::asset_register_xlsx(&r, &meta()).unwrap());
|
||||
assert_eq!(number_cell(&c, (7, 11)), Decimal::ZERO);
|
||||
assert!(
|
||||
!c.values()
|
||||
.any(|v| matches!(v, Cell::Text(t) if t.starts_with("Tax basis not recorded"))),
|
||||
"nothing missing"
|
||||
);
|
||||
assert_eq!(text_cell(&c, (6, 0)), "No fixed assets");
|
||||
assert_eq!(text_cell(&c, (7, 0)), "Total, assets held");
|
||||
assert_eq!(number_cell(&c, (7, 4)), Decimal::ZERO);
|
||||
|
||||
Reference in new issue
Block a user