Files
wanderbooks/crates/ledger-import/templates/asset_register.typ
T
swaitsandClaude Opus 5.5 6e6f9d65b2
check / check (push) Waiting to run
Fixed assets carry federal tax basis beside book value; tests sweep stale temp dirs
- 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
2026-10-15 13:30:09 -06:00

239 lines
7.2 KiB
Typst

// The fixed asset register: every asset, its cost, depreciation, and book
// 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],
// tax_title, tax_columns: [7 headings], empty,
// lines: [{ tag, description, serial, in_service, cost, salvage,
// 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 }.
#let d = json(bytes(sys.inputs.data))
#let ink = luma(25)
#let quiet = luma(110)
#let hair = 0.5pt + luma(160)
#let firm = 0.6pt + ink
#set document(title: d.company + " — " + d.title, author: d.company)
#set text(font: "Inter", size: 8pt, fill: ink, number-width: "tabular", hyphenate: false)
#set page(
paper: "us-letter",
flipped: true,
margin: (x: 0.6in, top: 0.7in, bottom: 0.7in),
header: context {
if counter(page).get().first() > 1 {
set text(7.5pt, fill: quiet)
[#d.company · #d.title #h(1fr) #d.period]
}
},
footer: context {
set text(7.5pt, fill: quiet)
grid(
columns: (1fr, auto, 1fr),
[],
d.footer,
align(right)[Page #counter(page).display() of #counter(page).final().first()],
)
},
)
#align(center)[
#text(15pt, fill: luma(45), d.company) \
#v(4pt)
#text(10.5pt, weight: "bold", d.title) \
#v(2pt)
#d.period
]
#v(12pt)
// An amount: negatives in parentheses, the closing one's width reserved on
// every row so decimal points line up; "$" on totals.
#let amount(a, currency: false) = {
let open = if a.negative { [(] } else { [] }
let close = if a.negative { [)] } else { hide[)] }
let sign = if currency [\$] else []
[#open#sign#a.digits#close]
}
// Columns: 4-8 are numbers (right-aligned).
#let numeric = (4, 5, 6, 7, 8)
#let ncols = d.columns.len()
#let full(body, ..args) = table.cell(colspan: ncols, ..args, body)
#table(
// Number columns are fixed widths (headings wrap); text takes the rest.
columns: (auto, 1.4fr, auto, auto, 6.4em, 5.6em, 6em, 7.4em, 6.4em, 1fr),
stroke: none,
inset: (x: 3.5pt, y: 3.4pt),
align: (col, row) => (if col in numeric { right } else { left }) + (if row == 0 { bottom } else { horizon }),
table.header(
..d.columns.map(c => table.cell(
stroke: (top: firm, bottom: hair),
text(6.5pt, weight: "bold", tracking: 0.04em, upper(c)),
)),
),
..if d.lines.len() == 0 {
(full(emph(d.empty)),)
} else {
d
.lines
.map(l => {
let t(s) = if l.disposed { text(fill: quiet, s) } else { s }
let a(x) = t(amount(x))
(
t(l.tag),
t(l.description),
t(l.serial),
t(l.in_service),
a(l.cost),
a(l.salvage),
t(l.life),
a(l.accumulated),
a(l.book_value),
t(l.status),
)
})
.flatten()
},
// Totals of the assets held: rule above, double rule beneath.
..{
let s = (top: firm)
let tot(x) = table.cell(stroke: s, text(weight: "bold", amount(x, currency: true)))
let blank = table.cell(stroke: s, [])
(
table.cell(stroke: s, colspan: 4, text(weight: "bold", "Total, assets held")),
tot(d.totals.at(0)),
blank,
blank,
tot(d.totals.at(1)),
tot(d.totals.at(2)),
blank,
)
},
full(
inset: (x: 0pt, y: 0pt),
stack(spacing: 1.6pt, line(length: 100%, stroke: firm), line(length: 100%, stroke: firm)),
),
)
// 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(
columns: (auto, 9em),
stroke: none,
inset: (x: 4pt, y: 3.4pt),
align: (left + horizon, right + horizon),
..d
.reconciliation
.map(x => {
if x.difference {
let s = (top: hair)
(
table.cell(stroke: s, text(weight: "bold", x.label)),
table.cell(stroke: s, text(weight: "bold", amount(x.amount, currency: true))),
table.cell(colspan: 2, inset: 2pt, []),
)
} else {
(pad(left: 1.4em, x.label), amount(x.amount))
}
})
.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))