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
check / check (push) Canceled after 0s

- 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 767fd469cd
commit ee7e205f5c
53 files changed
+7872 -104

No files matched your search

+335 -18
View File
@@ -54,7 +54,7 @@ use rust_decimal::Decimal;
use std::sync::{Arc, Mutex};
/// The agent operating contract, delivered via initialize.
const INSTRUCTIONS: &str = "You are the bookkeeping agent for these books. The engine enforces the accounting; you enforce the workflow.\n\nRULES\n- Money is exact decimal. Always pass amounts as decimal strings (\"123.45\"), never floats.\n- Never post directly. The flow is draft (books.draft_entry) -> add lines (books.add_line) -> preview (books.post_entry without confirmed) -> review the preview -> post (books.post_entry with confirmed=true).\n- Every entry must balance: debits equal credits, exactly. Every line needs a real account from books.chart. The engine rejects anything else.\n- Receipts: any entry moving more than $25.00 in debits needs at least one receipt attached (books.attach_receipt) before posting. The server rejects the post otherwise. This is enforced, not a suggestion.\n- Never invent accounts. If the account you need does not exist, add it with books.add_account first.\n- Corrections go through books.reverse_entry. Posted entries are immutable; there is no edit.\n- Receipt corrections: receipts can change, but nothing is erased. To replace one, attach the new receipt, then books.unlink_receipt the old (with a reason); retire unwanted receipts or documents with books.withdraw_attachment (with a reason). The journal keeps every step.\n- After posting, check books.digest to review what changed, so a human can review too.\n- Amounts in every response are decimal strings. Treat them as exact.\n- Fixed assets: register with books.register_asset, inspect with books.asset_schedule and books.list_assets, post monthly depreciation with books.run_depreciation (exempt from the receipt policy; the schedule is the evidence), and dispose with books.dispose_asset (preview first; the bill of sale is the receipt). Depreciation is straight-line, book basis only - tax depreciation stays with the CPA.\n- Prepaid expenses and deferred revenue: record the original payment (with its receipt), then books.create_schedule; books.run_schedules posts the monthly amortization (preview first; exempt from the receipt policy like depreciation). Recurring entries (books.create_recurring, books.run_recurring) only ever create drafts: attach each actual receipt and post through the normal preview.\n- Platform payouts (app stores, payment processors, marketplaces): never book the net deposit as revenue. books.record_payout grosses it up (gross revenue, fees, adjustments) into a draft and refuses unless it reconciles to the cent; attach the payout statement, then preview and post.\n- The viewer: the human usually has the read-only viewer open beside you (`just gui`). When they ask to see something, and after you post, call books.show so it's on their screen.\n- 1099s: mark contractors and landlords with books.set_vendor_1099 (and whether their W-9 is on file; never store a TIN), and tag every payment to them with its vendor (books.draft_entry vendor, or books.set_entry_vendor; books.pay_bill tags automatically). books.vendor_1099 is the year-end worksheet for the CPA.\n- Rules that change with the law: wanderbooks knows some legal amounts (like the 1099 threshold) only through a given year and never guesses past it. When a tool refuses with \"wanderbooks knows the ... only through\", or books.digest lists stale_rules: check today's date, find the current figure from the official source (the refusal names the vendored source file) or ask the human (owner, CPA), confirm it with them, and pass it explicitly. Then update the rule for everyone: open a pull request at https://github.com/swaits/wanderbooks with the new value and its official text (best), or at least an issue; if you can't reach GitHub, give the human the issue text to file.\n- Control roles: invoices, bills, payroll, and closes post to the account filling each role (receivables, revenue, payables, general expense, retained earnings, payroll expense and withholdings); books.roles lists them. A role uses its default code (1200, 4000, 2000, 6999, 3999, 6100, 6110, 2110-2150) until it is assigned one of the chart's own accounts with books.assign_role (journaled). If the chart uses a default code for a different kind of account, the engine refuses and says which. It can't tell an account of the right type but another purpose (a hosting expense at payroll's 6100): check books.roles before running payroll, bills, or invoices. Never assign roles or renumber the human's accounts on your own: explain the conflict, and when they decide, assign the role to their account (best: their numbering stays) or books.renumber_account the account (its history stays).\n- Settings: wanderbooks.toml beside the books holds the company's facts and settings (name, fiscal year, tax form, members and their capital accounts, basis, receipt threshold, cash flow roles, close target); books.settings shows what is in force and lists problems, settings that disagree with the chart. Edit the file when the human tells you a company fact, when a tool refuses naming a wanderbooks.toml setting, when bo Line truncated
const INSTRUCTIONS: &str = "You are the bookkeeping agent for these books. The engine enforces the accounting; you enforce the workflow.\n\nRULES\n- Money is exact decimal. Always pass amounts as decimal strings (\"123.45\"), never floats.\n- Never post directly. The flow is draft (books.draft_entry) -> add lines (books.add_line) -> preview (books.post_entry without confirmed) -> review the preview -> post (books.post_entry with confirmed=true).\n- Every entry must balance: debits equal credits, exactly. Every line needs a real account from books.chart. The engine rejects anything else.\n- Receipts: any entry moving more than $25.00 in debits needs at least one receipt attached (books.attach_receipt) before posting. The server rejects the post otherwise. This is enforced, not a suggestion.\n- Never invent accounts. If the account you need does not exist, add it with books.add_account first.\n- Corrections go through books.reverse_entry. Posted entries are immutable; there is no edit.\n- Receipt corrections: receipts can change, but nothing is erased. To replace one, attach the new receipt, then books.unlink_receipt the old (with a reason); retire unwanted receipts or documents with books.withdraw_attachment (with a reason). The journal keeps every step.\n- After posting, check books.digest to review what changed, so a human can review too.\n- Amounts in every response are decimal strings. Treat them as exact.\n- Fixed assets: register with books.register_asset, inspect with books.asset_schedule and books.list_assets, post monthly depreciation with books.run_depreciation (exempt from the receipt policy; the schedule is the evidence), and dispose with books.dispose_asset (preview first; the bill of sale is the receipt). Depreciation is straight-line, book basis only - tax depreciation stays with the CPA. Every asset gets a tag for the label on the thing itself (books.register_asset generates the next one, like WL-0001, unless the human has their own); record the manufacturer's serial number too, change either with books.label_asset, and print label sheets with books.asset_labels (save the PDF; print at 100% on Avery 5160/8160). Asset tools take the tag in place of the id. books.export fixed_asset_register is the register for the CPA; books.show asset opens one in the viewer.\n- Prepaid expenses and deferred revenue: record the original payment (with its receipt), then books.create_schedule; books.run_schedules posts the monthly amortization (preview first; exempt from the receipt policy like depreciation). Recurring entries (books.create_recurring, books.run_recurring) only ever create drafts: attach each actual receipt and post through the normal preview.\n- Platform payouts (app stores, payment processors, marketplaces): never book the net deposit as revenue. books.record_payout grosses it up (gross revenue, fees, adjustments) into a draft and refuses unless it reconciles to the cent; attach the payout statement, then preview and post.\n- The viewer: the human usually has the read-only viewer open beside you (`just gui`). When they ask to see something, and after you post, call books.show so it's on their screen.\n- 1099s: mark contractors and landlords with books.set_vendor_1099 (and whether their W-9 is on file; never store a TIN), and tag every payment to them with its vendor (books.draft_entry vendor, or books.set_entry_vendor; books.pay_bill tags automatically). books.vendor_1099 is the year-end worksheet for the CPA.\n- Rules that change with the law: wanderbooks knows some legal amounts (like the 1099 threshold) only through a given year and never guesses past it. When a tool refuses with \"wanderbooks knows the ... only through\", or books.digest lists stale_rules: check today's date, find the current figure from the official source (the refusal names the vendored source file) or ask the human (owner, CPA), confirm it with them, and pass it explicitly. Then update the rule for everyone: open a pull request at https://github.com/swaits/wanderbooks with the new value and its official text (best), or at least an issue; if you can't reach GitHub, give the human the issue text to file.\n- Control roles: invoices, bills, payroll, and closes post to the account filling each role (receivables, revenue, payables, general expense, retained earnings, payroll expense and withholdings); books.roles lists them. A role uses its default code (1200, 4000, 2000, 6999, 3999, 6100, 6110, 2110-2150) until it is assigned one of the chart's own accounts with books.assign_role (journaled). If the chart uses a default code for a different kind of account, the engine refuses and says which. It can't tell an account of the right type but another purpose (a hosting expense at payroll's 6100): check books.roles before running payroll, bills, or invoices. Never assign roles or renumber the human's accounts on your own: explain the conflict, and when they decide, assign the role to their account (best: their numbering stays) or Line truncated
/// Max receipt/document upload: 25 MB.
const MAX_UPLOAD_BYTES: usize = 25 * 1024 * 1024;
@@ -467,6 +467,36 @@ fn settings_problems(ledger: &Ledger, cfg: &Settings) -> Vec<String> {
out
}
/// An asset named by id or by tag (exactly one).
fn resolve_asset(store: &Store, id: Option<u64>, tag: Option<&str>) -> Result<u64, String> {
match (id, tag) {
(Some(id), None) => store
.ledger()
.get_asset(id)
.map(|a| a.id)
.ok_or_else(|| format!("unknown asset {id}")),
(None, Some(tag)) => store
.ledger()
.asset_by_tag(tag)
.map(|a| a.id)
.ok_or_else(|| format!("no asset is tagged {tag:?}")),
_ => Err("name the asset by asset_id or by tag (one of them)".into()),
}
}
/// An asset named by a string that is its current tag or its id. A tag
/// wins: tags like "1001" are allowed, and the label is what the human
/// reads.
fn resolve_asset_ref(store: &Store, r: &str) -> Result<u64, String> {
if let Some(a) = store.ledger().asset_by_tag(r) {
return Ok(a.id);
}
match r.parse::<u64>() {
Ok(id) if store.ledger().get_asset(id).is_some() => Ok(id),
_ => Err(format!("no asset has the tag or id {r:?}")),
}
}
/// Every control role and the account filling it, for books.roles.
fn roles_out(ledger: &Ledger) -> Vec<RoleOut> {
ledger_core::control_accounts()
@@ -1601,7 +1631,7 @@ impl BooksServer {
#[tool(
name = "books.show",
description = "Show something in the human's viewer (the read-only desktop app, `just gui`): an entry (with its lines, receipt, and history), an account's register (optionally for start/end dates), a report (optionally for start/end dates, split into month/quarter/year columns, compared with the prior period or prior year), a stored receipt or document, a search of the transactions, or a page. Give exactly one target; work out dates yourself (\"last quarter\" means its first and last day). Use it whenever the human asks to see something, and after posting so they can look at what you did. Changes nothing in the books.",
description = "Show something in the human's viewer (the read-only desktop app, `just gui`): an entry (with its lines, receipt, and history), an account's register (optionally for start/end dates), a report (optionally for start/end dates, split into month/quarter/year columns, compared with the prior period or prior year), a stored receipt or document, a fixed asset (by tag or id), a search of the transactions, or a page. Give exactly one target; work out dates yourself (\"last quarter\" means its first and last day). Use it whenever the human asks to see something, and after posting so they can look at what you did. Changes nothing in the books.",
annotations(read_only_hint = true)
)]
async fn show(&self, params: Parameters<ShowParams>) -> Result<Json<ShowOut>, String> {
@@ -1614,6 +1644,7 @@ impl BooksServer {
p.account.is_some(),
p.report.is_some(),
p.attachment_id.is_some(),
p.asset.is_some(),
p.search.is_some(),
p.page.is_some(),
]
@@ -1622,13 +1653,14 @@ impl BooksServer {
.count();
if given != 1 {
return Err(
"give exactly one of entry_id, account, report, attachment_id, search, page".into(),
"give exactly one of entry_id, account, report, attachment_id, asset, search, page"
.into(),
);
}
for d in [&p.start, &p.end].into_iter().flatten() {
parse_date(d)?;
}
const REPORTS: [&str; 10] = [
const REPORTS: [&str; 11] = [
"profit_and_loss",
"balance_sheet",
"cash_flow",
@@ -1639,19 +1671,35 @@ impl BooksServer {
"vendor_1099",
"ar_aging",
"ap_aging",
"fixed_asset_register",
];
const PAGES: [&str; 9] = [
const PAGES: [&str; 10] = [
"overview",
"cash",
"profit",
"transactions",
"reports",
"accounts",
"assets",
"documents",
"company",
"journal",
];
let (target, shown) = if let Some(id) = p.entry_id {
let (target, shown) = if let Some(r) = &p.asset {
let id = resolve_asset_ref(&store, r)?;
let a = ledger.get_asset(id).expect("resolved");
(
ViewerTarget::Asset { asset_id: id },
format!(
"asset {}{}",
a.tag
.as_deref()
.map(|t| format!("{t}, "))
.unwrap_or_default(),
a.description
),
)
} else if let Some(id) = p.entry_id {
let e = ledger
.get_entry(id)
.ok_or_else(|| format!("unknown entry {id}"))?;
@@ -2511,6 +2559,123 @@ impl BooksServer {
}))
}
#[tool(
name = "books.label_asset",
description = "Link a fixed asset to the entry that acquired it (acquisition_entry), or set or change its tag (the label on the thing: letters, digits, - _ . /, at most 32; unique ignoring case and never reused by another asset, even after a retag or disposal) and its manufacturer serial number. The asset is its id or current tag. Journaled. Print labels with books.asset_labels."
)]
async fn label_asset(
&self,
params: Parameters<LabelAssetParams>,
) -> Result<Json<LabelAssetOut>, String> {
let p = params.0;
let mut store = self.lock()?;
let id = resolve_asset_ref(&store, &p.asset)?;
let clear = p.clear_serial.unwrap_or(false);
if clear && p.serial.is_some() {
return Err("pass serial or clear_serial, not both".into());
}
if p.tag.is_none() && p.serial.is_none() && !clear && p.acquisition_entry.is_none() {
return Err(
"nothing to change: pass tag, serial, clear_serial, or acquisition_entry".into(),
);
}
if let Some(entry) = p.acquisition_entry {
store
.ledger()
.check_asset_acquisition(id, entry)
.map_err(|e| e.to_string())?;
}
// Both checked first, so a bad serial doesn't leave a new tag behind.
if let Some(tag) = &p.tag {
store
.ledger()
.check_asset_tag_for(Some(id), tag)
.map_err(|e| e.to_string())?;
}
let serial = if clear { None } else { p.serial.as_deref() };
if p.serial.is_some() || clear {
store
.ledger()
.check_asset_serial(id, serial)
.map_err(|e| e.to_string())?;
}
if let Some(tag) = &p.tag {
store.set_asset_tag(id, tag).map_err(|e| e.to_string())?;
}
if p.serial.is_some() || clear {
store
.set_asset_serial(id, serial)
.map_err(|e| e.to_string())?;
}
if let Some(entry) = p.acquisition_entry {
store
.set_asset_acquisition(id, entry)
.map_err(|e| e.to_string())?;
}
let a = store.ledger().get_asset(id).expect("resolved");
Ok(Json(LabelAssetOut {
asset_id: id,
tag: a.tag.clone(),
serial: a.serial.clone(),
acquisition_entry: a.acquired_by,
}))
}
#[tool(
name = "books.asset_labels",
description = "A printable sheet of asset labels (PDF, base64): each label shows the company, the tag, a QR code of the tag, the description, and the serial number. Avery 5160/8160 (US letter, 30 labels of 1\" x 2-5/8\"); skip reuses a partly used sheet. Omit assets for every active asset with a tag. Save the file and tell the human to print at 100% scale.",
annotations(read_only_hint = true)
)]
async fn asset_labels(
&self,
params: Parameters<AssetLabelsParams>,
) -> Result<Json<AssetLabelsOut>, String> {
use base64::Engine as _;
let p = params.0;
let store = self.lock()?;
let ledger = store.ledger();
let assets: Vec<&ledger_core::Asset> = match &p.assets {
Some(refs) => refs
.iter()
.map(|r| {
let id = resolve_asset_ref(&store, r)?;
Ok(ledger.get_asset(id).expect("resolved"))
})
.collect::<Result<_, String>>()?,
None => ledger
.registered_assets()
.filter(|a| a.status == ledger_core::AssetStatus::Active && a.tag.is_some())
.collect(),
};
let labels = assets
.iter()
.map(|a| {
Ok(ledger_import::AssetLabel {
tag: a.tag.clone().ok_or_else(|| {
format!(
"asset {} has no tag: give it one with books.label_asset first",
a.id
)
})?,
description: a.description.clone(),
serial: a.serial.clone(),
})
})
.collect::<Result<Vec<_>, String>>()?;
let pdf = ledger_import::asset_labels_pdf(
&labels,
&self.settings().company.name,
ledger_import::LabelSheet::Avery5160,
p.skip.unwrap_or(0) as usize,
)
.map_err(|e| e.to_string())?;
Ok(Json(AssetLabelsOut {
file_name: format!("asset-labels-{}.pdf", today()),
content_base64: base64::engine::general_purpose::STANDARD.encode(pdf),
tags: labels.into_iter().map(|l| l.tag).collect(),
}))
}
#[tool(
name = "books.settings",
description = "The settings in force from wanderbooks.toml (company, fiscal year, tax form, members, basis, receipt rule, cash flow roles, close target), the file they come from, and problems: settings that disagree with the chart, each saying what to change. The server reloads the file whenever it changes, with no restart; after editing it, call this to confirm the edit loaded (reload_error says why one didn't, and the last good settings stay in force).",
@@ -2764,7 +2929,7 @@ impl BooksServer {
#[tool(
name = "books.export",
description = "Export a report. Reports: profit_and_loss, balance_sheet, cash_flow (needs cash_accounts), trial_balance (as_of), members_equity (multi-member LLC; members from wanderbooks.toml), general_ledger (by account: running balances, splits), general_journal (every entry in date order; alias journal). Formats: pdf and xlsx (base64) for the five statements; xlsx, csv, json for every report; iif for general_journal (QuickBooks Desktop). Dates: start/end for period reports (start defaults to the first entry, end to today), as_of for balance_sheet and trial_balance.",
description = "Export a report. Reports: profit_and_loss, balance_sheet, cash_flow (needs cash_accounts), trial_balance (as_of), members_equity (multi-member LLC; members from wanderbooks.toml), general_ledger (by account: running balances, splits), general_journal (every entry in date order; alias journal), fixed_asset_register (as_of: every asset with tag, serial, cost, depreciation, book value, reconciled to the books). Formats: pdf and xlsx (base64) for the five statements and fixed_asset_register; xlsx, csv, json for every report; iif for general_journal (QuickBooks Desktop). Dates: start/end for period reports (start defaults to the first entry, end to today), as_of for balance_sheet, trial_balance, and fixed_asset_register.",
annotations(read_only_hint = true)
)]
async fn export(&self, params: Parameters<ExportParams>) -> Result<Json<ExportOut>, String> {
@@ -2811,9 +2976,12 @@ impl BooksServer {
"profit_and_loss" => self.export_pnl(&format, p.start.as_deref(), p.end.as_deref())?,
"balance_sheet" => self.export_balance_sheet(&format, p.as_of.as_deref())?,
"cash_flow" => self.export_cash_flow(&format, &p)?,
"fixed_asset_register" => {
self.export_fixed_asset_register(&format, p.as_of.as_deref())?
}
_ => {
return Err(format!(
"bad report {:?}: expected general_journal, general_ledger, trial_balance, profit_and_loss, balance_sheet, cash_flow, members_equity, or tax_summary",
"bad report {:?}: expected general_journal, general_ledger, trial_balance, profit_and_loss, balance_sheet, cash_flow, members_equity, tax_summary, or fixed_asset_register",
p.report
));
}
@@ -2893,6 +3061,14 @@ impl BooksServer {
let store = self.lock()?;
let meta = self.report_meta(&store);
let ledger = store.ledger();
if report == "fixed_asset_register" {
let as_of = match p.as_of.as_deref() {
Some(s) => parse_date(s)?,
None => today(),
};
let r = ledger_core::fixed_asset_register(ledger, as_of);
return ledger_import::asset_register_xlsx(&r, &meta).map_err(|e| e.to_string());
}
let xlsx = |r: XlsxReport<'_>| report_to_xlsx(&r, &meta).map_err(|e| e.to_string());
match report {
"general_ledger" => {
@@ -2918,6 +3094,14 @@ impl BooksServer {
}
let store = self.lock()?;
let meta = self.report_meta(&store);
if report == "fixed_asset_register" {
let as_of = match p.as_of.as_deref() {
Some(s) => parse_date(s)?,
None => today(),
};
let r = ledger_core::fixed_asset_register(store.ledger(), as_of);
return ledger_import::asset_register_pdf(&r, &meta).map_err(|e| e.to_string());
}
let st = self.layout_statement(store.ledger(), report, p)?;
ledger_import::statement_pdf(&st, &meta).map_err(|e| e.to_string())
}
@@ -2979,6 +3163,36 @@ impl BooksServer {
let cost_account = resolve_account(&store, &p.cost_account)?;
let accum_depr_account = resolve_account(&store, &p.accum_depr_account)?;
let expense_account = resolve_account(&store, &p.expense_account)?;
let tag = match p.tag {
Some(t) => t,
None => store.ledger().next_asset_tag(self.settings().tag_prefix()),
};
// Checked before registering, so a bad tag or serial registers
// nothing.
store
.ledger()
.check_asset_tag_for(None, &tag)
.map_err(|e| e.to_string())?;
if let Some(serial) = &p.serial {
ledger_core::assets::check_serial(serial)
.map_err(|why| format!("bad serial number: {why}"))?;
}
if let Some(entry) = p.acquisition_entry {
let e = store
.ledger()
.get_entry(entry)
.ok_or_else(|| format!("unknown entry {entry}"))?;
if !e
.lines()
.iter()
.any(|l| l.account == cost_account && l.side == Side::Debit)
|| e.status() != ledger_core::EntryStatus::Posted
{
return Err(format!(
"entry {entry} can't be the acquisition: it must be posted and debit the cost account"
));
}
}
let asset_id = store
.register_asset(NewAsset {
description: p.description,
@@ -2994,7 +3208,20 @@ impl BooksServer {
expense_account,
})
.map_err(|e| e.to_string())?;
Ok(Json(RegisterAssetOut { asset_id }))
store
.set_asset_tag(asset_id, &tag)
.map_err(|e| e.to_string())?;
if let Some(serial) = &p.serial {
store
.set_asset_serial(asset_id, Some(serial))
.map_err(|e| e.to_string())?;
}
if let Some(entry) = p.acquisition_entry {
store
.set_asset_acquisition(asset_id, entry)
.map_err(|e| e.to_string())?;
}
Ok(Json(RegisterAssetOut { asset_id, tag }))
}
#[tool(
@@ -3008,10 +3235,11 @@ impl BooksServer {
) -> Result<Json<AssetScheduleOut>, String> {
let p = params.0;
let store = self.lock()?;
let asset_id = resolve_asset(&store, p.asset_id, p.tag.as_deref())?;
let asset = store
.ledger()
.get_asset(p.asset_id)
.ok_or_else(|| format!("unknown asset {}", p.asset_id))?
.get_asset(asset_id)
.ok_or_else(|| format!("unknown asset {asset_id}"))?
.clone();
// The schedule clamps to the depreciation end internally, so an
// unbounded default just means "the whole life".
@@ -3055,6 +3283,9 @@ impl BooksServer {
.registered_assets()
.map(|a| AssetSummary {
asset_id: a.id,
tag: a.tag.clone(),
serial: a.serial.clone(),
acquisition_entry: a.acquired_by,
description: a.description.clone(),
purchase_date: a.purchase_date.to_string(),
cost: money(a.cost.amount),
@@ -3150,13 +3381,14 @@ impl BooksServer {
let proceeds = Money::new(parse_amount(&p.proceeds)?, base);
let proceeds_account = resolve_account(&store, &p.proceeds_account)?;
let gain_loss_account = resolve_account(&store, &p.gain_loss_account)?;
let asset_id = resolve_asset(&store, p.asset_id, p.tag.as_deref())?;
let asset = store
.ledger()
.get_asset(p.asset_id)
.ok_or_else(|| format!("unknown asset {}", p.asset_id))?
.get_asset(asset_id)
.ok_or_else(|| format!("unknown asset {asset_id}"))?
.clone();
let preview = store
.dispose_preview(p.asset_id, date, proceeds)
.dispose_preview(asset_id, date, proceeds)
.map_err(|e| e.to_string())?;
// The would-be disposal entry's lines, for the preview and the
@@ -3203,7 +3435,7 @@ impl BooksServer {
let out = DisposeAssetOut {
status: "preview".to_string(),
asset_id: p.asset_id,
asset_id,
entry_id: 0,
accumulated: money(preview.accumulated),
book_value: money(preview.book_value),
@@ -3227,7 +3459,7 @@ impl BooksServer {
self.settings().policy.describe()
),
entry_id: None,
asset_id: Some(p.asset_id),
asset_id: Some(asset_id),
debits: debits.to_string(),
policy: self.settings().policy.describe(),
remedy_tool: "books.file_document".to_string(),
@@ -3243,7 +3475,7 @@ impl BooksServer {
}
let entry_id = store
.dispose_asset(
p.asset_id,
asset_id,
date,
proceeds,
proceeds_account,
@@ -3930,7 +4162,7 @@ impl BooksServer {
#[tool(
name = "books.export_cpa_package",
description = "Build the CPA handoff package as a zip (base64): the profit and loss, balance sheet, cash flow, statement of changes in members' equity (multi-member LLCs), tax summary, and trial balance, each as PDF and XLSX; the general ledger (by account) and general journal (entry by entry) as XLSX; the journal as IIF (imports into QuickBooks Desktop); the 1099 worksheet as CSV; every receipt behind the year's entries (receipts/, with an index.csv tying each file to its entry) and the company's documents (documents/), unless include_receipts is false; and a README with the journal's verified hash. cash_accounts (e.g. [\"1000\"]) is required: the cash flow statement reconciles to them. as_of defaults to today; period reports cover the fiscal year to date.",
description = "Build the CPA handoff package as a zip (base64): the profit and loss, balance sheet, cash flow, statement of changes in members' equity (multi-member LLCs), tax summary, and trial balance, each as PDF and XLSX; the fixed asset register as PDF and XLSX when there are fixed assets; the general ledger (by account) and general journal (entry by entry) as XLSX; the journal as IIF (imports into QuickBooks Desktop); the 1099 worksheet as CSV; every receipt behind the year's entries (receipts/, with an index.csv tying each file to its entry) and the company's documents (documents/), unless include_receipts is false; and a README with the journal's verified hash. cash_accounts (e.g. [\"1000\"]) is required: the cash flow statement reconciles to them. as_of defaults to today; period reports cover the fiscal year to date.",
annotations(read_only_hint = true)
)]
async fn export_cpa_package(
@@ -4011,6 +4243,18 @@ impl BooksServer {
files.push((format!("{name}.pdf"), pdf(st)?));
files.push((format!("{name}.xlsx"), xlsx(XlsxReport::Statement(st))?));
}
// The fixed asset register, when there are fixed assets.
if ledger.registered_assets().next().is_some() {
let r = ledger_core::fixed_asset_register(ledger, as_of);
files.push((
"fixed-asset-register.pdf".into(),
ledger_import::asset_register_pdf(&r, &meta).map_err(|e| e.to_string())?,
));
files.push((
"fixed-asset-register.xlsx".into(),
ledger_import::asset_register_xlsx(&r, &meta).map_err(|e| e.to_string())?,
));
}
// The books behind them: by account, and entry by entry.
let gl = ledger_core::general_ledger(ledger, fy, as_of).map_err(|e| e.to_string())?;
files.push((
@@ -4252,6 +4496,79 @@ impl BooksServer {
Ok(csv)
}
/// The fixed asset register (ledger_core::fixed_asset_register) as
/// JSON or CSV, reconciled to the books.
fn export_fixed_asset_register(
&self,
format: &str,
as_of: Option<&str>,
) -> Result<String, String> {
let store = self.lock()?;
let as_of = match as_of {
Some(s) => parse_date(s)?,
None => today(),
};
let r = ledger_core::fixed_asset_register(store.ledger(), as_of);
// The same words as the XLSX and PDF register.
let status = |row: &ledger_core::AssetRegisterRow| match row.disposed {
Some((d, p)) => format!("Disposed {d}, proceeds {}", money(p)),
None => "Active".to_string(),
};
if format == "json" {
return serde_json::to_string_pretty(&serde_json::json!({
"as_of": as_of.to_string(),
"assets": r.rows.iter().map(|a| serde_json::json!({
"asset_id": a.asset_id,
"tag": a.tag,
"description": a.description,
"serial": a.serial,
"in_service": a.in_service.to_string(),
"cost": money(a.cost),
"salvage": money(a.salvage),
"useful_life_months": a.useful_life_months,
"accumulated": money(a.accumulated),
"book_value": money(a.book_value),
"status": status(a),
"proceeds": a.disposed.map(|(_, p)| money(p)),
})).collect::<Vec<_>>(),
"total_cost": money(r.total_cost),
"total_accumulated": money(r.total_accumulated),
"total_book_value": money(r.total_book_value),
"ledger_cost": money(r.ledger_cost),
"ledger_accumulated": money(r.ledger_accumulated),
"unposted_depreciation": money(r.unposted_depreciation()),
"unregistered_cost": money(r.unregistered_cost()),
}))
.map_err(|e| e.to_string());
}
let mut csv = String::from(
"tag,description,serial,in_service,cost,salvage,useful_life_months,accumulated,book_value,status,proceeds\n",
);
for a in &r.rows {
csv.push_str(&format!(
"{},{},{},{},{},{},{},{},{},{},{}\n",
csv_escape(a.tag.as_deref().unwrap_or("")),
csv_escape(&a.description),
csv_escape(a.serial.as_deref().unwrap_or("")),
a.in_service,
money(a.cost),
money(a.salvage),
a.useful_life_months,
money(a.accumulated),
money(a.book_value),
status(a),
a.disposed.map(|(_, p)| money(p)).unwrap_or_default(),
));
}
csv.push_str(&format!(
"TOTAL,,,,{},,,{},{},,",
money(r.total_cost),
money(r.total_accumulated),
money(r.total_book_value)
));
Ok(csv)
}
fn export_general_ledger(&self, format: &str, p: &ExportParams) -> Result<String, String> {
let store = self.lock()?;
let ledger = store.ledger();
+15
View File
@@ -47,6 +47,20 @@ pub struct Settings {
pub company: Company,
/// `[books]` cash flow roles.
pub roles: CashRoles,
/// The prefix of generated asset tags (`[books] asset_tag_prefix`, or
/// the company's initials).
pub asset_tag_prefix: String,
}
impl Settings {
/// The prefix for generated asset tags ("FA" when none is set).
pub fn tag_prefix(&self) -> &str {
if self.asset_tag_prefix.is_empty() {
"FA"
} else {
&self.asset_tag_prefix
}
}
}
/// Where the settings come from.
@@ -81,6 +95,7 @@ impl Source {
};
let policy = ledger_config::effective_receipt_policy(&self.config_json, &books)?;
Ok(Settings {
asset_tag_prefix: books.asset_tag_prefix(),
policy,
roles: CashRoles {
cash: books.cash_accounts,
+82 -6
View File
@@ -426,14 +426,16 @@ pub struct ShowParams {
pub account: Option<String>,
/// A report: profit_and_loss, balance_sheet, cash_flow,
/// members_equity, trial_balance, general_ledger, tax_summary,
/// vendor_1099, ar_aging, ap_aging.
/// vendor_1099, ar_aging, ap_aging, fixed_asset_register.
pub report: Option<String>,
/// A stored receipt or document (attachment id).
pub attachment_id: Option<u64>,
/// A fixed asset, by tag or id.
pub asset: Option<String>,
/// Search the transactions list (description, account, vendor, amount).
pub search: Option<String>,
/// A page: overview, cash, profit, transactions, reports, accounts,
/// documents, company, journal.
/// assets, documents, company, journal.
pub page: Option<String>,
/// For account and report: first date, YYYY-MM-DD.
pub start: Option<String>,
@@ -1504,6 +1506,15 @@ pub struct RegisterAssetParams {
pub accum_depr_account: String,
/// Depreciation expense account, id or code.
pub expense_account: String,
/// The asset tag for its label (letters, digits, - _ . /; at most 32).
/// Omit to generate the next one ("WL-0001", from `[books]
/// asset_tag_prefix` or the company's initials).
pub tag: Option<String>,
/// The manufacturer's serial number, if known.
pub serial: Option<String>,
/// The posted entry that put it on the books (its purchase, or a
/// member's contribution of it); it must debit the cost account.
pub acquisition_entry: Option<u64>,
}
/// Result of `books.register_asset`.
@@ -1511,6 +1522,61 @@ pub struct RegisterAssetParams {
pub struct RegisterAssetOut {
/// The new asset's id.
pub asset_id: u64,
/// Its tag (given or generated).
pub tag: String,
}
/// Parameters for `books.label_asset`.
#[derive(Debug, Deserialize, JsonSchema)]
pub struct LabelAssetParams {
/// The asset: its id or its current tag.
pub asset: String,
/// A new tag (never another asset's, now or before). Omit to keep it.
pub tag: Option<String>,
/// The manufacturer's serial number. Omit to keep it.
pub serial: Option<String>,
/// True to clear the serial number.
pub clear_serial: Option<bool>,
/// Link the posted entry that put it on the books (purchase or
/// contribution; it must debit the asset's cost account and not be
/// reversed). Use after correcting that entry, to link the replacement.
pub acquisition_entry: Option<u64>,
}
/// Result of `books.label_asset`.
#[derive(Debug, Serialize, JsonSchema)]
pub struct LabelAssetOut {
/// Asset id.
pub asset_id: u64,
/// Its tag now.
pub tag: Option<String>,
/// Its serial number now.
pub serial: Option<String>,
/// The entry that acquired it, if linked.
pub acquisition_entry: Option<u64>,
}
/// Parameters for `books.asset_labels`.
#[derive(Debug, Deserialize, JsonSchema)]
pub struct AssetLabelsParams {
/// The assets to print (ids or tags), in order. Omit for every active
/// asset with a tag.
pub assets: Option<Vec<String>>,
/// Label positions to skip on the first sheet, to reuse a partly used
/// one (0 to 29). Default 0.
pub skip: Option<u32>,
}
/// Result of `books.asset_labels`.
#[derive(Debug, Serialize, JsonSchema)]
pub struct AssetLabelsOut {
/// A file name for the sheet.
pub file_name: String,
/// The label sheet as PDF, base64. Avery 5160/8160 (US letter, 30
/// labels of 1" x 2-5/8"); print at 100% scale, not "fit to page".
pub content_base64: String,
/// The tags printed, in order.
pub tags: Vec<String>,
}
/// One month of a depreciation schedule.
@@ -1529,8 +1595,10 @@ pub struct ScheduleLineOut {
/// Parameters for `books.asset_schedule`.
#[derive(Debug, Deserialize, JsonSchema)]
pub struct AssetScheduleParams {
/// Asset id.
pub asset_id: u64,
/// Asset id (or pass `tag`).
pub asset_id: Option<u64>,
/// The asset's tag, instead of its id.
pub tag: Option<String>,
/// Run the schedule through this date, YYYY-MM-DD. Defaults to the end
/// of the asset's depreciable life.
pub through: Option<String>,
@@ -1560,6 +1628,12 @@ pub struct AssetScheduleOut {
pub struct AssetSummary {
/// Asset id.
pub asset_id: u64,
/// Its tag, if it has one.
pub tag: Option<String>,
/// Its serial number, if recorded.
pub serial: Option<String>,
/// The entry that acquired it, if linked.
pub acquisition_entry: Option<u64>,
/// Asset description.
pub description: String,
/// Purchase date, YYYY-MM-DD.
@@ -1633,8 +1707,10 @@ pub struct SkippedDepreciation {
/// Parameters for `books.dispose_asset`.
#[derive(Debug, Deserialize, JsonSchema)]
pub struct DisposeAssetParams {
/// Asset id.
pub asset_id: u64,
/// Asset id (or pass `tag`).
pub asset_id: Option<u64>,
/// The asset's tag, instead of its id.
pub tag: Option<String>,
/// Disposal date, YYYY-MM-DD.
pub date: String,
/// Cash (or receivable) received, decimal string.
+1 -1
View File
@@ -147,7 +147,7 @@ fn mcp_end_to_end_over_stdio() {
// Tool listing: the narrow tools; chart is read-only.
let list = mcp.request("tools/list", json!({}));
let tools = list["result"]["tools"].as_array().unwrap();
assert_eq!(tools.len(), 70, "expected 70 tools, got {}", tools.len());
assert_eq!(tools.len(), 72, "expected 72 tools, got {}", tools.len());
let chart = tools.iter().find(|t| t["name"] == "books.chart").unwrap();
assert_eq!(chart["annotations"]["readOnlyHint"], true);
assert!(
+891
View File
@@ -0,0 +1,891 @@
//! Spec-blind tests for the fixed-asset tag tools of `ledger-mcp`, driven
//! over stdio against the real binary.
//!
//! Expected behavior comes only from the tool descriptions and parameter /
//! result doc comments in `src/server.rs` and `src/types.rs` (the declared
//! interface, as `tools/list` serves it), `docs/mcp-interface.md` ("Tools —
//! fixed assets"), and `docs/spec/rules.md` B-24. The harness is the
//! minimal subset of `tests/spec_mcp.rs`: every success validates against
//! the tool's `outputSchema`, and every refusal leaves `journal.jsonl`
//! byte-identical (server invariant 2: a refused call changes nothing).
use serde_json::{Value, json};
use std::collections::HashMap;
use std::io::{BufRead, BufReader, Write};
use std::path::{Path, PathBuf};
use std::process::{Child, ChildStdin, Command, Stdio};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::mpsc::{Receiver, RecvTimeoutError, channel};
use std::time::Duration;
const TIMEOUT: Duration = Duration::from_secs(180);
// ---------------------------------------------------------------- harness
#[derive(Debug, Clone)]
enum Out {
Ok(Value),
ToolErr(String),
RpcErr(Value),
}
impl Out {
fn refused(&self) -> bool {
!matches!(self, Out::Ok(_))
}
fn err_text(&self) -> String {
match self {
Out::Ok(v) => panic!("expected a refusal, got {v}"),
Out::ToolErr(t) => t.clone(),
Out::RpcErr(e) => e.to_string(),
}
}
}
struct Mcp {
stdin: ChildStdin,
rx: Receiver<String>,
child: Child,
next_id: u64,
data: PathBuf,
validators: HashMap<String, jsonschema::Validator>,
}
impl Drop for Mcp {
fn drop(&mut self) {
let _ = self.child.kill();
let _ = self.child.wait();
}
}
static DIR_SEQ: AtomicU64 = AtomicU64::new(0);
fn test_dir(name: &str) -> PathBuf {
let n = DIR_SEQ.fetch_add(1, Ordering::Relaxed);
let dir = std::env::temp_dir().join(format!(
"ledger-mcp-spec-assets-{}-{name}-{n}",
std::process::id()
));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
/// `wanderbooks.toml` with a company name and extra `[books]` TOML. The
/// receipt threshold is high so ordinary test entries need no receipt.
fn books_toml(dir: &Path, company: &str, books_extra: &str) -> PathBuf {
let path = dir.join("wanderbooks.toml");
std::fs::write(
&path,
format!(
"[company]\nname = \"{company}\"\nfiscal_year_start = \"01-01\"\n\
[books]\nreceipt_threshold = \"1000000.00\"\ncash_accounts = [\"1000\"]\n{books_extra}"
),
)
.unwrap();
path
}
impl Mcp {
fn start(dir: &Path, toml: &Path) -> Self {
let run = dir.join("run");
std::fs::create_dir_all(&run).unwrap();
let mut child = Command::new(env!("CARGO_BIN_EXE_ledger-mcp"))
.arg("--data-dir")
.arg(dir.join("books-data"))
.arg("--books-toml")
.arg(toml)
.env("XDG_RUNTIME_DIR", &run)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.expect("spawn ledger-mcp");
let stdin = child.stdin.take().unwrap();
let stdout = BufReader::new(child.stdout.take().unwrap());
let (tx, rx) = channel();
std::thread::spawn(move || {
for line in stdout.lines() {
let Ok(line) = line else { break };
if tx.send(line).is_err() {
break;
}
}
});
let mut mcp = Self {
stdin,
rx,
child,
next_id: 1,
data: dir.join("books-data"),
validators: HashMap::new(),
};
let init = mcp.rpc(
"initialize",
json!({
"protocolVersion": "2025-06-18",
"capabilities": {},
"clientInfo": {"name": "spec-assets", "version": "0.1.0"},
}),
);
assert!(init.get("result").is_some(), "initialize failed: {init}");
mcp.send_line(
&json!({"jsonrpc": "2.0", "method": "notifications/initialized"}).to_string(),
);
let list = mcp.rpc("tools/list", json!({}));
for tool in list["result"]["tools"].as_array().expect("tools") {
let name = tool["name"].as_str().unwrap().to_string();
let validator = jsonschema::validator_for(&tool["outputSchema"])
.unwrap_or_else(|e| panic!("{name}'s outputSchema is not valid: {e}"));
mcp.validators.insert(name, validator);
}
mcp
}
fn journal(&self) -> Vec<u8> {
std::fs::read(self.data.join("journal.jsonl")).unwrap_or_default()
}
/// The journal's line types, in order.
fn journal_types(&self) -> Vec<String> {
String::from_utf8(self.journal())
.unwrap()
.lines()
.filter(|l| !l.trim().is_empty())
.map(|l| {
let v: Value = serde_json::from_str(l).unwrap();
v["type"]
.as_str()
.or_else(|| v["line_type"].as_str())
.unwrap_or_default()
.to_string()
})
.collect()
}
fn send_line(&mut self, line: &str) {
writeln!(self.stdin, "{line}").unwrap();
self.stdin.flush().unwrap();
}
fn recv(&mut self) -> Option<Value> {
loop {
match self.rx.recv_timeout(TIMEOUT) {
Ok(line) => {
let v: Value = serde_json::from_str(&line)
.unwrap_or_else(|e| panic!("server wrote non-JSON {line:?}: {e}"));
if v.get("id").is_none() && v.get("method").is_some() {
continue;
}
return Some(v);
}
Err(RecvTimeoutError::Timeout | RecvTimeoutError::Disconnected) => return None,
}
}
}
fn rpc(&mut self, method: &str, params: Value) -> Value {
let id = self.next_id;
self.next_id += 1;
let msg = json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params});
self.send_line(&msg.to_string());
let resp = self
.recv()
.unwrap_or_else(|| panic!("no response to {method} (id {id})"));
assert_eq!(resp["id"], json!(id), "response id mismatch: {resp}");
resp
}
/// Call a tool; a refusal must leave the journal unchanged, a success
/// must match the outputSchema.
fn tool(&mut self, name: &str, args: Value) -> Out {
let before = self.journal();
let resp = self.rpc("tools/call", json!({"name": name, "arguments": args}));
let out = if let Some(err) = resp.get("error") {
Out::RpcErr(err.clone())
} else {
let result = &resp["result"];
if result.get("isError") == Some(&json!(true)) {
Out::ToolErr(
result["content"]
.as_array()
.and_then(|c| c.iter().find_map(|b| b["text"].as_str()))
.unwrap_or_default()
.to_string(),
)
} else {
let sc = result["structuredContent"].clone();
let v = self
.validators
.get(name)
.unwrap_or_else(|| panic!("{name} is not in tools/list"));
let errors: Vec<String> = v.iter_errors(&sc).map(|e| e.to_string()).collect();
assert!(
errors.is_empty(),
"{name} violates its outputSchema: {errors:?}\n{sc}"
);
Out::Ok(sc)
}
};
if out.refused() {
assert!(
self.journal() == before,
"refused {name} {args} changed the journal ({out:?})"
);
}
out
}
fn ok(&mut self, name: &str, args: Value) -> Value {
let a = args.to_string();
match self.tool(name, args) {
Out::Ok(v) => v,
other => panic!("{name} {a}: expected success, got {other:?}"),
}
}
/// `tool`, expecting a refusal that says why.
fn refuse(&mut self, name: &str, args: Value) -> String {
let a = args.to_string();
let out = self.tool(name, args);
assert!(out.refused(), "{name} {a} should be refused, got {out:?}");
let why = out.err_text();
assert!(!why.trim().is_empty(), "{name} {a}: a refusal says why");
why
}
/// `ok`, and the journal must not change (read-only tools).
fn read_only(&mut self, name: &str, args: Value) -> Value {
let before = self.journal();
let v = self.ok(name, args);
assert!(
self.journal() == before,
"read-only {name} changed the journal"
);
v
}
fn accounts(&mut self, accts: &[(&str, &str, &str)]) {
for (code, name, ty) in accts {
self.ok(
"books.add_account",
json!({"code": code, "name": name, "account_type": ty}),
);
}
}
/// Post an entry (high receipt threshold: none needed).
fn post(&mut self, date: &str, lines: &[(&str, &str, &str)]) {
let id = self.ok(
"books.draft_entry",
json!({"date": date, "description": "test entry"}),
)["entry_id"]
.as_u64()
.unwrap();
for (account, side, amount) in lines {
self.ok(
"books.add_line",
json!({"entry_id": id, "account": account, "side": side, "amount": amount}),
);
}
self.ok(
"books.post_entry",
json!({"entry_id": id, "confirmed": true}),
);
}
/// Register a $1,200, 12-month laptop from 2026-01-01 with `extra` args
/// (tag, serial, description).
fn register(&mut self, extra: Value) -> Out {
let mut args = json!({"description": "Laptop", "purchase_date": "2026-01-01",
"cost": "1200.00", "salvage_value": "0.00", "useful_life_months": 12,
"cost_account": "1500", "accum_depr_account": "1510", "expense_account": "6500"});
for (k, v) in extra.as_object().unwrap() {
args[k] = v.clone();
}
self.tool("books.register_asset", args)
}
fn register_ok(&mut self, extra: Value) -> (u64, String) {
match self.register(extra.clone()) {
Out::Ok(v) => (
v["asset_id"].as_u64().unwrap(),
v["tag"].as_str().unwrap().to_string(),
),
other => panic!("register {extra}: {other:?}"),
}
}
fn assets(&mut self) -> Vec<Value> {
self.ok("books.list_assets", json!({}))["assets"]
.as_array()
.unwrap()
.clone()
}
fn asset(&mut self, id: u64) -> Value {
self.assets()
.into_iter()
.find(|a| a["asset_id"] == json!(id))
.unwrap_or_else(|| panic!("asset {id} not listed"))
}
fn file_bill_of_sale(&mut self) -> u64 {
self.ok(
"books.file_document",
json!({"category": "legal", "file_name": "bill-of-sale.txt", "mime_type": "text/plain",
"content_base64": b64(b"bill of sale"), "description": "Bill of sale"}),
)["attachment_id"]
.as_u64()
.unwrap()
}
}
const CHART: &[(&str, &str, &str)] = &[
("1000", "Bank", "asset"),
("1500", "Equipment", "asset"),
("1510", "Accumulated Depreciation", "asset"),
("3000", "Capital", "equity"),
("6500", "Depreciation Expense", "expense"),
("7100", "Gain or Loss on Disposal", "revenue"),
];
/// Books with the chart, generated tags prefixed "WL".
fn books(name: &str) -> Mcp {
let dir = test_dir(name);
let toml = books_toml(&dir, "Spec Co", "asset_tag_prefix = \"WL\"\n");
let mut m = Mcp::start(&dir, &toml);
m.accounts(CHART);
m
}
fn b64(bytes: &[u8]) -> String {
const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut out = String::new();
for chunk in bytes.chunks(3) {
let n = (u32::from(chunk[0]) << 16)
| (u32::from(*chunk.get(1).unwrap_or(&0)) << 8)
| u32::from(*chunk.get(2).unwrap_or(&0));
for i in 0..4 {
if i <= chunk.len() {
out.push(T[((n >> (18 - 6 * i)) & 63) as usize] as char);
} else {
out.push('=');
}
}
}
out
}
fn unb64(s: &str) -> Vec<u8> {
let val = |c: u8| -> u32 {
match c {
b'A'..=b'Z' => u32::from(c - b'A'),
b'a'..=b'z' => u32::from(c - b'a') + 26,
b'0'..=b'9' => u32::from(c - b'0') + 52,
b'+' => 62,
b'/' => 63,
_ => panic!("not base64: {c}"),
}
};
let clean: Vec<u8> = s.bytes().filter(|b| !b.is_ascii_whitespace()).collect();
let mut out = Vec::new();
for chunk in clean.chunks(4) {
let pad = chunk.iter().filter(|&&b| b == b'=').count();
let mut n = 0u32;
for (i, &b) in chunk.iter().enumerate() {
n |= if b == b'=' { 0 } else { val(b) } << (18 - 6 * i);
}
let bytes = [(n >> 16) as u8, (n >> 8) as u8, n as u8];
out.extend_from_slice(&bytes[..3 - pad]);
}
out
}
/// Decimal strings compared by value.
fn canon(s: &str) -> String {
let (neg, s) = match s.strip_prefix('-') {
Some(r) => (true, r),
None => (false, s),
};
let (int, frac) = s.split_once('.').unwrap_or((s, ""));
let int = int.trim_start_matches('0');
let frac = frac.trim_end_matches('0');
let int = if int.is_empty() { "0" } else { int };
let body = if frac.is_empty() {
int.to_string()
} else {
format!("{int}.{frac}")
};
if neg && body != "0" {
format!("-{body}")
} else {
body
}
}
/// Every value under `key` anywhere in `v`.
fn find_key<'a>(v: &'a Value, key: &str, out: &mut Vec<&'a Value>) {
match v {
Value::Object(m) => {
for (k, child) in m {
if k == key {
out.push(child);
}
find_key(child, key, out);
}
}
Value::Array(a) => a.iter().for_each(|c| find_key(c, key, out)),
_ => {}
}
}
fn the_value(v: &Value, key: &str) -> String {
let mut found = Vec::new();
find_key(v, key, &mut found);
assert_eq!(found.len(), 1, "{key} in {v}");
match found[0] {
Value::String(s) => canon(s),
other => panic!("{key} is not a decimal string: {other}"),
}
}
// ------------------------------------------------- books.register_asset
/// "Without `tag`, the next free `PREFIX-0001` ([books] asset_tag_prefix)";
/// result `{ asset_id, tag }` (the tag given or generated).
#[test]
fn register_generates_the_next_tag_from_the_prefix() {
let mut m = books("gen");
let (a, ta) = m.register_ok(json!({}));
assert_eq!(ta, "WL-0001");
let (_, tb) = m.register_ok(json!({"description": "Monitor"}));
assert_eq!(tb, "WL-0002");
let (c, tc) = m.register_ok(json!({"tag": "DESK-9", "serial": "SN 9"}));
assert_eq!(tc, "DESK-9");
let (_, td) = m.register_ok(json!({}));
assert_eq!(td, "WL-0003");
// list_assets carries tag and serial.
let la = m.asset(a);
assert_eq!(la["tag"], "WL-0001");
assert_eq!(la["serial"], Value::Null);
let lc = m.asset(c);
assert_eq!(lc["tag"], "DESK-9");
assert_eq!(lc["serial"], "SN 9");
}
/// "else the company name's initials without LLC/Inc".
#[test]
fn register_default_prefix_is_the_company_initials() {
let dir = test_dir("initials");
let toml = books_toml(&dir, "Wanderland Labs LLC", "");
let mut m = Mcp::start(&dir, &toml);
m.accounts(CHART);
let (_, t) = m.register_ok(json!({}));
assert_eq!(t, "WL-0001");
}
/// "\"FA\" when none."
#[test]
fn register_default_prefix_falls_back_to_fa() {
let dir = test_dir("fa");
let toml = books_toml(&dir, "LLC", "");
let mut m = Mcp::start(&dir, &toml);
m.accounts(CHART);
let (_, t) = m.register_ok(json!({}));
assert_eq!(t, "FA-0001");
}
/// A bad tag or serial registers nothing (the harness checks the journal
/// is unchanged on every refusal), and the next generated tag is unmoved.
#[test]
fn register_with_a_bad_tag_or_serial_registers_nothing() {
let mut m = books("bad-register");
let (_, t) = m.register_ok(json!({}));
assert_eq!(t, "WL-0001");
let n = m.assets().len();
for extra in [
json!({"tag": "has space"}),
json!({"tag": ""}),
json!({"tag": "Ü-1"}),
json!({"tag": "A".repeat(33)}),
json!({"tag": "---"}),
json!({"tag": "wl-0001"}), // taken, ignoring case
json!({"serial": " lead"}),
json!({"serial": "trail "}),
json!({"serial": ""}),
json!({"serial": "ctl\u{1}"}),
json!({"serial": "S".repeat(65)}),
] {
assert!(
m.register(extra.clone()).refused(),
"{extra} should be refused"
);
}
assert_eq!(m.assets().len(), n, "nothing registered");
let (_, t) = m.register_ok(json!({}));
assert_eq!(t, "WL-0002");
}
/// A generated tag is never one any asset held (B-24).
#[test]
fn register_never_generates_a_released_tag() {
let mut m = books("released");
let (a, _) = m.register_ok(json!({}));
m.ok(
"books.label_asset",
json!({"asset": a.to_string(), "tag": "LAPTOP-A"}),
);
let (_, t) = m.register_ok(json!({}));
assert_eq!(t, "WL-0002");
// And a released tag can't be given to a new asset explicitly.
assert!(m.register(json!({"tag": "WL-0001"})).refused());
}
// ------------------------------------------------- books.label_asset
/// "The asset is its id or current tag"; result `{ asset_id, tag, serial }`;
/// journaled as `asset.tagged`, `asset.serial_set`.
#[test]
fn label_asset_sets_tag_and_serial_by_id_or_tag() {
let mut m = books("label");
let (a, _) = m.register_ok(json!({}));
let before = m.journal_types().len();
let r = m.ok(
"books.label_asset",
json!({"asset": a.to_string(), "serial": "C02XG0FDJGH5"}),
);
assert_eq!(r["asset_id"], json!(a));
assert_eq!(r["tag"], "WL-0001", "omitted tag is kept");
assert_eq!(r["serial"], "C02XG0FDJGH5");
let r = m.ok(
"books.label_asset",
json!({"asset": "wl-0001", "tag": "LAPTOP-1"}),
);
assert_eq!(r["asset_id"], json!(a));
assert_eq!(r["tag"], "LAPTOP-1");
assert_eq!(r["serial"], "C02XG0FDJGH5", "omitted serial is kept");
let types = m.journal_types();
let new: Vec<&str> = types[before..].iter().map(String::as_str).collect();
assert!(new.contains(&"asset.serial_set"), "{new:?}");
assert!(new.contains(&"asset.tagged"), "{new:?}");
// clear_serial.
let r = m.ok(
"books.label_asset",
json!({"asset": "LAPTOP-1", "clear_serial": true}),
);
assert_eq!(r["serial"], Value::Null);
assert_eq!(r["tag"], "LAPTOP-1");
let l = m.asset(a);
assert_eq!(l["tag"], "LAPTOP-1");
assert_eq!(l["serial"], Value::Null);
// The old tag is no longer current: it names nothing.
m.refuse(
"books.label_asset",
json!({"asset": "WL-0001", "serial": "x"}),
);
}
/// Tags are unique ignoring case and never reused by another asset, even
/// after a retag; bad forms and unknown assets are refused.
#[test]
fn label_asset_refusals() {
let mut m = books("label-refuse");
let (a, _) = m.register_ok(json!({}));
let (b, _) = m.register_ok(json!({}));
m.ok(
"books.label_asset",
json!({"asset": a.to_string(), "tag": "LAPTOP-A"}),
);
// b may not take a's current tag (any case) or its released WL-0001.
for t in ["laptop-a", "LAPTOP-A", "WL-0001", "wl-0001"] {
m.refuse(
"books.label_asset",
json!({"asset": b.to_string(), "tag": t}),
);
}
for t in ["bad tag", "", "--", "É"] {
m.refuse(
"books.label_asset",
json!({"asset": b.to_string(), "tag": t}),
);
}
for s in ["", " x", "x ", "a\nb"] {
m.refuse(
"books.label_asset",
json!({"asset": b.to_string(), "serial": s}),
);
}
m.refuse("books.label_asset", json!({"asset": "999", "tag": "NEW-1"}));
m.refuse(
"books.label_asset",
json!({"asset": "NOPE-1", "tag": "NEW-1"}),
);
// a may take back its own old tag.
let r = m.ok(
"books.label_asset",
json!({"asset": "LAPTOP-A", "tag": "WL-0001"}),
);
assert_eq!(r["asset_id"], json!(a));
assert_eq!(m.asset(b)["tag"], "WL-0002");
}
/// resolve_asset_ref: "A tag wins: tags like \"1001\" are allowed".
#[test]
fn an_asset_ref_that_is_a_tag_means_the_tagged_asset() {
let mut m = books("tag-wins");
let (a, _) = m.register_ok(json!({}));
let (b, _) = m.register_ok(json!({"tag": a.to_string()}));
assert_ne!(a, b);
let r = m.ok(
"books.label_asset",
json!({"asset": a.to_string(), "serial": "which"}),
);
assert_eq!(r["asset_id"], json!(b), "the tag {a} wins over the id {a}");
assert_eq!(m.asset(b)["serial"], "which");
assert_eq!(m.asset(a)["serial"], Value::Null);
}
// ------------------------------------------------- tag in place of id
/// `books.asset_schedule { asset_id | tag }`: the same schedule either way.
#[test]
fn asset_schedule_takes_the_tag_in_place_of_the_id() {
let mut m = books("schedule");
let (a, t) = m.register_ok(json!({}));
let by_id = m.read_only("books.asset_schedule", json!({"asset_id": a}));
let by_tag = m.read_only("books.asset_schedule", json!({"tag": t}));
let by_lower = m.read_only(
"books.asset_schedule",
json!({"tag": t.to_ascii_lowercase()}),
);
assert_eq!(by_id, by_tag);
assert_eq!(by_id, by_lower);
assert_eq!(by_tag["asset_id"], json!(a));
// Exactly one of asset_id and tag.
m.refuse("books.asset_schedule", json!({}));
m.refuse("books.asset_schedule", json!({"asset_id": a, "tag": t}));
m.refuse("books.asset_schedule", json!({"tag": "NOPE-1"}));
}
/// `books.dispose_asset { asset_id | tag, ... }`: the same preview either
/// way; confirmed by tag disposes it; its tag stays reserved.
#[test]
fn dispose_asset_takes_the_tag_and_the_tag_stays_reserved() {
let mut m = books("dispose");
m.post(
"2026-01-01",
&[("1000", "debit", "5000.00"), ("3000", "credit", "5000.00")],
);
m.post(
"2026-01-01",
&[("1500", "debit", "1200.00"), ("1000", "credit", "1200.00")],
);
let (a, t) = m.register_ok(json!({"serial": "SN-A"}));
let (b, tb) = m.register_ok(json!({}));
let base = json!({"date": "2026-06-30", "proceeds": "500.00",
"proceeds_account": "1000", "gain_loss_account": "7100"});
let mut by_id = base.clone();
by_id["asset_id"] = json!(a);
let mut by_tag = base.clone();
by_tag["tag"] = json!(t.to_ascii_lowercase());
let p1 = m.ok("books.dispose_asset", by_id.clone());
let p2 = m.ok("books.dispose_asset", by_tag.clone());
assert_eq!(p1, p2, "same preview by id and by tag");
let mut both = by_id.clone();
both["tag"] = json!(t);
m.refuse("books.dispose_asset", both);
m.refuse("books.dispose_asset", base.clone());
let doc = m.file_bill_of_sale();
let mut confirm = by_tag;
confirm["confirmed"] = json!(true);
confirm["receipt_attachment_ids"] = json!([doc]);
m.ok("books.dispose_asset", confirm);
assert_eq!(m.asset(a)["status"], "disposed");
// B-24: a disposed asset's tag is never reused.
m.refuse(
"books.label_asset",
json!({"asset": b.to_string(), "tag": t}),
);
assert!(m.register(json!({"tag": t})).refused());
// Default labels: active tagged assets only.
let labels = m.read_only("books.asset_labels", json!({}));
assert_eq!(labels["tags"], json!([tb]));
}
// ------------------------------------------------- books.asset_labels
#[test]
fn asset_labels_prints_a_pdf_of_the_tags_in_order() {
let mut m = books("labels");
let (_, t1) = m.register_ok(json!({"serial": "SN-1"}));
let (_, t2) = m.register_ok(json!({}));
let (a3, t3) = m.register_ok(json!({"tag": "DESK/7"}));
let r = m.read_only("books.asset_labels", json!({"assets": [t3, t1]}));
assert_eq!(r["tags"], json!([t3, t1]), "the tags printed, in order");
let pdf = unb64(r["content_base64"].as_str().unwrap());
assert!(pdf.starts_with(b"%PDF"), "a PDF");
assert!(!r["file_name"].as_str().unwrap().is_empty());
// By id too.
let r = m.read_only("books.asset_labels", json!({"assets": [a3.to_string()]}));
assert_eq!(r["tags"], json!(["DESK/7"]));
// Default: every active asset with a tag, all three here.
let r = m.read_only("books.asset_labels", json!({}));
let mut tags: Vec<String> = r["tags"]
.as_array()
.unwrap()
.iter()
.map(|v| v.as_str().unwrap().to_string())
.collect();
tags.sort();
let mut want = vec![t1.clone(), t2.clone(), t3.clone()];
want.sort();
assert_eq!(tags, want);
// skip: 0 to 29.
let r = m.read_only("books.asset_labels", json!({"assets": [t1], "skip": 29}));
assert!(unb64(r["content_base64"].as_str().unwrap()).starts_with(b"%PDF"));
m.refuse("books.asset_labels", json!({"assets": [t2], "skip": 30}));
// Unknown asset.
m.refuse("books.asset_labels", json!({"assets": ["NOPE-1"]}));
}
/// "asset {} has no tag: give it one with books.label_asset first": an
/// asset with no tag can't be printed. Assets registered before tags
/// existed have none; here one is made by the store directly.
#[test]
fn asset_labels_refuses_an_asset_without_a_tag() {
let dir = test_dir("untagged");
let toml = books_toml(&dir, "Spec Co", "asset_tag_prefix = \"WL\"\n");
// Books written by the store, outside the server: an untagged asset.
{
use ledger_core::{AccountType, Currency, Money, NewAsset};
let data = dir.join("books-data");
std::fs::create_dir_all(&data).unwrap();
let mut s = ledger_store::Store::create(&data, Currency::USD).unwrap();
let mut add = |c: &str, n: &str, t| s.add_account(c, n, t, None, false).unwrap();
let cost = add("1500", "Equipment", AccountType::Asset);
let accum = add("1510", "Accumulated Depreciation", AccountType::Asset);
let exp = add("6500", "Depreciation Expense", AccountType::Expense);
let usd = |a: &str| Money::new(a.parse().unwrap(), Currency::USD);
s.register_asset(NewAsset {
description: "Old laptop".into(),
purchase_date: chrono::NaiveDate::from_ymd_opt(2026, 1, 1).unwrap(),
cost: usd("1200.00"),
salvage_value: usd("0.00"),
useful_life_months: 12,
cost_account: cost,
accum_depr_account: accum,
expense_account: exp,
})
.unwrap();
}
let mut m = Mcp::start(&dir, &toml);
let assets = m.assets();
assert_eq!(assets.len(), 1);
assert_eq!(assets[0]["tag"], Value::Null);
let id = assets[0]["asset_id"].as_u64().unwrap();
m.refuse("books.asset_labels", json!({"assets": [id.to_string()]}));
// The default (every active tagged asset) has nothing to print, or
// prints nothing; either way it never prints the untagged one.
if let Out::Ok(r) = m.tool("books.asset_labels", json!({})) {
assert_eq!(r["tags"], json!([]));
}
// Tag it and it prints.
m.ok(
"books.label_asset",
json!({"asset": id.to_string(), "tag": "OLD-1"}),
);
let r = m.read_only("books.asset_labels", json!({"assets": ["OLD-1"]}));
assert_eq!(r["tags"], json!(["OLD-1"]));
}
// ------------------------------------------------- books.show { asset }
#[test]
fn show_opens_an_asset_by_tag_or_id() {
let mut m = books("show");
let (a, t) = m.register_ok(json!({}));
m.read_only("books.show", json!({"asset": t}));
m.read_only("books.show", json!({"asset": a.to_string()}));
m.read_only("books.show", json!({"report": "fixed_asset_register"}));
m.read_only("books.show", json!({"page": "assets"}));
// Exactly one target.
m.refuse("books.show", json!({"asset": t, "page": "assets"}));
}
// ------------------------------------------------- export fixed_asset_register
/// `books.export { report: "fixed_asset_register", format: json | csv,
/// as_of? }`: tag, serial, ..., `unposted_depreciation` and
/// `unregistered_cost` (zero when register and books agree).
#[test]
fn export_fixed_asset_register_reconciles_to_the_books() {
let mut m = books("export");
m.post(
"2026-01-01",
&[("1000", "debit", "5000.00"), ("3000", "credit", "5000.00")],
);
m.post(
"2026-01-01",
&[("1500", "debit", "1200.00"), ("1000", "credit", "1200.00")],
);
// $1,200 over 12 months from Jan 1: $100 a month.
m.register_ok(json!({"tag": "LAPTOP-1", "serial": "C02XG0FDJGH5"}));
m.ok("books.run_depreciation", json!({"period": "2026-01"}));
m.ok("books.run_depreciation", json!({"period": "2026-02"}));
let export = |m: &mut Mcp, format: &str, as_of: &str| -> String {
let r = m.read_only(
"books.export",
json!({"report": "fixed_asset_register", "format": format, "as_of": as_of}),
);
assert_eq!(r["report"], "fixed_asset_register");
assert_eq!(r["format"], format);
r["content"].as_str().unwrap().to_string()
};
// Agreeing: depreciation posted through February.
let j: Value = serde_json::from_str(&export(&mut m, "json", "2026-02-28")).unwrap();
assert_eq!(the_value(&j, "unposted_depreciation"), "0");
assert_eq!(the_value(&j, "unregistered_cost"), "0");
let text = j.to_string();
assert!(
text.contains("LAPTOP-1") && text.contains("C02XG0FDJGH5"),
"{text}"
);
// March due by schedule, not posted.
let j: Value = serde_json::from_str(&export(&mut m, "json", "2026-03-31")).unwrap();
assert_eq!(the_value(&j, "unposted_depreciation"), "100");
assert_eq!(the_value(&j, "unregistered_cost"), "0");
// Equipment in the books that no asset accounts for.
m.post(
"2026-03-15",
&[("1500", "debit", "250.00"), ("1000", "credit", "250.00")],
);
let j: Value = serde_json::from_str(&export(&mut m, "json", "2026-03-31")).unwrap();
assert_eq!(the_value(&j, "unregistered_cost"), "250");
let csv = export(&mut m, "csv", "2026-02-28");
assert!(csv.contains("LAPTOP-1"), "{csv}");
assert!(csv.contains("C02XG0FDJGH5"), "{csv}");
let header = csv.lines().next().unwrap().to_ascii_lowercase();
assert!(header.contains("tag"), "{header}");
assert!(header.contains("serial"), "{header}");
}
/// The register lists assets registered by the date only.
#[test]
fn export_fixed_asset_register_as_of_leaves_out_later_assets() {
let mut m = books("export-later");
m.register_ok(json!({"tag": "EARLY-1"}));
m.register_ok(json!({"tag": "LATE-1", "purchase_date": "2026-06-01"}));
let r = m.read_only(
"books.export",
json!({"report": "fixed_asset_register", "format": "json", "as_of": "2026-03-31"}),
);
let text = r["content"].as_str().unwrap();
assert!(text.contains("EARLY-1"));
assert!(!text.contains("LATE-1"), "{text}");
}
+18 -4
View File
@@ -778,15 +778,29 @@ fn happy_path(name: &str) -> Mcp {
m.ok("books.ap_aging", json!({"as_of": "2026-06-30"}));
// Fixed assets.
let asset = m.ok(
let registered = m.ok(
"books.register_asset",
json!({"description": "Laptop", "purchase_date": "2026-01-10", "cost": "3000.00",
"salvage_value": "0.00", "useful_life_months": 36, "cost_account": "1500",
"accum_depr_account": "1510", "expense_account": "6500"}),
)["asset_id"]
.as_u64()
.unwrap();
);
assert_eq!(registered["tag"], "S-0001", "\"Spec Co\" without \"Co\": S");
let asset = registered["asset_id"].as_u64().unwrap();
m.ok("books.asset_schedule", json!({"asset_id": asset}));
m.ok("books.asset_schedule", json!({"tag": "s-0001"}));
m.ok(
"books.label_asset",
json!({"asset": "S-0001", "tag": "LAPTOP-1", "serial": "SN 123"}),
);
m.ok("books.asset_labels", json!({}));
m.ok(
"books.asset_labels",
json!({"assets": ["LAPTOP-1"], "skip": 3}),
);
m.ok(
"books.export",
json!({"report": "fixed_asset_register", "format": "json", "as_of": "2026-03-31"}),
);
m.ok("books.list_assets", json!({}));
m.ok("books.run_depreciation", json!({"period": "2026-01"}));
let dispose = json!({"asset_id": asset, "date": "2026-06-15", "proceeds": "1000.00",