diff --git a/Cargo.lock b/Cargo.lock index d7c28b0..cf25489 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1182,6 +1182,7 @@ dependencies = [ "serde_json", "thiserror", "tokio", + "toml", "tower", "zip 9.0.2", ] diff --git a/crates/ledger-import/src/lib.rs b/crates/ledger-import/src/lib.rs index 7ebba78..27b8fb7 100644 --- a/crates/ledger-import/src/lib.rs +++ b/crates/ledger-import/src/lib.rs @@ -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, cash_flow_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}; diff --git a/crates/ledger-import/src/pdf.rs b/crates/ledger-import/src/pdf.rs index 45a56a9..bdf88dd 100644 --- a/crates/ledger-import/src/pdf.rs +++ b/crates/ledger-import/src/pdf.rs @@ -3,13 +3,11 @@ //! Simple, correct, readable — not pretty. Uses printpdf (MIT) with built-in //! Helvetica. Amounts are exact decimals rendered as strings; no floats. -use ledger_core::reports::CashFlowStatement; -use ledger_core::{AccountType, Ledger}; +use crate::report::{ReportMeta, RowKind, Statement, format_amount}; use printpdf::{ BuiltinFont, Color, Mm, Op, PdfDocument, PdfFontHandle, PdfPage, PdfSaveOptions, Point, Pt, Rgb, TextItem, }; -use rust_decimal::Decimal; use thiserror::Error; /// PDF rendering errors. @@ -20,11 +18,6 @@ pub enum PdfError { Save(String), } -fn money(d: Decimal) -> String { - // Two decimals, no float anywhere. - format!("{:.2}", d) -} - fn text_ops( title: &str, subtitle: &str, @@ -103,160 +96,28 @@ fn save_pdf(title: &str, ops: Vec) -> Result, PdfError> { 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, 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))), - _ => {} +/// Render a laid-out statement (see [`crate::report`]) as a PDF. +pub fn statement_pdf(st: &Statement, meta: &ReportMeta) -> Result, PdfError> { + let mut rows = Vec::new(); + let mut totals = Vec::new(); + for r in &st.rows { + let amount = r.amount.map(format_amount).unwrap_or_default(); + match r.kind { + RowKind::Spacer => rows.push((String::new(), String::new())), + RowKind::Section => rows.push((r.label.to_uppercase(), String::new())), + RowKind::Line => rows.push((format!(" {}", r.label), amount)), + RowKind::Subtotal | RowKind::Total => rows.push((r.label.clone(), amount)), } } - 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())), - ]; - + totals.push(( + format!("Prepared from journal through seq {}", meta.journal_seq), + String::new(), + )); let ops = text_ops( - "Profit and Loss", - &format!("{} to {}", start, end), + &format!("{} — {}", meta.company, st.title), + &st.period, &rows, &totals, ); - save_pdf("Profit and Loss", ops) -} - -/// 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, 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))), - _ => {} - } - } - 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), - )); - - let totals = vec![ - ("Total assets".to_string(), money(bs.assets)), - ( - "Total liabilities + equity".to_string(), - money(bs.liabilities + bs.equity), - ), - ]; - - let ops = text_ops("Balance Sheet", &format!("As of {}", as_of), &rows, &totals); - save_pdf("Balance Sheet", ops) -} - -/// Render a cash flow statement PDF (indirect method): operating (net -/// income, depreciation add-back, working-capital changes), investing, and -/// financing sections, then the net change reconciled to beginning and -/// ending cash. Takes the computed statement, so the period and cash -/// accounts are whatever the caller chose. -pub fn cash_flow_pdf(cf: &CashFlowStatement) -> Result, PdfError> { - let line = |l: &ledger_core::reports::CashFlowLine| (format!(" {}", l.label), money(l.amount)); - let mut rows = vec![("OPERATING ACTIVITIES".to_string(), "".to_string())]; - rows.push((" Net income".to_string(), money(cf.net_income))); - if !cf.depreciation.is_zero() { - rows.push(( - " Depreciation (non-cash)".to_string(), - money(cf.depreciation), - )); - } - rows.extend(cf.working_capital.iter().map(line)); - rows.push(( - "Net cash from operating activities".to_string(), - money(cf.cash_from_operations), - )); - rows.push(("".to_string(), "".to_string())); - rows.push(("INVESTING ACTIVITIES".to_string(), "".to_string())); - rows.extend(cf.investing.iter().map(line)); - rows.push(( - "Net cash from investing activities".to_string(), - money(cf.cash_from_investing), - )); - rows.push(("".to_string(), "".to_string())); - rows.push(("FINANCING ACTIVITIES".to_string(), "".to_string())); - rows.extend(cf.financing.iter().map(line)); - rows.push(( - "Net cash from financing activities".to_string(), - money(cf.cash_from_financing), - )); - - let totals = vec![ - ( - "Net change in cash".to_string(), - money(cf.net_change_in_cash), - ), - ( - "Cash at beginning of period".to_string(), - money(cf.cash_beginning), - ), - ("Cash at end of period".to_string(), money(cf.cash_ending)), - ]; - - let ops = text_ops( - "Statement of Cash Flows", - &format!("{} to {}", cf.start, cf.end), - &rows, - &totals, - ); - save_pdf("Statement of Cash Flows", ops) + save_pdf(&st.title, ops) } diff --git a/crates/ledger-import/src/report.rs b/crates/ledger-import/src/report.rs new file mode 100644 index 0000000..0d0ba9b --- /dev/null +++ b/crates/ledger-import/src/report.rs @@ -0,0 +1,287 @@ +//! 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, +} + +/// 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, +} + +fn row(kind: RowKind, label: impl Into, amount: Option) -> Row { + Row { + kind, + label: label.into(), + amount, + } +} + +fn section(label: &str) -> Row { + row(RowKind::Section, label, None) +} + +fn spacer() -> Row { + row(RowKind::Spacer, "", None) +} + +fn account_lines(lines: &[StatementLine]) -> impl Iterator + '_ { + lines.iter().map(|l| { + row( + RowKind::Line, + format!("{} {}", l.code, l.name), + Some(l.amount), + ) + }) +} + +fn flow_lines(lines: &[CashFlowLine]) -> impl Iterator + '_ { + 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 { + title: "Profit and Loss".to_string(), + period: 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 { + title: "Balance Sheet".to_string(), + period: 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::Total, + "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 { + title: "Statement of Cash Flows".to_string(), + period: 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 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" + ); + } +} diff --git a/crates/ledger-import/src/xlsx.rs b/crates/ledger-import/src/xlsx.rs index 9952ac5..7328c37 100644 --- a/crates/ledger-import/src/xlsx.rs +++ b/crates/ledger-import/src/xlsx.rs @@ -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,120 @@ pub enum XlsxError { Write(String), } +impl From for XlsxError { + fn from(e: rust_xlsxwriter::XlsxError) -> Self { + XlsxError::Write(e.to_string()) + } +} + +const MONEY: &str = "#,##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 { + 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), + ¬e, + )?; + Ok(()) +} + /// Render a report to XLSX bytes. -pub fn report_to_xlsx(report: &XlsxReport<'_>) -> Result, XlsxError> { +pub fn report_to_xlsx(report: &XlsxReport<'_>, meta: &ReportMeta) -> Result, 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(MONEY); + let money_bold = money.clone().set_bold(); - 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 = money_bold.clone().set_border_top(FormatBorder::Thin); + 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 = money_bold.clone().set_border_top(FormatBorder::Thin); + let total = money_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 { + sheet.write_number_with_format(row, 1, a, &money)?; + } + } + RowKind::Subtotal => { + sheet.write_string_with_format(row, 0, &r.label, &bold)?; + if let Some(a) = amount { + sheet.write_number_with_format(row, 1, a, &subtotal)?; + } + } + 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 +161,7 @@ pub fn report_to_xlsx(report: &XlsxReport<'_>) -> Result, 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 +180,24 @@ pub fn report_to_xlsx(report: &XlsxReport<'_>) -> Result, 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 +223,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); diff --git a/crates/ledger-mcp/Cargo.toml b/crates/ledger-mcp/Cargo.toml index 3fa702f..4bf9a33 100644 --- a/crates/ledger-mcp/Cargo.toml +++ b/crates/ledger-mcp/Cargo.toml @@ -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 diff --git a/crates/ledger-mcp/src/company.rs b/crates/ledger-mcp/src/company.rs new file mode 100644 index 0000000..41c4b8e --- /dev/null +++ b/crates/ledger-mcp/src/company.rs @@ -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, +} + +#[derive(Deserialize)] +struct BooksToml { + #[serde(default)] + company: CompanyToml, + #[serde(default)] + books: BooksSection, +} + +#[derive(Default, Deserialize)] +struct CompanyToml { + name: Option, + fiscal_year_start: Option, +} + +#[derive(Default, Deserialize)] +struct BooksSection { + receipt_threshold: Option, +} + +/// 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 { + 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::() + .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 { + 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()); + } +} diff --git a/crates/ledger-mcp/src/main.rs b/crates/ledger-mcp/src/main.rs index ea7095b..0f9033a 100644 --- a/crates/ledger-mcp/src/main.rs +++ b/crates/ledger-mcp/src/main.rs @@ -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, - /// 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, } 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 /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 /../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> { let mut data_dir = default_data_dir(); let mut config_path: Option = None; + let mut books_toml: Option = None; let mut http_addr: Option = None; let mut args = std::env::args().skip(1); while let Some(arg) = args.next() { @@ -55,6 +67,11 @@ fn main() -> Result<(), Box> { "--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> { }; // 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> { .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> { 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()), diff --git a/crates/ledger-mcp/src/server.rs b/crates/ledger-mcp/src/server.rs index ef4f1d3..9418d00 100644 --- a/crates/ledger-mcp/src/server.rs +++ b/crates/ledger-mcp/src/server.rs @@ -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::{ @@ -33,6 +35,7 @@ const MAX_UPLOAD_BYTES: usize = 25 * 1024 * 1024; pub struct BooksServer { store: Arc>, 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>, 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, String> { @@ -283,14 +305,7 @@ fn cash_flow_statement( return Err("cash_accounts is required for the cash flow statement".to_string()); } let financing_accounts = resolve(financing_codes, "financing")?; - let start = match start { - Some(s) => parse_date(s)?, - None => NaiveDate::from_ymd_opt(1, 1, 1).unwrap(), - }; - let end = match end { - Some(s) => parse_date(s)?, - None => chrono::Utc::now().date_naive(), - }; + let (start, end) = statement_range(ledger, start, end)?; ledger_core::cash_flow( ledger, &ledger_core::CashFlowParams { @@ -303,50 +318,13 @@ fn cash_flow_statement( .map_err(|e| e.to_string()) } -/// 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, - } -} - /// 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, -) -> 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, @@ -359,6 +337,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, +) -> Result { + 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 { + 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 { + lines + .iter() + .map(|l| StatementLineOut { + code: l.code.clone(), + name: l.name.clone(), + amount: money(l.amount), + }) + .collect() +} + // --------------------------------------------------------------------------- // Tools // --------------------------------------------------------------------------- @@ -456,44 +498,24 @@ impl BooksServer { ) -> Result, 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), })) } @@ -508,38 +530,17 @@ impl BooksServer { ) -> Result, 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), })) } @@ -1437,48 +1438,23 @@ impl BooksServer { })) } - /// Render a report to XLSX bytes via ledger-import. - fn export_xlsx(&self, report: &str, p: &ExportParams) -> Result, String> { - use ledger_import::{XlsxReport, report_to_xlsx}; - let store = self.lock()?; - 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), - } - 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" => Owned::Cf(cash_flow_statement( + /// Lay out a financial statement for rendering, from export params. + fn layout_statement( + &self, + ledger: &Ledger, + report: &str, + p: &ExportParams, + ) -> Result { + 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(), @@ -1487,47 +1463,35 @@ impl BooksServer { )?), _ => { return Err(format!( - "bad report {report:?} for xlsx: expected trial_balance, profit_and_loss, balance_sheet, or cash_flow" + "bad report {report:?}: expected 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()) + }) + } + + /// Render a report to XLSX bytes via ledger-import. + fn export_xlsx(&self, report: &str, p: &ExportParams) -> Result, String> { + use ledger_import::{XlsxReport, report_to_xlsx}; + let store = self.lock()?; + let meta = self.report_meta(&store); + let ledger = store.ledger(); + 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 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, String> { let store = self.lock()?; - let ledger = store.ledger(); - let today = || chrono::Utc::now().date_naive().to_string(); - match report { - "profit_and_loss" => ledger_import::profit_and_loss_pdf( - ledger, - p.start.as_deref().unwrap_or("0001-01-01"), - &p.end.clone().unwrap_or_else(today), - ), - "balance_sheet" => { - ledger_import::balance_sheet_pdf(ledger, &p.as_of.clone().unwrap_or_else(today)) - } - "cash_flow" => ledger_import::cash_flow_pdf(&cash_flow_statement( - ledger, - p.start.as_deref(), - p.end.as_deref(), - &p.cash_accounts, - &p.financing_accounts, - )?), - _ => { - return Err(format!( - "bad report {report:?} for pdf: expected profit_and_loss, balance_sheet, or cash_flow" - )); - } - } - .map_err(|e| e.to_string()) + 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. @@ -2541,44 +2505,42 @@ impl BooksServer { &self, params: Parameters, ) -> Result, 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())?; - - // 5. Cash flow PDF (fiscal year to date, like the P&L). - let cf = cash_flow_statement( + 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.to_string()), - Some(&as_of.to_string()), + 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, - )?; - let cf_pdf = ledger_import::cash_flow_pdf(&cf).map_err(|e| e.to_string())?; + )?))?; drop(store); // Zip it. @@ -2708,86 +2670,75 @@ impl BooksServer { end: Option<&str>, ) -> Result { 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 { 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 { diff --git a/crates/ledger-mcp/src/types.rs b/crates/ledger-mcp/src/types.rs index 9bfeed7..3381c99 100644 --- a/crates/ledger-mcp/src/types.rs +++ b/crates/ledger-mcp/src/types.rs @@ -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, + /// Expense accounts with movement in the period. + pub expense_accounts: Vec, +} + +/// 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, + /// Liability accounts with a balance. + pub liability_accounts: Vec, + /// Equity accounts with a balance (current earnings is separate). + pub equity_accounts: Vec, } // --------------------------------------------------------------------------- diff --git a/crates/ledger-mcp/tests/mcp_stdio.rs b/crates/ledger-mcp/tests/mcp_stdio.rs index 3bff05e..a2cbbd4 100644 --- a/crates/ledger-mcp/tests/mcp_stdio.rs +++ b/crates/ledger-mcp/tests/mcp_stdio.rs @@ -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()) @@ -878,3 +883,99 @@ fn mcp_statement_pdfs_and_cpa_package_over_stdio() { 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 mut 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); +} diff --git a/crates/ledger-store/src/store.rs b/crates/ledger-store/src/store.rs index a9e92e8..255442a 100644 --- a/crates/ledger-store/src/store.rs +++ b/crates/ledger-store/src/store.rs @@ -1981,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. diff --git a/docs/mcp-interface.md b/docs/mcp-interface.md index 73bfdcf..c2e0183 100644 --- a/docs/mcp-interface.md +++ b/docs/mcp-interface.md @@ -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. diff --git a/plan/STATUS.md b/plan/STATUS.md index c7952d2..9e56b5d 100644 --- a/plan/STATUS.md +++ b/plan/STATUS.md @@ -170,3 +170,11 @@ interface is MCP, not a cloned GUI. The first storage + MCP slice is done 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".