Files
wanderbooks/crates/ledger-import/templates/asset_register.typ
T
swaitsandClaude Opus 5.5 ee7e205f5c
check / check (push) Waiting to run
Fixed assets, fully featured: tags, serials, acquisition links, label sheets, the fixed asset register, and an Assets section in the viewer; fix a flaky verify
- core: asset tags (generated PREFIX-0001 or the human's own; unique ignoring case; never reused by another asset) and serial numbers (rules.md B-24); a journaled link from an asset to the entry that acquired it (posted, unreversed, debiting the cost account); fixed_asset_register(as_of), reconciled to the GL (unposted depreciation, unregistered cost)
- store: asset.tagged, asset.serial_set, asset.acquisition_linked journaled, replayed, projected (schema v9); differs_from now also compares control roles and tag history
- config: [books] asset_tag_prefix, default the company's initials without legal-form words
- import: Avery 5160/8160 label sheets with QR codes (geometry vendored from Avery, docs/spec/sources/avery-5160.md); the register as XLSX and PDF
- mcp: register_asset tags (and links) automatically; books.label_asset, books.asset_labels; asset tools take a tag; books.show asset / assets page / register report; export fixed_asset_register (json, csv, xlsx, pdf) and in the CPA package
- viewer: Assets page (register, reconciliation), asset page (identity, schedule posted vs not, entries, documents), Fixed asset register report, global search by tag/serial/description, journal text, needs-attention items
- fix: verify compared snapshot amounts as strings; a zero total summed in hash-map order could print "0" or "0.00", so agreeing books failed verify at random. Now by value (regression test)
- tests: spec-blind suites for tags, register, prefix, store, MCP; label and register PDF/XLSX tests; viewer tests

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-15 12:18:00 -06:00

150 lines
4.4 KiB
Typst

// The fixed asset register: every asset, its cost, depreciation, and book
// value, then the totals and the reconciliation to the general ledger.
//
// 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,
// lines: [{ tag, description, serial, in_service, cost, salvage,
// life, accumulated, book_value, status, disposed }],
// totals: [cost, accumulated, book_value],
// 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)),
),
)
#v(14pt)
#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(),
)
})