Compare commits

...
7 Commits
Author SHA1 Message Date
swaitsandClaude Opus 5.5 f4cb01b3f9 fix: money strings always carry cents
money() printed whatever scale a Decimal happened to have, so an empty
sum came back as "0" while every other amount read "207.68". Amounts now
have at least two places ("0.00", "1000.00"), padded and never rounded:
an exact FX-converted amount with more places keeps them. Negative zero
normalizes to "0.00".

Test: money_always_has_cents_and_never_rounds; three stdio assertions
that pinned the old "0" now expect "0.00".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MtYEaR6M6Ybdhm6X98BWNu
2026-10-11 19:38:01 -07:00
swaitsandClaude Opus 5.5 42e600bbf2 chore: clippy clean across the workspace, all targets
Zero warnings from `cargo clippy --workspace --all-targets`, which is now
a hard rule rather than "clean except pre-existing":

- ChartOfAccounts::add takes the Account it registers instead of eight
  loose parameters (too_many_arguments); Ledger::add_account builds it.
  Same validation, same errors.
- close_period tool: collapse the nested if-let into a let chain.
- iif.rs docs: the IIF layout example shows tabs as `\t` instead of
  literal tab characters (tabs_in_doc_comments).
- Tests: drop an unused binding; assert! instead of assert_eq! against a
  literal bool.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MtYEaR6M6Ybdhm6X98BWNu
