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
This commit is contained in:
swaitsandClaude Opus 5.5 committed 2026-10-15 12:18:00 -06:00
1 parent 4e96b5f54d
commit cd085108c1
53 files changed
+7872 -104

No files matched your search

+2
View File
@@ -17,9 +17,11 @@ typst-pdf = "0.15.1"
typst = "0.15.1"
serde_json = "1"
typst-layout = "0.15.1"
qrcode = { version = "0.14.1", default-features = false, features = ["svg"] }
[lints]
workspace = true
[dev-dependencies]
proptest = "1"
rqrr = { version = "0.11.0", default-features = false }
+370
View File
@@ -0,0 +1,370 @@
//! The fixed asset register as XLSX and PDF, for the CPA.
//!
//! [`ledger_core::fixed_asset_register`] computes the register; [`layout`]
//! lays it out once — columns, one line per asset, the totals, and the
//! reconciliation to the general ledger — and both renderers draw from
//! that layout, so they can't drift apart. Amounts stay exact `Decimal`s
//! until a renderer writes them, through the same paths as the statements
//! ([`crate::report::amount_parts`] for PDF, the accounting number formats
//! of [`crate::xlsx`] for XLSX).
//!
//! ```text
//! GOAL: one list of what the company owns, its cost, depreciation, and
//! book value, that a CPA can file, and that shows at a glance
//! whether the register and the books agree.
//! SOURCE: ledger_core::fixed_asset_register (INVARIANTS 1-4); CONVENTION
//! (layout, the statements' header and footer).
//! INVARIANTS:
//! 1. One line per register row, in the register's order, disposed ones
//! included and marked "Disposed DATE, proceeds AMOUNT"; held ones
//! "Active".
//! 2. The totals are the register's own totals (held assets only),
//! never re-summed here.
//! 3. Reconciliation: cost per register, per books, and their difference
//! (unregistered_cost); accumulated depreciation per schedule, per
//! books, and their difference (unposted_depreciation), exactly.
//! 4. An empty register says "No fixed assets" and still shows totals
//! and the reconciliation (the books may carry unregistered cost).
//! 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.
//! ```
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::reports::{AssetRegister, AssetRegisterRow};
use rust_decimal::Decimal;
use rust_xlsxwriter::{Format, FormatAlign, Workbook};
use std::sync::OnceLock;
use typst::foundations::{Dict, IntoValue};
use typst_as_lib::{TypstEngine, TypstTemplateMainFile};
use typst_layout::PagedDocument;
/// The report's title.
pub const TITLE: &str = "Fixed Asset Register";
/// Column headings, left to right.
pub const COLUMNS: [&str; 10] = [
"Tag",
"Description",
"Serial",
"In service",
"Cost",
"Salvage",
"Life (months)",
"Accumulated depreciation",
"Book value",
"Status",
];
/// The line shown when there are no assets.
pub const EMPTY: &str = "No fixed assets";
/// One asset's line.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct AssetLine {
/// Tag, or empty.
pub tag: String,
/// What it is.
pub description: String,
/// Serial number, or empty.
pub serial: String,
/// In-service date, ISO 8601.
pub in_service: String,
/// Capitalized cost.
pub cost: Decimal,
/// Salvage value.
pub salvage: Decimal,
/// Useful life in months.
pub life_months: u32,
/// Accumulated depreciation by schedule (zero once disposed).
pub accumulated: Decimal,
/// Book value (zero once disposed).
pub book_value: Decimal,
/// "Active", or "Disposed 2026-09-30, proceeds 450.00".
pub status: String,
/// Disposed by the date (shown, not in the totals).
pub disposed: bool,
}
/// Which register column a reconciliation amount sits under.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ReconColumn {
/// Under "Cost".
Cost,
/// Under "Accumulated depreciation".
Accumulated,
}
/// One reconciliation line.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ReconLine {
/// "Cost per register", ...
pub label: String,
/// The amount.
pub amount: Decimal,
/// The column it reconciles.
pub column: ReconColumn,
/// A difference line (ruled above, bold).
pub difference: bool,
}
/// The register laid out for rendering.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RegisterLayout {
/// [`TITLE`].
pub title: String,
/// "As of October 15, 2026".
pub period: String,
/// One per asset, in register order.
pub lines: Vec<AssetLine>,
/// Total cost, accumulated depreciation, and book value of the assets
/// held.
pub totals: [Decimal; 3],
/// Cost then accumulated depreciation: per register, per books,
/// difference.
pub reconciliation: Vec<ReconLine>,
}
fn status(a: &AssetRegisterRow) -> String {
match a.disposed {
Some((date, proceeds)) => {
format!("Disposed {date}, proceeds {}", format_amount(proceeds))
}
None => "Active".to_string(),
}
}
/// Lay the register out (see the module spec).
pub fn layout(r: &AssetRegister) -> RegisterLayout {
let recon = |label: &str, amount, column, difference| ReconLine {
label: label.to_string(),
amount,
column,
difference,
};
RegisterLayout {
title: TITLE.to_string(),
period: format!("As of {}", long_date(r.as_of)),
lines: r
.rows
.iter()
.map(|a| AssetLine {
tag: a.tag.clone().unwrap_or_default(),
description: a.description.clone(),
serial: a.serial.clone().unwrap_or_default(),
in_service: a.in_service.to_string(),
cost: a.cost,
salvage: a.salvage,
life_months: a.useful_life_months,
accumulated: a.accumulated,
book_value: a.book_value,
status: status(a),
disposed: a.disposed.is_some(),
})
.collect(),
totals: [r.total_cost, r.total_accumulated, r.total_book_value],
reconciliation: vec![
recon("Cost per register", r.total_cost, ReconColumn::Cost, false),
recon("Cost per books", r.ledger_cost, ReconColumn::Cost, false),
recon(
"Difference (unregistered cost)",
r.unregistered_cost(),
ReconColumn::Cost,
true,
),
recon(
"Accumulated depreciation per schedule",
r.total_accumulated,
ReconColumn::Accumulated,
false,
),
recon(
"Accumulated depreciation per books",
r.ledger_accumulated,
ReconColumn::Accumulated,
false,
),
recon(
"Difference (not yet posted)",
r.unposted_depreciation(),
ReconColumn::Accumulated,
true,
),
],
}
}
/// 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.
pub fn asset_register_xlsx(r: &AssetRegister, meta: &ReportMeta) -> Result<Vec<u8>, XlsxError> {
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 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) {
&f.header_right
} else {
&f.header
};
sheet.write_string_with_format(row, i as u16, h.to_uppercase(), fmt)?;
}
row += 1;
if l.lines.is_empty() {
sheet.write_string_with_format(row, 0, EMPTY, &f.sub_label)?;
row += 1;
}
let quiet = Format::new().set_italic().set_font_color("#666666");
let quiet_plain = f.plain.clone().set_italic().set_font_color("#666666");
for a in &l.lines {
let (text, amount) = if a.disposed {
(&quiet, &quiet_plain)
} else {
(&Format::new(), &f.plain)
};
sheet.write_string_with_format(row, 0, &a.tag, text)?;
sheet.write_string_with_format(row, 1, &a.description, text)?;
sheet.write_string_with_format(row, 2, &a.serial, text)?;
sheet.write_string_with_format(row, 3, &a.in_service, text)?;
number(sheet, row, 4, a.cost, amount)?;
number(sheet, row, 5, a.salvage, amount)?;
sheet.write_number_with_format(
row,
6,
f64::from(a.life_months),
&text.clone().set_align(FormatAlign::Right),
)?;
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)?;
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)) {
sheet.write_blank(row, c, &f.dollars_total)?;
}
for (c, d) in [4u16, 7, 8].into_iter().zip(l.totals) {
number(sheet, row, c, d, &f.dollars_total)?;
}
row += 2;
sheet.write_string_with_format(row, 0, "Reconciliation to the books", &f.bold)?;
row += 1;
for line in &l.reconciliation {
let col = match line.column {
ReconColumn::Cost => 4,
ReconColumn::Accumulated => 7,
};
let (label_fmt, amount_fmt) = if line.difference {
(&f.bold, &f.dollars_sum)
} else {
(&f.line_label, &f.plain)
};
sheet.write_string_with_format(row, 0, &line.label, label_fmt)?;
number(sheet, row, col, line.amount, amount_fmt)?;
row += 1;
if line.difference {
row += 1;
}
}
for (c, w) in [
(0, 12.0),
(1, 40.0),
(2, 20.0),
(3, 12.0),
(4, 14.0),
(5, 12.0),
(6, 9.0),
(7, 16.0),
(8, 14.0),
(9, 34.0),
] {
sheet.set_column_width(c, w)?;
}
footer(sheet, &f, meta, row.saturating_sub(1), width)?;
Ok(workbook.save_to_buffer()?)
}
static TEMPLATE: &str = include_str!("../templates/asset_register.typ");
fn engine() -> &'static TypstEngine<TypstTemplateMainFile> {
static ENGINE: OnceLock<TypstEngine<TypstTemplateMainFile>> = OnceLock::new();
ENGINE.get_or_init(|| {
TypstEngine::builder()
.main_file(TEMPLATE)
.fonts(INTER)
.build()
})
}
fn amount(d: Decimal) -> serde_json::Value {
let p = amount_parts(d);
serde_json::json!({ "digits": p.digits, "negative": p.negative })
}
/// The PDF template's input: the layout, every amount split for
/// alignment.
pub fn template_data(r: &AssetRegister, meta: &ReportMeta) -> serde_json::Value {
let l = layout(r);
serde_json::json!({
"company": meta.company,
"title": l.title,
"period": l.period,
"footer": meta.footer(),
"columns": COLUMNS,
"empty": EMPTY,
"lines": l.lines.iter().map(|a| serde_json::json!({
"tag": a.tag,
"description": a.description,
"serial": a.serial,
"in_service": a.in_service,
"cost": amount(a.cost),
"salvage": amount(a.salvage),
"life": a.life_months.to_string(),
"accumulated": amount(a.accumulated),
"book_value": amount(a.book_value),
"status": a.status,
"disposed": a.disposed,
})).collect::<Vec<_>>(),
"totals": l.totals.iter().map(|d| amount(*d)).collect::<Vec<_>>(),
"reconciliation": l.reconciliation.iter().map(|x| serde_json::json!({
"label": x.label,
"amount": amount(x.amount),
"difference": x.difference,
})).collect::<Vec<_>>(),
})
}
/// Typeset the register into a document (pages, before PDF encoding), so
/// the layout can be inspected.
pub fn typeset_asset_register(
r: &AssetRegister,
meta: &ReportMeta,
) -> Result<PagedDocument, PdfError> {
let mut inputs = Dict::new();
inputs.insert(
"data".into(),
template_data(r, meta).to_string().into_value(),
);
engine()
.compile_with_input(inputs)
.output
.map_err(|e| PdfError::Compile(format!("{e:?}")))
}
/// The fixed asset register as PDF bytes (US Letter, landscape). See the
/// module spec.
pub fn asset_register_pdf(r: &AssetRegister, meta: &ReportMeta) -> Result<Vec<u8>, PdfError> {
let doc = typeset_asset_register(r, meta)?;
typst_pdf::pdf(&doc, &typst_pdf::PdfOptions::default())
.map_err(|e| PdfError::Save(format!("{e:?}")))
}
+247
View File
@@ -0,0 +1,247 @@
//! Printable asset label sheets: one tag per label, with a QR code.
//!
//! Each label carries the company name, the asset tag (its headline), what
//! the asset is, its serial number when recorded, and a QR code holding
//! exactly the tag. Labels are typeset by `templates/labels.typ` with the
//! same bundled Inter fonts as [`crate::pdf`]; the labels reach Typst as
//! JSON data (`sys.inputs.data`), never as markup. The sheet geometry comes
//! from the label maker's own specification, vendored in
//! `docs/spec/sources/avery-5160.md`.
//!
//! ```text
//! GOAL: tag every fixed asset with a label a phone can scan back to its
//! record, printed on stock label sheets.
//! SOURCE: docs/spec/sources/avery-5160.md (Avery 5160: 1" x 2-5/8", 30 per
//! sheet, 3 x 10, top margin 0.5", side margin 0.1875", column pitch
//! 2.75", row pitch 1"); ISO/IEC 18004 QR codes via the `qrcode`
//! crate, error correction level M; CONVENTION (what a label shows).
//! INVARIANTS:
//! 1. Every label prints inside its cell: each label is a clipped box the
//! size of its cell, and its text shrinks, then ellipsizes, to fit (the
//! description in two lines, the rest in one; the tag shrinks to 6 pt
//! before it would ever be cut, and the QR code always holds it whole).
//! 2. Order: labels fill positions left to right, then top to bottom,
//! starting at position `skip` of the first sheet, then on to the
//! next sheet at position 0.
//! 3. One page per sheet: pages = ceil((skip + labels) / per_sheet).
//! 4. The QR code decodes to exactly the tag.
//! 5. Deterministic: the same labels, company, sheet, and skip render to
//! the same bytes.
//! 6. Text is data: a description holding Typst syntax ("#panic()",
//! "*bold*") prints literally and never runs.
//! 7. Refused: no labels; skip >= per_sheet.
//! SCENARIOS:
//! - 1 label, skip 0: one page, the label at the top-left cell.
//! - 30 labels, skip 0: one full sheet. 30 labels, skip 1: two pages, the
//! last label at the top-left of sheet 2.
//! - 61 labels: three pages, one label on the third.
//! - A partly used sheet with 7 labels left: skip 23.
//! ```
use crate::pdf::{INTER, PdfError};
use qrcode::render::svg;
use qrcode::{EcLevel, QrCode};
use std::sync::OnceLock;
use typst::foundations::{Dict, IntoValue};
use typst_as_lib::{TypstEngine, TypstTemplateMainFile};
use typst_layout::PagedDocument;
/// One asset label.
#[derive(Clone, Debug)]
pub struct AssetLabel {
/// The asset tag, e.g. "FL-0001" (the label's headline and the QR code's content).
pub tag: String,
/// What it is, e.g. "Framework Laptop 13".
pub description: String,
/// Manufacturer serial number, if recorded.
pub serial: Option<String>,
}
/// A sheet layout.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum LabelSheet {
/// Avery 5160 (and compatibles): 1" x 2-5/8", 30 per US Letter sheet.
Avery5160,
}
/// Where a sheet's labels sit, in PDF points (1/72 inch), measured from the
/// page's top-left corner.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct SheetGeometry {
/// Page width.
pub page_width: f64,
/// Page height.
pub page_height: f64,
/// One label's width.
pub label_width: f64,
/// One label's height.
pub label_height: f64,
/// Left edge of the first column (the side margin).
pub left: f64,
/// Top edge of the first row (the top margin).
pub top: f64,
/// Distance from one column's left edge to the next.
pub pitch_x: f64,
/// Distance from one row's top edge to the next.
pub pitch_y: f64,
/// Labels across.
pub columns: usize,
/// Labels down.
pub rows: usize,
/// Space kept clear inside each label's edge (printer drift).
pub inset: f64,
}
/// A label's cell on its page: `(x, y, width, height)` in points from the
/// page's top-left corner.
pub type Cell = (f64, f64, f64, f64);
impl SheetGeometry {
/// Labels per sheet.
pub fn per_sheet(&self) -> usize {
self.columns * self.rows
}
/// The cell at `position` (0-based, reading order) on a sheet.
pub fn cell(&self, position: usize) -> Cell {
let (row, col) = (position / self.columns, position % self.columns);
(
self.left + col as f64 * self.pitch_x,
self.top + row as f64 * self.pitch_y,
self.label_width,
self.label_height,
)
}
}
const INCH: f64 = 72.0;
impl LabelSheet {
/// The sheet's geometry, from the maker's specification
/// (docs/spec/sources/avery-5160.md).
pub fn geometry(self) -> SheetGeometry {
match self {
LabelSheet::Avery5160 => SheetGeometry {
page_width: 8.5 * INCH,
page_height: 11.0 * INCH,
label_width: 2.625 * INCH,
label_height: 1.0 * INCH,
left: 0.1875 * INCH,
top: 0.5 * INCH,
pitch_x: 2.75 * INCH,
pitch_y: 1.0 * INCH,
columns: 3,
rows: 10,
inset: 4.0,
},
}
}
}
static TEMPLATE: &str = include_str!("../templates/labels.typ");
fn engine() -> &'static TypstEngine<TypstTemplateMainFile> {
static ENGINE: OnceLock<TypstEngine<TypstTemplateMainFile>> = OnceLock::new();
ENGINE.get_or_init(|| {
TypstEngine::builder()
.main_file(TEMPLATE)
.fonts(INTER)
.build()
})
}
/// The QR code for `tag` (error correction level M) as an SVG document,
/// its 4-module quiet zone included.
pub fn qr_svg(tag: &str) -> Result<String, PdfError> {
let code = QrCode::with_error_correction_level(tag.as_bytes(), EcLevel::M)
.map_err(|e| PdfError::Qr(format!("{tag:?}: {e}")))?;
Ok(code
.render::<svg::Color<'_>>()
.quiet_zone(true)
.module_dimensions(1, 1)
.dark_color(svg::Color("#000000"))
.light_color(svg::Color("#ffffff"))
.build())
}
/// The template's input: the geometry and every sheet's positions, with
/// each label's QR code rendered.
pub fn template_data(
labels: &[AssetLabel],
company: &str,
sheet: LabelSheet,
skip: usize,
) -> Result<serde_json::Value, PdfError> {
let g = sheet.geometry();
let per = g.per_sheet();
if labels.is_empty() {
return Err(PdfError::NoLabels);
}
if skip >= per {
return Err(PdfError::SkipOutOfRange {
skip,
per_sheet: per,
});
}
let mut positions: Vec<serde_json::Value> = vec![serde_json::Value::Null; skip];
for l in labels {
positions.push(serde_json::json!({
"tag": l.tag,
"description": l.description,
"serial": l.serial,
"qr": qr_svg(&l.tag)?,
}));
}
let pages: Vec<_> = positions.chunks(per).map(<[_]>::to_vec).collect();
Ok(serde_json::json!({
"company": company,
"geometry": {
"page_width": g.page_width,
"page_height": g.page_height,
"label_width": g.label_width,
"label_height": g.label_height,
"left": g.left,
"top": g.top,
"pitch_x": g.pitch_x,
"pitch_y": g.pitch_y,
"columns": g.columns,
"rows": g.rows,
"inset": g.inset,
},
"pages": pages,
}))
}
/// Typeset label sheets into a document (pages, before PDF encoding), so
/// the layout can be inspected. Same arguments and refusals as
/// [`asset_labels_pdf`].
pub fn typeset_labels(
labels: &[AssetLabel],
company: &str,
sheet: LabelSheet,
skip: usize,
) -> Result<PagedDocument, PdfError> {
let data = template_data(labels, company, sheet, skip)?;
let mut inputs = Dict::new();
inputs.insert("data".into(), data.to_string().into_value());
engine()
.compile_with_input(inputs)
.output
.map_err(|e| PdfError::Compile(format!("{e:?}")))
}
/// Render labels onto sheets, starting `skip` positions into the first sheet (to reuse a partly used sheet), as PDF bytes.
///
/// See the module documentation for the spec. Refuses an empty `labels`
/// ([`PdfError::NoLabels`]) and `skip` of a whole sheet or more
/// ([`PdfError::SkipOutOfRange`]).
pub fn asset_labels_pdf(
labels: &[AssetLabel],
company: &str,
sheet: LabelSheet,
skip: usize,
) -> Result<Vec<u8>, PdfError> {
let doc = typeset_labels(labels, company, sheet, skip)?;
typst_pdf::pdf(&doc, &typst_pdf::PdfOptions::default())
.map_err(|e| PdfError::Save(format!("{e:?}")))
}
+6
View File
@@ -6,20 +6,26 @@
//! - [`report`]: the statement layout every renderer draws from.
//! - [`xlsx`]: generate XLSX reports.
//! - [`pdf`]: generate PDF financial statements (CPA handoff).
//! - [`asset_register`]: the fixed asset register as XLSX and PDF.
//! - [`labels`]: print asset label sheets with QR codes.
//!
//! All money stays `Decimal`; no floats in money paths.
pub mod asset_register;
pub mod bank_csv;
pub mod chart_csv;
pub mod iif;
pub mod labels;
pub mod pdf;
pub mod report;
pub mod templates;
pub mod xlsx;
pub use asset_register::{asset_register_pdf, asset_register_xlsx};
pub use bank_csv::{BankCsvError, BankTransaction, DateOrder, parse_bank_csv, parse_bank_csv_with};
pub use chart_csv::{ChartRow, parse_chart_csv};
pub use iif::{IifAccount, IifError, QbAccountType, iif_accounts, journal_to_iif};
pub use labels::{AssetLabel, LabelSheet, asset_labels_pdf};
pub use pdf::{PdfError, statement_pdf};
pub use report::{Member, ReportMeta, Row, RowKind, Statement, YearEarnings, format_amount};
pub use templates::{ChartTemplate, EntityType, chart_template};
+15 -1
View File
@@ -25,10 +25,24 @@ pub enum PdfError {
/// The PDF writer failed.
#[error("pdf save failed: {0}")]
Save(String),
/// No labels to print.
#[error("no labels to print")]
NoLabels,
/// The skip would start past the first sheet.
#[error("skip {skip} is out of range: a sheet has {per_sheet} labels, so skip must be 0 to {}", per_sheet - 1)]
SkipOutOfRange {
/// The requested skip.
skip: usize,
/// Labels per sheet.
per_sheet: usize,
},
/// A tag could not be encoded as a QR code (too long).
#[error("qr code failed: {0}")]
Qr(String),
}
static TEMPLATE: &str = include_str!("../templates/statement.typ");
static INTER: [&[u8]; 3] = [
pub(crate) static INTER: [&[u8]; 3] = [
include_bytes!("../fonts/Inter-Regular.ttf"),
include_bytes!("../fonts/Inter-SemiBold.ttf"),
include_bytes!("../fonts/Inter-Bold.ttf"),
+18 -18
View File
@@ -44,25 +44,25 @@ impl From<rust_xlsxwriter::XlsxError> for XlsxError {
/// Accounting number formats: the `_)` reserves a closing parenthesis's
/// width on positives so decimal points line up with negatives.
const PLAIN: &str = "#,##0.00_);(#,##0.00)";
const DOLLARS: &str = "$#,##0.00_);($#,##0.00)";
pub(crate) const PLAIN: &str = "#,##0.00_);(#,##0.00)";
pub(crate) const DOLLARS: &str = "$#,##0.00_);($#,##0.00)";
struct Formats {
bold: Format,
company: Format,
centered: Format,
header: Format,
header_right: Format,
plain: Format,
dollars_sum: Format,
dollars_total: Format,
line_label: Format,
sub_label: Format,
note: Format,
pub(crate) struct Formats {
pub(crate) bold: Format,
pub(crate) company: Format,
pub(crate) centered: Format,
pub(crate) header: Format,
pub(crate) header_right: Format,
pub(crate) plain: Format,
pub(crate) dollars_sum: Format,
pub(crate) dollars_total: Format,
pub(crate) line_label: Format,
pub(crate) sub_label: Format,
pub(crate) note: Format,
}
impl Formats {
fn new() -> Self {
pub(crate) fn new() -> Self {
let bold = Format::new().set_bold();
let ruled = |f: Format| f.set_border_top(FormatBorder::Thin);
Self {
@@ -97,7 +97,7 @@ impl Formats {
/// Company, title, and period centered over `width` columns; returns the
/// next free row.
fn header(
pub(crate) fn header(
sheet: &mut Worksheet,
f: &Formats,
meta: &ReportMeta,
@@ -120,7 +120,7 @@ fn header(
}
/// Basis, preparation date, and journal seq under the last row.
fn footer(
pub(crate) fn footer(
sheet: &mut Worksheet,
f: &Formats,
meta: &ReportMeta,
@@ -143,7 +143,7 @@ fn footer(
/// with more than 13 digits before the cents can't show exactly: such an
/// amount is written as its exact text instead, right-aligned, so the
/// spreadsheet never shows a wrong number.
fn number(
pub(crate) fn number(
sheet: &mut Worksheet,
row: u32,
col: u16,
@@ -0,0 +1,149 @@
// 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(),
)
})
+91
View File
@@ -0,0 +1,91 @@
// Asset label sheets: one tag per label, with its QR code.
//
// Everything arrives as data in sys.inputs.data (JSON), built by
// ledger-import::labels: the sheet geometry (in points, from
// docs/spec/sources/avery-5160.md), and one entry per sheet position. The
// template only typesets: no label text is ever parsed as markup, and every
// label is drawn inside a clipped box the size of its cell, so nothing can
// print past its edge.
//
// Shape: { company, geometry: { page_width, page_height, label_width,
// label_height, left, top, pitch_x, pitch_y, columns, rows, inset },
// pages: [[null | { tag, description, serial: null | str, qr: svg }]] }
// Each inner list is one sheet, positions in reading order (left to right,
// then top to bottom); null is a position left blank.
#let d = json(bytes(sys.inputs.data))
#let g = d.geometry
#let pt1 = 1pt
#let ink = luma(15)
#let quiet = luma(90)
#set document(title: d.company + " — Asset labels", author: d.company)
#set text(font: "Inter", fill: ink, number-width: "tabular", hyphenate: false)
#set par(leading: 0.28em, spacing: 0pt)
#set page(width: g.page_width * pt1, height: g.page_height * pt1, margin: 0pt)
// The inner box every label's content lives in.
#let inset = g.inset * pt1
#let cell-w = g.label_width * pt1
#let cell-h = g.label_height * pt1
// The QR code (its 4-module quiet zone included) fills the label's height.
#let qr-size = cell-h - 2 * inset
#let gap = 4pt
#let text-w = cell-w - 2 * inset - qr-size - gap
// The largest of `sizes` at which `s` fits in `lines` lines of `width`;
// at the smallest size, the longest prefix that fits, ending in "…".
// Text is set as a string, so it is always literal.
#let fit(s, width, sizes, lines, weight: "regular", fill: ink) = context {
let set-at(size, body) = block(width: width, text(size: size, weight: weight, fill: fill, body))
let limit(size) = measure(set-at(size, ("X\n" * lines).trim())).height + 0.01pt
// Fits: wraps into `lines` lines, and no word is wider than the box
// (an unbreakable word would overflow sideways, not down).
let fits(size, body) = (
measure(set-at(size, body)).height <= limit(size)
and body.split(" ").all(w => measure(text(size: size, weight: weight, w)).width <= width)
)
let chosen = sizes.find(size => fits(size, s))
if chosen != none {
set-at(chosen, s)
} else {
let size = sizes.last()
let cs = s.clusters()
// Binary search for the longest prefix that fits with the ellipsis.
let lo = 0
let hi = cs.len()
while lo < hi {
let mid = calc.quo(lo + hi + 1, 2)
if fits(size, cs.slice(0, mid).join().trim() + "…") { lo = mid } else { hi = mid - 1 }
}
let prefix = if lo == 0 { "" } else { cs.slice(0, lo).join().trim() }
set-at(size, prefix + "…")
}
}
#let label(l) = box(width: cell-w, height: cell-h, clip: true, inset: inset, {
grid(
columns: (qr-size, gap, text-w),
rows: (qr-size,),
image(bytes(l.qr), format: "svg", width: qr-size, height: qr-size),
[],
align(horizon, stack(
spacing: 2.2pt,
fit(d.company, text-w, (6pt, 5.5pt, 5pt), 1, fill: quiet),
fit(l.tag, text-w, (15pt, 13pt, 11pt, 9pt, 8pt, 7pt, 6pt), 1, weight: "bold"),
fit(l.description, text-w, (7.5pt, 7pt, 6.5pt, 6pt), 2),
if l.serial != none { fit("S/N " + l.serial, text-w, (6.5pt, 6pt, 5.5pt, 5pt), 1, fill: quiet) },
)),
)
})
#for (i, sheet) in d.pages.enumerate() {
if i > 0 { pagebreak() }
for (p, l) in sheet.enumerate() {
if l != none {
let col = calc.rem(p, g.columns)
let row = calc.quo(p, g.columns)
place(top + left, dx: (g.left + col * g.pitch_x) * pt1, dy: (g.top + row * g.pitch_y) * pt1, label(l))
}
}
}
@@ -0,0 +1,221 @@
//! Spec tests for the fixed asset register PDF
//! (`ledger_import::asset_register`); the XLSX checks live in
//! spec_import.rs beside its workbook reader. Each test names the module
//! INVARIANT it checks.
use chrono::NaiveDate;
use ledger_core::{AssetRegister, AssetRegisterRow};
use ledger_import::asset_register::{layout, typeset_asset_register};
use ledger_import::report::ReportMeta;
use ledger_import::{PdfError, asset_register_pdf};
use rust_decimal::Decimal;
use typst::layout::{Frame, FrameItem};
fn d(y: i32, m: u32, day: u32) -> NaiveDate {
NaiveDate::from_ymd_opt(y, m, day).unwrap()
}
fn cents(c: i64) -> Decimal {
Decimal::new(c, 2)
}
fn meta() -> ReportMeta {
ReportMeta {
company: "Fernhill Labs LLC".into(),
basis: "Accrual".into(),
prepared_on: d(2026, 10, 15),
journal_seq: 314,
}
}
fn row(id: u64, tag: &str, desc: &str, cost: i64, acc: i64) -> AssetRegisterRow {
AssetRegisterRow {
asset_id: id,
tag: Some(tag.into()),
description: desc.into(),
serial: None,
in_service: d(2026, 1, 15),
cost: cents(cost),
salvage: Decimal::ZERO,
useful_life_months: 36,
accumulated: cents(acc),
book_value: cents(cost - acc),
disposed: None,
}
}
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();
AssetRegister {
as_of: d(2026, 10, 15),
total_book_value: total_cost - total_accumulated,
ledger_cost: total_cost,
ledger_accumulated: total_accumulated - cents(1234),
total_cost,
total_accumulated,
rows,
}
}
fn sample() -> AssetRegister {
let mut disposed = row(3, "FL-0003", "Old monitor", 40000, 0);
disposed.book_value = Decimal::ZERO;
disposed.disposed = Some((d(2026, 9, 30), cents(4500)));
let mut laptop = row(
1,
"FL-0001",
"Framework Laptop 13 (Ryzen 7 7840U, 64 GB, 4 TB)",
250000,
52778,
);
laptop.serial = Some("FRANBMCP0A1234".into());
laptop.salvage = cents(10000);
register(vec![
laptop,
row(2, "FL-0002", "MacBook Air", 129900, 32475),
disposed,
])
}
fn texts(frame: &Frame, out: &mut String) {
for (_, item) in frame.items() {
match item {
FrameItem::Group(g) => texts(&g.frame, out),
FrameItem::Text(t) => {
out.push_str(&t.text);
out.push(' ');
}
_ => {}
}
}
}
fn all_text(r: &AssetRegister, m: &ReportMeta) -> String {
let doc = typeset_asset_register(r, m).expect("typesets");
let mut out = String::new();
for p in doc.pages() {
texts(&p.frame, &mut out);
}
// Runs end at line breaks: collapse the seams to single spaces.
out.split_whitespace().collect::<Vec<_>>().join(" ")
}
/// INVARIANT 6: the same register renders to the same bytes.
#[test]
fn pdf_is_deterministic() {
let r = sample();
let a = asset_register_pdf(&r, &meta()).unwrap();
assert!(a.starts_with(b"%PDF"));
assert_eq!(a, asset_register_pdf(&r, &meta()).unwrap());
}
/// INVARIANTS 1, 2, 3, 5: header, every asset, the status of each, the
/// register's totals, and the reconciliation with both differences.
#[test]
fn pdf_shows_assets_totals_and_reconciliation() {
let t = all_text(&sample(), &meta());
for s in [
"Fernhill Labs LLC",
"Fixed Asset Register",
"As of October 15, 2026",
"FL-0001",
"FRANBMCP0A1234",
"MacBook Air",
"Active",
"Disposed 2026-09-30, proceeds 45.00",
"Total, assets held",
"3,799.00", // cost held: 2,500.00 + 1,299.00
"852.53", // accumulated held
"2,946.47", // book value held
"Cost per register",
"Cost per books",
"Difference (unregistered cost)",
"Accumulated depreciation per schedule",
"Accumulated depreciation per books",
"Difference (not yet posted)",
"12.34",
"Journal seq 314",
] {
assert!(t.contains(s), "{s:?} missing from {t:?}");
}
// 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)]);
}
/// INVARIANT 4: an empty register renders a "No fixed assets" line, its
/// totals, and the reconciliation.
#[test]
fn empty_register_renders() {
let r = register(vec![]);
let pdf = asset_register_pdf(&r, &meta()).expect("empty renders");
assert!(pdf.starts_with(b"%PDF"));
let t = all_text(&r, &meta());
for s in [
"No fixed assets",
"Total, assets held",
"Difference (not yet posted)",
] {
assert!(t.contains(s), "{s:?} missing from {t:?}");
}
}
/// INVARIANT 6: markup in every text field prints literally and never runs.
#[test]
fn text_is_data() {
let mut a = row(1, "#FL-*1*", "#panic() *bold* $x$ <l> @r [x] \\", 100, 0);
a.serial = Some("_u_ // /*".into());
let m = ReportMeta {
company: "#co *Inc*".into(),
..meta()
};
let t = all_text(&register(vec![a]), &m);
for s in [
"#FL-*1*", "#panic()", "*bold*", "$x$", "<l>", "@r", "[x]", "_u_", "#co", "*Inc*",
] {
assert!(t.contains(s), "{s:?} not literal in {t:?}");
}
for s in [
"#panic()", "*", "_", "$", "[", "]", "\\", "#", "<a>", "@a", "//", "/*", "\"", "`",
] {
let mut a = row(1, s, s, 100, 0);
a.serial = Some(s.into());
let m = ReportMeta {
company: s.into(),
..meta()
};
let r: Result<Vec<u8>, PdfError> = asset_register_pdf(&register(vec![a]), &m);
r.unwrap_or_else(|e| panic!("{s:?}: {e}"));
}
}
/// A long register flows onto more landscape pages instead of being cut.
#[test]
fn long_register_paginates_landscape() {
let rows = (1..=120)
.map(|i| {
row(
i,
&format!("FL-{i:04}"),
&format!("Asset {i}"),
100_000,
1_000,
)
})
.collect();
let doc = typeset_asset_register(&register(rows), &meta()).unwrap();
assert!(doc.pages().len() > 1);
let size = doc.pages()[0].frame.size();
assert!(size.x > size.y, "landscape");
}
/// The sample register for eyeballing: writes `$REGISTER_SAMPLE` (a path).
#[test]
#[ignore = "writes a sample PDF for a human to look at"]
fn write_sample() {
let out = std::env::var("REGISTER_SAMPLE").expect("set REGISTER_SAMPLE to the output path");
std::fs::write(out, asset_register_pdf(&sample(), &meta()).unwrap()).unwrap();
}
+393
View File
@@ -0,0 +1,393 @@
//! Spec tests for `ledger_import::labels` (asset label sheets). Each test
//! names the module INVARIANT it checks.
use ledger_import::labels::{SheetGeometry, qr_svg, template_data, typeset_labels};
use ledger_import::{AssetLabel, LabelSheet, PdfError, asset_labels_pdf};
use typst::layout::{Abs, Frame, FrameItem, Point, Transform};
const COMPANY: &str = "Fernhill Labs LLC";
fn label(i: usize) -> AssetLabel {
AssetLabel {
tag: format!("FL-{i:04}"),
description: format!("Asset number {i}"),
serial: i.is_multiple_of(2).then(|| format!("SN{i:08}")),
}
}
fn labels(n: usize) -> Vec<AssetLabel> {
(1..=n).map(label).collect()
}
fn pages(n: usize, skip: usize) -> usize {
typeset_labels(&labels(n), COMPANY, LabelSheet::Avery5160, skip)
.expect("typesets")
.pages()
.len()
}
/// SOURCE: docs/spec/sources/avery-5160.md, figure by figure, in inches.
#[test]
fn avery_5160_geometry_matches_the_vendored_spec() {
let g = LabelSheet::Avery5160.geometry();
let inch = |x: f64| x * 72.0;
assert_eq!(g.page_width, inch(8.5));
assert_eq!(g.page_height, inch(11.0));
assert_eq!(g.label_width, inch(2.625));
assert_eq!(g.label_height, inch(1.0));
assert_eq!(g.top, inch(0.5));
assert_eq!(g.left, inch(0.1875));
assert_eq!(g.pitch_x, inch(2.75));
assert_eq!(g.pitch_y, inch(1.0));
assert_eq!((g.columns, g.rows, g.per_sheet()), (3, 10, 30));
// The template's cell edges, in PDF points from the bottom-left corner.
let lefts: Vec<f64> = (0..3).map(|c| g.cell(c).0).collect();
assert_eq!(lefts, [13.5, 211.5, 409.5]);
let bottoms: Vec<f64> = (0..10)
.map(|r| g.page_height - (g.cell(r * 3).1 + g.label_height))
.collect();
assert_eq!(
bottoms,
[
684.0, 612.0, 540.0, 468.0, 396.0, 324.0, 252.0, 180.0, 108.0, 36.0
]
);
// Margins and gaps close the page exactly.
let gap = g.pitch_x - g.label_width;
assert_eq!(gap, inch(0.125));
assert_eq!(2.0 * g.left + 3.0 * g.label_width + 2.0 * gap, g.page_width);
assert_eq!(2.0 * g.top + 10.0 * g.pitch_y, g.page_height);
}
/// INVARIANT 3: pages = ceil((skip + labels) / 30).
#[test]
fn one_page_per_sheet() {
assert_eq!(pages(1, 0), 1);
assert_eq!(pages(30, 0), 1);
assert_eq!(pages(30, 1), 2);
assert_eq!(pages(61, 0), 3);
assert_eq!(pages(7, 23), 1);
assert_eq!(pages(8, 23), 2);
}
/// INVARIANT 7: a skip of a whole sheet or more, and no labels, are refused.
#[test]
fn refusals() {
let s = LabelSheet::Avery5160;
for skip in [30, 31, usize::MAX] {
let e = asset_labels_pdf(&labels(1), COMPANY, s, skip).unwrap_err();
assert!(
matches!(e, PdfError::SkipOutOfRange { skip: k, per_sheet: 30 } if k == skip),
"{e:?}"
);
assert!(e.to_string().contains("0 to 29"), "{e}");
}
let e = asset_labels_pdf(&[], COMPANY, s, 0).unwrap_err();
assert!(matches!(e, PdfError::NoLabels), "{e:?}");
assert!(asset_labels_pdf(&labels(1), COMPANY, s, 29).is_ok());
}
/// INVARIANT 5: the same input renders to the same bytes.
#[test]
fn deterministic_bytes() {
let ls = labels(5);
let a = asset_labels_pdf(&ls, COMPANY, LabelSheet::Avery5160, 3).unwrap();
let b = asset_labels_pdf(&ls, COMPANY, LabelSheet::Avery5160, 3).unwrap();
assert!(a.starts_with(b"%PDF"));
assert_eq!(a, b);
}
/// A laid-out item with its absolute bounding box on the page, in points
/// from the top-left corner.
#[derive(Debug)]
struct Placed {
text: Option<String>,
image: bool,
x0: f64,
y0: f64,
x1: f64,
y1: f64,
}
fn walk(frame: &Frame, ts: Transform, out: &mut Vec<Placed>) {
for (pos, item) in frame.items() {
let at = |dx: Abs, dy: Abs| Point::new(pos.x + dx, pos.y + dy).transform(ts);
match item {
FrameItem::Group(g) => {
let ts = ts
.pre_concat(Transform::translate(pos.x, pos.y))
.pre_concat(g.transform);
walk(&g.frame, ts, out);
}
FrameItem::Text(t) => {
// The run's ink box: baseline at pos.y; Inter's ascender and
// descender are within 1em above and 0.3em below.
let a = at(Abs::zero(), -t.size);
let b = at(t.width(), t.size * 0.3);
out.push(Placed {
text: Some(t.text.to_string()),
image: false,
x0: a.x.to_pt(),
y0: a.y.to_pt(),
x1: b.x.to_pt(),
y1: b.y.to_pt(),
});
}
FrameItem::Image(_, size, _) => {
let (a, b) = (at(Abs::zero(), Abs::zero()), at(size.x, size.y));
out.push(Placed {
text: None,
image: true,
x0: a.x.to_pt(),
y0: a.y.to_pt(),
x1: b.x.to_pt(),
y1: b.y.to_pt(),
});
}
FrameItem::Shape(..) | FrameItem::Link(..) | FrameItem::Tag(..) => {}
}
}
}
fn placed(doc: &typst_layout::PagedDocument, page: usize) -> Vec<Placed> {
let mut out = Vec::new();
walk(&doc.pages()[page].frame, Transform::identity(), &mut out);
out
}
fn inside(p: &Placed, (x, y, w, h): (f64, f64, f64, f64)) -> bool {
const EPS: f64 = 1e-6;
p.x0 >= x - EPS && p.y0 >= y - EPS && p.x1 <= x + w + EPS && p.y1 <= y + h + EPS
}
fn cell_of(g: &SheetGeometry, p: &Placed) -> Option<usize> {
(0..g.per_sheet()).find(|&i| inside(p, g.cell(i)))
}
/// INVARIANTS 1 and 2: every text run and image lies wholly inside one cell,
/// each label's tag and QR code sit in the cell its order and the skip put
/// it in, and the description, however long, never leaves the cell.
#[test]
fn every_label_lies_inside_its_cell_in_order() {
let g = LabelSheet::Avery5160.geometry();
let mut ls = labels(40);
ls[3].description = "An extraordinarily long description that goes on and on, \
far longer than any label could hold on two lines at any legible size"
.repeat(3);
ls[4].tag = "FL-0005-LONGER-TAG".to_string();
// An absurd tag: cut with an ellipsis (the QR code holds it whole), but
// still inside its cell.
ls[6].tag = "X".repeat(60);
ls[5].serial = Some("S".repeat(80));
let skip = 7;
let doc = typeset_labels(&ls, COMPANY, LabelSheet::Avery5160, skip).unwrap();
assert_eq!(doc.pages().len(), 2);
for page in 0..2 {
let items = placed(&doc, page);
assert!(!items.is_empty());
for p in &items {
assert!(
cell_of(&g, p).is_some(),
"page {page}: outside every cell: {p:?}"
);
}
// The cells holding something are exactly this page's labels.
let mut used: Vec<usize> = items.iter().filter_map(|p| cell_of(&g, p)).collect();
used.sort_unstable();
used.dedup();
let expected: Vec<usize> = if page == 0 {
(skip..30).collect()
} else {
(0..17).collect()
};
assert_eq!(used, expected, "page {page}");
// One QR image per label, in its cell.
let images: Vec<usize> = items
.iter()
.filter(|p| p.image)
.filter_map(|p| cell_of(&g, p))
.collect();
assert_eq!(images, expected, "page {page}: one QR per label, in order");
}
// Each tag is printed whole in its own cell.
for (i, l) in ls.iter().enumerate() {
let (page, pos) = ((skip + i) / 30, (skip + i) % 30);
if i == 6 {
assert!(cell_text(&doc, page, pos).contains("XXX…"));
continue;
}
let items = placed(&doc, page);
let text: String = items
.iter()
.filter(|p| cell_of(&g, p) == Some(pos))
.filter_map(|p| p.text.clone())
.collect::<Vec<_>>()
.join(" ");
assert!(
text.contains(&l.tag),
"label {i} at page {page} pos {pos}: {text:?}"
);
}
}
/// The text runs in one cell, joined.
fn cell_text(doc: &typst_layout::PagedDocument, page: usize, pos: usize) -> String {
let g = LabelSheet::Avery5160.geometry();
placed(doc, page)
.iter()
.filter(|p| cell_of(&g, p) == Some(pos))
.filter_map(|p| p.text.clone())
.collect::<Vec<_>>()
.join(" ")
}
/// INVARIANT 6: markup in any field prints literally and never runs.
#[test]
fn text_is_data() {
let l = AssetLabel {
tag: "#FL-*1*".to_string(),
description: "#panic() *bold* $x$".to_string(),
serial: Some("_u_ @ref".to_string()),
};
let doc = typeset_labels(&[l], "<co> #co", LabelSheet::Avery5160, 0).expect("markup is inert");
let text = cell_text(&doc, 0, 0);
for s in [
"#FL-*1*", "#panic()", "*bold*", "$x$", "S/N", "_u_", "@ref", "<co>", "#co",
] {
assert!(text.contains(s), "{s:?} not printed literally in {text:?}");
}
// Each markup string alone, in every field, still renders.
for m in [
"#panic()", "*", "_", "$", "[", "]", "\\", "#", "<a>", "@a", "//", "/*", "\"", "`",
] {
let l = AssetLabel {
tag: m.to_string(),
description: m.to_string(),
serial: Some(m.to_string()),
};
asset_labels_pdf(&[l], m, LabelSheet::Avery5160, 0)
.unwrap_or_else(|e| panic!("{m:?}: {e}"));
}
}
/// INVARIANT 1: an overlong description is shortened with an ellipsis;
/// a short one prints whole.
#[test]
fn long_text_is_ellipsized_short_text_is_whole() {
let long = "word ".repeat(80);
let ls = [
AssetLabel {
tag: "FL-0001".into(),
description: long.clone(),
serial: None,
},
AssetLabel {
tag: "FL-0002".into(),
description: "MacBook Air".into(),
serial: None,
},
];
let doc = typeset_labels(&ls, COMPANY, LabelSheet::Avery5160, 0).unwrap();
let t0 = cell_text(&doc, 0, 0);
assert!(t0.contains('…'), "{t0:?}");
assert!(t0.matches("word").count() < 80);
let t1 = cell_text(&doc, 0, 1);
assert!(t1.contains("MacBook Air") && !t1.contains('…'), "{t1:?}");
assert!(!t1.contains("S/N"), "no serial, no S/N line: {t1:?}");
}
/// Rasterize the SVG the template receives (qrcode's renderer: one
/// background rect, then `M{x} {y}h{w}v{h}H{x}V{y}` per dark run).
fn rasterize(svg: &str, scale: usize) -> (usize, Vec<bool>) {
let attr = |name: &str| -> usize {
let at = svg.find(&format!(" {name}=\"")).unwrap() + name.len() + 3;
svg[at..].split('"').next().unwrap().parse().unwrap()
};
let (w, h) = (attr("width"), attr("height"));
assert_eq!(w, h);
let d = svg
.split(" d=\"")
.nth(1)
.unwrap()
.split('"')
.next()
.unwrap();
let mut dark = vec![false; w * w];
for run in d.split('M').filter(|s| !s.is_empty()) {
let nums: Vec<usize> = run
.split(|c: char| c.is_ascii_alphabetic() || c == ' ')
.filter(|s| !s.is_empty())
.map(|s| s.parse().unwrap())
.collect();
let [x, y, rw, rh, ..] = nums[..] else {
panic!("bad run {run:?}")
};
for yy in y..y + rh {
for xx in x..x + rw {
dark[yy * w + xx] = true;
}
}
}
let n = w * scale;
let px = (0..n * n)
.map(|i| dark[(i / n / scale) * w + (i % n) / scale])
.collect();
(n, px)
}
/// INVARIANT 4: the QR SVG each label prints decodes, with an independent
/// decoder (rqrr), to exactly its tag; and it is the SVG the template gets.
#[test]
fn qr_decodes_to_exactly_the_tag() {
let tags = [
"FL-0001",
"FL-0002",
"FL-9999",
"A",
"fl-0001 lower case & symbols: /?#",
"FL-0005-WITH-A-RATHER-LONG-TAG",
];
for tag in tags {
let svg = qr_svg(tag).unwrap();
let (n, px) = rasterize(&svg, 4);
let mut img = rqrr::PreparedImage::prepare_from_bitmap(n, n, |x, y| px[y * n + x]);
let grids = img.detect_grids();
assert_eq!(grids.len(), 1, "{tag:?}: one code");
let (meta, content) = grids[0].decode().unwrap();
assert_eq!(content, tag);
assert_eq!(meta.ecc_level, 0, "{tag:?}: level M (rqrr numbers M as 0)");
}
let data = template_data(&[label(1)], COMPANY, LabelSheet::Avery5160, 0).unwrap();
assert_eq!(data["pages"][0][0]["qr"], qr_svg("FL-0001").unwrap());
}
/// The sample sheet, for eyeballing: writes `$LABELS_SAMPLE` (a path).
/// Run with `LABELS_SAMPLE=/path/out.pdf cargo test -p ledger-import --test
/// labels -- --ignored sample`.
#[test]
#[ignore = "writes a sample PDF for a human to look at"]
fn sample() {
let out = std::env::var("LABELS_SAMPLE").expect("set LABELS_SAMPLE to the output path");
let ls = [
AssetLabel {
tag: "FL-0001".into(),
description: "Framework Laptop 13 (Ryzen 7 7840U, 64 GB, 4 TB)".into(),
serial: Some("FRANBMCP0A1234".into()),
},
AssetLabel {
tag: "FL-0002".into(),
description: "MacBook Air".into(),
serial: None,
},
AssetLabel {
tag: "FL-0003".into(),
description: "Studio monitor arm with dual gas springs, cable management channel, \
VESA 75/100 adapter plate, desk clamp and grommet mount, and a spare set of \
every bolt the box came with"
.into(),
serial: Some("ARM-2026-0099887766".into()),
},
];
let pdf = asset_labels_pdf(&ls, COMPANY, LabelSheet::Avery5160, 0).unwrap();
std::fs::write(out, pdf).unwrap();
}
+162
View File
@@ -3004,3 +3004,165 @@ fn tax_summary_optional_sections() {
assert!(!has(&bare, "Cost of goods sold"));
assert!(!has(&bare, "Not yet categorized"));
}
// ---------------------------------------------------------------------------
// Fixed asset register (ledger_import::asset_register)
// ---------------------------------------------------------------------------
/// 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.
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| {
AssetRegisterRow {
asset_id: id,
tag: tag.map(Into::into),
description: desc.into(),
serial: serial.map(Into::into),
in_service: d(2026, 1, 15),
cost: cents(cost),
salvage: cents(10000),
useful_life_months: 36,
accumulated: cents(acc),
book_value: cents(cost - acc),
disposed,
}
};
ledger_core::AssetRegister {
as_of: d(2026, 10, 15),
rows: vec![
row(
1,
Some("FL-0001"),
"Framework Laptop 13",
Some("FRAN123"),
250000,
52778,
None,
),
row(2, None, "Desk", None, 120000, 25000, None),
{
let mut r = row(
3,
Some("FL-0003"),
"Old monitor",
None,
40000,
0,
Some((d(2026, 9, 30), cents(4500))),
);
r.book_value = Decimal::ZERO;
r
},
],
total_cost: cents(370000),
total_accumulated: cents(77778),
total_book_value: cents(292222),
ledger_cost: cents(400000),
ledger_accumulated: cents(70000),
}
}
fn number_cell(cells: &BTreeMap<(u32, u32), Cell>, at: (u32, u32)) -> Decimal {
match cells.get(&at) {
Some(Cell::Num(v)) => parse_num(v),
other => panic!("{at:?}: expected a number, got {other:?}"),
}
}
fn text_cell(cells: &BTreeMap<(u32, u32), Cell>, at: (u32, u32)) -> String {
match cells.get(&at) {
Some(Cell::Text(t)) => t.clone(),
other => panic!("{at:?}: expected text, got {other:?}"),
}
}
/// asset_register INVARIANTS 1-3 and 5, read back from the workbook's XML:
/// header, headings, one row per asset in order (disposed marked), the
/// register's own totals, and the reconciliation under Cost and
/// Accumulated depreciation.
#[test]
fn asset_register_xlsx_cells() {
let r = asset_register_fixture();
let x = ledger_import::asset_register_xlsx(&r, &meta()).unwrap();
let c = sheet_cells(&x);
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();
assert_eq!(
heads,
[
"TAG",
"DESCRIPTION",
"SERIAL",
"IN SERVICE",
"COST",
"SALVAGE",
"LIFE (MONTHS)",
"ACCUMULATED DEPRECIATION",
"BOOK VALUE",
"STATUS"
]
);
assert_eq!(text_cell(&c, (6, 0)), "FL-0001");
assert_eq!(text_cell(&c, (6, 1)), "Framework Laptop 13");
assert_eq!(text_cell(&c, (6, 2)), "FRAN123");
assert_eq!(text_cell(&c, (6, 3)), "2026-01-15");
assert_eq!(number_cell(&c, (6, 4)), dec("2500.00"));
assert_eq!(number_cell(&c, (6, 5)), dec("100.00"));
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");
assert_eq!(text_cell(&c, (7, 1)), "Desk");
assert_eq!(text_cell(&c, (8, 0)), "FL-0003");
assert_eq!(text_cell(&c, (8, 9)), "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");
// Reconciliation.
assert_eq!(text_cell(&c, (11, 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"),
];
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}");
}
let footer = c
.values()
.any(|v| matches!(v, Cell::Text(t) if t.contains("Journal seq 42")));
assert!(footer, "footer present");
}
/// asset_register INVARIANT 4: an empty register says so, still totals,
/// and still reconciles (the books may carry unregistered cost).
#[test]
fn asset_register_xlsx_empty() {
let r = ledger_core::AssetRegister {
as_of: d(2026, 10, 15),
rows: vec![],
total_cost: Decimal::ZERO,
total_accumulated: Decimal::ZERO,
total_book_value: Decimal::ZERO,
ledger_cost: cents(99900),
ledger_accumulated: Decimal::ZERO,
};
let c = sheet_cells(&ledger_import::asset_register_xlsx(&r, &meta()).unwrap());
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);
assert_eq!(text_cell(&c, (12, 0)), "Difference (unregistered cost)");
assert_eq!(number_cell(&c, (12, 4)), dec("999.00"));
}