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
268 lines
9.9 KiB
Rust
268 lines
9.9 KiB
Rust
//! 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
|
||
);
|
||
}
|
||
}
|