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:
1 parent
e59e8001c6
commit
5548902f27
7 files changed
+304
-54
No files matched your search
@@ -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};
|
||||
@@ -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)
|
||||
}
|
||||
+105
-40
@@ -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(
|
||||
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<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;
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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
@@ -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.
|
||||
@@ -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".
|
||||
Reference in new issue
Block a user