feat: cash flow statement PDF, pdf export format, cash flow in CPA package

The cash flow statement was computed and exportable as csv/json/xlsx but
missing from every PDF path, so the CPA package carried two of the three
financial statements. Now:

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MtYEaR6M6Ybdhm6X98BWNu
This commit is contained in:
swaitsandClaude Opus 5.5 committed 2026-10-11 18:57:12 -07:00
1 parent e59e8001c6
commit 5548902f27
7 files changed
+303 -53

No files matched your search

+1 -1
View File
@@ -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};
+57
View File
@@ -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<Vec<u8>, 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<Vec<u8>, 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)
}
+104 -39
View File
@@ -256,6 +256,53 @@ fn preview_receipts(store: &Store, entry_id: u64) -> Result<Vec<PreviewReceipt>,
.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<ledger_core::reports::CashFlowStatement, String> {
let resolve = |codes: &[String], what: &str| -> Result<Vec<AccountId>, String> {
codes
.iter()
.map(|code| {
ledger
.chart()
.find_by_code(code)
.map(|a| a.id)
.ok_or_else(|| format!("unknown {what} account code: {code}"))
})
.collect()
};
let cash_accounts = resolve(cash_codes, "cash")?;
if cash_accounts.is_empty() {
return Err("cash_accounts is required for the cash flow statement".to_string());
}
let financing_accounts = resolve(financing_codes, "financing")?;
let start = 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<ExportParams>) -> Result<Json<ExportOut>, String> {
let p = params.0;
let format = p.format.to_lowercase();
if !["csv", "json", "xlsx", "iif"].contains(&format.as_str()) {
if !["csv", "json", "xlsx", "pdf", "iif"].contains(&format.as_str()) {
return Err(format!(
"bad format {:?}: expected \"csv\", \"json\", \"xlsx\", or \"iif\"",
"bad format {:?}: expected \"csv\", \"json\", \"xlsx\", \"pdf\", or \"iif\"",
p.format
));
}
@@ -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(
"cash_flow" => Owned::Cf(cash_flow_statement(
ledger,
&ledger_core::CashFlowParams {
start,
end,
cash_accounts,
financing_accounts: vec![],
},
)
.map_err(|e| e.to_string())?,
)
}
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<Vec<u8>, 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<String, String> {
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;
+17 -4
View File
@@ -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<String>,
/// Liability account codes treated as financing (debt) for cash_flow.
/// Defaults to empty: liabilities are operating working capital.
#[serde(default)]
pub financing_accounts: Vec<String>,
}
/// Result of `books.export`.
@@ -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<String>,
}
@@ -1447,6 +1452,14 @@ pub struct ProfitAndLossByClassOut {
pub struct ExportCpaPackageParams {
/// As-of date, YYYY-MM-DD. Defaults to today.
pub as_of: Option<String>,
/// Cash account codes the cash flow statement reconciles to (required),
/// e.g. `["1000"]`.
#[serde(default)]
pub cash_accounts: Vec<String>,
/// Liability account codes treated as financing (debt). Defaults to
/// empty: liabilities are operating working capital.
#[serde(default)]
pub financing_accounts: Vec<String>,
}
/// Output of `books.export_cpa_package`.
+102
View File
@@ -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<String> = (0..zip.len())
.map(|i| zip.by_index(i).unwrap().name().unwrap().to_string())
.collect();
names.sort();
assert_eq!(
names,
[
"balance-sheet.pdf",
"cash-flow.pdf",
"general-ledger.xlsx",
"profit-and-loss.pdf",
"trial-balance.xlsx"
]
);
let mut cf = Vec::new();
std::io::Read::read_to_end(&mut zip.by_name("cash-flow.pdf").unwrap(), &mut cf).unwrap();
assert!(cf.starts_with(b"%PDF"));
}
+13 -9
View File
@@ -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.
+9
View File
@@ -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".