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 bank_csv::{BankTransaction, parse_bank_csv};
|
||||||
pub use chart_csv::{ChartRow, parse_chart_csv};
|
pub use chart_csv::{ChartRow, parse_chart_csv};
|
||||||
pub use iif::{IifEntry, journal_to_iif};
|
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};
|
pub use xlsx::{XlsxReport, report_to_xlsx};
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
//! Simple, correct, readable — not pretty. Uses printpdf (MIT) with built-in
|
//! Simple, correct, readable — not pretty. Uses printpdf (MIT) with built-in
|
||||||
//! Helvetica. Amounts are exact decimals rendered as strings; no floats.
|
//! Helvetica. Amounts are exact decimals rendered as strings; no floats.
|
||||||
|
|
||||||
|
use ledger_core::reports::CashFlowStatement;
|
||||||
use ledger_core::{AccountType, Ledger};
|
use ledger_core::{AccountType, Ledger};
|
||||||
use printpdf::{
|
use printpdf::{
|
||||||
BuiltinFont, Color, Mm, Op, PdfDocument, PdfFontHandle, PdfPage, PdfSaveOptions, Point, Pt,
|
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);
|
let ops = text_ops("Balance Sheet", &format!("As of {}", as_of), &rows, &totals);
|
||||||
save_pdf("Balance Sheet", ops)
|
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())
|
.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
|
/// Net movement of one account over entries on or before `through`, in the
|
||||||
/// account's normal side.
|
/// account's normal side.
|
||||||
fn movement_through(ledger: &Ledger, account: AccountId, through: NaiveDate) -> Decimal {
|
fn movement_through(ledger: &Ledger, account: AccountId, through: NaiveDate) -> Decimal {
|
||||||
@@ -1331,15 +1378,15 @@ impl BooksServer {
|
|||||||
|
|
||||||
#[tool(
|
#[tool(
|
||||||
name = "books.export",
|
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)
|
annotations(read_only_hint = true)
|
||||||
)]
|
)]
|
||||||
async fn export(&self, params: Parameters<ExportParams>) -> Result<Json<ExportOut>, String> {
|
async fn export(&self, params: Parameters<ExportParams>) -> Result<Json<ExportOut>, String> {
|
||||||
let p = params.0;
|
let p = params.0;
|
||||||
let format = p.format.to_lowercase();
|
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!(
|
return Err(format!(
|
||||||
"bad format {:?}: expected \"csv\", \"json\", \"xlsx\", or \"iif\"",
|
"bad format {:?}: expected \"csv\", \"json\", \"xlsx\", \"pdf\", or \"iif\"",
|
||||||
p.format
|
p.format
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
@@ -1347,8 +1394,12 @@ impl BooksServer {
|
|||||||
if format == "iif" && report != "journal" {
|
if format == "iif" && report != "journal" {
|
||||||
return Err("iif format is only valid for the journal report".to_string());
|
return Err("iif format is only valid for the journal report".to_string());
|
||||||
}
|
}
|
||||||
if format == "xlsx" {
|
if format == "xlsx" || format == "pdf" {
|
||||||
let bytes = self.export_xlsx(&report, &p)?;
|
let bytes = if format == "xlsx" {
|
||||||
|
self.export_xlsx(&report, &p)?
|
||||||
|
} else {
|
||||||
|
self.export_pdf(&report, &p)?
|
||||||
|
};
|
||||||
return Ok(Json(ExportOut {
|
return Ok(Json(ExportOut {
|
||||||
format,
|
format,
|
||||||
report,
|
report,
|
||||||
@@ -1427,40 +1478,13 @@ impl BooksServer {
|
|||||||
as_of.to_string(),
|
as_of.to_string(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
"cash_flow" => {
|
"cash_flow" => Owned::Cf(cash_flow_statement(
|
||||||
let mut cash_accounts = Vec::new();
|
ledger,
|
||||||
for code in &p.cash_accounts {
|
p.start.as_deref(),
|
||||||
let id = ledger
|
p.end.as_deref(),
|
||||||
.chart()
|
&p.cash_accounts,
|
||||||
.find_by_code(code)
|
&p.financing_accounts,
|
||||||
.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())?,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
_ => {
|
_ => {
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
"bad report {report:?} for xlsx: expected trial_balance, profit_and_loss, balance_sheet, or cash_flow"
|
"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())
|
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.
|
/// Render the journal as IIF text via ledger-import.
|
||||||
fn export_iif(&self) -> Result<String, String> {
|
fn export_iif(&self) -> Result<String, String> {
|
||||||
use ledger_import::{IifEntry, journal_to_iif};
|
use ledger_import::{IifEntry, journal_to_iif};
|
||||||
@@ -2480,7 +2534,7 @@ impl BooksServer {
|
|||||||
|
|
||||||
#[tool(
|
#[tool(
|
||||||
name = "books.export_cpa_package",
|
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)
|
annotations(read_only_hint = true)
|
||||||
)]
|
)]
|
||||||
async fn export_cpa_package(
|
async fn export_cpa_package(
|
||||||
@@ -2515,6 +2569,16 @@ impl BooksServer {
|
|||||||
// 4. Balance sheet PDF.
|
// 4. Balance sheet PDF.
|
||||||
let bs_pdf = ledger_import::balance_sheet_pdf(ledger, &as_of.to_string())
|
let bs_pdf = ledger_import::balance_sheet_pdf(ledger, &as_of.to_string())
|
||||||
.map_err(|e| e.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);
|
drop(store);
|
||||||
|
|
||||||
// Zip it.
|
// Zip it.
|
||||||
@@ -2527,6 +2591,7 @@ impl BooksServer {
|
|||||||
("general-ledger.xlsx", gl_xlsx.as_slice()),
|
("general-ledger.xlsx", gl_xlsx.as_slice()),
|
||||||
("profit-and-loss.pdf", pnl_pdf.as_slice()),
|
("profit-and-loss.pdf", pnl_pdf.as_slice()),
|
||||||
("balance-sheet.pdf", bs_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())?;
|
zip.start_file(name, opts).map_err(|e| e.to_string())?;
|
||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
|
|||||||
@@ -757,8 +757,9 @@ pub struct DigestOut {
|
|||||||
/// Parameters for `books.export`.
|
/// Parameters for `books.export`.
|
||||||
#[derive(Debug, Deserialize, JsonSchema)]
|
#[derive(Debug, Deserialize, JsonSchema)]
|
||||||
pub struct ExportParams {
|
pub struct ExportParams {
|
||||||
/// "csv", "json", "xlsx", or "iif". XLSX is base64-encoded; IIF is only
|
/// "csv", "json", "xlsx", "pdf", or "iif". XLSX and PDF are
|
||||||
/// valid with report "journal".
|
/// base64-encoded; PDF covers profit_and_loss, balance_sheet, and
|
||||||
|
/// cash_flow; IIF is only valid with report "journal".
|
||||||
pub format: String,
|
pub format: String,
|
||||||
/// "journal", "trial_balance", "profit_and_loss", "balance_sheet", or
|
/// "journal", "trial_balance", "profit_and_loss", "balance_sheet", or
|
||||||
/// "cash_flow".
|
/// "cash_flow".
|
||||||
@@ -772,6 +773,10 @@ pub struct ExportParams {
|
|||||||
/// Cash account codes for cash_flow (required for that report).
|
/// Cash account codes for cash_flow (required for that report).
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub cash_accounts: Vec<String>,
|
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`.
|
/// Result of `books.export`.
|
||||||
@@ -782,9 +787,9 @@ pub struct ExportOut {
|
|||||||
/// The requested report.
|
/// The requested report.
|
||||||
pub report: String,
|
pub report: String,
|
||||||
/// The exported content. Base64-encoded when `content_encoding` is
|
/// The exported content. Base64-encoded when `content_encoding` is
|
||||||
/// "base64" (XLSX); otherwise raw text.
|
/// "base64" (XLSX, PDF); otherwise raw text.
|
||||||
pub content: String,
|
pub content: String,
|
||||||
/// "base64" for XLSX, otherwise absent.
|
/// "base64" for XLSX and PDF, otherwise absent.
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub content_encoding: Option<String>,
|
pub content_encoding: Option<String>,
|
||||||
}
|
}
|
||||||
@@ -1447,6 +1452,14 @@ pub struct ProfitAndLossByClassOut {
|
|||||||
pub struct ExportCpaPackageParams {
|
pub struct ExportCpaPackageParams {
|
||||||
/// As-of date, YYYY-MM-DD. Defaults to today.
|
/// As-of date, YYYY-MM-DD. Defaults to today.
|
||||||
pub as_of: Option<String>,
|
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`.
|
/// Output of `books.export_cpa_package`.
|
||||||
|
|||||||
@@ -776,3 +776,105 @@ fn mcp_import_export_over_stdio() {
|
|||||||
assert_eq!(xlsx["content_encoding"], "base64");
|
assert_eq!(xlsx["content_encoding"], "base64");
|
||||||
assert!(xlsx["content"].as_str().unwrap().starts_with("UEs"));
|
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
|
## Tools — export
|
||||||
|
|
||||||
- `books.export { format: "csv" | "json" | "xlsx" | "iif", report: "journal" |
|
- `books.export { format: "csv" | "json" | "xlsx" | "pdf" | "iif", report:
|
||||||
"trial_balance" | "profit_and_loss" | "balance_sheet" | "cash_flow" }` →
|
"journal" | "trial_balance" | "profit_and_loss" | "balance_sheet" |
|
||||||
`{ content, content_encoding? }`. CPA-ready output. `xlsx` is base64-encoded;
|
"cash_flow", start?, end?, as_of?, cash_accounts?, financing_accounts? }` →
|
||||||
`iif` is journal-only (QuickBooks Desktop). Cash flow needs `cash_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
|
## Tools — import
|
||||||
|
|
||||||
@@ -318,9 +320,11 @@ entries.
|
|||||||
|
|
||||||
## Tools — CPA handoff
|
## Tools — CPA handoff
|
||||||
|
|
||||||
- `books.export_cpa_package { as_of? }` → `{ file_name, content_base64 }`.
|
- `books.export_cpa_package { as_of?, cash_accounts, financing_accounts? }`
|
||||||
A zip with `trial-balance.xlsx`, `general-ledger.xlsx` (every posted
|
→ `{ file_name, content_base64 }`. A zip with `trial-balance.xlsx`,
|
||||||
line: date, entry, description, account, class, debit, credit),
|
`general-ledger.xlsx` (every posted line: date, entry, description,
|
||||||
`profit-and-loss.pdf` (fiscal year to date, per-account detail), and
|
account, class, debit, credit), `profit-and-loss.pdf` (fiscal year to
|
||||||
`balance-sheet.pdf` (per-account detail). What the CPA needs; they never
|
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.
|
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
|
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
|
check their receipt ids before posting. Schema v5. First real use: swapping
|
||||||
the migrated Wanderland Labs `.eml` receipts for their PDF renders.
|
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