diff --git a/crates/ledger-import/src/lib.rs b/crates/ledger-import/src/lib.rs index 1e24f04..7ebba78 100644 --- a/crates/ledger-import/src/lib.rs +++ b/crates/ledger-import/src/lib.rs @@ -17,5 +17,5 @@ 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, balance_sheet_pdf, cash_flow_pdf, profit_and_loss_pdf}; pub use xlsx::{XlsxReport, report_to_xlsx}; diff --git a/crates/ledger-import/src/pdf.rs b/crates/ledger-import/src/pdf.rs index 6cd4d82..45a56a9 100644 --- a/crates/ledger-import/src/pdf.rs +++ b/crates/ledger-import/src/pdf.rs @@ -3,6 +3,7 @@ //! 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 printpdf::{ BuiltinFont, Color, Mm, Op, PdfDocument, PdfFontHandle, PdfPage, PdfSaveOptions, Point, Pt, @@ -203,3 +204,59 @@ pub fn balance_sheet_pdf(ledger: &Ledger, as_of: &str) -> Result, PdfErr 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) +} diff --git a/crates/ledger-mcp/src/server.rs b/crates/ledger-mcp/src/server.rs index 91ce0ab..ef4f1d3 100644 --- a/crates/ledger-mcp/src/server.rs +++ b/crates/ledger-mcp/src/server.rs @@ -256,6 +256,53 @@ fn preview_receipts(store: &Store, entry_id: u64) -> Result, .collect()) } +/// 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 { + let resolve = |codes: &[String], what: &str| -> Result, 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 = 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(), + }; + 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 on or before `through`, in the /// account's normal side. fn movement_through(ledger: &Ledger, account: AccountId, through: NaiveDate) -> Decimal { @@ -1331,15 +1378,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) -> Result, 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 )); } @@ -1347,8 +1394,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, @@ -1427,40 +1478,13 @@ impl BooksServer { 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())?, - ) - } + "cash_flow" => Owned::Cf(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 xlsx: expected trial_balance, profit_and_loss, balance_sheet, or cash_flow" @@ -1476,6 +1500,36 @@ impl BooksServer { report_to_xlsx(&xlsx_report).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()) + } + /// Render the journal as IIF text via ledger-import. fn export_iif(&self) -> Result { use ledger_import::{IifEntry, journal_to_iif}; @@ -2480,7 +2534,7 @@ 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( @@ -2515,6 +2569,16 @@ impl BooksServer { // 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( + ledger, + Some(&fy_start.to_string()), + Some(&as_of.to_string()), + &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. @@ -2527,6 +2591,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; diff --git a/crates/ledger-mcp/src/types.rs b/crates/ledger-mcp/src/types.rs index 6b2f5e1..9bfeed7 100644 --- a/crates/ledger-mcp/src/types.rs +++ b/crates/ledger-mcp/src/types.rs @@ -757,8 +757,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". @@ -772,6 +773,10 @@ pub struct ExportParams { /// Cash account codes for cash_flow (required for that report). #[serde(default)] pub cash_accounts: Vec, + /// Liability account codes treated as financing (debt) for cash_flow. + /// Defaults to empty: liabilities are operating working capital. + #[serde(default)] + pub financing_accounts: Vec, } /// Result of `books.export`. @@ -782,9 +787,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, } @@ -1447,6 +1452,14 @@ pub struct ProfitAndLossByClassOut { pub struct ExportCpaPackageParams { /// As-of date, YYYY-MM-DD. Defaults to today. pub as_of: Option, + /// Cash account codes the cash flow statement reconciles to (required), + /// e.g. `["1000"]`. + #[serde(default)] + pub cash_accounts: Vec, + /// Liability account codes treated as financing (debt). Defaults to + /// empty: liabilities are operating working capital. + #[serde(default)] + pub financing_accounts: Vec, } /// Output of `books.export_cpa_package`. diff --git a/crates/ledger-mcp/tests/mcp_stdio.rs b/crates/ledger-mcp/tests/mcp_stdio.rs index 0e759e6..3bff05e 100644 --- a/crates/ledger-mcp/tests/mcp_stdio.rs +++ b/crates/ledger-mcp/tests/mcp_stdio.rs @@ -776,3 +776,105 @@ 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 = (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")); +} diff --git a/docs/mcp-interface.md b/docs/mcp-interface.md index 58abc82..73bfdcf 100644 --- a/docs/mcp-interface.md +++ b/docs/mcp-interface.md @@ -177,10 +177,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 @@ -318,9 +320,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. diff --git a/plan/STATUS.md b/plan/STATUS.md index fac40d7..c7952d2 100644 --- a/plan/STATUS.md +++ b/plan/STATUS.md @@ -161,3 +161,12 @@ interface is MCP, not a cloned GUI. The first storage + MCP slice is done 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".