2026-10-11 19:24:10 -07:00
swaitsandClaude Opus 5.5 03212265ce feat: Typst-typeset statements in US accounting format
The printpdf statements padded columns with spaces in a proportional font
(amounts didn't line up), had no company name, printed negatives as
"-207.68", and put everything on one page (a long statement was silently
cut off past ~70 lines). Now:

- PDFs are typeset by Typst from one template,
  ledger-import/templates/statement.typ. The laid-out statement goes in as
  JSON data (sys.inputs), so account names are never parsed as markup.
  Fonts are bundled (typst-assets: Libertinus Serif), so output doesn't
  depend on the machine and is byte-for-byte deterministic. Statements
  paginate, with the company/title repeated on later pages and "Page n of
  m" plus "Prepared from journal through seq N" in the footer.
- US accounting presentation, decided once in ledger-import::report and
  applied by both PDF and XLSX: tabular figures; "$" on each section's
  first amount and on every subtotal and total, pinned to the column's
  left edge; negatives in parentheses with the closing paren hanging (its
  width reserved on every row, so decimal points align); zero as a dash;
  a single rule above sums (none over an empty section) and a double rule
  under statement totals; amounts never bold, because bold figures are
  wider and would leave the decimal column. XLSX uses Excel's own
  Accounting formats for the same result.
- Cash flow: "Net change in cash" is a subtotal; only "Cash at end of
  period" takes the double rule.
- Core fix: cash_flow walked the chart in HashMap order, so the order of
  working-capital and financing lines could change between runs. Lines
  now follow account code. Test: five owners, twenty runs, always
  3000/3001/3003/3005/3009; fails without the sort.

New dependencies (ledger-import): typst, typst-layout, typst-pdf,
typst-assets (fonts), typst-as-lib (World/font plumbing), serde_json (the
template's data). Removed: printpdf. Release ledger-mcp grows by the
bundled fonts.

Tests: exact template data for a hand-built P&L (like a golden); same
statement renders to identical bytes; 200 rows of Typst markup
characters render literally and span several pages; currency-sign and
rule placement; amount splitting.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MtYEaR6M6Ybdhm6X98BWNu
2026-10-11 19:23:01 -07:00
swaitsandClaude Opus 5.5 b5af21bb0e feat: real dates and per-account detail in every statement export
Every statement export rendered life-of-ledger totals under whatever date
range or as-of label was asked for, and the P&L and balance sheet XLSX
were totals-only. Now:

- books.profit_and_loss / books.balance_sheet and the csv, json, xlsx,
  pdf exports and CPA package all use core's profit_and_loss_for_period
  and balance_sheet_as_of, with a line per account. The tools keep their
  totals and add revenue/expense/asset/liability/equity account lines.
  An omitted start is the first posted entry, never "0001-01-01".
- ledger-import::report lays a statement out once (section, line,
  subtotal, total) for every renderer, with one exact amount formatter:
  thousands separators, negatives in parentheses, no floats. XLSX keeps
  numeric cells with an accounting number format, named sheets, company
  header, totals with borders; the trial balance gains its totals row.
- ledger-mcp reads wanderbooks.toml (next to --data-dir, or
  --books-toml): company name for headers, fiscal_year_start for the CPA
  package's fiscal year to date (end derived; Feb 29 rejected), and
  [books] receipt_threshold, which the server had been ignoring.
  config.json's receipt_policy still wins when set. New dependency: toml
  1.x (already in the lockfile via another crate).
- Store::journal_seq(); every report notes the journal seq it reflects.

Tests: company.rs unit tests (fiscal years incl. a July start, scaffold
parsing, bad settings); report.rs formatting tests; an MCP stdio test with
--books-toml (threshold 5.00 enforced, February-only P&L with account
lines, start defaults to the first entry, as-of balance sheet).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MtYEaR6M6Ybdhm6X98BWNu
2026-10-11 19:11:36 -07:00
swaitsandClaude Opus 5.5 c6faeeb827 feat: period-scoped P&L and as-of balance sheet; closes don't zero periods
The engine only had life-of-ledger P&L and balance sheet totals, so every
export labeled with a date range or as-of date (PDF, XLSX, CPA package)
printed all-time numbers under that label. Core now has:

- profit_and_loss_for_period(start, end, class?) and
  balance_sheet_as_of(as_of), each with a line per account
  (StatementLine: code, name, normal-side amount) and exact totals.
- Ledger::closing_entry_ids(). Period statements exclude closing entries.
  A close (Dr revenue / Cr expense / net to Retained Earnings, dated at
  period end) moves earnings; it doesn't earn or spend. Counting it made a
  closed period's P&L read as break-even, and made the cash flow show zero
  net income plus a fake "owner contribution" to Retained Earnings. The
  as-of balance sheet keeps closing entries: after a close the earnings
  belong in Retained Earnings, not current earnings.

Tests: tests/statements.rs, a two-month fixture with every expected number
computed by hand (Jan NI 1,700; Feb 880; balance sheets on four dates; a
closed January still reporting 1,700 and a cash flow with one financing
line). Mutation-checked: dropping the closing exclusion fails it (0 vs
1,700). Properties: period P&Ls partition a year at any split, and a
range's net income equals the change in current earnings across it, with
A = L + E on both dates.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MtYEaR6M6Ybdhm6X98BWNu
2026-10-11 19:04:18 -07:00
swaitsandClaude Opus 5.5 5548902f27 feat: cash flow statement PDF, pdf export format, cash flow in CPA package
The cash flow statement was computed and exportable as csv/json/xlsx but
missing from every PDF path, so the CPA package carried two of the three
financial statements. Now:

- ledger-import: cash_flow_pdf renders the indirect-method statement
  (operating with net income, depreciation add-back, working-capital
  lines; investing; financing; net change reconciled to beginning and
  ending cash) from the computed CashFlowStatement, so the period and
  cash accounts are the caller's.
- books.export gains format "pdf" for profit_and_loss, balance_sheet, and
  cash_flow (base64, like xlsx), plus financing_accounts for cash_flow.
- books.export_cpa_package adds cash-flow.pdf (fiscal year to date, like
  the P&L) and requires cash_accounts: without them the statement has
  nothing to reconcile to.
- One cash_flow_statement helper resolves codes and dates for xlsx, pdf,
  and the package.

Test: MCP stdio test exports all three statement PDFs, checks the
cash_accounts and bad-report refusals, and unzips the CPA package to find
all five files. Known report gaps are logged in plan/STATUS.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MtYEaR6M6Ybdhm6X98BWNu
2026-10-11 18:57:12 -07:00
swaitsandClaude Opus 5.5 e59e8001c6 feat: receipt unlink and attachment withdrawal
Posted entries stay immutable (R-3), but their evidence can now change the
same way the ledger does: by appending events, never by editing or
deleting. New R-13a.

- books.unlink_receipt {attachment_id, reason} journals receipt.unlinked
  and leaves the receipt filed, unlinked. The receipt policy holds: an
  entry that requires a receipt can't lose its last one (structured
  receipt_required error), so a replacement is attach-new, then
  unlink-old. Drafts and reversals are exempt, as at posting.
- books.withdraw_attachment {attachment_id, reason} journals
  attachment.withdrawn for an unlinked receipt or a filed document: hidden
  from list_receipts (unless include_withdrawn), never linkable again. The
  blob stays in the store, hash-verified and readable.
- link_receipt now refuses an already-linked or withdrawn attachment.
  Before, re-linking silently moved a receipt off a posted entry,
  bypassing the policy; evidence now leaves an entry only through a
  recorded unlink. dispose_asset checks its receipt ids before posting
  instead of after.
- Schema v5 adds attachments.withdrawn_at/withdrawn_reason; existing
  stores migrate on open and replay rebuilds the projection.

Tests: store test (errors, reopen, rebuild, verify, journal history) and
an MCP stdio test (policy refusal, replace flow, withdraw, listings,
under-threshold unlink). Dry-run and live use on the Wanderland Labs books:
8 migrated .eml attachments swapped for their PDF renders, trial balance
unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MtYEaR6M6Ybdhm6X98BWNu
2026-10-11 18:40:51 -07:00
32 changed files with 5210 additions and 1155 deletions

No files matched your search

Generated
+2367 -417
View File
File diff suppressed because it is too large. Load diff
+7 -27
View File
@@ -84,40 +84,20 @@ pub struct ChartOfAccounts {
impl ChartOfAccounts {
/// Register an account. Codes must be unique and non-empty; a parent, if
/// given, must already exist.
pub fn add(
&mut self,
id: AccountId,
code: &str,
name: &str,
account_type: AccountType,
parent: Option<AccountId>,
placeholder: bool,
currency: Currency,
) -> Result<(), LedgerError> {
if code.is_empty() {
pub fn add(&mut self, account: Account) -> Result<(), LedgerError> {
if account.code.is_empty() {
return Err(LedgerError::EmptyAccountCode);
}
if self.by_code.contains_key(code) {
return Err(LedgerError::DuplicateAccountCode(code.to_string()));
if self.by_code.contains_key(&account.code) {
return Err(LedgerError::DuplicateAccountCode(account.code));
}
if let Some(p) = parent
if let Some(p) = account.parent
&& !self.accounts.contains_key(&p)
{
return Err(LedgerError::UnknownParentAccount(p));
}
self.by_code.insert(code.to_string(), id);
self.accounts.insert(
id,
Account {
id,
code: code.to_string(),
name: name.to_string(),
account_type,
parent,
placeholder,
currency,
},
);
self.by_code.insert(account.code.clone(), account.id);
self.accounts.insert(account.id, account);
Ok(())
}
+19 -9
View File
@@ -7,10 +7,10 @@
//! audit trail and what will make the future oracle harness trustworthy.
use crate::{
AccountId, AccountType, AgingRow, Asset, AssetId, AssetStatus, Bill, BillId, ChartOfAccounts,
Class, ClassId, Currency, Customer, CustomerId, DocumentLine, DocumentStatus, EntryId,
EntryStatus, Invoice, InvoiceId, JournalEntry, JournalLine, LedgerError, Money, NewAsset,
Period, PeriodId, PeriodStatus, Side, Vendor, VendorId, arap, assets,
Account, AccountId, AccountType, AgingRow, Asset, AssetId, AssetStatus, Bill, BillId,
ChartOfAccounts, Class, ClassId, Currency, Customer, CustomerId, DocumentLine, DocumentStatus,
EntryId, EntryStatus, Invoice, InvoiceId, JournalEntry, JournalLine, LedgerError, Money,
NewAsset, Period, PeriodId, PeriodStatus, Side, Vendor, VendorId, arap, assets,
payroll::{
PayrollAccounts, PayrollLine, PayrollRun, PayrollRunId, payroll_entry_plan,
payroll_remit_plan, validate_payroll_lines,
@@ -220,15 +220,15 @@ impl Ledger {
) -> Result<AccountId, LedgerError> {
let id = self.next_account_id;
self.next_account_id += 1;
self.chart.add(
self.chart.add(Account {
id,
code,
name,
code: code.to_string(),
name: name.to_string(),
account_type,
parent,
placeholder,
self.base_currency,
)?;
currency: self.base_currency,
})?;
Ok(id)
}
@@ -568,6 +568,16 @@ impl Ledger {
self.periods.get(&id)
}
/// Ids of every period-closing entry. Period statements (P&L, cash
/// flow) exclude them: a close moves earnings into retained earnings; it
/// earns and spends nothing, so counting it would zero out the period.
pub fn closing_entry_ids(&self) -> std::collections::BTreeSet<EntryId> {
self.periods
.values()
.filter_map(|p| p.closing_entry)
.collect()
}
/// All periods, in creation order.
pub fn list_periods(&self) -> Vec<&Period> {
let mut ids: Vec<PeriodId> = self.periods.keys().copied().collect();
+3 -2
View File
@@ -45,6 +45,7 @@ pub use money::{Currency, Money};
pub use payroll::{PayrollLine, PayrollRun, PayrollRunId};
pub use periods::{Period, PeriodId, PeriodStatus};
pub use reports::{
BalanceSheet, CashFlowLine, CashFlowParams, CashFlowStatement, ClassProfitAndLoss,
ProfitAndLoss, TrialBalance, TrialBalanceLine, cash_flow,
BalanceSheet, BalanceSheetStatement, CashFlowLine, CashFlowParams, CashFlowStatement,
ClassProfitAndLoss, ProfitAndLoss, ProfitAndLossStatement, StatementLine, TrialBalance,
TrialBalanceLine, balance_sheet_as_of, cash_flow, profit_and_loss_for_period,
};
+209 -7
View File
@@ -4,10 +4,11 @@
//! (`docs/oracle.md`) diffs: the same fixture books through two engines must
//! produce byte-identical [`snapshot`] output.
use crate::{AccountType, Class, ClassId, JournalLine, Ledger, LedgerError};
use crate::{AccountType, Class, ClassId, JournalEntry, JournalLine, Ledger, LedgerError};
use chrono::NaiveDate;
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use std::collections::BTreeSet;
use std::collections::{BTreeMap, BTreeSet};
/// One line of a trial balance.
#[derive(Clone, Debug, Serialize, Deserialize)]
@@ -78,8 +79,8 @@ pub fn trial_balance(ledger: &Ledger) -> TrialBalance {
}
}
/// Profit and loss: revenue and expenses for the life of the ledger.
/// (Period filtering is R-FUTURE work; see `docs/requirements.md` R-7.)
/// Profit and loss: revenue and expenses for the life of the ledger. For a
/// date range, with a line per account, use [`profit_and_loss_for_period`].
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct ProfitAndLoss {
/// Total revenue (credit-normal balances, reported positive).
@@ -265,6 +266,200 @@ pub fn balance_sheet(ledger: &Ledger) -> Result<BalanceSheet, LedgerError> {
})
}
/// One account's line on a financial statement.
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct StatementLine {
/// Account code, e.g. `"6000"`.
pub code: String,
/// Account name.
pub name: String,
/// Amount on the account's normal side: positive is a normal balance
/// (or movement), negative a contra one.
pub amount: Decimal,
}
/// Profit and loss for a date range, with a line per account.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct ProfitAndLossStatement {
/// First date, inclusive.
pub start: NaiveDate,
/// Last date, inclusive.
pub end: NaiveDate,
/// Revenue accounts with movement in the range, by code.
pub revenue: Vec<StatementLine>,
/// Expense accounts with movement in the range, by code.
pub expenses: Vec<StatementLine>,
/// Sum of the revenue lines.
pub total_revenue: Decimal,
/// Sum of the expense lines.
pub total_expenses: Decimal,
}
impl ProfitAndLossStatement {
/// Revenue minus expenses. Positive is profit.
pub fn net_income(&self) -> Decimal {
self.total_revenue - self.total_expenses
}
}
/// Balance sheet as of a date, with a line per account.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct BalanceSheetStatement {
/// The as-of date, inclusive.
pub as_of: NaiveDate,
/// Asset accounts with a balance, by code.
pub assets: Vec<StatementLine>,
/// Liability accounts with a balance, by code.
pub liabilities: Vec<StatementLine>,
/// Equity accounts with a balance, by code (current earnings excluded).
pub equity: Vec<StatementLine>,
/// Revenue minus expenses through `as_of` not yet closed to retained
/// earnings. Shown as its own equity line.
pub current_earnings: Decimal,
/// Sum of the asset lines.
pub total_assets: Decimal,
/// Sum of the liability lines.
pub total_liabilities: Decimal,
/// Sum of the equity lines plus current earnings.
pub total_equity: Decimal,
}
impl BalanceSheetStatement {
/// The accounting equation: assets == liabilities + equity, exactly.
pub fn balanced(&self) -> bool {
self.total_assets == self.total_liabilities + self.total_equity
}
}
/// Normal-side amount per account over the posted lines accepted by
/// `include(entry, line)`, as statement lines keyed (and so sorted) by
/// code, skipping placeholders and zero amounts.
fn statement_lines(
ledger: &Ledger,
include: &dyn Fn(&JournalEntry, &JournalLine) -> bool,
) -> Result<BTreeMap<String, (AccountType, StatementLine)>, LedgerError> {
let mut movement: BTreeMap<crate::AccountId, Decimal> = BTreeMap::new();
for entry in ledger.posted_entries() {
for line in entry.lines() {
if !include(entry, line) {
continue;
}
let signed = match line.side {
crate::Side::Debit => line.amount.amount,
crate::Side::Credit => -line.amount.amount,
};
*movement.entry(line.account).or_default() += signed;
}
}
let mut out = BTreeMap::new();
for (id, m) in movement {
let account = ledger
.chart()
.get(id)
.ok_or(LedgerError::UnknownAccount(id))?;
if account.placeholder || m.is_zero() {
continue;
}
let amount = match account.account_type.normal_side() {
crate::Side::Debit => m,
crate::Side::Credit => -m,
};
out.insert(
account.code.clone(),
(
account.account_type,
StatementLine {
code: account.code.clone(),
name: account.name.clone(),
amount,
},
),
);
}
Ok(out)
}
/// Profit and loss for `[start, end]`, optionally for one class (`None`
/// counts every line). Closing entries are excluded: they move a period's
/// earnings into retained earnings, so counting them would report a closed
/// period as breaking even.
pub fn profit_and_loss_for_period(
ledger: &Ledger,
start: NaiveDate,
end: NaiveDate,
class_id: Option<ClassId>,
) -> Result<ProfitAndLossStatement, LedgerError> {
if end < start {
return Err(LedgerError::InvalidDateRange {
start: start.to_string(),
end: end.to_string(),
});
}
let closings = ledger.closing_entry_ids();
let lines = statement_lines(ledger, &|entry, line| {
entry.date() >= start
&& entry.date() <= end
&& !closings.contains(&entry.id())
&& class_id.is_none_or(|c| line.class_id == Some(c))
})?;
let mut revenue = Vec::new();
let mut expenses = Vec::new();
for (typ, line) in lines.into_values() {
match typ {
AccountType::Revenue => revenue.push(line),
AccountType::Expense => expenses.push(line),
_ => {}
}
}
let total_revenue = revenue.iter().map(|l| l.amount).sum();
let total_expenses = expenses.iter().map(|l| l.amount).sum();
Ok(ProfitAndLossStatement {
start,
end,
revenue,
expenses,
total_revenue,
total_expenses,
})
}
/// Balance sheet as of `as_of`: every posted entry dated on or before it.
/// Revenue and expense not yet closed to retained earnings appear as
/// current earnings inside equity, so the accounting equation holds on
/// every date, before and after closes.
pub fn balance_sheet_as_of(
ledger: &Ledger,
as_of: NaiveDate,
) -> Result<BalanceSheetStatement, LedgerError> {
let lines = statement_lines(ledger, &|entry, _| entry.date() <= as_of)?;
let mut assets = Vec::new();
let mut liabilities = Vec::new();
let mut equity = Vec::new();
let mut current_earnings = Decimal::ZERO;
for (typ, line) in lines.into_values() {
match typ {
AccountType::Asset => assets.push(line),
AccountType::Liability => liabilities.push(line),
AccountType::Equity => equity.push(line),
AccountType::Revenue => current_earnings += line.amount,
AccountType::Expense => current_earnings -= line.amount,
}
}
let total_assets = assets.iter().map(|l| l.amount).sum();
let total_liabilities = liabilities.iter().map(|l| l.amount).sum();
let total_equity = equity.iter().map(|l| l.amount).sum::<Decimal>() + current_earnings;
Ok(BalanceSheetStatement {
as_of,
assets,
liabilities,
equity,
current_earnings,
total_assets,
total_liabilities,
total_equity,
})
}
/// Render the three core reports as canonical JSON for oracle diffing.
///
/// Amounts are decimal strings (never floats, never locale-formatted), lines
@@ -406,11 +601,14 @@ pub fn cash_flow(
let financing_set: HashSet<crate::AccountId> =
params.financing_accounts.iter().copied().collect();
// Movement helpers: debits minus credits over a date predicate.
// Movement helpers: debits minus credits over a date predicate. Closing
// entries are skipped: a close moves earnings into retained earnings,
// which is neither net income nor an owner flow.
let closings = ledger.closing_entry_ids();
let movement_where = |account: crate::AccountId, pred: &dyn Fn(chrono::NaiveDate) -> bool| {
let mut total = Decimal::ZERO;
for entry in ledger.posted_entries() {
if !pred(entry.date()) {
if !pred(entry.date()) || closings.contains(&entry.id()) {
continue;
}
for line in entry.lines() {
@@ -471,7 +669,11 @@ pub fn cash_flow(
format!("{} {}", a.code, a.name)
};
for account in ledger.chart().all() {
// By code: the chart iterates in hash order, and a statement's lines
// must come out in the same order every time.
let mut accounts: Vec<_> = ledger.chart().all().collect();
accounts.sort_by(|a, b| a.code.cmp(&b.code));
for account in accounts {
if account.placeholder {
continue;
}
+1 -1
View File
@@ -550,7 +550,7 @@ fn cash_flow_with_registered_asset_and_disposal() {
false,
)
.unwrap();
let revenue = ledger
ledger
.add_account("4000", "Service Revenue", AccountType::Revenue, None, false)
.unwrap();
let gain = ledger
+50 -1
View File
@@ -9,7 +9,10 @@ use chrono::NaiveDate;
use ledger_core::{
AccountType, Currency, Ledger, Money, NewAsset, Side, accumulated_depreciation, book_value,
depreciation_schedule,
reports::{balance_sheet, trial_balance},
reports::{
balance_sheet, balance_sheet_as_of, profit_and_loss, profit_and_loss_for_period,
trial_balance,
},
};
use proptest::prelude::*;
use rust_decimal::Decimal;
@@ -136,6 +139,52 @@ proptest! {
prop_assert_eq!(bs.assets, bs.liabilities + bs.equity);
}
/// Period P&Ls partition the year: split anywhere, the two halves sum
/// to the whole, and the whole is the life-of-ledger P&L.
#[test]
fn prop_period_pnl_partitions((chart, entries) in arb_ops(), split in 0u32..365) {
let (ledger, _) = build_ledger(&chart, &entries);
let jan1 = NaiveDate::from_ymd_opt(2026, 1, 1).unwrap();
let dec31 = NaiveDate::from_ymd_opt(2026, 12, 31).unwrap();
let cut = jan1 + chrono::Days::new(split as u64);
let first = profit_and_loss_for_period(&ledger, jan1, cut, None).unwrap();
let rest = if cut < dec31 {
Some(profit_and_loss_for_period(&ledger, cut.succ_opt().unwrap(), dec31, None).unwrap())
} else {
None
};
let whole = profit_and_loss_for_period(&ledger, jan1, dec31, None).unwrap();
let (r2, e2) = rest.map_or((Decimal::ZERO, Decimal::ZERO), |p| (p.total_revenue, p.total_expenses));
prop_assert_eq!(first.total_revenue + r2, whole.total_revenue);
prop_assert_eq!(first.total_expenses + e2, whole.total_expenses);
let all = profit_and_loss(&ledger).unwrap();
prop_assert_eq!((whole.total_revenue, whole.total_expenses), (all.revenue, all.expenses));
}
/// The accounting equation holds as of every date, and a period's net
/// income is exactly the change in current earnings across it.
#[test]
fn prop_balance_sheet_as_of_balances((chart, entries) in arb_ops(), a in 0u32..365, b in 0u32..365) {
let (ledger, _) = build_ledger(&chart, &entries);
let jan1 = NaiveDate::from_ymd_opt(2026, 1, 1).unwrap();
let (lo, hi) = (a.min(b), a.max(b));
let start = jan1 + chrono::Days::new(lo as u64);
let end = jan1 + chrono::Days::new(hi as u64);
let before = balance_sheet_as_of(&ledger, start.pred_opt().unwrap()).unwrap();
let after = balance_sheet_as_of(&ledger, end).unwrap();
prop_assert!(before.balanced());
prop_assert!(after.balanced());
let pnl = profit_and_loss_for_period(&ledger, start, end, None).unwrap();
prop_assert_eq!(after.current_earnings - before.current_earnings, pnl.net_income());
// Past the last entry, as-of agrees with the life-of-ledger sheet.
let all = balance_sheet(&ledger).unwrap();
let last = balance_sheet_as_of(&ledger, NaiveDate::from_ymd_opt(2026, 12, 31).unwrap()).unwrap();
prop_assert_eq!(
(last.total_assets, last.total_liabilities, last.total_equity),
(all.assets, all.liabilities, all.equity)
);
}
/// Every posted entry balances on its own, exactly.
#[test]
fn prop_posted_entries_balance((chart, entries) in arb_ops()) {
+267
View File
@@ -0,0 +1,267 @@
//! Period-scoped financial statements: profit and loss for a date range and
//! the balance sheet as of a date, line per account.
//!
//! Every expected number below is computed by hand from the entries, not
//! read off the engine. The fixture is two months of a small company:
//!
//! | date | entry | Dr | Cr |
//! |--------|-------------------------------|---------------|---------------|
//! | Jan 2 | owner contributes 10,000 | 1000 Cash | 3000 Capital |
//! | Jan 15 | client pays 2,500 | 1000 Cash | 4000 Revenue |
//! | Jan 20 | rent 800 | 6100 Rent | 1000 Cash |
//! | Feb 3 | software 120 on the card | 6000 Software | 2000 Card |
//! | Feb 10 | client pays 1,000 | 1000 Cash | 4000 Revenue |
//! | Feb 25 | pay the card 120 | 2000 Card | 1000 Cash |
use chrono::NaiveDate;
use ledger_core::reports::{
balance_sheet, balance_sheet_as_of, profit_and_loss, profit_and_loss_for_period,
};
use ledger_core::{
AccountId, AccountType, CashFlowParams, ClassId, Currency, Ledger, Money, Side, StatementLine,
cash_flow,
};
use rust_decimal::Decimal;
fn d(m: u32, day: u32) -> NaiveDate {
NaiveDate::from_ymd_opt(2026, m, day).unwrap()
}
fn usd(n: i64) -> Money {
Money::new(Decimal::from(n), Currency::USD)
}
fn dec(n: i64) -> Decimal {
Decimal::from(n)
}
struct Books {
ledger: Ledger,
cash: AccountId,
software_class: ClassId,
}
fn post(ledger: &mut Ledger, date: NaiveDate, desc: &str, dr: AccountId, cr: AccountId, n: i64) {
let e = ledger.create_entry(date, desc);
ledger.add_line(e, dr, Side::Debit, usd(n)).unwrap();
ledger.add_line(e, cr, Side::Credit, usd(n)).unwrap();
ledger.post_entry(e).unwrap();
}
/// The fixture. With `close_january`, January is closed before any February
/// entry exists (the close sweeps every revenue and expense balance).
fn books(close_january: bool) -> Books {
let mut l = Ledger::new(Currency::USD);
let cash = l
.add_account("1000", "Cash", AccountType::Asset, None, false)
.unwrap();
let card = l
.add_account("2000", "Card", AccountType::Liability, None, false)
.unwrap();
let capital = l
.add_account("3000", "Capital", AccountType::Equity, None, false)
.unwrap();
let revenue = l
.add_account("4000", "Revenue", AccountType::Revenue, None, false)
.unwrap();
let software = l
.add_account("6000", "Software", AccountType::Expense, None, false)
.unwrap();
let rent = l
.add_account("6100", "Rent", AccountType::Expense, None, false)
.unwrap();
let software_class = l.add_class("Apps").unwrap();
post(&mut l, d(1, 2), "Owner contribution", cash, capital, 10_000);
post(&mut l, d(1, 15), "Client payment", cash, revenue, 2_500);
post(&mut l, d(1, 20), "Rent", rent, cash, 800);
if close_january {
let jan = l.create_period("2026-01", d(1, 1), d(1, 31)).unwrap();
l.close_period(jan).unwrap();
}
// Software on the card, tagged to the Apps class.
let e = l.create_entry(d(2, 3), "Software");
l.add_line(e, software, Side::Debit, usd(120)).unwrap();
l.add_line(e, card, Side::Credit, usd(120)).unwrap();
l.set_line_class(e, 0, Some(software_class)).unwrap();
l.post_entry(e).unwrap();
post(&mut l, d(2, 10), "Client payment", cash, revenue, 1_000);
post(&mut l, d(2, 25), "Pay card", card, cash, 120);
Books {
ledger: l,
cash,
software_class,
}
}
fn line(code: &str, name: &str, n: i64) -> StatementLine {
StatementLine {
code: code.to_string(),
name: name.to_string(),
amount: dec(n),
}
}
#[test]
fn profit_and_loss_is_scoped_to_the_period() {
let b = books(false);
let jan = profit_and_loss_for_period(&b.ledger, d(1, 1), d(1, 31), None).unwrap();
assert_eq!(jan.revenue, vec![line("4000", "Revenue", 2_500)]);
assert_eq!(jan.expenses, vec![line("6100", "Rent", 800)]);
assert_eq!(jan.net_income(), dec(1_700));
let feb = profit_and_loss_for_period(&b.ledger, d(2, 1), d(2, 28), None).unwrap();
assert_eq!(feb.revenue, vec![line("4000", "Revenue", 1_000)]);
assert_eq!(feb.expenses, vec![line("6000", "Software", 120)]);
assert_eq!(feb.net_income(), dec(880));
// Both months: 3,500 revenue, 920 expenses, lines sorted by code.
let both = profit_and_loss_for_period(&b.ledger, d(1, 1), d(2, 28), None).unwrap();
assert_eq!(
both.expenses,
vec![line("6000", "Software", 120), line("6100", "Rent", 800)]
);
assert_eq!(
(both.total_revenue, both.total_expenses),
(dec(3_500), dec(920))
);
// ... which is the life-of-ledger P&L, because nothing is outside it.
let all = profit_and_loss(&b.ledger).unwrap();
assert_eq!((all.revenue, all.expenses), (dec(3_500), dec(920)));
// One class: only the tagged software line.
let apps =
profit_and_loss_for_period(&b.ledger, d(1, 1), d(12, 31), Some(b.software_class)).unwrap();
assert!(apps.revenue.is_empty());
assert_eq!(apps.expenses, vec![line("6000", "Software", 120)]);
// A backwards range is rejected, not silently empty.
assert!(profit_and_loss_for_period(&b.ledger, d(2, 1), d(1, 1), None).is_err());
}
#[test]
fn balance_sheet_is_as_of_the_date() {
let b = books(false);
// Before anything happened: empty and balanced.
let start = balance_sheet_as_of(&b.ledger, d(1, 1)).unwrap();
assert!(start.assets.is_empty() && start.equity.is_empty());
assert!(start.balanced());
// Jan 31: cash 10,000 + 2,500 − 800 = 11,700; equity 10,000 + 1,700.
let jan = balance_sheet_as_of(&b.ledger, d(1, 31)).unwrap();
assert_eq!(jan.assets, vec![line("1000", "Cash", 11_700)]);
assert!(jan.liabilities.is_empty());
assert_eq!(jan.equity, vec![line("3000", "Capital", 10_000)]);
assert_eq!(jan.current_earnings, dec(1_700));
assert_eq!(jan.total_equity, dec(11_700));
assert!(jan.balanced());
// Feb 15: cash 12,700; the card owes 120; earnings 1,700 + 880.
let mid = balance_sheet_as_of(&b.ledger, d(2, 15)).unwrap();
assert_eq!(mid.total_assets, dec(12_700));
assert_eq!(mid.liabilities, vec![line("2000", "Card", 120)]);
assert_eq!(mid.current_earnings, dec(2_580));
assert_eq!(mid.total_liabilities + mid.total_equity, dec(12_700));
// Feb 28: card paid off, so it drops off the statement.
let end = balance_sheet_as_of(&b.ledger, d(2, 28)).unwrap();
assert_eq!(end.total_assets, dec(12_580));
assert!(end.liabilities.is_empty());
assert!(end.balanced());
// ... and agrees with the life-of-ledger balance sheet.
let all = balance_sheet(&b.ledger).unwrap();
assert_eq!(
(all.assets, all.liabilities, all.equity),
(end.total_assets, end.total_liabilities, end.total_equity)
);
}
#[test]
fn a_closed_period_still_reports_its_earnings() {
let b = books(true);
// The close posted Dr 4000 2,500 / Cr 6100 800 / Cr 3999 1,700 on Jan
// 31. The January P&L must not count it: January earned 1,700.
let jan = profit_and_loss_for_period(&b.ledger, d(1, 1), d(1, 31), None).unwrap();
assert_eq!(jan.net_income(), dec(1_700));
assert_eq!(jan.revenue, vec![line("4000", "Revenue", 2_500)]);
let feb = profit_and_loss_for_period(&b.ledger, d(2, 1), d(2, 28), None).unwrap();
assert_eq!(feb.net_income(), dec(880));
// After the close, January's earnings sit in retained earnings, not in
// current earnings; February's are current.
let jan_bs = balance_sheet_as_of(&b.ledger, d(1, 31)).unwrap();
assert_eq!(
jan_bs.equity,
vec![
line("3000", "Capital", 10_000),
line("3999", "Retained Earnings", 1_700)
]
);
assert_eq!(jan_bs.current_earnings, Decimal::ZERO);
assert!(jan_bs.balanced());
let feb_bs = balance_sheet_as_of(&b.ledger, d(2, 28)).unwrap();
assert_eq!(feb_bs.current_earnings, dec(880));
assert_eq!(feb_bs.total_equity, dec(12_580));
assert!(feb_bs.balanced());
// The cash flow for January: net income 1,700, and the only financing
// flow is the owner's 10,000. The close is not an owner contribution.
let cf = cash_flow(
&b.ledger,
&CashFlowParams {
start: d(1, 1),
end: d(1, 31),
cash_accounts: vec![b.cash],
financing_accounts: vec![],
},
)
.unwrap();
assert_eq!(cf.net_income, dec(1_700));
assert_eq!(cf.financing.len(), 1);
assert_eq!(cf.cash_from_financing, dec(10_000));
assert_eq!(cf.net_change_in_cash, dec(11_700));
}
/// Cash flow lines come out in account-code order, every time. (The chart
/// iterates in hash order; the statement must not.)
#[test]
fn cash_flow_lines_are_in_code_order() {
for _ in 0..20 {
let mut l = Ledger::new(Currency::USD);
let cash = l
.add_account("1000", "Cash", AccountType::Asset, None, false)
.unwrap();
let mut owners = Vec::new();
for code in ["3005", "3001", "3009", "3000", "3003"] {
owners.push(
l.add_account(code, code, AccountType::Equity, None, false)
.unwrap(),
);
}
for o in &owners {
post(&mut l, d(3, 1), "Contribution", cash, *o, 100);
}
let cf = cash_flow(
&l,
&CashFlowParams {
start: d(1, 1),
end: d(12, 31),
cash_accounts: vec![cash],
financing_accounts: vec![],
},
)
.unwrap();
// Labels read "Owner contributions — 3000 3000" (code, then name).
let codes: Vec<&str> = cf
.financing
.iter()
.map(|f| f.label.rsplit(' ').next().unwrap())
.collect();
assert_eq!(
codes,
["3000", "3001", "3003", "3005", "3009"],
"{:?}",
cf.financing
);
}
}
+6 -1
View File
@@ -12,4 +12,9 @@ chrono = "0.4"
serde = { version = "1", features = ["derive"] }
thiserror = "2"
rust_xlsxwriter = "0.90"
printpdf = "0.12.8"
typst-as-lib = { version = "0.17", default-features = false }
typst-pdf = "0.15.1"
typst-assets = { version = "0.15.1", features = ["fonts"] }
typst = "0.15.1"
serde_json = "1"
typst-layout = "0.15.1"
+5 -5
View File
@@ -1,14 +1,14 @@
//! Generate Intuit Interchange Format (IIF) journal entries for QuickBooks
//! Desktop, which many CPAs still run.
//!
//! Layout per entry:
//! Layout per entry (fields are tab-separated; `\t` marks each tab):
//!
//! ```text
//! !TRNS TRNSTYPE DATE ACCNT NAME AMOUNT DOCNUM MEMO
//! !SPL TRNSTYPE DATE ACCNT AMOUNT DOCNUM MEMO
//! !TRNS\tTRNSTYPE\tDATE\tACCNT\tNAME\tAMOUNT\tDOCNUM\tMEMO
//! !SPL\tTRNSTYPE\tDATE\tACCNT\tAMOUNT\tDOCNUM\tMEMO
//! !ENDTRNS
//! TRNS GENERAL JOURNAL 01/15/2026 Cash 1000.00 1 Memo
//! SPL GENERAL JOURNAL 01/15/2026 Revenue -1000.00 1 Memo
//! TRNS\tGENERAL JOURNAL\t01/15/2026\tCash\t\t1000.00\t1\tMemo
//! SPL\tGENERAL JOURNAL\t01/15/2026\tRevenue\t-1000.00\t1\tMemo
//! ENDTRNS
//! ```
//!
+4 -1
View File
@@ -3,6 +3,7 @@
//! - [`bank_csv`]: parse QBO bank/credit-card upload CSVs.
//! - [`chart_csv`]: parse chart-of-accounts CSVs.
//! - [`iif`]: generate IIF journal entries for QuickBooks Desktop.
//! - [`report`]: the statement layout every renderer draws from.
//! - [`xlsx`]: generate XLSX reports.
//! - [`pdf`]: generate PDF financial statements (CPA handoff).
//!
@@ -12,10 +13,12 @@ pub mod bank_csv;
pub mod chart_csv;
pub mod iif;
pub mod pdf;
pub mod report;
pub mod xlsx;
pub use bank_csv::{BankTransaction, parse_bank_csv};
pub use chart_csv::{ChartRow, parse_chart_csv};
pub use iif::{IifEntry, journal_to_iif};
pub use pdf::{PdfError, balance_sheet_pdf, profit_and_loss_pdf};
pub use pdf::{PdfError, statement_pdf};
pub use report::{ReportMeta, Row, RowKind, Statement, format_amount};
pub use xlsx::{XlsxReport, report_to_xlsx};
+159 -180
View File
@@ -1,205 +1,184 @@
//! PDF financial statements for the CPA handoff package.
//! PDF financial statements, typeset with Typst.
//!
//! Simple, correct, readable — not pretty. Uses printpdf (MIT) with built-in
//! Helvetica. Amounts are exact decimals rendered as strings; no floats.
//! [`crate::report`] lays a statement out as rows; this module hands those
//! rows to `templates/statement.typ` as JSON data (`sys.inputs.data`) and
//! renders the PDF. Amounts arrive already split by
//! [`crate::report::amount_parts`] — Typst never does arithmetic on money,
//! and labels are data, never markup. Fonts are bundled (`typst-assets`),
//! so output doesn't depend on what's installed, and it is deterministic:
//! the same statement renders to the same bytes.
use ledger_core::{AccountType, Ledger};
use printpdf::{
BuiltinFont, Color, Mm, Op, PdfDocument, PdfFontHandle, PdfPage, PdfSaveOptions, Point, Pt,
Rgb, TextItem,
};
use rust_decimal::Decimal;
use crate::report::{ReportMeta, RowKind, Statement, amount_parts};
use std::sync::OnceLock;
use thiserror::Error;
use typst::foundations::{Dict, IntoValue};
use typst_as_lib::{TypstEngine, TypstTemplateMainFile};
use typst_layout::PagedDocument;
/// PDF rendering errors.
#[derive(Debug, Error)]
pub enum PdfError {
/// printpdf failed to save.
/// Typst failed to compile the statement.
#[error("pdf typesetting failed: {0}")]
Compile(String),
/// The PDF writer failed.
#[error("pdf save failed: {0}")]
Save(String),
}
fn money(d: Decimal) -> String {
// Two decimals, no float anywhere.
format!("{:.2}", d)
static TEMPLATE: &str = include_str!("../templates/statement.typ");
/// The engine, built once: parsing the bundled fonts is the expensive part.
fn engine() -> &'static TypstEngine<TypstTemplateMainFile> {
static ENGINE: OnceLock<TypstEngine<TypstTemplateMainFile>> = OnceLock::new();
ENGINE.get_or_init(|| {
TypstEngine::builder()
.main_file(TEMPLATE)
.fonts(typst_assets::fonts())
.build()
})
}
fn text_ops(
title: &str,
subtitle: &str,
rows: &[(String, String)],
totals: &[(String, String)],
) -> Vec<Op> {
let mut ops = vec![
Op::StartTextSection,
Op::SetTextCursor {
pos: Point::new(Mm(20.0), Mm(280.0)),
},
Op::SetFont {
font: PdfFontHandle::Builtin(BuiltinFont::HelveticaBold),
size: Pt(18.0),
},
Op::SetLineHeight { lh: Pt(22.0) },
Op::SetFillColor {
col: Color::Rgb(Rgb {
r: 0.0,
g: 0.0,
b: 0.0,
icc_profile: None,
/// The template's input: the statement with every amount formatted.
pub fn template_data(st: &Statement, meta: &ReportMeta) -> serde_json::Value {
let kind = |k: RowKind| match k {
RowKind::Section => "section",
RowKind::Line => "line",
RowKind::Subtotal => "subtotal",
RowKind::Total => "total",
RowKind::Spacer => "spacer",
};
serde_json::json!({
"company": meta.company,
"title": st.title,
"period": st.period,
"footer": format!("Prepared from journal through seq {}", meta.journal_seq),
"rows": st.rows.iter().map(|r| serde_json::json!({
"kind": kind(r.kind),
"label": r.label,
"rule": r.rule_above,
"amount": r.amount.map(|a| {
let p = amount_parts(a);
serde_json::json!({
"digits": p.digits,
"negative": p.negative,
"zero": p.zero,
"currency": r.currency,
})
}),
},
Op::ShowText {
items: vec![TextItem::Text(title.to_string())],
},
Op::AddLineBreak,
Op::SetFont {
font: PdfFontHandle::Builtin(BuiltinFont::Helvetica),
size: Pt(11.0),
},
Op::SetLineHeight { lh: Pt(14.0) },
Op::ShowText {
items: vec![TextItem::Text(subtitle.to_string())],
},
Op::AddLineBreak,
Op::AddLineBreak,
];
for (label, amount) in rows {
// Label left, amount right-ish via padding. Monospace would be
// nicer; Helvetica with padding is readable enough for a CPA file.
let line = format!("{:<50} {:>14}", label, amount);
ops.push(Op::ShowText {
items: vec![TextItem::Text(line)],
});
ops.push(Op::AddLineBreak);
}
if !totals.is_empty() {
ops.push(Op::AddLineBreak);
ops.push(Op::SetFont {
font: PdfFontHandle::Builtin(BuiltinFont::HelveticaBold),
size: Pt(11.0),
});
for (label, amount) in totals {
let line = format!("{:<50} {:>14}", label, amount);
ops.push(Op::ShowText {
items: vec![TextItem::Text(line)],
});
ops.push(Op::AddLineBreak);
}
}
ops.push(Op::EndTextSection);
ops
})).collect::<Vec<_>>(),
})
}
fn save_pdf(title: &str, ops: Vec<Op>) -> Result<Vec<u8>, PdfError> {
let mut doc = PdfDocument::new(title);
let page = PdfPage::new(Mm(210.0), Mm(297.0), ops);
let bytes = doc
.with_pages(vec![page])
.save(&PdfSaveOptions::default(), &mut Vec::new());
Ok(bytes)
}
/// Render a profit-and-loss statement PDF: revenue and expense accounts with
/// balances, then totals and net income.
pub fn profit_and_loss_pdf(ledger: &Ledger, start: &str, end: &str) -> Result<Vec<u8>, PdfError> {
let mut revenue_rows: Vec<(String, String)> = Vec::new();
let mut expense_rows: Vec<(String, String)> = Vec::new();
let mut accounts: Vec<_> = ledger.chart().all().collect();
accounts.sort_by(|a, b| a.code.cmp(&b.code));
for account in accounts {
if account.placeholder {
continue;
}
let balance = ledger
.account_balance(account.id)
.map_err(|e| PdfError::Save(e.to_string()))?;
if balance.is_zero() {
continue;
}
let label = format!("{} {}", account.code, account.name);
match account.account_type {
AccountType::Revenue => revenue_rows.push((label, money(balance))),
AccountType::Expense => expense_rows.push((label, money(balance))),
_ => {}
}
}
let pl =
ledger_core::reports::profit_and_loss(ledger).map_err(|e| PdfError::Save(e.to_string()))?;
let mut rows = vec![("REVENUE".to_string(), "".to_string())];
rows.extend(revenue_rows);
rows.push(("".to_string(), "".to_string()));
rows.push(("EXPENSES".to_string(), "".to_string()));
rows.extend(expense_rows);
let totals = vec![
("Total revenue".to_string(), money(pl.revenue)),
("Total expenses".to_string(), money(pl.expenses)),
("Net income".to_string(), money(pl.net_income())),
];
let ops = text_ops(
"Profit and Loss",
&format!("{} to {}", start, end),
&rows,
&totals,
/// Typeset a statement into a document (pages, before PDF encoding).
fn typeset(st: &Statement, meta: &ReportMeta) -> Result<PagedDocument, PdfError> {
let mut inputs = Dict::new();
inputs.insert(
"data".into(),
template_data(st, meta).to_string().into_value(),
);
save_pdf("Profit and Loss", ops)
engine()
.compile_with_input(inputs)
.output
.map_err(|e| PdfError::Compile(format!("{e:?}")))
}
/// Render a balance-sheet PDF: assets, liabilities, equity with balances,
/// then totals proving the accounting equation.
pub fn balance_sheet_pdf(ledger: &Ledger, as_of: &str) -> Result<Vec<u8>, PdfError> {
let mut asset_rows: Vec<(String, String)> = Vec::new();
let mut liab_rows: Vec<(String, String)> = Vec::new();
let mut equity_rows: Vec<(String, String)> = Vec::new();
let mut accounts: Vec<_> = ledger.chart().all().collect();
accounts.sort_by(|a, b| a.code.cmp(&b.code));
for account in accounts {
if account.placeholder {
continue;
}
let balance = ledger
.account_balance(account.id)
.map_err(|e| PdfError::Save(e.to_string()))?;
if balance.is_zero() {
continue;
}
let label = format!("{} {}", account.code, account.name);
match account.account_type {
AccountType::Asset => asset_rows.push((label, money(balance))),
AccountType::Liability => liab_rows.push((label, money(balance))),
AccountType::Equity => equity_rows.push((label, money(balance))),
_ => {}
/// Render a laid-out statement (see [`crate::report`]) as a PDF.
pub fn statement_pdf(st: &Statement, meta: &ReportMeta) -> Result<Vec<u8>, PdfError> {
let doc = typeset(st, meta)?;
typst_pdf::pdf(&doc, &typst_pdf::PdfOptions::default())
.map_err(|e| PdfError::Save(format!("{e:?}")))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::report::{Row, profit_and_loss};
use chrono::NaiveDate;
use ledger_core::reports::{ProfitAndLossStatement, StatementLine};
use rust_decimal::Decimal;
fn meta() -> ReportMeta {
ReportMeta {
company: "Test Co".to_string(),
journal_seq: 42,
}
}
let bs =
ledger_core::reports::balance_sheet(ledger).map_err(|e| PdfError::Save(e.to_string()))?;
let mut rows = vec![("ASSETS".to_string(), "".to_string())];
rows.extend(asset_rows);
rows.push(("".to_string(), "".to_string()));
rows.push(("LIABILITIES".to_string(), "".to_string()));
rows.extend(liab_rows);
rows.push(("".to_string(), "".to_string()));
rows.push(("EQUITY".to_string(), "".to_string()));
rows.extend(equity_rows);
rows.push((
"Current earnings (in equity)".to_string(),
money(bs.current_earnings),
));
fn pl() -> ProfitAndLossStatement {
let line = |code: &str, name: &str, cents| StatementLine {
code: code.to_string(),
name: name.to_string(),
amount: Decimal::new(cents, 2),
};
ProfitAndLossStatement {
start: NaiveDate::from_ymd_opt(2026, 10, 4).unwrap(),
end: NaiveDate::from_ymd_opt(2026, 10, 11).unwrap(),
revenue: vec![],
expenses: vec![
line("6000", "Organizational Costs", 5900),
line("6400", "Memberships & Licenses", 10638),
],
total_revenue: Decimal::ZERO,
total_expenses: Decimal::new(16538, 2),
}
}
let totals = vec![
("Total assets".to_string(), money(bs.assets)),
(
"Total liabilities + equity".to_string(),
money(bs.liabilities + bs.equity),
),
];
/// The exact data the template typesets: hand-checked, like a golden.
#[test]
fn template_data_is_exact() {
let data = template_data(&profit_and_loss(&pl()), &meta());
assert_eq!(
data,
serde_json::json!({
"company": "Test Co",
"title": "Profit and Loss",
"period": "October 4 – October 11, 2026",
"footer": "Prepared from journal through seq 42",
"rows": [
{"kind": "section", "label": "Revenue", "rule": false, "amount": null},
{"kind": "subtotal", "label": "Total revenue", "rule": false, "amount":
{"digits": "", "negative": false, "zero": true, "currency": true}},
{"kind": "spacer", "label": "", "rule": false, "amount": null},
{"kind": "section", "label": "Expenses", "rule": false, "amount": null},
{"kind": "line", "label": "6000 Organizational Costs", "rule": false, "amount":
{"digits": "59.00", "negative": false, "zero": false, "currency": true}},
{"kind": "line", "label": "6400 Memberships & Licenses", "rule": false, "amount":
{"digits": "106.38", "negative": false, "zero": false, "currency": false}},
{"kind": "subtotal", "label": "Total expenses", "rule": true, "amount":
{"digits": "165.38", "negative": false, "zero": false, "currency": true}},
{"kind": "spacer", "label": "", "rule": false, "amount": null},
{"kind": "total", "label": "Net income", "rule": true, "amount":
{"digits": "165.38", "negative": true, "zero": false, "currency": true}},
],
})
);
}
let ops = text_ops("Balance Sheet", &format!("As of {}", as_of), &rows, &totals);
save_pdf("Balance Sheet", ops)
#[test]
fn renders_a_pdf_deterministically() {
let st = profit_and_loss(&pl());
let a = statement_pdf(&st, &meta()).unwrap();
assert!(a.starts_with(b"%PDF"));
assert_eq!(a, statement_pdf(&st, &meta()).unwrap());
}
/// Labels are data: Typst markup characters print literally, and a
/// statement too long for one page flows onto more instead of being cut.
#[test]
fn markup_is_inert_and_long_statements_paginate() {
let mut st = profit_and_loss(&pl());
st.rows = (0..200)
.map(|i| Row {
kind: RowKind::Line,
label: format!("#{i} *not bold* $x$ <label> @ref [link] \\ #panic()"),
amount: Some(Decimal::new(i, 0)),
currency: false,
rule_above: false,
})
.collect();
let doc = typeset(&st, &meta()).unwrap();
assert!(doc.pages().len() > 1, "200 rows should span pages");
assert!(statement_pdf(&st, &meta()).unwrap().starts_with(b"%PDF"));
}
}
+399
View File
@@ -0,0 +1,399 @@
//! The presentation model every statement renderer (XLSX, PDF) draws from.
//!
//! Core computes the statements; this module lays them out as rows —
//! sections, account lines, subtotals, totals — once, so the formats can't
//! drift apart. Amounts stay exact `Decimal`s here; [`format_amount`] is the
//! one place they become text ("1,234.56", "(207.68)").
use chrono::NaiveDate;
use ledger_core::reports::{
BalanceSheetStatement, CashFlowLine, CashFlowStatement, ProfitAndLossStatement, StatementLine,
};
use rust_decimal::Decimal;
use serde::Serialize;
/// Who the report is for and exactly what it was prepared from.
#[derive(Clone, Debug, Serialize)]
pub struct ReportMeta {
/// Company name for the header, e.g. "Wanderland Labs LLC".
pub company: String,
/// Sequence number of the last journal line the report reflects.
pub journal_seq: u64,
}
/// What a row is, which decides how renderers style it.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum RowKind {
/// A section heading ("Revenue"); no amount.
Section,
/// One account or one cash-flow line, indented under its section.
Line,
/// A section total ("Total revenue").
Subtotal,
/// A statement total ("Net income").
Total,
/// Vertical space between sections.
Spacer,
}
/// One row of a statement.
#[derive(Clone, Debug, Serialize)]
pub struct Row {
/// Row kind.
pub kind: RowKind,
/// Label text.
pub label: String,
/// Amount, when the row has one.
pub amount: Option<Decimal>,
/// Print a currency sign with the amount: US accounting style puts "$"
/// on the first amount under each section heading and on every
/// subtotal and total.
pub currency: bool,
/// Draw a single rule above the amount: the "sum of the above" line.
/// Totals always have one; a subtotal only when amounts sit above it
/// (an empty section gets no rule over nothing).
pub rule_above: bool,
}
/// A laid-out financial statement.
#[derive(Clone, Debug, Serialize)]
pub struct Statement {
/// "Profit and Loss", "Balance Sheet", "Statement of Cash Flows".
pub title: String,
/// "October 4 – October 11, 2026" or "As of October 11, 2026".
pub period: String,
/// The rows, top to bottom.
pub rows: Vec<Row>,
}
fn row(kind: RowKind, label: impl Into<String>, amount: Option<Decimal>) -> Row {
Row {
kind,
label: label.into(),
amount,
currency: false,
rule_above: false,
}
}
/// Finish a statement: mark which amounts carry the currency sign and which
/// get a rule above.
fn statement(title: &str, period: String, mut rows: Vec<Row>) -> Statement {
let mut first_in_section = true;
let mut above: Option<RowKind> = None;
for r in &mut rows {
r.rule_above = match r.kind {
RowKind::Total => true,
RowKind::Subtotal => matches!(above, Some(RowKind::Line | RowKind::Subtotal)),
_ => false,
};
if r.kind != RowKind::Spacer {
above = Some(r.kind);
}
match r.kind {
RowKind::Section => first_in_section = true,
RowKind::Subtotal | RowKind::Total => r.currency = true,
RowKind::Line if r.amount.is_some() && first_in_section => {
r.currency = true;
first_in_section = false;
}
RowKind::Line | RowKind::Spacer => {}
}
}
Statement {
title: title.to_string(),
period,
rows,
}
}
/// An amount split for column alignment: the digits, and whether it is a
/// negative (printed in parentheses) or zero (printed as a dash). Renderers
/// right-align `digits` and reserve the closing parenthesis's width on every
/// row, so decimal points line up whether or not a value is negative.
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct AmountParts {
/// "1,234.56" — absolute value, grouped, two decimals. Empty for zero.
pub digits: String,
/// Below zero.
pub negative: bool,
/// Exactly zero (after rounding to cents).
pub zero: bool,
}
/// Split an amount for aligned accounting display. Exact: no floats.
pub fn amount_parts(d: Decimal) -> AmountParts {
let rounded = d.round_dp(2);
if rounded.is_zero() {
return AmountParts {
digits: String::new(),
negative: false,
zero: true,
};
}
let text = format_amount(rounded.abs());
AmountParts {
digits: text,
negative: rounded.is_sign_negative(),
zero: false,
}
}
fn section(label: &str) -> Row {
row(RowKind::Section, label, None)
}
fn spacer() -> Row {
row(RowKind::Spacer, "", None)
}
fn account_lines(lines: &[StatementLine]) -> impl Iterator<Item = Row> + '_ {
lines.iter().map(|l| {
row(
RowKind::Line,
format!("{} {}", l.code, l.name),
Some(l.amount),
)
})
}
fn flow_lines(lines: &[CashFlowLine]) -> impl Iterator<Item = Row> + '_ {
lines
.iter()
.map(|l| row(RowKind::Line, l.label.clone(), Some(l.amount)))
}
/// Lay out a profit and loss statement.
pub fn profit_and_loss(pl: &ProfitAndLossStatement) -> Statement {
let mut rows = vec![section("Revenue")];
rows.extend(account_lines(&pl.revenue));
rows.push(row(
RowKind::Subtotal,
"Total revenue",
Some(pl.total_revenue),
));
rows.push(spacer());
rows.push(section("Expenses"));
rows.extend(account_lines(&pl.expenses));
rows.push(row(
RowKind::Subtotal,
"Total expenses",
Some(pl.total_expenses),
));
rows.push(spacer());
rows.push(row(RowKind::Total, "Net income", Some(pl.net_income())));
statement("Profit and Loss", period_label(pl.start, pl.end), rows)
}
/// Lay out a balance sheet.
pub fn balance_sheet(bs: &BalanceSheetStatement) -> Statement {
let mut rows = vec![section("Assets")];
rows.extend(account_lines(&bs.assets));
rows.push(row(
RowKind::Subtotal,
"Total assets",
Some(bs.total_assets),
));
rows.push(spacer());
rows.push(section("Liabilities"));
rows.extend(account_lines(&bs.liabilities));
rows.push(row(
RowKind::Subtotal,
"Total liabilities",
Some(bs.total_liabilities),
));
rows.push(spacer());
rows.push(section("Equity"));
rows.extend(account_lines(&bs.equity));
rows.push(row(
RowKind::Line,
"Current earnings",
Some(bs.current_earnings),
));
rows.push(row(
RowKind::Subtotal,
"Total equity",
Some(bs.total_equity),
));
rows.push(spacer());
rows.push(row(
RowKind::Total,
"Total liabilities and equity",
Some(bs.total_liabilities + bs.total_equity),
));
statement(
"Balance Sheet",
format!("As of {}", long_date(bs.as_of)),
rows,
)
}
/// Lay out a cash flow statement (indirect method).
pub fn cash_flow(cf: &CashFlowStatement) -> Statement {
let mut rows = vec![section("Operating activities")];
rows.push(row(RowKind::Line, "Net income", Some(cf.net_income)));
if !cf.depreciation.is_zero() {
rows.push(row(
RowKind::Line,
"Depreciation (non-cash)",
Some(cf.depreciation),
));
}
rows.extend(flow_lines(&cf.working_capital));
rows.push(row(
RowKind::Subtotal,
"Net cash from operating activities",
Some(cf.cash_from_operations),
));
rows.push(spacer());
rows.push(section("Investing activities"));
rows.extend(flow_lines(&cf.investing));
rows.push(row(
RowKind::Subtotal,
"Net cash from investing activities",
Some(cf.cash_from_investing),
));
rows.push(spacer());
rows.push(section("Financing activities"));
rows.extend(flow_lines(&cf.financing));
rows.push(row(
RowKind::Subtotal,
"Net cash from financing activities",
Some(cf.cash_from_financing),
));
rows.push(spacer());
rows.push(row(
RowKind::Subtotal,
"Net change in cash",
Some(cf.net_change_in_cash),
));
rows.push(row(
RowKind::Line,
"Cash at beginning of period",
Some(cf.cash_beginning),
));
rows.push(row(
RowKind::Total,
"Cash at end of period",
Some(cf.cash_ending),
));
statement(
"Statement of Cash Flows",
period_label(cf.start, cf.end),
rows,
)
}
/// "October 11, 2026".
pub fn long_date(d: NaiveDate) -> String {
d.format("%B %-d, %Y").to_string()
}
/// "October 4 – October 11, 2026", or with both years when they differ.
pub fn period_label(start: NaiveDate, end: NaiveDate) -> String {
use chrono::Datelike;
if start.year() == end.year() {
format!("{} – {}", start.format("%B %-d"), long_date(end))
} else {
format!("{} – {}", long_date(start), long_date(end))
}
}
/// Exact accounting format: thousands separators, two decimals, negatives
/// in parentheses. "1,234.56", "(207.68)", "0.00". Never a float.
pub fn format_amount(d: Decimal) -> String {
let negative = d.is_sign_negative() && !d.is_zero();
let fixed = format!("{:.2}", d.abs());
let (int, frac) = fixed.split_once('.').expect("two decimals");
let mut grouped = String::new();
for (i, c) in int.chars().enumerate() {
if i > 0 && (int.len() - i) % 3 == 0 {
grouped.push(',');
}
grouped.push(c);
}
if negative {
format!("({grouped}.{frac})")
} else {
format!("{grouped}.{frac}")
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::str::FromStr;
fn dec(s: &str) -> Decimal {
Decimal::from_str(s).unwrap()
}
#[test]
fn amounts_format_exactly() {
assert_eq!(format_amount(dec("0")), "0.00");
assert_eq!(format_amount(dec("-0")), "0.00");
assert_eq!(format_amount(dec("5.8")), "5.80");
assert_eq!(format_amount(dec("207.68")), "207.68");
assert_eq!(format_amount(dec("-207.68")), "(207.68)");
assert_eq!(format_amount(dec("1000")), "1,000.00");
assert_eq!(format_amount(dec("1234567.891")), "1,234,567.89");
assert_eq!(format_amount(dec("-100000")), "(100,000.00)");
}
#[test]
fn amounts_split_for_alignment() {
let p = amount_parts(dec("-1234.5"));
assert_eq!(
(p.digits.as_str(), p.negative, p.zero),
("1,234.50", true, false)
);
assert!(amount_parts(dec("0.004")).zero);
assert_eq!(amount_parts(dec("207.68")).digits, "207.68");
}
#[test]
fn currency_signs_go_on_first_lines_and_totals() {
let rows = vec![
section("Assets"),
row(RowKind::Line, "a", Some(dec("1"))),
row(RowKind::Line, "b", Some(dec("2"))),
row(RowKind::Subtotal, "t", Some(dec("3"))),
spacer(),
section("Equity"),
row(RowKind::Line, "c", Some(dec("3"))),
row(RowKind::Total, "T", Some(dec("3"))),
];
let st = statement("X", String::new(), rows);
let signs: Vec<bool> = st.rows.iter().map(|r| r.currency).collect();
assert_eq!(signs, [false, true, false, true, false, false, true, true]);
let rules: Vec<bool> = st.rows.iter().map(|r| r.rule_above).collect();
assert_eq!(
rules,
[false, false, false, true, false, false, false, true]
);
// A subtotal straight under its heading (empty section): no rule.
let empty = statement(
"Y",
String::new(),
vec![
section("Investing"),
row(RowKind::Subtotal, "t", Some(dec("0"))),
],
);
assert!(!empty.rows[1].rule_above);
}
#[test]
fn periods_read_like_english() {
let d = |y, m, dd| NaiveDate::from_ymd_opt(y, m, dd).unwrap();
assert_eq!(
period_label(d(2026, 10, 4), d(2026, 10, 11)),
"October 4 – October 11, 2026"
);
assert_eq!(
period_label(d(2026, 12, 1), d(2027, 1, 31)),
"December 1, 2026 – January 31, 2027"
);
}
}
+136 -161
View File
@@ -2,12 +2,15 @@
//!
//! Money is converted to f64 ONLY at this presentation boundary — never in
//! a money path. All accounting math stays `Decimal`; the conversion happens
//! when writing a cell, rounded to cents.
//! when writing a cell, rounded to cents. Cells stay numeric (so a CPA can
//! sum them) with an accounting number format: thousands separators,
//! negatives in parentheses.
use crate::report::{ReportMeta, RowKind, Statement};
use ledger_core::Ledger;
use ledger_core::reports::{BalanceSheet, CashFlowStatement, ProfitAndLoss, TrialBalance};
use ledger_core::reports::TrialBalance;
use rust_decimal::Decimal;
use rust_xlsxwriter::{Format, Workbook};
use rust_xlsxwriter::{Format, FormatBorder, Workbook, Worksheet};
use thiserror::Error;
/// Which report to render.
@@ -15,12 +18,9 @@ use thiserror::Error;
pub enum XlsxReport<'a> {
/// Trial balance lines.
TrialBalance(&'a TrialBalance),
/// Profit and loss.
ProfitAndLoss(&'a ProfitAndLoss, String, String),
/// Balance sheet.
BalanceSheet(&'a BalanceSheet, String),
/// Cash flow statement.
CashFlow(&'a CashFlowStatement),
/// A laid-out financial statement (P&L, balance sheet, cash flow); see
/// [`crate::report`].
Statement(&'a Statement),
/// General ledger: every posted journal line. Needs the ledger for
/// account names and class names.
GeneralLedger(&'a Ledger),
@@ -34,155 +34,133 @@ pub enum XlsxError {
Write(String),
}
impl From<rust_xlsxwriter::XlsxError> for XlsxError {
fn from(e: rust_xlsxwriter::XlsxError) -> Self {
XlsxError::Write(e.to_string())
}
}
/// Excel's Accounting format, with and without the currency symbol: "$"
/// pinned left, digits right, negatives in parentheses with the closing
/// parenthesis hanging (`_)` reserves its width on positives, so decimal
/// points line up), zero as a dash.
const ACCOUNTING: &str = r#"_(* #,##0.00_);_(* (#,##0.00);_(* "-"??_);_(@_)"#;
const ACCOUNTING_USD: &str = r#"_($* #,##0.00_);_($* (#,##0.00);_($* "-"??_);_(@_)"#;
/// Company name, title, and period across the top; returns the next row.
fn header(
sheet: &mut Worksheet,
meta: &ReportMeta,
title: &str,
period: Option<&str>,
) -> Result<u32, XlsxError> {
let bold = Format::new().set_bold();
sheet.write_string_with_format(0, 0, &meta.company, &bold.clone().set_font_size(14))?;
sheet.write_string_with_format(1, 0, title, &bold)?;
let mut row = 2;
if let Some(period) = period {
sheet.write_string(row, 0, period)?;
row += 1;
}
Ok(row + 1)
}
/// "Prepared from journal through seq N" under the last row.
fn footer(sheet: &mut Worksheet, meta: &ReportMeta, row: u32) -> Result<(), XlsxError> {
let note = Format::new().set_italic().set_font_color("#666666");
sheet.write_string_with_format(
row + 1,
0,
format!("Prepared from journal through seq {}", meta.journal_seq),
&note,
)?;
Ok(())
}
/// Render a report to XLSX bytes.
pub fn report_to_xlsx(report: &XlsxReport<'_>) -> Result<Vec<u8>, XlsxError> {
pub fn report_to_xlsx(report: &XlsxReport<'_>, meta: &ReportMeta) -> Result<Vec<u8>, XlsxError> {
let mut workbook = Workbook::new();
let sheet = workbook.add_worksheet();
let bold = Format::new().set_bold();
let money_fmt = Format::new().set_num_format("#,##0.00");
let money = Format::new().set_num_format(ACCOUNTING);
let usd = Format::new().set_num_format(ACCOUNTING_USD);
// Totals are marked by rules, not bold: bold figures are wider and would
// break the decimal column.
let usd_bold = usd.clone();
let mut row: u32 = 0;
match report {
XlsxReport::TrialBalance(tb) => {
sheet.write_string(row, 0, "Trial Balance").unwrap();
row += 1;
sheet.set_name("Trial Balance")?;
let mut row = header(sheet, meta, "Trial Balance", None)?;
for (i, h) in ["Code", "Account", "Debit", "Credit"].iter().enumerate() {
sheet
.write_string_with_format(row, i as u16, *h, &bold)
.unwrap();
sheet.write_string_with_format(row, i as u16, *h, &bold)?;
}
row += 1;
for line in &tb.lines {
sheet.write_string(row, 0, &line.code).unwrap();
sheet.write_string(row, 1, &line.name).unwrap();
sheet
.write_number_with_format(row, 2, dec_to_f64(line.debit), &money_fmt)
.unwrap();
sheet
.write_number_with_format(row, 3, dec_to_f64(line.credit), &money_fmt)
.unwrap();
sheet.write_string(row, 0, &line.code)?;
sheet.write_string(row, 1, &line.name)?;
sheet.write_number_with_format(row, 2, dec_to_f64(line.debit), &money)?;
sheet.write_number_with_format(row, 3, dec_to_f64(line.credit), &money)?;
row += 1;
}
let total = usd_bold
.clone()
.set_border_top(FormatBorder::Thin)
.set_border_bottom(FormatBorder::Double);
sheet.write_string_with_format(row, 1, "Total", &bold)?;
sheet.write_number_with_format(row, 2, dec_to_f64(tb.total_debits), &total)?;
sheet.write_number_with_format(row, 3, dec_to_f64(tb.total_credits), &total)?;
sheet.set_column_width(1, 40)?;
sheet.set_column_width(2, 14)?;
sheet.set_column_width(3, 14)?;
footer(sheet, meta, row + 1)?;
}
XlsxReport::ProfitAndLoss(pl, start, end) => {
sheet
.write_string(row, 0, format!("Profit and Loss ({start} to {end})"))
.unwrap();
row += 2;
sheet.write_string(row, 0, "Revenue").unwrap();
sheet
.write_number_with_format(row, 1, dec_to_f64(pl.revenue), &money_fmt)
.unwrap();
row += 1;
sheet.write_string(row, 0, "Expenses").unwrap();
sheet
.write_number_with_format(row, 1, dec_to_f64(pl.expenses), &money_fmt)
.unwrap();
row += 1;
sheet
.write_string_with_format(row, 0, "Net income", &bold)
.unwrap();
let net = pl.revenue - pl.expenses;
sheet
.write_number_with_format(row, 1, dec_to_f64(net), &money_fmt)
.unwrap();
}
XlsxReport::BalanceSheet(bs, as_of) => {
sheet
.write_string(row, 0, format!("Balance Sheet (as of {as_of})"))
.unwrap();
row += 2;
for (label, val) in [
("Assets", bs.assets),
("Liabilities", bs.liabilities),
("Equity", bs.equity),
] {
sheet.write_string(row, 0, label).unwrap();
sheet
.write_number_with_format(row, 1, dec_to_f64(val), &money_fmt)
.unwrap();
XlsxReport::Statement(st) => {
sheet.set_name(&st.title)?;
let mut row = header(sheet, meta, &st.title, Some(&st.period))?;
let line_label = Format::new().set_indent(1);
let subtotal = usd_bold.clone().set_border_top(FormatBorder::Thin);
let total = usd_bold
.clone()
.set_border_top(FormatBorder::Thin)
.set_border_bottom(FormatBorder::Double);
for r in &st.rows {
let amount = r.amount.map(dec_to_f64);
match r.kind {
RowKind::Spacer => {}
RowKind::Section => {
sheet.write_string_with_format(row, 0, &r.label, &bold)?;
}
RowKind::Line => {
sheet.write_string_with_format(row, 0, &r.label, &line_label)?;
if let Some(a) = amount {
let fmt = if r.currency { &usd } else { &money };
sheet.write_number_with_format(row, 1, a, fmt)?;
}
}
RowKind::Subtotal => {
sheet.write_string_with_format(row, 0, &r.label, &bold)?;
if let Some(a) = amount {
let fmt = if r.rule_above { &subtotal } else { &usd_bold };
sheet.write_number_with_format(row, 1, a, fmt)?;
}
}
RowKind::Total => {
sheet.write_string_with_format(row, 0, &r.label, &bold)?;
if let Some(a) = amount {
sheet.write_number_with_format(row, 1, a, &total)?;
}
}
}
row += 1;
}
}
XlsxReport::CashFlow(cf) => {
sheet
.write_string(row, 0, format!("Cash Flow ({} to {})", cf.start, cf.end))
.unwrap();
row += 2;
sheet
.write_string_with_format(row, 0, "Operating activities", &bold)
.unwrap();
row += 1;
sheet.write_string(row, 0, "Net income").unwrap();
sheet
.write_number_with_format(row, 1, dec_to_f64(cf.net_income), &money_fmt)
.unwrap();
row += 1;
sheet.write_string(row, 0, "Depreciation").unwrap();
sheet
.write_number_with_format(row, 1, dec_to_f64(cf.depreciation), &money_fmt)
.unwrap();
row += 1;
for line in &cf.working_capital {
sheet.write_string(row, 0, &line.label).unwrap();
sheet
.write_number_with_format(row, 1, dec_to_f64(line.amount), &money_fmt)
.unwrap();
row += 1;
}
sheet
.write_string_with_format(row, 0, "Cash from operations", &bold)
.unwrap();
sheet
.write_number_with_format(row, 1, dec_to_f64(cf.cash_from_operations), &money_fmt)
.unwrap();
row += 2;
sheet
.write_string_with_format(row, 0, "Investing activities", &bold)
.unwrap();
row += 1;
for line in &cf.investing {
sheet.write_string(row, 0, &line.label).unwrap();
sheet
.write_number_with_format(row, 1, dec_to_f64(line.amount), &money_fmt)
.unwrap();
row += 1;
}
sheet
.write_string_with_format(row, 0, "Cash from investing", &bold)
.unwrap();
sheet
.write_number_with_format(row, 1, dec_to_f64(cf.cash_from_investing), &money_fmt)
.unwrap();
row += 2;
sheet
.write_string_with_format(row, 0, "Financing activities", &bold)
.unwrap();
row += 1;
for line in &cf.financing {
sheet.write_string(row, 0, &line.label).unwrap();
sheet
.write_number_with_format(row, 1, dec_to_f64(line.amount), &money_fmt)
.unwrap();
row += 1;
}
sheet
.write_string_with_format(row, 0, "Cash from financing", &bold)
.unwrap();
sheet
.write_number_with_format(row, 1, dec_to_f64(cf.cash_from_financing), &money_fmt)
.unwrap();
row += 2;
sheet
.write_string_with_format(row, 0, "Net change in cash", &bold)
.unwrap();
sheet
.write_number_with_format(row, 1, dec_to_f64(cf.net_change_in_cash), &money_fmt)
.unwrap();
sheet.set_column_width(0, 60)?;
sheet.set_column_width(1, 16)?;
footer(sheet, meta, row)?;
}
XlsxReport::GeneralLedger(ledger) => {
sheet.write_string(row, 0, "General Ledger").unwrap();
row += 1;
sheet.set_name("General Ledger")?;
let mut row = header(sheet, meta, "General Ledger", None)?;
for (i, h) in [
"Date",
"Entry",
@@ -196,9 +174,7 @@ pub fn report_to_xlsx(report: &XlsxReport<'_>) -> Result<Vec<u8>, XlsxError> {
.iter()
.enumerate()
{
sheet
.write_string_with_format(row, i as u16, *h, &bold)
.unwrap();
sheet.write_string_with_format(row, i as u16, *h, &bold)?;
}
row += 1;
for entry in ledger.posted_entries() {
@@ -217,29 +193,24 @@ pub fn report_to_xlsx(report: &XlsxReport<'_>) -> Result<Vec<u8>, XlsxError> {
ledger_core::Side::Debit => (line.amount.amount, Decimal::ZERO),
ledger_core::Side::Credit => (Decimal::ZERO, line.amount.amount),
};
sheet
.write_string(row, 0, entry.date().to_string())
.unwrap();
sheet.write_number(row, 1, entry.id() as f64).unwrap();
sheet.write_string(row, 2, entry.description()).unwrap();
sheet.write_string(row, 3, code).unwrap();
sheet.write_string(row, 4, name).unwrap();
sheet.write_string(row, 5, class_name).unwrap();
sheet
.write_number_with_format(row, 6, dec_to_f64(debit), &money_fmt)
.unwrap();
sheet
.write_number_with_format(row, 7, dec_to_f64(credit), &money_fmt)
.unwrap();
sheet.write_string(row, 0, entry.date().to_string())?;
sheet.write_number(row, 1, entry.id() as f64)?;
sheet.write_string(row, 2, entry.description())?;
sheet.write_string(row, 3, code)?;
sheet.write_string(row, 4, name)?;
sheet.write_string(row, 5, class_name)?;
sheet.write_number_with_format(row, 6, dec_to_f64(debit), &money)?;
sheet.write_number_with_format(row, 7, dec_to_f64(credit), &money)?;
row += 1;
}
}
sheet.set_column_width(2, 50)?;
sheet.set_column_width(4, 32)?;
footer(sheet, meta, row)?;
}
}
workbook
.save_to_buffer()
.map_err(|e| XlsxError::Write(e.to_string()))
Ok(workbook.save_to_buffer()?)
}
/// Decimal to f64 at the presentation boundary. Rounded to cents first.
@@ -265,7 +236,11 @@ mod tests {
total_debits: Decimal::new(10000, 2),
total_credits: Decimal::new(10000, 2),
};
let bytes = report_to_xlsx(&XlsxReport::TrialBalance(&tb)).unwrap();
let meta = ReportMeta {
company: "Test Co".to_string(),
journal_seq: 7,
};
let bytes = report_to_xlsx(&XlsxReport::TrialBalance(&tb), &meta).unwrap();
// XLSX is a ZIP: starts with PK.
assert_eq!(&bytes[0..2], b"PK");
assert!(bytes.len() > 1000);
@@ -0,0 +1,112 @@
// A financial statement: P&L, balance sheet, or cash flow.
//
// Everything arrives as data in sys.inputs.data (JSON), already laid out by
// ledger-import::report, with amounts already split into exact parts. The
// template only typesets: no arithmetic happens here, and no label is ever
// parsed as markup.
//
// Shape: { company, title, period, footer,
// rows: [{ kind: "section" | "line" | "subtotal" | "total"
// | "spacer", label, rule (single rule above),
// amount: null | { digits: "1,234.56", negative, zero,
// currency } }] }
//
// Amounts follow US accounting presentation: tabular figures, "$" pinned
// to the left of the amount column on the first line of each section and on
// totals, negatives in parentheses with the closing parenthesis hanging
// (its width is reserved on every row, so decimal points always line up),
// and zero as a dash in the ones position. Amounts are never bold: bold
// figures are wider, which would knock totals out of the decimal column —
// the rules mark subtotals and totals instead.
#let d = json(bytes(sys.inputs.data))
#set document(title: d.company + " — " + d.title, author: d.company)
#set text(
font: "Libertinus Serif",
size: 10.5pt,
number-type: "lining",
number-width: "tabular",
)
#set page(
paper: "us-letter",
margin: (x: 1in, top: 0.9in, bottom: 0.8in),
header: context {
if counter(page).get().first() > 1 {
set text(8.5pt, fill: luma(110))
[#d.company — #d.title #h(1fr) #d.period]
}
},
footer: context {
set text(8pt, fill: luma(110))
[#d.footer #h(1fr) Page #counter(page).display() of #counter(page).final().first()]
},
)
#align(center)[
#text(15pt, weight: "bold", d.company) \
#v(2pt)
#text(12.5pt, d.title) \
#v(1pt)
#text(10pt, style: "italic", d.period)
]
#v(16pt)
// Two lines under a statement total, the accounting double rule, set
// clear of the descenders.
#let double-rule(body) = stack(
body,
v(2.4pt),
line(length: 100%, stroke: 0.5pt),
v(1.4pt),
line(length: 100%, stroke: 0.5pt),
)
#let amount(r) = {
let a = r.amount
if a == none { return [] }
let open = if a.negative { [(] } else { [] }
let close = if a.negative { [)] } else { hide[)] }
let body = if a.zero { [–#hide[.00]] } else { [#open#a.digits] }
grid(
columns: (auto, 1fr),
align: (left, right),
if a.currency [\$] else [],
[#body#close],
)
}
#table(
columns: (1fr, 9em),
stroke: none,
inset: (x: 0pt, y: 3.2pt),
align: (left + horizon, right + horizon),
..d
.rows
.map(r => {
if r.kind == "spacer" {
(table.cell(colspan: 2, v(3pt)),)
} else if r.kind == "section" {
(table.cell(colspan: 2, text(weight: "bold", r.label)),)
} else if r.kind == "line" {
(pad(left: 1.4em, r.label), amount(r))
} else if r.kind == "subtotal" {
(
text(weight: "bold", r.label),
table.cell(
stroke: if r.rule { (top: 0.5pt) } else { none },
amount(r),
),
)
} else {
(
align(top, text(weight: "bold", r.label)),
table.cell(
stroke: (top: 0.5pt),
double-rule(amount(r)),
),
)
}
})
.flatten(),
)
+1
View File
@@ -27,6 +27,7 @@ chrono = "0.4"
thiserror = "2"
tokio = { version = "1", features = ["rt-multi-thread", "macros", "io-util", "sync"] }
zip = "9.0.1"
toml = "1"
[lints]
workspace = true
+184
View File
@@ -0,0 +1,184 @@
//! Company settings from the books dir's `wanderbooks.toml`.
//!
//! `wanderbooks init` writes that file next to `books-data/`; onboarding
//! fills in `[company]`. The server reads the company name (report headers),
//! the fiscal year start (fiscal-year-to-date reports), and
//! `[books] receipt_threshold` (the receipt policy, unless `config.json`
//! sets one). The fiscal year *end* is never stored: it is the day before
//! the next start, so the two can't disagree.
use chrono::{Datelike, NaiveDate};
use rust_decimal::Decimal;
use serde::Deserialize;
use std::path::Path;
/// The company the books belong to.
#[derive(Clone, Debug, PartialEq)]
pub struct Company {
/// Legal name, for report headers.
pub name: String,
/// Month and day the fiscal year starts. Default January 1.
pub fiscal_year_start: (u32, u32),
}
impl Default for Company {
fn default() -> Self {
Self {
name: "Unnamed company".to_string(),
fiscal_year_start: (1, 1),
}
}
}
impl Company {
/// The fiscal year containing `d`: `(first day, last day)`, inclusive.
pub fn fiscal_year_containing(&self, d: NaiveDate) -> (NaiveDate, NaiveDate) {
let (m, day) = self.fiscal_year_start;
let on = |year| NaiveDate::from_ymd_opt(year, m, day).expect("validated month-day");
let start = if on(d.year()) <= d {
on(d.year())
} else {
on(d.year() - 1)
};
let end = on(start.year() + 1)
.pred_opt()
.expect("not the minimum date");
(start, end)
}
}
/// What the server takes from `wanderbooks.toml`.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct BooksSettings {
/// `[company]`.
pub company: Company,
/// `[books] receipt_threshold`, validated as a decimal string.
pub receipt_threshold: Option<String>,
}
#[derive(Deserialize)]
struct BooksToml {
#[serde(default)]
company: CompanyToml,
#[serde(default)]
books: BooksSection,
}
#[derive(Default, Deserialize)]
struct CompanyToml {
name: Option<String>,
fiscal_year_start: Option<String>,
}
#[derive(Default, Deserialize)]
struct BooksSection {
receipt_threshold: Option<String>,
}
/// Parse `"MM-DD"`. February 29 is rejected: a fiscal year must start on a
/// day every year has.
fn parse_month_day(s: &str) -> Result<(u32, u32), String> {
let bad = || format!("fiscal_year_start {s:?}: expected \"MM-DD\", e.g. \"01-01\"");
let (m, d) = s.split_once('-').ok_or_else(bad)?;
let (m, d): (u32, u32) = (m.parse().map_err(|_| bad())?, d.parse().map_err(|_| bad())?);
// 2001 is not a leap year: a valid date there exists every year.
NaiveDate::from_ymd_opt(2001, m, d)
.ok_or_else(|| format!("fiscal_year_start {s:?} is not a day every year has"))?;
Ok((m, d))
}
/// Parse `wanderbooks.toml` text.
pub fn parse(text: &str) -> Result<BooksSettings, String> {
let raw: BooksToml = toml::from_str(text).map_err(|e| e.to_string())?;
let mut company = Company::default();
if let Some(name) = raw.company.name.filter(|n| !n.trim().is_empty()) {
company.name = name.trim().to_string();
}
if let Some(fy) = raw.company.fiscal_year_start {
company.fiscal_year_start = parse_month_day(fy.trim())?;
}
let receipt_threshold = match raw.books.receipt_threshold {
Some(t) => {
t.trim()
.parse::<Decimal>()
.map_err(|_| format!("receipt_threshold {t:?} is not a decimal"))?;
Some(t.trim().to_string())
}
None => None,
};
Ok(BooksSettings {
company,
receipt_threshold,
})
}
/// Load `wanderbooks.toml` from a path.
pub fn load(path: &Path) -> Result<BooksSettings, String> {
let text = std::fs::read_to_string(path).map_err(|e| format!("{}: {e}", path.display()))?;
parse(&text).map_err(|e| format!("{}: {e}", path.display()))
}
#[cfg(test)]
mod tests {
use super::*;
fn d(y: i32, m: u32, day: u32) -> NaiveDate {
NaiveDate::from_ymd_opt(y, m, day).unwrap()
}
#[test]
fn calendar_year_by_default() {
let c = parse("").unwrap().company;
assert_eq!(c.fiscal_year_start, (1, 1));
assert_eq!(
c.fiscal_year_containing(d(2026, 10, 11)),
(d(2026, 1, 1), d(2026, 12, 31))
);
assert_eq!(
c.fiscal_year_containing(d(2026, 1, 1)),
(d(2026, 1, 1), d(2026, 12, 31))
);
}
#[test]
fn a_july_fiscal_year_spans_two_calendar_years() {
let c = parse("[company]\nfiscal_year_start = \"07-01\"\n")
.unwrap()
.company;
assert_eq!(
c.fiscal_year_containing(d(2026, 3, 15)),
(d(2025, 7, 1), d(2026, 6, 30))
);
assert_eq!(
c.fiscal_year_containing(d(2026, 7, 1)),
(d(2026, 7, 1), d(2027, 6, 30))
);
assert_eq!(
c.fiscal_year_containing(d(2026, 6, 30)),
(d(2025, 7, 1), d(2026, 6, 30))
);
}
#[test]
fn reads_the_scaffolded_file() {
let s = parse(
"[company]\nname = \"Wanderland Labs LLC\"\nentity_type = \"x\"\nfiscal_year_start = \"01-01\"\n\n[books]\ndata_dir = \"books-data\"\nreceipt_threshold = \"25.00\"\n",
)
.unwrap();
assert_eq!(s.company.name, "Wanderland Labs LLC");
assert_eq!(s.receipt_threshold.as_deref(), Some("25.00"));
// An empty name (fresh scaffold) keeps the placeholder.
assert_eq!(
parse("[company]\nname = \"\"\n").unwrap().company.name,
"Unnamed company"
);
}
#[test]
fn rejects_bad_settings() {
assert!(parse("[company]\nfiscal_year_start = \"02-29\"\n").is_err());
assert!(parse("[company]\nfiscal_year_start = \"13-01\"\n").is_err());
assert!(parse("[company]\nfiscal_year_start = \"Jan 1\"\n").is_err());
assert!(parse("[books]\nreceipt_threshold = \"25 dollars\"\n").is_err());
}
}
+46 -9
View File
@@ -1,12 +1,15 @@
//! `ledger-mcp`: the AI-first bookkeeping interface, over stdio.
//!
//! Usage: `ledger-mcp [--data-dir PATH] [--config PATH]`
//! Usage: `ledger-mcp [--data-dir PATH] [--config PATH] [--books-toml PATH]`
//!
//! The data dir holds `journal.jsonl` (the source of truth), `wanderbooks.db`
//! (the disposable SQLite projection), `blobs/` (content-addressed receipts),
//! and optionally `config.json`. If no store exists, one is created with the
//! configured base currency.
//! configured base currency. Company settings (name, fiscal year start,
//! receipt threshold) come from the books dir's `wanderbooks.toml`, by
//! default the one next to the data dir.
mod company;
mod policy;
mod server;
mod types;
@@ -25,9 +28,10 @@ struct Config {
/// ISO 4217 base currency for a new store. Default "USD".
#[serde(default)]
base_currency: Option<String>,
/// Receipt policy. Defaults to require-receipts-above $25.00.
/// Receipt policy. Overrides `wanderbooks.toml`'s receipt_threshold;
/// with neither, receipts are required above $25.00.
#[serde(default)]
receipt_policy: ReceiptPolicy,
receipt_policy: Option<ReceiptPolicy>,
}
fn default_data_dir() -> PathBuf {
@@ -36,15 +40,23 @@ fn default_data_dir() -> PathBuf {
}
fn print_usage() {
eprintln!("usage: ledger-mcp [--data-dir PATH] [--config PATH] [--http ADDR]");
eprintln!(
"usage: ledger-mcp [--data-dir PATH] [--config PATH] [--books-toml PATH] [--http ADDR]"
);
eprintln!(" --data-dir directory holding journal.jsonl (default ~/.local/share/wanderbooks)");
eprintln!(" --config JSON config file (default <data-dir>/config.json, optional)");
eprintln!(" --http serve over Streamable HTTP at ADDR (e.g. 127.0.0.1:8080) instead of stdio");
eprintln!(
" --books-toml company settings (default <data-dir>/../wanderbooks.toml, optional)"
);
eprintln!(
" --http serve over Streamable HTTP at ADDR (e.g. 127.0.0.1:8080) instead of stdio"
);
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut data_dir = default_data_dir();
let mut config_path: Option<PathBuf> = None;
let mut books_toml: Option<PathBuf> = None;
let mut http_addr: Option<String> = None;
let mut args = std::env::args().skip(1);
while let Some(arg) = args.next() {
@@ -55,6 +67,11 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
"--config" => {
config_path = Some(PathBuf::from(args.next().ok_or("--config needs a path")?));
}
"--books-toml" => {
books_toml = Some(PathBuf::from(
args.next().ok_or("--books-toml needs a path")?,
));
}
"--http" => {
http_addr = Some(args.next().ok_or("--http needs an address")?);
}
@@ -79,6 +96,24 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
};
// Logs go to stderr: stdout is the MCP channel.
eprintln!("ledger-mcp: data dir {}", data_dir.display());
// An explicit --books-toml must exist; the default is optional.
let settings = match books_toml {
Some(path) => company::load(&path)?,
None => match data_dir.parent().map(|p| p.join("wanderbooks.toml")) {
Some(path) if path.exists() => company::load(&path)?,
_ => company::BooksSettings::default(),
},
};
eprintln!("ledger-mcp: company {:?}", settings.company.name);
let policy = match (config.receipt_policy, settings.receipt_threshold) {
(Some(policy), _) => policy,
(None, Some(threshold)) => ReceiptPolicy {
require_receipts_above: threshold,
..ReceiptPolicy::default()
},
(None, None) => ReceiptPolicy::default(),
};
let company = settings.company;
let store = if data_dir.join(ledger_store::JOURNAL_FILE).exists() {
Store::open(&data_dir)?
@@ -99,12 +134,12 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
.build()?;
if let Some(addr) = http_addr {
eprintln!("ledger-mcp: store verified, serving Streamable HTTP on {addr}");
rt.block_on(serve_http(addr, store, config.receipt_policy))?;
rt.block_on(serve_http(addr, store, policy, company))?;
return Ok(());
}
eprintln!("ledger-mcp: store verified, serving over stdio");
let server = BooksServer::new(store, config.receipt_policy);
let server = BooksServer::new(store, policy).with_company(company);
rt.block_on(async {
let running = server.serve(stdio()).await?;
let _ = running.waiting().await;
@@ -117,6 +152,7 @@ async fn serve_http(
addr: String,
store: Store,
policy: ReceiptPolicy,
company: company::Company,
) -> Result<(), Box<dyn std::error::Error>> {
use rmcp::transport::streamable_http_server::{
StreamableHttpService, session::local::LocalSessionManager,
@@ -126,7 +162,8 @@ async fn serve_http(
let store = Arc::new(std::sync::Mutex::new(store));
let service = StreamableHttpService::new(
move || {
let server = server::BooksServer::new_shared(store.clone(), policy.clone());
let server = server::BooksServer::new_shared(store.clone(), policy.clone())
.with_company(company.clone());
Ok(server)
},
Arc::new(LocalSessionManager::default()),
+430 -290
View File
@@ -1,14 +1,16 @@
//! The MCP server: narrow typed tools over the store, with the receipt
//! policy enforced server-side.
use crate::company::Company;
use crate::policy::ReceiptPolicy;
use crate::types::*;
use chrono::{Datelike, NaiveDate};
use chrono::NaiveDate;
use ledger_core::{
AccountId, AccountType, AssetStatus, Currency, DocumentLine, DocumentStatus, JournalEntry,
Ledger, Money, NewAsset, PeriodStatus, Side, accumulated_depreciation, book_value,
depreciation_schedule, reports,
};
use ledger_import::ReportMeta;
use ledger_store::Store;
use rmcp::handler::server::wrapper::{Json, Parameters};
use rmcp::model::{
@@ -21,7 +23,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- 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.";
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.";
/// Max receipt/document upload: 25 MB.
const MAX_UPLOAD_BYTES: usize = 25 * 1024 * 1024;
@@ -33,6 +35,7 @@ const MAX_UPLOAD_BYTES: usize = 25 * 1024 * 1024;
pub struct BooksServer {
store: Arc<Mutex<Store>>,
policy: ReceiptPolicy,
company: Company,
}
impl BooksServer {
@@ -41,13 +44,32 @@ impl BooksServer {
Self {
store: Arc::new(Mutex::new(store)),
policy,
company: Company::default(),
}
}
/// Build the server around a shared store (for HTTP: each connection
/// gets a handle to the same store).
pub fn new_shared(store: Arc<Mutex<Store>>, policy: ReceiptPolicy) -> Self {
Self { store, policy }
Self {
store,
policy,
company: Company::default(),
}
}
/// Set the company (name for report headers, fiscal year).
pub fn with_company(mut self, company: Company) -> Self {
self.company = company;
self
}
/// Header and provenance for a rendered report.
fn report_meta(&self, store: &Store) -> ReportMeta {
ReportMeta {
company: self.company.name.clone(),
journal_seq: store.journal_seq(),
}
}
fn lock(&self) -> Result<std::sync::MutexGuard<'_, Store>, String> {
@@ -55,6 +77,43 @@ impl BooksServer {
.lock()
.map_err(|_| "store lock poisoned".to_string())
}
/// Fail with the structured `receipt_required` error when the receipt
/// policy requires this posted entry to keep at least one receipt.
/// Drafts pass (posting enforces the policy later); reversals pass (they
/// are exempt: the original carries the evidence).
fn ensure_receipt_not_required(&self, store: &Store, entry_id: u64) -> Result<(), String> {
let entry = store
.ledger()
.get_entry(entry_id)
.ok_or_else(|| format!("unknown entry {entry_id}"))?;
if entry.status() != ledger_core::EntryStatus::Posted || entry.reversal_of().is_some() {
return Ok(());
}
let split_group = self.policy.split_group(store.ledger(), entry_id, entry);
if !self.policy.receipt_required(store.ledger(), entry) && split_group.is_empty() {
return Ok(());
}
let total = ReceiptPolicy::entry_debits(entry);
Err(ReceiptRequiredError {
code: "receipt_required".to_string(),
message: format!(
"entry {entry_id} moves {} in debits ({}); unlinking its last receipt would leave it without one.",
money(total),
self.policy.describe()
),
entry_id: Some(entry_id),
asset_id: None,
debits: total.to_string(),
policy: self.policy.describe(),
remedy_tool: "books.attach_receipt".to_string(),
remedy_hint: format!(
"attach the replacement receipt to entry {entry_id} with books.attach_receipt first, then retry books.unlink_receipt"
),
split_group,
}
.to_tool_error())
}
}
// ---------------------------------------------------------------------------
@@ -145,8 +204,14 @@ fn resolve_class(store: &Store, class: &str) -> Result<u32, String> {
})
}
/// A money amount as a decimal string with at least two places: "0.00",
/// "207.68", "1000.00". Padding only, never rounding: an exact amount with
/// more places (an FX-converted line) keeps them. Never "-0".
fn money(n: Decimal) -> String {
// Decimal's Display never emits floats or "-0" for exact values.
let mut n = if n.is_zero() { Decimal::ZERO } else { n };
if n.scale() < 2 {
n.rescale(2);
}
n.to_string()
}
@@ -219,50 +284,53 @@ fn preview_receipts(store: &Store, entry_id: u64) -> Result<Vec<PreviewReceipt>,
.collect())
}
/// Net movement of one account over entries on or before `through`, in the
/// account's normal side.
fn movement_through(ledger: &Ledger, account: AccountId, through: NaiveDate) -> Decimal {
let movement: Decimal = ledger
.posted_entries()
.filter(|e| e.date() <= through)
.flat_map(|e| e.lines())
.filter(|l| l.account == account)
.map(|l| match l.side {
Side::Debit => l.amount.amount,
Side::Credit => -l.amount.amount,
})
.sum();
let account = ledger.chart().get(account).expect("account exists");
match account.account_type.normal_side() {
Side::Debit => movement,
Side::Credit => -movement,
/// Build the cash flow statement from account codes, as the exports need it:
/// `start` defaults to the beginning of time, `end` to today, and at least
/// one cash account is required (the statement reconciles to them).
fn cash_flow_statement(
ledger: &Ledger,
start: Option<&str>,
end: Option<&str>,
cash_codes: &[String],
financing_codes: &[String],
) -> Result<ledger_core::reports::CashFlowStatement, String> {
let resolve = |codes: &[String], what: &str| -> Result<Vec<AccountId>, String> {
codes
.iter()
.map(|code| {
ledger
.chart()
.find_by_code(code)
.map(|a| a.id)
.ok_or_else(|| format!("unknown {what} account code: {code}"))
})
.collect()
};
let cash_accounts = resolve(cash_codes, "cash")?;
if cash_accounts.is_empty() {
return Err("cash_accounts is required for the cash flow statement".to_string());
}
let financing_accounts = resolve(financing_codes, "financing")?;
let (start, end) = statement_range(ledger, start, end)?;
ledger_core::cash_flow(
ledger,
&ledger_core::CashFlowParams {
start,
end,
cash_accounts,
financing_accounts,
},
)
.map_err(|e| e.to_string())
}
/// Net movement of one account over entries in `[from, to]`, normal side.
fn movement_in(ledger: &Ledger, account: AccountId, from: NaiveDate, to: NaiveDate) -> Decimal {
movement_in_class(ledger, account, from, to, None)
}
/// Signed movement of an account in its normal side over a date range,
/// optionally filtered to one class. `None` class matches all lines
/// (including untagged ones); `Some(id)` matches only lines tagged with it.
fn movement_in_class(
ledger: &Ledger,
account: AccountId,
from: NaiveDate,
to: NaiveDate,
class_id: Option<u32>,
) -> Decimal {
let movement: Decimal = ledger
.posted_entries()
.filter(|e| e.date() >= from && e.date() <= to)
.flat_map(|e| e.lines())
.filter(|l| l.account == account)
.filter(|l| match class_id {
None => true,
Some(cid) => l.class_id == Some(cid),
})
.map(|l| match l.side {
Side::Debit => l.amount.amount,
Side::Credit => -l.amount.amount,
@@ -275,6 +343,70 @@ fn movement_in_class(
}
}
fn today() -> NaiveDate {
chrono::Utc::now().date_naive()
}
/// Resolve a statement's date range: `end` defaults to today and `start` to
/// the first posted entry (where "from the beginning" really begins, so no
/// statement says "0001-01-01"), or to `end` for empty books.
fn statement_range(
ledger: &Ledger,
start: Option<&str>,
end: Option<&str>,
) -> Result<(NaiveDate, NaiveDate), String> {
let end = match end {
Some(s) => parse_date(s)?,
None => today(),
};
let start = match start {
Some(s) => parse_date(s)?,
None => ledger
.posted_entries()
.map(|e| e.date())
.min()
.map_or(end, |first| first.min(end)),
};
if end < start {
return Err("end is before start".to_string());
}
Ok((start, end))
}
/// Profit and loss for a range (see [`statement_range`]), optionally one class.
fn pnl_statement(
ledger: &Ledger,
start: Option<&str>,
end: Option<&str>,
class_id: Option<ledger_core::ClassId>,
) -> Result<ledger_core::ProfitAndLossStatement, String> {
let (start, end) = statement_range(ledger, start, end)?;
ledger_core::profit_and_loss_for_period(ledger, start, end, class_id).map_err(|e| e.to_string())
}
/// Balance sheet as of a date (default today).
fn bs_statement(
ledger: &Ledger,
as_of: Option<&str>,
) -> Result<ledger_core::BalanceSheetStatement, String> {
let as_of = match as_of {
Some(s) => parse_date(s)?,
None => today(),
};
ledger_core::balance_sheet_as_of(ledger, as_of).map_err(|e| e.to_string())
}
fn lines_out(lines: &[ledger_core::StatementLine]) -> Vec<StatementLineOut> {
lines
.iter()
.map(|l| StatementLineOut {
code: l.code.clone(),
name: l.name.clone(),
amount: money(l.amount),
})
.collect()
}
// ---------------------------------------------------------------------------
// Tools
// ---------------------------------------------------------------------------
@@ -372,44 +504,24 @@ impl BooksServer {
) -> Result<Json<ProfitAndLossOut>, String> {
let p = params.0;
let store = self.lock()?;
let ledger = store.ledger();
let start = match p.start {
Some(s) => parse_date(&s)?,
None => NaiveDate::from_ymd_opt(1, 1, 1).unwrap(),
};
let end = match p.end {
Some(s) => parse_date(&s)?,
None => chrono::Utc::now().date_naive(),
};
if end < start {
return Err("end is before start".to_string());
}
let class_id = match p.class {
Some(c) => Some(resolve_class(&store, &c)?),
let class_id = match &p.class {
Some(c) => Some(resolve_class(&store, c)?),
None => None,
};
let mut revenue = Decimal::ZERO;
let mut expenses = Decimal::ZERO;
for a in ledger.chart().all() {
if a.placeholder {
continue;
}
match a.account_type {
AccountType::Revenue => {
revenue += movement_in_class(ledger, a.id, start, end, class_id)
}
AccountType::Expense => {
expenses += movement_in_class(ledger, a.id, start, end, class_id)
}
_ => {}
}
}
let pl = pnl_statement(
store.ledger(),
p.start.as_deref(),
p.end.as_deref(),
class_id,
)?;
Ok(Json(ProfitAndLossOut {
start: start.to_string(),
end: end.to_string(),
revenue: money(revenue),
expenses: money(expenses),
net_income: money(revenue - expenses),
start: pl.start.to_string(),
end: pl.end.to_string(),
revenue: money(pl.total_revenue),
expenses: money(pl.total_expenses),
net_income: money(pl.net_income()),
revenue_accounts: lines_out(&pl.revenue),
expense_accounts: lines_out(&pl.expenses),
}))
}
@@ -424,38 +536,17 @@ impl BooksServer {
) -> Result<Json<BalanceSheetOut>, String> {
let p = params.0;
let store = self.lock()?;
let ledger = store.ledger();
let as_of = match p.as_of {
Some(s) => parse_date(&s)?,
None => chrono::Utc::now().date_naive(),
};
let mut assets = Decimal::ZERO;
let mut liabilities = Decimal::ZERO;
let mut equity = Decimal::ZERO;
let mut revenue = Decimal::ZERO;
let mut expenses = Decimal::ZERO;
for a in ledger.chart().all() {
if a.placeholder {
continue;
}
let m = movement_through(ledger, a.id, as_of);
match a.account_type {
AccountType::Asset => assets += m,
AccountType::Liability => liabilities += m,
AccountType::Equity => equity += m,
AccountType::Revenue => revenue += m,
AccountType::Expense => expenses += m,
}
}
let current_earnings = revenue - expenses;
equity += current_earnings;
let bs = bs_statement(store.ledger(), p.as_of.as_deref())?;
Ok(Json(BalanceSheetOut {
as_of: as_of.to_string(),
assets: money(assets),
liabilities: money(liabilities),
equity: money(equity),
current_earnings: money(current_earnings),
balanced: assets == liabilities + equity,
as_of: bs.as_of.to_string(),
assets: money(bs.total_assets),
liabilities: money(bs.total_liabilities),
equity: money(bs.total_equity),
current_earnings: money(bs.current_earnings),
balanced: bs.balanced(),
asset_accounts: lines_out(&bs.assets),
liability_accounts: lines_out(&bs.liabilities),
equity_accounts: lines_out(&bs.equity),
}))
}
@@ -846,9 +937,65 @@ impl BooksServer {
Ok(Json(LinkReceiptOut { ok: true }))
}
#[tool(
name = "books.unlink_receipt",
description = "Detach a receipt from its entry, with a reason recorded in the journal. The receipt stays filed (unlinked); link it elsewhere or withdraw it with books.withdraw_attachment. The receipt policy still holds: an entry that requires a receipt cannot be left with none, so to replace a receipt attach the new one first, then unlink the old."
)]
async fn unlink_receipt(
&self,
params: Parameters<UnlinkReceiptParams>,
) -> Result<Json<UnlinkReceiptOut>, String> {
let p = params.0;
let mut store = self.lock()?;
let attachment = store
.list_attachments(None)
.map_err(|e| e.to_string())?
.into_iter()
.find(|a| a.id == p.attachment_id)
.ok_or_else(|| format!("unknown attachment {}", p.attachment_id))?;
if let Some(entry_id) = attachment.entry_id {
let remaining = store
.list_attachments(Some(entry_id))
.map_err(|e| e.to_string())?
.len()
- 1;
if remaining == 0 {
self.ensure_receipt_not_required(&store, entry_id)?;
}
}
let entry_id = store
.unlink_receipt(p.attachment_id, &p.reason)
.map_err(|e| e.to_string())?;
let remaining_receipts = store
.list_attachments(Some(entry_id))
.map_err(|e| e.to_string())?
.len() as u64;
Ok(Json(UnlinkReceiptOut {
ok: true,
entry_id,
remaining_receipts,
}))
}
#[tool(
name = "books.withdraw_attachment",
description = "Withdraw an unlinked receipt or a filed document, with a reason recorded in the journal. It drops out of books.list_receipts and can never be linked again. Nothing is deleted: the bytes stay in the blob store and the journal keeps the filing and the withdrawal. Unlink a linked receipt first."
)]
async fn withdraw_attachment(
&self,
params: Parameters<WithdrawAttachmentParams>,
) -> Result<Json<WithdrawAttachmentOut>, String> {
let p = params.0;
let mut store = self.lock()?;
store
.withdraw_attachment(p.attachment_id, &p.reason)
.map_err(|e| e.to_string())?;
Ok(Json(WithdrawAttachmentOut { ok: true }))
}
#[tool(
name = "books.list_receipts",
description = "List receipts and filed documents, optionally filtered to one entry.",
description = "List receipts and filed documents, optionally filtered to one entry. Withdrawn attachments are hidden unless include_withdrawn=true.",
annotations(read_only_hint = true)
)]
async fn list_receipts(
@@ -857,10 +1004,12 @@ impl BooksServer {
) -> Result<Json<ListReceiptsOut>, String> {
let p = params.0;
let store = self.lock()?;
let include_withdrawn = p.include_withdrawn.unwrap_or(false);
let receipts = store
.list_attachments(p.entry_id)
.map_err(|e| e.to_string())?
.into_iter()
.filter(|a| include_withdrawn || a.withdrawn_at.is_none())
.map(|a| ReceiptOut {
attachment_id: a.id,
sha256: a.blob_sha,
@@ -870,6 +1019,8 @@ impl BooksServer {
mime_type: a.mime_type,
size_bytes: a.size_bytes,
description: a.description,
withdrawn: a.withdrawn_at.is_some(),
withdrawn_reason: a.withdrawn_reason,
})
.collect();
Ok(Json(ListReceiptsOut { receipts }))
@@ -1234,15 +1385,15 @@ impl BooksServer {
#[tool(
name = "books.export",
description = "Export a report. Formats: csv, json, xlsx (base64), iif (journal only, for QuickBooks Desktop). Reports: journal, trial_balance, profit_and_loss, balance_sheet, cash_flow.",
description = "Export a report. Formats: csv, json, xlsx (base64), pdf (base64; profit_and_loss, balance_sheet, cash_flow), iif (journal only, for QuickBooks Desktop). Reports: journal, trial_balance, profit_and_loss, balance_sheet, cash_flow. cash_flow needs cash_accounts.",
annotations(read_only_hint = true)
)]
async fn export(&self, params: Parameters<ExportParams>) -> Result<Json<ExportOut>, String> {
let p = params.0;
let format = p.format.to_lowercase();
if !["csv", "json", "xlsx", "iif"].contains(&format.as_str()) {
if !["csv", "json", "xlsx", "pdf", "iif"].contains(&format.as_str()) {
return Err(format!(
"bad format {:?}: expected \"csv\", \"json\", \"xlsx\", or \"iif\"",
"bad format {:?}: expected \"csv\", \"json\", \"xlsx\", \"pdf\", or \"iif\"",
p.format
));
}
@@ -1250,8 +1401,12 @@ impl BooksServer {
if format == "iif" && report != "journal" {
return Err("iif format is only valid for the journal report".to_string());
}
if format == "xlsx" {
let bytes = self.export_xlsx(&report, &p)?;
if format == "xlsx" || format == "pdf" {
let bytes = if format == "xlsx" {
self.export_xlsx(&report, &p)?
} else {
self.export_pdf(&report, &p)?
};
return Ok(Json(ExportOut {
format,
report,
@@ -1289,94 +1444,60 @@ impl BooksServer {
}))
}
/// Lay out a financial statement for rendering, from export params.
fn layout_statement(
&self,
ledger: &Ledger,
report: &str,
p: &ExportParams,
) -> Result<ledger_import::Statement, String> {
use ledger_import::report;
Ok(match report {
"profit_and_loss" => report::profit_and_loss(&pnl_statement(
ledger,
p.start.as_deref(),
p.end.as_deref(),
None,
)?),
"balance_sheet" => report::balance_sheet(&bs_statement(ledger, p.as_of.as_deref())?),
"cash_flow" => report::cash_flow(&cash_flow_statement(
ledger,
p.start.as_deref(),
p.end.as_deref(),
&p.cash_accounts,
&p.financing_accounts,
)?),
_ => {
return Err(format!(
"bad report {report:?}: expected profit_and_loss, balance_sheet, or cash_flow"
));
}
})
}
/// Render a report to XLSX bytes via ledger-import.
fn export_xlsx(&self, report: &str, p: &ExportParams) -> Result<Vec<u8>, String> {
use ledger_import::{XlsxReport, report_to_xlsx};
let store = self.lock()?;
let meta = self.report_meta(&store);
let ledger = store.ledger();
// Build owned report data first (all report structs are owned).
enum Owned {
Tb(ledger_core::reports::TrialBalance),
Pnl(ledger_core::reports::ProfitAndLoss, String, String),
Bs(ledger_core::reports::BalanceSheet, String),
Cf(ledger_core::reports::CashFlowStatement),
if report == "trial_balance" {
let tb = reports::trial_balance(ledger);
return report_to_xlsx(&XlsxReport::TrialBalance(&tb), &meta)
.map_err(|e| e.to_string());
}
let owned = match report {
"trial_balance" => Owned::Tb(reports::trial_balance(ledger)),
"profit_and_loss" => {
let start = match &p.start {
Some(s) => parse_date(s)?,
None => NaiveDate::from_ymd_opt(1, 1, 1).unwrap(),
};
let end = match &p.end {
Some(s) => parse_date(s)?,
None => chrono::Utc::now().date_naive(),
};
Owned::Pnl(
reports::profit_and_loss(ledger).map_err(|e| e.to_string())?,
start.to_string(),
end.to_string(),
)
}
"balance_sheet" => {
let as_of = match &p.as_of {
Some(s) => parse_date(s)?,
None => chrono::Utc::now().date_naive(),
};
// Engine balance_sheet is as-of now; xlsx uses the same.
// (The books.balance_sheet tool does as-of via movement_through.)
Owned::Bs(
reports::balance_sheet(ledger).map_err(|e| e.to_string())?,
as_of.to_string(),
)
}
"cash_flow" => {
let mut cash_accounts = Vec::new();
for code in &p.cash_accounts {
let id = ledger
.chart()
.find_by_code(code)
.map(|a| a.id)
.ok_or_else(|| format!("unknown cash account code: {code}"))?;
cash_accounts.push(id);
}
if cash_accounts.is_empty() {
return Err("cash_accounts is required for cash_flow".to_string());
}
let start = match &p.start {
Some(s) => parse_date(s)?,
None => NaiveDate::from_ymd_opt(1, 1, 1).unwrap(),
};
let end = match &p.end {
Some(s) => parse_date(s)?,
None => chrono::Utc::now().date_naive(),
};
Owned::Cf(
ledger_core::cash_flow(
ledger,
&ledger_core::CashFlowParams {
start,
end,
cash_accounts,
financing_accounts: vec![],
},
)
.map_err(|e| e.to_string())?,
)
}
_ => {
return Err(format!(
"bad report {report:?} for xlsx: expected trial_balance, profit_and_loss, balance_sheet, or cash_flow"
));
}
};
let xlsx_report = match &owned {
Owned::Tb(tb) => XlsxReport::TrialBalance(tb),
Owned::Pnl(pl, s, e) => XlsxReport::ProfitAndLoss(pl, s.clone(), e.clone()),
Owned::Bs(bs, a) => XlsxReport::BalanceSheet(bs, a.clone()),
Owned::Cf(cf) => XlsxReport::CashFlow(cf),
};
report_to_xlsx(&xlsx_report).map_err(|e| e.to_string())
let st = self.layout_statement(ledger, report, p)?;
report_to_xlsx(&XlsxReport::Statement(&st), &meta).map_err(|e| e.to_string())
}
/// Render a financial statement to PDF bytes via ledger-import.
fn export_pdf(&self, report: &str, p: &ExportParams) -> Result<Vec<u8>, String> {
let store = self.lock()?;
let meta = self.report_meta(&store);
let st = self
.layout_statement(store.ledger(), report, p)
.map_err(|e| e.replace("bad report", "bad report for pdf"))?;
ledger_import::statement_pdf(&st, &meta).map_err(|e| e.to_string())
}
/// Render the journal as IIF text via ledger-import.
@@ -1687,6 +1808,9 @@ impl BooksServer {
}
.to_tool_error());
}
for aid in &attachment_ids {
store.check_linkable(*aid).map_err(|e| e.to_string())?;
}
let entry_id = store
.dispose_asset(
p.asset_id,
@@ -1775,17 +1899,17 @@ impl BooksServer {
let closing = store.close_period(p.period_id).map_err(|e| e.to_string())?;
// Build line descriptions from the posted closing entry.
let mut lines = Vec::new();
if let Some(eid) = closing {
if let Some(entry) = store.ledger().get_entry(eid) {
for line in entry.lines() {
let code = store
.ledger()
.chart()
.get(line.account)
.map(|a| a.code.clone())
.unwrap_or_default();
lines.push(format!("{:?} {} {}", line.side, code, line.amount.amount));
}
if let Some(eid) = closing
&& let Some(entry) = store.ledger().get_entry(eid)
{
for line in entry.lines() {
let code = store
.ledger()
.chart()
.get(line.account)
.map(|a| a.code.clone())
.unwrap_or_default();
lines.push(format!("{:?} {} {}", line.side, code, line.amount.amount));
}
}
Ok(Json(ClosePeriodOut {
@@ -2380,41 +2504,49 @@ impl BooksServer {
#[tool(
name = "books.export_cpa_package",
description = "Build the CPA handoff package as a zip (base64): trial-balance.xlsx, general-ledger.xlsx (every posted line), profit-and-loss.pdf, balance-sheet.pdf. The as_of date defaults to today; the P&L covers the fiscal year to date.",
description = "Build the CPA handoff package as a zip (base64): trial-balance.xlsx, general-ledger.xlsx (every posted line), profit-and-loss.pdf, balance-sheet.pdf, cash-flow.pdf. cash_accounts (e.g. [\"1000\"]) is required: the cash flow statement reconciles to them. The as_of date defaults to today; the P&L and cash flow cover the fiscal year to date.",
annotations(read_only_hint = true)
)]
async fn export_cpa_package(
&self,
params: Parameters<ExportCpaPackageParams>,
) -> Result<Json<ExportCpaPackageOut>, String> {
use ledger_import::{XlsxReport, report_to_xlsx};
use ledger_import::{XlsxReport, report, report_to_xlsx, statement_pdf};
let p = params.0;
let as_of = match p.as_of {
Some(s) => parse_date(&s)?,
None => chrono::Utc::now().date_naive(),
None => today(),
};
let store = self.lock()?;
let meta = self.report_meta(&store);
let ledger = store.ledger();
// Fiscal year to date for the P&L: Jan 1 of the as_of year.
let fy_start = NaiveDate::from_ymd_opt(as_of.year(), 1, 1).unwrap();
// Fiscal year to date, per wanderbooks.toml's fiscal_year_start.
let (fy_start, _) = self.company.fiscal_year_containing(as_of);
let (fy_start, as_of_s) = (fy_start.to_string(), as_of.to_string());
let xlsx = |r: XlsxReport<'_>| report_to_xlsx(&r, &meta).map_err(|e| e.to_string());
let pdf =
|st: ledger_import::Statement| statement_pdf(&st, &meta).map_err(|e| e.to_string());
// 1. Trial balance XLSX.
let tb = ledger_core::reports::trial_balance(ledger);
let tb_xlsx = report_to_xlsx(&XlsxReport::TrialBalance(&tb)).map_err(|e| e.to_string())?;
// 2. General ledger XLSX (every posted line).
let gl_xlsx =
report_to_xlsx(&XlsxReport::GeneralLedger(ledger)).map_err(|e| e.to_string())?;
// 3. P&L PDF (fiscal year to date).
let pnl_pdf =
ledger_import::profit_and_loss_pdf(ledger, &fy_start.to_string(), &as_of.to_string())
.map_err(|e| e.to_string())?;
// 4. Balance sheet PDF.
let bs_pdf = ledger_import::balance_sheet_pdf(ledger, &as_of.to_string())
.map_err(|e| e.to_string())?;
let tb_xlsx = xlsx(XlsxReport::TrialBalance(&reports::trial_balance(ledger)))?;
let gl_xlsx = xlsx(XlsxReport::GeneralLedger(ledger))?;
let pnl_pdf = pdf(report::profit_and_loss(&pnl_statement(
ledger,
Some(&fy_start),
Some(&as_of_s),
None,
)?))?;
let bs_pdf = pdf(report::balance_sheet(&bs_statement(
ledger,
Some(&as_of_s),
)?))?;
let cf_pdf = pdf(report::cash_flow(&cash_flow_statement(
ledger,
Some(&fy_start),
Some(&as_of_s),
&p.cash_accounts,
&p.financing_accounts,
)?))?;
drop(store);
// Zip it.
@@ -2427,6 +2559,7 @@ impl BooksServer {
("general-ledger.xlsx", gl_xlsx.as_slice()),
("profit-and-loss.pdf", pnl_pdf.as_slice()),
("balance-sheet.pdf", bs_pdf.as_slice()),
("cash-flow.pdf", cf_pdf.as_slice()),
] {
zip.start_file(name, opts).map_err(|e| e.to_string())?;
use std::io::Write;
@@ -2543,86 +2676,75 @@ impl BooksServer {
end: Option<&str>,
) -> Result<String, String> {
let store = self.lock()?;
let ledger = store.ledger();
let start_d = match start {
Some(s) => parse_date(s)?,
None => NaiveDate::from_ymd_opt(1, 1, 1).unwrap(),
};
let end_d = match end {
Some(s) => parse_date(s)?,
None => chrono::Utc::now().date_naive(),
};
let mut revenue = Decimal::ZERO;
let mut expenses = Decimal::ZERO;
for a in ledger.chart().all() {
if a.placeholder {
continue;
}
match a.account_type {
AccountType::Revenue => revenue += movement_in(ledger, a.id, start_d, end_d),
AccountType::Expense => expenses += movement_in(ledger, a.id, start_d, end_d),
_ => {}
}
}
let pl = pnl_statement(store.ledger(), start, end, None)?;
if format == "json" {
return serde_json::to_string_pretty(&serde_json::json!({
"start": start_d.to_string(), "end": end_d.to_string(),
"revenue": money(revenue), "expenses": money(expenses),
"net_income": money(revenue - expenses),
"start": pl.start.to_string(), "end": pl.end.to_string(),
"revenue_accounts": lines_out(&pl.revenue),
"expense_accounts": lines_out(&pl.expenses),
"revenue": money(pl.total_revenue), "expenses": money(pl.total_expenses),
"net_income": money(pl.net_income()),
}))
.map_err(|e| e.to_string());
}
Ok(format!(
"metric,amount\nrevenue,{}\nexpenses,{}\nnet_income,{}\n",
money(revenue),
money(expenses),
money(revenue - expenses)
))
let mut csv = String::from("section,code,name,amount\n");
for (section, lines) in [("revenue", &pl.revenue), ("expenses", &pl.expenses)] {
for l in lines {
csv.push_str(&format!(
"{section},{},{},{}\n",
csv_escape(&l.code),
csv_escape(&l.name),
money(l.amount)
));
}
}
csv.push_str(&format!(
"total,,revenue,{}\ntotal,,expenses,{}\ntotal,,net_income,{}\n",
money(pl.total_revenue),
money(pl.total_expenses),
money(pl.net_income())
));
Ok(csv)
}
fn export_balance_sheet(&self, format: &str, as_of: Option<&str>) -> Result<String, String> {
let store = self.lock()?;
let ledger = store.ledger();
let as_of_d = match as_of {
Some(s) => parse_date(s)?,
None => chrono::Utc::now().date_naive(),
};
let mut assets = Decimal::ZERO;
let mut liabilities = Decimal::ZERO;
let mut equity = Decimal::ZERO;
let mut revenue = Decimal::ZERO;
let mut expenses = Decimal::ZERO;
for a in ledger.chart().all() {
if a.placeholder {
continue;
}
let m = movement_through(ledger, a.id, as_of_d);
match a.account_type {
AccountType::Asset => assets += m,
AccountType::Liability => liabilities += m,
AccountType::Equity => equity += m,
AccountType::Revenue => revenue += m,
AccountType::Expense => expenses += m,
}
}
let current_earnings = revenue - expenses;
equity += current_earnings;
let bs = bs_statement(store.ledger(), as_of)?;
if format == "json" {
return serde_json::to_string_pretty(&serde_json::json!({
"as_of": as_of_d.to_string(),
"assets": money(assets), "liabilities": money(liabilities),
"equity": money(equity), "current_earnings": money(current_earnings),
"balanced": assets == liabilities + equity,
"as_of": bs.as_of.to_string(),
"asset_accounts": lines_out(&bs.assets),
"liability_accounts": lines_out(&bs.liabilities),
"equity_accounts": lines_out(&bs.equity),
"assets": money(bs.total_assets), "liabilities": money(bs.total_liabilities),
"equity": money(bs.total_equity), "current_earnings": money(bs.current_earnings),
"balanced": bs.balanced(),
}))
.map_err(|e| e.to_string());
}
Ok(format!(
"metric,amount\nassets,{}\nliabilities,{}\nequity,{}\ncurrent_earnings,{}\n",
money(assets),
money(liabilities),
money(equity),
money(current_earnings)
))
let mut csv = String::from("section,code,name,amount\n");
for (section, lines) in [
("assets", &bs.assets),
("liabilities", &bs.liabilities),
("equity", &bs.equity),
] {
for l in lines {
csv.push_str(&format!(
"{section},{},{},{}\n",
csv_escape(&l.code),
csv_escape(&l.name),
money(l.amount)
));
}
}
csv.push_str(&format!(
"equity,,current_earnings,{}\ntotal,,assets,{}\ntotal,,liabilities,{}\ntotal,,equity,{}\n",
money(bs.current_earnings),
money(bs.total_assets),
money(bs.total_liabilities),
money(bs.total_equity)
));
Ok(csv)
}
fn export_cash_flow(&self, format: &str, p: &ExportParams) -> Result<String, String> {
@@ -2883,3 +3005,21 @@ impl ServerHandler for BooksServer {
)))
}
}
#[cfg(test)]
mod tests {
use super::money;
use rust_decimal::Decimal;
use std::str::FromStr;
#[test]
fn money_always_has_cents_and_never_rounds() {
let m = |s: &str| money(Decimal::from_str(s).unwrap());
assert_eq!(money(Decimal::ZERO), "0.00");
assert_eq!(m("-0"), "0.00");
assert_eq!(m("1000"), "1000.00");
assert_eq!(m("5.8"), "5.80");
assert_eq!(m("-207.68"), "-207.68");
assert_eq!(m("12.3456"), "12.3456");
}
}
+82 -4
View File
@@ -114,6 +114,21 @@ pub struct ProfitAndLossOut {
pub expenses: String,
/// Revenue minus expenses, decimal string. Positive is profit.
pub net_income: String,
/// Revenue accounts with movement in the period.
pub revenue_accounts: Vec<StatementLineOut>,
/// Expense accounts with movement in the period.
pub expense_accounts: Vec<StatementLineOut>,
}
/// One account's line on a statement.
#[derive(Debug, Serialize, JsonSchema)]
pub struct StatementLineOut {
/// Account code.
pub code: String,
/// Account name.
pub name: String,
/// Amount on the account's normal side, decimal string.
pub amount: String,
}
/// Parameters for `books.cash_flow`.
@@ -285,6 +300,12 @@ pub struct BalanceSheetOut {
pub current_earnings: String,
/// True when assets equal liabilities plus equity exactly.
pub balanced: bool,
/// Asset accounts with a balance.
pub asset_accounts: Vec<StatementLineOut>,
/// Liability accounts with a balance.
pub liability_accounts: Vec<StatementLineOut>,
/// Equity accounts with a balance (current earnings is separate).
pub equity_accounts: Vec<StatementLineOut>,
}
// ---------------------------------------------------------------------------
@@ -503,11 +524,51 @@ pub struct LinkReceiptOut {
pub ok: bool,
}
/// Parameters for `books.unlink_receipt`.
#[derive(Debug, Deserialize, JsonSchema)]
pub struct UnlinkReceiptParams {
/// Attachment id of the linked receipt.
pub attachment_id: u64,
/// Why it is being unlinked, recorded in the journal (e.g. "replaced by
/// the PDF render, attachment 9").
pub reason: String,
}
/// Result of `books.unlink_receipt`.
#[derive(Debug, Serialize, JsonSchema)]
pub struct UnlinkReceiptOut {
/// True when unlinked.
pub ok: bool,
/// The entry it was unlinked from.
pub entry_id: u64,
/// Receipts still linked to that entry.
pub remaining_receipts: u64,
}
/// Parameters for `books.withdraw_attachment`.
#[derive(Debug, Deserialize, JsonSchema)]
pub struct WithdrawAttachmentParams {
/// Attachment id of an unlinked receipt or a filed document.
pub attachment_id: u64,
/// Why it is being withdrawn, recorded in the journal.
pub reason: String,
}
/// Result of `books.withdraw_attachment`.
#[derive(Debug, Serialize, JsonSchema)]
pub struct WithdrawAttachmentOut {
/// True when withdrawn.
pub ok: bool,
}
/// Parameters for `books.list_receipts`.
#[derive(Debug, Deserialize, JsonSchema)]
pub struct ListReceiptsParams {
/// Filter to one entry. Omit for all.
pub entry_id: Option<u64>,
/// Include withdrawn attachments. Default false.
#[serde(default)]
pub include_withdrawn: Option<bool>,
}
/// One receipt or document.
@@ -529,6 +590,10 @@ pub struct ReceiptOut {
pub size_bytes: u64,
/// Description, if any.
pub description: Option<String>,
/// True when withdrawn (only listed with include_withdrawn).
pub withdrawn: bool,
/// Why it was withdrawn, if it was.
pub withdrawn_reason: Option<String>,
}
/// Result of `books.list_receipts`.
@@ -713,8 +778,9 @@ pub struct DigestOut {
/// Parameters for `books.export`.
#[derive(Debug, Deserialize, JsonSchema)]
pub struct ExportParams {
/// "csv", "json", "xlsx", or "iif". XLSX is base64-encoded; IIF is only
/// valid with report "journal".
/// "csv", "json", "xlsx", "pdf", or "iif". XLSX and PDF are
/// base64-encoded; PDF covers profit_and_loss, balance_sheet, and
/// cash_flow; IIF is only valid with report "journal".
pub format: String,
/// "journal", "trial_balance", "profit_and_loss", "balance_sheet", or
/// "cash_flow".
@@ -728,6 +794,10 @@ pub struct ExportParams {
/// Cash account codes for cash_flow (required for that report).
#[serde(default)]
pub cash_accounts: Vec<String>,
/// Liability account codes treated as financing (debt) for cash_flow.
/// Defaults to empty: liabilities are operating working capital.
#[serde(default)]
pub financing_accounts: Vec<String>,
}
/// Result of `books.export`.
@@ -738,9 +808,9 @@ pub struct ExportOut {
/// The requested report.
pub report: String,
/// The exported content. Base64-encoded when `content_encoding` is
/// "base64" (XLSX); otherwise raw text.
/// "base64" (XLSX, PDF); otherwise raw text.
pub content: String,
/// "base64" for XLSX, otherwise absent.
/// "base64" for XLSX and PDF, otherwise absent.
#[serde(skip_serializing_if = "Option::is_none")]
pub content_encoding: Option<String>,
}
@@ -1403,6 +1473,14 @@ pub struct ProfitAndLossByClassOut {
pub struct ExportCpaPackageParams {
/// As-of date, YYYY-MM-DD. Defaults to today.
pub as_of: Option<String>,
/// Cash account codes the cash flow statement reconciles to (required),
/// e.g. `["1000"]`.
#[serde(default)]
pub cash_accounts: Vec<String>,
/// Liability account codes treated as financing (debt). Defaults to
/// empty: liabilities are operating working capital.
#[serde(default)]
pub financing_accounts: Vec<String>,
}
/// Output of `books.export_cpa_package`.
+343 -5
View File
@@ -15,10 +15,15 @@ struct Mcp {
impl Mcp {
fn spawn(data_dir: &std::path::Path) -> Self {
Self::spawn_with(data_dir, &[])
}
fn spawn_with(data_dir: &std::path::Path, extra: &[&std::ffi::OsStr]) -> Self {
let bin = env!("CARGO_BIN_EXE_ledger-mcp");
let mut child = Command::new(bin)
.arg("--data-dir")
.arg(data_dir)
.args(extra)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
@@ -134,7 +139,7 @@ fn mcp_end_to_end_over_stdio() {
// Tool listing: 23 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(), 48, "expected 48 tools, got {}", tools.len());
assert_eq!(tools.len(), 50, "expected 50 tools, got {}", tools.len());
let chart = tools.iter().find(|t| t["name"] == "books.chart").unwrap();
assert_eq!(chart["annotations"]["readOnlyHint"], true);
assert!(
@@ -196,7 +201,7 @@ fn mcp_end_to_end_over_stdio() {
.iter()
.find(|l| l["code"] == "6000")
.unwrap();
assert_eq!(rent_line["balance_before"], "0");
assert_eq!(rent_line["balance_before"], "0.00");
assert_eq!(rent_line["balance_after"], "100.00");
// Confirmed post without a receipt is rejected: >$25, no receipt.
@@ -270,7 +275,7 @@ fn mcp_end_to_end_over_stdio() {
assert_eq!(rev["status"], "posted");
let tb2 = mcp.call("books.trial_balance", json!({}));
assert_eq!(tb2["balanced"], true);
assert_eq!(tb2["total_debits"], "0");
assert_eq!(tb2["total_debits"], "0.00");
}
#[test]
@@ -376,7 +381,7 @@ fn mcp_assets_end_to_end_over_stdio() {
assert_eq!(preview["status"], "preview");
assert_eq!(preview["book_value"], "58451.61");
assert_eq!(preview["gain_loss"], "1548.39");
assert_eq!(preview["final_depreciation"], "0");
assert_eq!(preview["final_depreciation"], "0.00");
assert_eq!(preview["receipt_required"], true);
// Confirmed without the bill of sale: rejected.
@@ -534,6 +539,141 @@ fn mcp_receipt_policy_rejects_split_entries() {
assert_eq!(p3["status"], "posted");
}
#[test]
fn mcp_receipt_corrections_over_stdio() {
let dir = test_dir("receipt-corrections");
let mut mcp = Mcp::spawn(&dir);
mcp.request(
"initialize",
json!({
"protocolVersion": "2025-06-18",
"capabilities": {},
"clientInfo": {"name": "smoke-test", "version": "0.1.0"},
}),
);
mcp.notify("notifications/initialized");
for (code, name, typ) in [("1000", "Cash", "asset"), ("6000", "Software", "expense")] {
mcp.call(
"books.add_account",
json!({"code": code, "name": name, "account_type": typ}),
);
}
let post = |mcp: &mut Mcp, desc: &str, amount: &str, receipt: &[u8]| -> (u64, u64) {
let e = mcp.call(
"books.draft_entry",
json!({"date": "2026-04-01", "description": desc}),
);
let id = e["entry_id"].as_u64().unwrap();
mcp.call(
"books.add_line",
json!({"entry_id": id, "account": "6000", "side": "debit", "amount": amount}),
);
mcp.call(
"books.add_line",
json!({"entry_id": id, "account": "1000", "side": "credit", "amount": amount}),
);
let att = mcp.call(
"books.attach_receipt",
json!({"entry_id": id, "file_name": "receipt.eml", "mime_type": "message/rfc822",
"content_base64": base64_like(receipt)}),
);
let posted = mcp.call(
"books.post_entry",
json!({"entry_id": id, "confirmed": true}),
);
assert_eq!(posted["status"], "posted");
(id, att["attachment_id"].as_u64().unwrap())
};
// A $100 entry whose only receipt is an .eml.
let (big, eml) = post(&mut mcp, "Annual license", "100.00", b"eml-bytes");
// Unlinking its only receipt would break the policy: refused, with the remedy.
let err = mcp.call_err(
"books.unlink_receipt",
json!({"attachment_id": eml, "reason": "replacing with PDF"}),
);
let err_json: serde_json::Value = serde_json::from_str(&err).unwrap();
assert_eq!(err_json["code"], "receipt_required");
assert_eq!(err_json["entry_id"], big);
assert_eq!(err_json["remedy_tool"], "books.attach_receipt");
// Replace: attach the PDF first, then unlink the .eml.
let pdf = mcp.call(
"books.attach_receipt",
json!({"entry_id": big, "file_name": "receipt.pdf", "mime_type": "application/pdf",
"content_base64": base64_like(b"pdf-bytes")}),
)["attachment_id"]
.as_u64()
.unwrap();
let un = mcp.call(
"books.unlink_receipt",
json!({"attachment_id": eml, "reason": "replaced by the PDF render"}),
);
assert_eq!(un["entry_id"], big);
assert_eq!(un["remaining_receipts"], 1);
// A linked receipt can't be withdrawn; an unlinked one can, with a reason.
let err = mcp.call_err(
"books.withdraw_attachment",
json!({"attachment_id": pdf, "reason": "x"}),
);
assert!(err.contains("unlink it first"), "{err}");
let err = mcp.call_err(
"books.withdraw_attachment",
json!({"attachment_id": eml, "reason": " "}),
);
assert!(err.contains("reason"), "{err}");
mcp.call(
"books.withdraw_attachment",
json!({"attachment_id": eml, "reason": "file PDFs, not .eml"}),
);
// Listings hide the withdrawn .eml unless asked; the entry keeps the PDF.
let listed = mcp.call("books.list_receipts", json!({}));
let ids: Vec<u64> = listed["receipts"]
.as_array()
.unwrap()
.iter()
.map(|r| r["attachment_id"].as_u64().unwrap())
.collect();
assert_eq!(ids, vec![pdf]);
let all = mcp.call("books.list_receipts", json!({"include_withdrawn": true}));
let gone = all["receipts"]
.as_array()
.unwrap()
.iter()
.find(|r| r["attachment_id"] == eml)
.unwrap()
.clone();
assert_eq!(gone["withdrawn"], true);
assert_eq!(gone["withdrawn_reason"], "file PDFs, not .eml");
assert_eq!(gone["entry_id"], serde_json::Value::Null);
let entry = mcp.call("books.get_entry", json!({"entry_id": big}));
assert_eq!(entry["receipts"].as_array().unwrap().len(), 1);
assert_eq!(entry["receipts"][0]["attachment_id"], pdf);
// A withdrawn attachment can never be linked again.
let err = mcp.call_err(
"books.link_receipt",
json!({"attachment_id": eml, "entry_id": big}),
);
assert!(err.contains("withdrawn"), "{err}");
// Under the threshold, the last receipt may go.
let (small, small_eml) = post(&mut mcp, "Domain renewal", "10.00", b"small-eml");
let un = mcp.call(
"books.unlink_receipt",
json!({"attachment_id": small_eml, "reason": "wrong file"}),
);
assert_eq!(un["entry_id"], small);
assert_eq!(un["remaining_receipts"], 0);
// The books never moved.
let tb = mcp.call("books.trial_balance", json!({}));
assert_eq!(tb["balanced"], true);
assert_eq!(tb["total_debits"], "110.00");
}
#[test]
fn mcp_resources_over_stdio() {
let dir = test_dir("resources");
@@ -548,7 +688,7 @@ fn mcp_resources_over_stdio() {
);
assert_eq!(init["result"]["serverInfo"]["name"], "wanderbooks");
// Resources are advertised.
assert_eq!(init["result"]["capabilities"]["resources"].is_null(), false);
assert!(!init["result"]["capabilities"]["resources"].is_null());
mcp.notify("notifications/initialized");
mcp.call(
@@ -641,3 +781,201 @@ fn mcp_import_export_over_stdio() {
assert_eq!(xlsx["content_encoding"], "base64");
assert!(xlsx["content"].as_str().unwrap().starts_with("UEs"));
}
#[test]
fn mcp_statement_pdfs_and_cpa_package_over_stdio() {
use base64::Engine;
let b64 = base64::engine::general_purpose::STANDARD;
let dir = test_dir("statement-pdfs");
let mut mcp = Mcp::spawn(&dir);
mcp.request(
"initialize",
json!({
"protocolVersion": "2025-06-18",
"capabilities": {},
"clientInfo": {"name": "smoke-test", "version": "0.1.0"},
}),
);
mcp.notify("notifications/initialized");
mcp.call(
"books.import_chart_csv",
json!({"csv_text": "code,name,type,parent_code\n1000,Cash,asset,\n3000,Owner Capital,equity,\n6000,Software,expense,\n"}),
);
for (desc, debit, credit, amount) in [
("Owner contribution", "1000", "3000", "1000.00"),
("Domain", "6000", "1000", "12.00"),
] {
let e = mcp.call(
"books.draft_entry",
json!({"date": "2026-10-05", "description": desc}),
);
let id = e["entry_id"].as_u64().unwrap();
mcp.call(
"books.add_line",
json!({"entry_id": id, "account": debit, "side": "debit", "amount": amount}),
);
mcp.call(
"books.add_line",
json!({"entry_id": id, "account": credit, "side": "credit", "amount": amount}),
);
mcp.call(
"books.attach_receipt",
json!({"entry_id": id, "file_name": "r.pdf", "mime_type": "application/pdf",
"content_base64": base64_like(desc.as_bytes())}),
);
mcp.call(
"books.post_entry",
json!({"entry_id": id, "confirmed": true}),
);
}
// All three statements export as PDF.
for (report, extra) in [
("profit_and_loss", json!({})),
("balance_sheet", json!({})),
("cash_flow", json!({"cash_accounts": ["1000"]})),
] {
let mut args = json!({"format": "pdf", "report": report});
args.as_object_mut()
.unwrap()
.extend(extra.as_object().unwrap().clone());
let out = mcp.call("books.export", args);
assert_eq!(out["content_encoding"], "base64");
let bytes = b64.decode(out["content"].as_str().unwrap()).unwrap();
assert!(bytes.starts_with(b"%PDF"), "{report} is not a PDF");
}
// The cash flow statement reconciles to named cash accounts: required.
let err = mcp.call_err(
"books.export",
json!({"format": "pdf", "report": "cash_flow"}),
);
assert!(err.contains("cash_accounts"), "{err}");
let err = mcp.call_err(
"books.export",
json!({"format": "pdf", "report": "trial_balance"}),
);
assert!(err.contains("for pdf"), "{err}");
// The CPA package carries all three statements.
let err = mcp.call_err("books.export_cpa_package", json!({"as_of": "2026-10-31"}));
assert!(err.contains("cash_accounts"), "{err}");
let pkg = mcp.call(
"books.export_cpa_package",
json!({"as_of": "2026-10-31", "cash_accounts": ["1000"]}),
);
let zip_bytes = b64.decode(pkg["content_base64"].as_str().unwrap()).unwrap();
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(zip_bytes)).unwrap();
let mut names: Vec<String> = (0..zip.len())
.map(|i| zip.by_index(i).unwrap().name().unwrap().to_string())
.collect();
names.sort();
assert_eq!(
names,
[
"balance-sheet.pdf",
"cash-flow.pdf",
"general-ledger.xlsx",
"profit-and-loss.pdf",
"trial-balance.xlsx"
]
);
let mut cf = Vec::new();
std::io::Read::read_to_end(&mut zip.by_name("cash-flow.pdf").unwrap(), &mut cf).unwrap();
assert!(cf.starts_with(b"%PDF"));
}
#[test]
fn mcp_period_statements_over_stdio() {
let dir = test_dir("period-statements");
// A rejected receipt policy would show up as a failed post below: the
// toml lowers the threshold from 25.00 to 5.00.
let toml = dir.join("wanderbooks.toml");
std::fs::write(
&toml,
"[company]\nname = \"Test Co\"\nfiscal_year_start = \"02-01\"\n[books]\nreceipt_threshold = \"5.00\"\n",
)
.unwrap();
let data = dir.join("books-data");
let mut mcp = Mcp::spawn_with(
&data,
&[std::ffi::OsStr::new("--books-toml"), toml.as_os_str()],
);
mcp.request(
"initialize",
json!({
"protocolVersion": "2025-06-18",
"capabilities": {},
"clientInfo": {"name": "smoke-test", "version": "0.1.0"},
}),
);
mcp.notify("notifications/initialized");
mcp.call(
"books.import_chart_csv",
json!({"csv_text": "code,name,type,parent_code\n1000,Cash,asset,\n3000,Capital,equity,\n4000,Sales,revenue,\n6000,Software,expense,\n"}),
);
let post = |mcp: &mut Mcp, date: &str, dr: &str, cr: &str, amount: &str| {
let e = mcp.call(
"books.draft_entry",
json!({"date": date, "description": "x"}),
);
let id = e["entry_id"].as_u64().unwrap();
mcp.call(
"books.add_line",
json!({"entry_id": id, "account": dr, "side": "debit", "amount": amount}),
);
mcp.call(
"books.add_line",
json!({"entry_id": id, "account": cr, "side": "credit", "amount": amount}),
);
id
};
let id = post(&mut mcp, "2026-01-02", "1000", "3000", "1000.00");
// 1000.00 > 5.00 (the toml threshold): a receipt is required.
let err = mcp.call_err(
"books.post_entry",
json!({"entry_id": id, "confirmed": true}),
);
assert!(err.contains("5.00"), "{err}");
mcp.call(
"books.attach_receipt",
json!({"entry_id": id, "file_name": "r.pdf", "mime_type": "application/pdf",
"content_base64": base64_like(b"r")}),
);
mcp.call(
"books.post_entry",
json!({"entry_id": id, "confirmed": true}),
);
for (date, dr, cr, amt) in [
("2026-01-20", "1000", "4000", "4.00"),
("2026-02-05", "6000", "1000", "3.00"),
("2026-02-09", "1000", "4000", "2.50"),
] {
let id = post(&mut mcp, date, dr, cr, amt);
mcp.call(
"books.post_entry",
json!({"entry_id": id, "confirmed": true}),
);
}
// February only: 2.50 sales, 3.00 software.
let feb = mcp.call(
"books.profit_and_loss",
json!({"start": "2026-02-01", "end": "2026-02-28"}),
);
assert_eq!(feb["revenue"], "2.50");
assert_eq!(feb["expenses"], "3.00");
assert_eq!(feb["net_income"], "-0.50");
assert_eq!(feb["expense_accounts"][0]["code"], "6000");
assert_eq!(feb["expense_accounts"][0]["amount"], "3.00");
// No start: the statement begins at the first entry, not year 1.
let all = mcp.call("books.profit_and_loss", json!({"end": "2026-02-28"}));
assert_eq!(all["start"], "2026-01-02");
assert_eq!(all["revenue"], "6.50");
// As of Jan 31: cash 1,004.00 = capital 1,000.00 + earnings 4.00.
let jan = mcp.call("books.balance_sheet", json!({"as_of": "2026-01-31"}));
assert_eq!(jan["assets"], "1004.00");
assert_eq!(jan["current_earnings"], "4.00");
assert_eq!(jan["equity_accounts"][0]["amount"], "1000.00");
assert_eq!(jan["balanced"], true);
}
+16
View File
@@ -41,6 +41,22 @@ pub enum StoreError {
#[error("unknown attachment {0}")]
UnknownAttachment(u64),
/// The attachment is not linked to any entry.
#[error("attachment {0} is not linked to an entry")]
AttachmentNotLinked(u64),
/// The attachment is linked to an entry; unlink it first.
#[error("attachment {0} is linked to entry {1}; unlink it first with books.unlink_receipt")]
AttachmentLinked(u64, u64),
/// The attachment was withdrawn and can no longer be linked or changed.
#[error("attachment {0} is withdrawn")]
AttachmentWithdrawn(u64),
/// A correction to the evidence trail needs a reason.
#[error("a non-empty reason is required")]
ReasonRequired,
/// The store's schema is newer than this binary understands.
#[error("schema version {0} newer than supported {1}")]
SchemaTooNew(u32, u32),
+2 -1
View File
@@ -29,7 +29,8 @@ pub struct JournalLine {
pub ts: String,
/// Event type: `account.added`, `entry.created`, `entry.line_added`,
/// `entry.posted`, `entry.reversed`, `entry.line_class_set`,
/// `receipt.attached`, `receipt.linked`, `document.filed`,
/// `receipt.attached`, `receipt.linked`, `receipt.unlinked`,
/// `document.filed`, `attachment.withdrawn`,
/// `asset.registered`, `asset.depreciation_posted`, `asset.disposed`,
/// `period.created`, `period.closed`, `line.cleared`, `customer.added`,
/// `vendor.added`, `invoice.created`, `bill.created`,
+8 -1
View File
@@ -5,7 +5,7 @@
//! is the current version.
/// Current schema version.
pub const SCHEMA_VERSION: u32 = 4;
pub const SCHEMA_VERSION: u32 = 5;
/// Migrations, in order. `MIGRATIONS[i]` migrates from version `i` to `i+1`.
pub const MIGRATIONS: &[&str] = &[
@@ -172,5 +172,12 @@ CREATE TABLE IF NOT EXISTS payroll_lines(
);
CREATE INDEX IF NOT EXISTS idx_payroll_lines_run ON payroll_lines(run_id);
ALTER TABLE lines ADD COLUMN class_id INTEGER;
"#,
r#"
-- v5: withdrawn attachments. Withdrawal retires a receipt or document from
-- listings and linking; the bytes stay in the blob store and the journal
-- keeps the filing and the withdrawal.
ALTER TABLE attachments ADD COLUMN withdrawn_at TEXT;
ALTER TABLE attachments ADD COLUMN withdrawn_reason TEXT;
"#,
];
+123 -11
View File
@@ -115,6 +115,10 @@ pub struct Attachment {
pub description: Option<String>,
/// UTC timestamp when filed.
pub created_at: String,
/// UTC timestamp when withdrawn, if it was.
pub withdrawn_at: Option<String>,
/// Why it was withdrawn, if it was.
pub withdrawn_reason: Option<String>,
}
/// Input to [`Store::run_payroll`]: one employee's gross pay and every
@@ -1757,7 +1761,10 @@ impl Store {
Ok((id, sha, dedup))
}
/// Link a previously filed receipt to an entry.
/// Link a previously filed (unlinked) receipt to an entry. A receipt
/// already linked elsewhere must be unlinked first, so moving evidence
/// off an entry always goes through [`Store::unlink_receipt`] and its
/// recorded reason.
pub fn link_receipt(
&mut self,
attachment_id: u64,
@@ -1766,14 +1773,7 @@ impl Store {
self.ledger
.get_entry(entry_id)
.ok_or(LedgerError::UnknownEntry(entry_id))?;
let exists: i64 = self.conn.query_row(
"SELECT COUNT(*) FROM attachments WHERE id = ?",
params![attachment_id as i64],
|r| r.get(0),
)?;
if exists == 0 {
return Err(StoreError::UnknownAttachment(attachment_id));
}
self.check_linkable(attachment_id)?;
let line = self.append_journal(
"receipt.linked",
json!({ "attachment_id": attachment_id, "entry_id": entry_id }),
@@ -1782,6 +1782,89 @@ impl Store {
Ok(())
}
/// Fail unless the attachment exists, is unlinked, and is not
/// withdrawn: the precondition of [`Store::link_receipt`], checkable
/// before posting anything that will link it.
pub fn check_linkable(&self, attachment_id: u64) -> Result<(), StoreError> {
let (entry_id, withdrawn) = self.attachment_state(attachment_id)?;
if withdrawn {
return Err(StoreError::AttachmentWithdrawn(attachment_id));
}
if let Some(eid) = entry_id {
return Err(StoreError::AttachmentLinked(attachment_id, eid));
}
Ok(())
}
/// Detach a receipt from its entry, recording why. The attachment stays
/// filed (unlinked) and can be linked again or withdrawn. The journal
/// keeps both the link and the unlink: the evidence history is
/// append-only even when the current evidence set changes. Returns the
/// entry it was linked to.
///
/// Policy-free like the rest of the store: whether an entry may be left
/// without a receipt is the MCP layer's call.
pub fn unlink_receipt(
&mut self,
attachment_id: u64,
reason: &str,
) -> Result<EntryId, StoreError> {
let reason = reason.trim();
if reason.is_empty() {
return Err(StoreError::ReasonRequired);
}
let (entry_id, withdrawn) = self.attachment_state(attachment_id)?;
if withdrawn {
return Err(StoreError::AttachmentWithdrawn(attachment_id));
}
let entry_id = entry_id.ok_or(StoreError::AttachmentNotLinked(attachment_id))?;
let line = self.append_journal(
"receipt.unlinked",
json!({ "attachment_id": attachment_id, "entry_id": entry_id, "reason": reason }),
)?;
apply_line_to_sqlite(&self.conn, &line)?;
Ok(entry_id)
}
/// Withdraw a receipt or document, recording why: it drops out of
/// listings and can never be linked again. Nothing is deleted: the bytes
/// stay in the blob store and the journal keeps the filing and the
/// withdrawal. A linked receipt must be unlinked first.
pub fn withdraw_attachment(
&mut self,
attachment_id: u64,
reason: &str,
) -> Result<(), StoreError> {
let reason = reason.trim();
if reason.is_empty() {
return Err(StoreError::ReasonRequired);
}
self.check_linkable(attachment_id)?;
let line = self.append_journal(
"attachment.withdrawn",
json!({ "attachment_id": attachment_id, "reason": reason }),
)?;
apply_line_to_sqlite(&self.conn, &line)?;
Ok(())
}
/// `(linked entry, withdrawn?)` for an attachment.
fn attachment_state(&self, attachment_id: u64) -> Result<(Option<EntryId>, bool), StoreError> {
self.conn
.query_row(
"SELECT entry_id, withdrawn_at IS NOT NULL FROM attachments WHERE id = ?",
params![attachment_id as i64],
|r| {
Ok((
r.get::<_, Option<i64>>(0)?.map(|v| v as EntryId),
r.get::<_, bool>(1)?,
))
},
)
.optional()?
.ok_or(StoreError::UnknownAttachment(attachment_id))
}
/// List attachments, optionally filtered to one entry.
pub fn list_attachments(
&self,
@@ -1789,7 +1872,8 @@ impl Store {
) -> Result<Vec<Attachment>, StoreError> {
let mut sql = String::from(
"SELECT a.id, a.blob_sha, a.entry_id, a.category, b.original_name, b.mime_type, \
b.size_bytes, a.description, a.created_at FROM attachments a \
b.size_bytes, a.description, a.created_at, a.withdrawn_at, a.withdrawn_reason \
FROM attachments a \
JOIN blobs b ON b.sha256 = a.blob_sha",
);
if entry_id.is_some() {
@@ -1897,6 +1981,12 @@ impl Store {
// Maintenance: verify and rebuild
// ------------------------------------------------------------------
/// Sequence number of the last journal line: the exact journal state a
/// report reflects. Zero for an empty journal.
pub fn journal_seq(&self) -> u64 {
self.journal.next_seq() - 1
}
/// Verify everything: the journal hash chain, a fresh replay's reports
/// against the live ledger, and the SQLite projection against the replay.
/// Loud on any divergence.
@@ -2594,7 +2684,11 @@ fn replay_line_into_ledger(
)));
}
}
"receipt.attached" | "receipt.linked" | "document.filed" => {
"receipt.attached"
| "receipt.linked"
| "receipt.unlinked"
| "document.filed"
| "attachment.withdrawn" => {
// Not accounting state; the projection records them.
}
"asset.registered" => {
@@ -2923,6 +3017,22 @@ fn apply_line_to_sqlite(conn: &Connection, line: &JournalLine) -> Result<(), Sto
],
)?;
}
"receipt.unlinked" => {
conn.execute(
"UPDATE attachments SET entry_id = NULL WHERE id = ?",
params![get_u64(p, "attachment_id")? as i64],
)?;
}
"attachment.withdrawn" => {
conn.execute(
"UPDATE attachments SET withdrawn_at = ?, withdrawn_reason = ? WHERE id = ?",
params![
line.ts,
get_str(p, "reason")?,
get_u64(p, "attachment_id")? as i64,
],
)?;
}
"asset.registered" => {
let cost = &p["cost"];
let salvage = &p["salvage_value"];
@@ -3184,5 +3294,7 @@ fn row_to_attachment(r: &rusqlite::Row) -> rusqlite::Result<Attachment> {
size_bytes: r.get::<_, i64>(6)? as u64,
description: r.get(7)?,
created_at: r.get(8)?,
withdrawn_at: r.get(9)?,
withdrawn_reason: r.get(10)?,
})
}
+111 -1
View File
@@ -5,7 +5,7 @@
use chrono::NaiveDate;
use ledger_core::{AccountType, Currency, Money, Side};
use ledger_store::{JournalWriter, Store};
use ledger_store::{JournalWriter, Store, StoreError};
use rust_decimal::Decimal;
use std::path::PathBuf;
@@ -347,3 +347,113 @@ fn corrupt_projection_is_quarantined_and_rebuilt() {
assert_eq!(tb.total_debits, Decimal::from(14000));
store.verify().unwrap();
}
#[test]
fn receipt_corrections_are_journaled_and_survive_rebuild() {
let dir = test_dir("receipt-corrections");
let mut store = Store::create(&dir, Currency::USD).unwrap();
let cash = store
.add_account("1000", "Cash", AccountType::Asset, None, false)
.unwrap();
let rent = store
.add_account("6000", "Rent Expense", AccountType::Expense, None, false)
.unwrap();
let e = store
.create_entry(
NaiveDate::from_ymd_opt(2026, 2, 1).unwrap(),
"February rent",
)
.unwrap();
store.add_line(e, rent, Side::Debit, usd(800)).unwrap();
store.add_line(e, cash, Side::Credit, usd(800)).unwrap();
store.post_entry(e).unwrap();
let (eml, _, _) = store
.attach_receipt(Some(e), "rent.eml", "message/rfc822", b"eml-bytes", None)
.unwrap();
let (pdf, _, _) = store
.attach_receipt(Some(e), "rent.pdf", "application/pdf", b"pdf-bytes", None)
.unwrap();
// Every correction needs a reason.
assert!(matches!(
store.unlink_receipt(eml, " "),
Err(StoreError::ReasonRequired)
));
// A linked receipt can't be withdrawn or moved: unlink it first.
assert!(matches!(
store.withdraw_attachment(eml, "dup"),
Err(StoreError::AttachmentLinked(a, b)) if a == eml && b == e
));
assert!(matches!(
store.link_receipt(pdf, e),
Err(StoreError::AttachmentLinked(_, _))
));
// Swap the .eml for the PDF: unlink, then withdraw.
assert_eq!(
store
.unlink_receipt(eml, "replaced by the PDF render")
.unwrap(),
e
);
assert!(matches!(
store.unlink_receipt(eml, "again"),
Err(StoreError::AttachmentNotLinked(_))
));
store
.withdraw_attachment(eml, "file PDFs, not .eml")
.unwrap();
assert!(matches!(
store.link_receipt(eml, e),
Err(StoreError::AttachmentWithdrawn(_))
));
assert!(matches!(
store.withdraw_attachment(eml, "again"),
Err(StoreError::AttachmentWithdrawn(_))
));
assert!(matches!(
store.unlink_receipt(999, "x"),
Err(StoreError::UnknownAttachment(999))
));
let check = |store: &Store| {
let linked: Vec<u64> = store
.list_attachments(Some(e))
.unwrap()
.iter()
.map(|a| a.id)
.collect();
assert_eq!(linked, vec![pdf]);
let all = store.list_attachments(None).unwrap();
let gone = all.iter().find(|a| a.id == eml).unwrap();
assert_eq!(gone.entry_id, None);
assert!(gone.withdrawn_at.is_some());
assert_eq!(
gone.withdrawn_reason.as_deref(),
Some("file PDFs, not .eml")
);
// Withdrawn is not deleted: the bytes are still there, hash-verified.
assert_eq!(store.attachment_bytes(eml).unwrap(), b"eml-bytes");
};
check(&store);
// The journal is the history: the attach, the unlink, and the withdrawal.
let journal = std::fs::read_to_string(dir.join("journal.jsonl")).unwrap();
for t in [
"receipt.attached",
"receipt.unlinked",
"attachment.withdrawn",
] {
assert!(
journal.contains(&format!("\"type\":\"{t}\"")),
"missing {t}"
);
}
drop(store);
let mut store = Store::open(&dir).unwrap();
check(&store);
store.rebuild().unwrap();
check(&store);
store.verify().unwrap();
}
+1 -1
View File
@@ -18,7 +18,7 @@ Statuses: `covered` (built and tested), `partial` (built, known gaps listed),
| 4 | Live Balance Sheet tab | engine + `books.balance_sheet` | covered |
| 5 | Live Cash Flow tab | engine `cash_flow` (indirect) + `books.cash_flow` | covered |
| 6 | Transaction Journal as append-only log | engine posting log + `books.search_entries` | covered |
| 7 | Receipt storage in Drive | blob store + `books.attach_receipt` / `link_receipt` | covered |
| 7 | Receipt storage in Drive | blob store + `books.attach_receipt` / `link_receipt` / `unlink_receipt` / `withdraw_attachment` | covered |
| 8 | Document filing by category (01 Formation / 02 Legal / 03 Tax / 04 Accounting) | `books.file_document` | covered |
| 9 | Ad-hoc questions ("what did we spend on X?") | `search_entries` + reports; the AI itself is the query layer | covered |
| 10 | Human review of what the assistant recorded | `books.digest` | covered |
+46 -13
View File
@@ -21,6 +21,15 @@ If a behavior can't be expressed through `ledger-core`'s public API, the
core is missing something — fix the core, don't work around it in the MCP
layer.
## Company settings
`ledger-mcp` reads the books dir's `wanderbooks.toml` (default: next to
`--data-dir`; override with `--books-toml PATH`): `[company] name` heads
every report, `[company] fiscal_year_start` ("MM-DD", default "01-01";
the year's end is derived, never stored) defines fiscal-year-to-date for the
CPA package, and `[books] receipt_threshold` sets the receipt policy unless
`config.json` sets `receipt_policy`.
## Transports
1. **stdio** (build first). For Claude Code, Claude Desktop, local agents.
@@ -78,8 +87,14 @@ below.
→ entries with lines, newest first. `limit` caps at 100.
- `books.trial_balance {}` → canonical JSON (same shape as
`reports::snapshot` trial-balance section).
- `books.balance_sheet {}` → canonical JSON.
- `books.profit_and_loss {}` → canonical JSON.
- `books.balance_sheet { as_of? }` → totals (`assets`, `liabilities`,
`equity`, `current_earnings`, `balanced`) plus `asset_accounts`,
`liability_accounts`, `equity_accounts` (`{code, name, amount}`), as of
the date (default today).
- `books.profit_and_loss { start?, end?, class? }` → totals plus
`revenue_accounts` and `expense_accounts` for the range. `end` defaults to
today, `start` to the first posted entry. Closing entries are excluded, so
a closed period still shows what it earned.
- `books.cash_flow { start?, end?, cash_accounts: ["1000"], financing_accounts? }`
→ indirect-method cash flow statement. `cash_accounts` are the reconciling
accounts (required); the three sections always sum to their net change.
@@ -117,8 +132,22 @@ so the lifetime total is exactly (cost − salvage).
→ `{ receipt_id, sha256, deduplicated, size_bytes }`. `entry_id` is
optional: a receipt can be filed before its entry exists.
- `books.link_receipt { receipt_id, entry_id }` → link a previously filed
receipt to its entry.
- `books.list_receipts { entry_id? }` → metadata list (no bytes).
(unlinked, not withdrawn) receipt to its entry. A receipt linked elsewhere
must be unlinked first.
- `books.unlink_receipt { attachment_id, reason }` → `{ ok, entry_id,
remaining_receipts }`. Detaches a receipt from its entry; it stays filed,
unlinked. Journaled as `receipt.unlinked` with the reason. Subject to the
receipt policy: an entry that requires a receipt cannot be left with none
(`receipt_required` error, remedy `books.attach_receipt`), so replacing a
receipt is attach-new, then unlink-old.
- `books.withdraw_attachment { attachment_id, reason }` → `{ ok }`. Retires
an unlinked receipt or a filed document: hidden from listings, never
linkable again. Journaled as `attachment.withdrawn`; the bytes stay in the
blob store. Nothing in the evidence trail is ever erased — receipts change
by new events, the same way entries change by reversals.
- `books.list_receipts { entry_id?, include_withdrawn? }` → metadata list
(no bytes), each with `withdrawn` / `withdrawn_reason`. Withdrawn
attachments are omitted unless `include_withdrawn` is true.
- `books.get_receipt { receipt_id }` → `{ metadata, content_base64 }`.
- `books.file_document { category: "formation" | "legal" | "tax" |
"accounting" | "other", file_name, mime_type, content_base64,
@@ -163,10 +192,12 @@ posting.
## Tools — export
- `books.export { format: "csv" | "json" | "xlsx" | "iif", report: "journal" |
"trial_balance" | "profit_and_loss" | "balance_sheet" | "cash_flow" }` →
`{ content, content_encoding? }`. CPA-ready output. `xlsx` is base64-encoded;
`iif` is journal-only (QuickBooks Desktop). Cash flow needs `cash_accounts`.
- `books.export { format: "csv" | "json" | "xlsx" | "pdf" | "iif", report:
"journal" | "trial_balance" | "profit_and_loss" | "balance_sheet" |
"cash_flow", start?, end?, as_of?, cash_accounts?, financing_accounts? }` →
`{ content, content_encoding? }`. CPA-ready output. `xlsx` and `pdf` are
base64-encoded; `pdf` covers the three financial statements; `iif` is
journal-only (QuickBooks Desktop). Cash flow needs `cash_accounts`.
## Tools — import
@@ -304,9 +335,11 @@ entries.
## Tools — CPA handoff
- `books.export_cpa_package { as_of? }` → `{ file_name, content_base64 }`.
A zip with `trial-balance.xlsx`, `general-ledger.xlsx` (every posted
line: date, entry, description, account, class, debit, credit),
`profit-and-loss.pdf` (fiscal year to date, per-account detail), and
`balance-sheet.pdf` (per-account detail). What the CPA needs; they never
- `books.export_cpa_package { as_of?, cash_accounts, financing_accounts? }`
→ `{ file_name, content_base64 }`. A zip with `trial-balance.xlsx`,
`general-ledger.xlsx` (every posted line: date, entry, description,
account, class, debit, credit), `profit-and-loss.pdf` (fiscal year to
date, per-account detail), `balance-sheet.pdf` (per-account detail), and
`cash-flow.pdf` (fiscal year to date, indirect method, reconciled to
`cash_accounts`). What the CPA needs; they never
log into the system.
+2
View File
@@ -19,6 +19,8 @@ No other statuses.
| Trial balance | Reports → Trial Balance | done | golden-covered |
| Balance sheet (A = L + E) | Reports → Balance Sheet | done | current earnings folded in pre-close; golden-covered |
| Profit & loss | Reports → Income Statement | done | golden-covered |
| P&L for a date range, line per account (closing entries excluded) | Reports → Income Statement (custom dates) | partial | hand-verified fixture + property tests; no golden file yet |
| Balance sheet as of a date, line per account | Reports → Balance Sheet (as of) | partial | hand-verified fixture + property tests; no golden file yet |
| Reversing entries | Reverse transaction | done | linked via `reversal_of` |
| Canonical report snapshots (JSON) | — (our oracle format) | done | `reports::snapshot` |
| Fixed-asset register + straight-line depreciation | Fixed Asset register | done | `assets.rs`; golden-covered schedule; idempotent runs; disposal with gain/loss |
+11 -1
View File
@@ -50,7 +50,9 @@ apply is safe — on startup the applier replays every journal line with
- `seq`: 1-based, gapless, assigned by the writer.
- `ts`: UTC, RFC 3339. Recorded, never trusted for ordering (`seq` orders).
- `type`: `account.added`, `entry.posted`, `entry.reversed`,
`receipt.attached`, `receipt.linked`, `document.filed`.
`receipt.attached`, `receipt.linked`, `receipt.unlinked`,
`document.filed`, `attachment.withdrawn` (plus the A/R, A/P, asset,
period, payroll, and class events listed on `JournalLine`).
- `payload`: the full domain facts (account fields, entry with lines, blob
sha, ...). Everything the applier needs; nothing it must look up elsewhere.
- `prev`: hex SHA-256 of the previous line's canonical JSON (keys sorted, no
@@ -99,6 +101,14 @@ before backups.
plus `receipts`.
- Documents filed via `books.file_document` and receipts via
`books.attach_receipt` share the blob store; they differ only in metadata.
- **Receipts change by events, never by edits.** `receipt.unlinked`
(`attachment_id`, `entry_id`, `reason`) detaches a receipt from its entry;
`attachment.withdrawn` (`attachment_id`, `reason`) retires an unlinked
receipt or a document (`withdrawn_at`, `withdrawn_reason` columns, schema
v5). Linking requires an unlinked, non-withdrawn attachment, so evidence
only leaves an entry through a recorded unlink. Blobs are untouched: a
withdrawn file is still hash-verified and readable, and the journal shows
when it was filed, linked, unlinked, and withdrawn, and why.
- The store is policy-free: it records attachments faithfully. Whether a
posting *requires* a receipt is decided by the MCP layer's receipt policy
(`docs/mcp-interface.md`), which reads the `attachments` table.
+19 -4
View File
@@ -34,10 +34,19 @@ requirement conflict, the requirement wins.
total credits (the `balanced()` check).
- **R-8 — Balance sheet.** Assets, liabilities, equity grouped by account
type. The accounting equation holds exactly: assets = liabilities + equity.
Because period close (R-FUTURE-2) doesn't exist yet, current earnings (net
income to date) are folded into equity so the equation holds on a live book.
- **R-9 — Profit and loss.** Revenue and expenses for the life of the ledger,
with net income. Period filtering arrives with R-FUTURE-2.
Current earnings (revenue minus expenses not yet closed to retained
earnings) are folded into equity so the equation holds on a live book.
`balance_sheet_as_of` gives the statement as of any date, a line per
account, counting every entry dated on or before it (closing entries
included: after a close the earnings sit in retained earnings).
- **R-9 — Profit and loss.** Revenue and expenses with net income.
`profit_and_loss_for_period` scopes it to a date range (optionally one
class), a line per account, and **excludes closing entries**: a close moves
earnings into retained earnings and must not make a closed period read as
break-even. The cash flow statement excludes closing entries for the same
reason. Period P&Ls partition: split a range anywhere and the halves sum
to the whole, and a range's net income equals the change in current
earnings across it (property-tested).
- **R-10 — Canonical snapshots.** `reports::snapshot()` renders the three
core reports as deterministic JSON (decimal strings, sorted lines, fixed
keys). Two engines fed the same books must produce identical output. This
@@ -52,6 +61,12 @@ requirement conflict, the requirement wins.
## Receipt policy (MCP phase)
- **R-13a — Receipt corrections.** Posted entries are immutable (R-3); their
evidence is not frozen but is append-only. A receipt is replaced by
attaching the new one and unlinking the old with a reason; unwanted
receipts and documents are withdrawn with a reason. Unlinking may never
leave an entry the policy (R-13) requires a receipt for with none. Every
step is a journal event; no blob is deleted.
- **R-13 — Receipt enforcement.** The MCP layer (not the engine — the engine
stays policy-free) enforces a configurable receipt policy: postings at or
above a threshold (`require_receipts_above`, default `"25.00"` in base
+41 -2
View File
@@ -51,8 +51,9 @@ public. Parity is 12 of 12 against the spreadsheet workflow.
the last caught a real month-length-drift bug, fixed with the final-month
true-up); `cargo-mutants` run on `ledger-core` (228 mutants); `cargo-fuzz`
targets (journal-line parsing, entry validation, op sequences) scaffolded.
- 40+ tests green workspace-wide; `cargo fmt` clean; clippy clean except one
pre-existing phase-1 style lint on a public engine signature.
- 120+ tests green workspace-wide; `cargo fmt` clean; `cargo clippy
--workspace --all-targets` clean, zero warnings (a hard rule: Stephen,
2026-10-11).
- Docs: AGENTS.md (operating manual), requirements, architecture, oracle
design, parity catalog (floor: 10 done), glossary, mcp-interface (incl. the
5 asset tools), receipt-store (journal design), capability-parity
@@ -151,3 +152,41 @@ interface is MCP, not a cloned GUI. The first storage + MCP slice is done
(QBO/CSV/email/sheets, conversational). 5 integration tests green; clippy
clean. Bootstrap: `mise trust && mise install`, `mise run build`,
`mise run mcp-add`, then `claude` in the dir.
- 2026-10-11: Receipts are mutable through events, never edits (Stephen:
"the journal is just a history of every single thing that happens").
`books.unlink_receipt` and `books.withdraw_attachment` (both require a
reason) journal `receipt.unlinked` / `attachment.withdrawn`; nothing is
erased from the journal or blob store. Unlinking can't strand an entry the
receipt policy requires evidence for (attach the replacement first).
`link_receipt` now refuses an already-linked or withdrawn attachment, which
closes a silent path for moving a receipt off a posted entry; disposals
check their receipt ids before posting. Schema v5. First real use: swapping
the migrated Wanderland Labs `.eml` receipts for their PDF renders.
- 2026-10-11: The cash flow statement is a first-class output everywhere the
other two statements are: `cash_flow_pdf` in ledger-import, a `pdf`
format on `books.export` (P&L, balance sheet, cash flow), and
`cash-flow.pdf` in the CPA package, which now requires `cash_accounts`
(the statement reconciles to them). Known gaps, next: the P&L and balance
sheet PDF/XLSX ignore start/end/as_of (all-time balances under the
requested label; core needs period-scoped statements); P&L and balance
sheet XLSX are totals-only; PDF amounts don't align (padding in a
proportional font), no company name, negatives as "-x".
- 2026-10-11: Every statement export (csv, json, xlsx, pdf, CPA package)
now renders core's period statements: the date range and as-of date are
real, with a line per account. One layout (`ledger-import::report`:
sections, lines, subtotals, totals; exact "(1,234.56)" formatting) feeds
every renderer. The server reads `wanderbooks.toml`: company name for
report headers, `fiscal_year_start` for fiscal YTD (end derived), and
`receipt_threshold` (it was silently ignored before). Reports carry
"Prepared from journal through seq N".
- 2026-10-11: PDFs are typeset with Typst (typst 0.15 via typst-as-lib,
fonts bundled by typst-assets; printpdf removed). One template,
`ledger-import/templates/statement.typ`, takes the laid-out statement as
JSON data (labels are never markup). US accounting presentation in PDF
and XLSX alike: tabular figures, "$" on each section's first amount and
on totals, negatives in parentheses with the closing paren hanging so
decimal points align, zero as a dash, single rule over sums, double rule
under totals, amounts never bold (bold figures are wider). Pages flow
with a repeated header and "Page n of m". Core's cash flow now orders
lines by account code (it followed HashMap order, so line order could
change between runs).