feat: real dates and per-account detail in every statement export
Every statement export rendered life-of-ledger totals under whatever date range or as-of label was asked for, and the P&L and balance sheet XLSX were totals-only. Now: - books.profit_and_loss / books.balance_sheet and the csv, json, xlsx, pdf exports and CPA package all use core's profit_and_loss_for_period and balance_sheet_as_of, with a line per account. The tools keep their totals and add revenue/expense/asset/liability/equity account lines. An omitted start is the first posted entry, never "0001-01-01". - ledger-import::report lays a statement out once (section, line, subtotal, total) for every renderer, with one exact amount formatter: thousands separators, negatives in parentheses, no floats. XLSX keeps numeric cells with an accounting number format, named sheets, company header, totals with borders; the trial balance gains its totals row. - ledger-mcp reads wanderbooks.toml (next to --data-dir, or --books-toml): company name for headers, fiscal_year_start for the CPA package's fiscal year to date (end derived; Feb 29 rejected), and [books] receipt_threshold, which the server had been ignoring. config.json's receipt_policy still wins when set. New dependency: toml 1.x (already in the lockfile via another crate). - Store::journal_seq(); every report notes the journal seq it reflects. Tests: company.rs unit tests (fiscal years incl. a July start, scaffold parsing, bad settings); report.rs formatting tests; an MCP stdio test with --books-toml (threshold 5.00 enforced, February-only P&L with account lines, start defaults to the first entry, as-of balance sheet). 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
c6faeeb827
commit
b5af21bb0e
14 files changed
+1048
-610
No files matched your search
Generated
+1
@@ -1182,6 +1182,7 @@ dependencies = [
|
|||||||
"serde_json",
|
"serde_json",
|
||||||
"thiserror",
|
"thiserror",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
"toml",
|
||||||
"tower",
|
"tower",
|
||||||
"zip 9.0.2",
|
"zip 9.0.2",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
//! - [`bank_csv`]: parse QBO bank/credit-card upload CSVs.
|
//! - [`bank_csv`]: parse QBO bank/credit-card upload CSVs.
|
||||||
//! - [`chart_csv`]: parse chart-of-accounts CSVs.
|
//! - [`chart_csv`]: parse chart-of-accounts CSVs.
|
||||||
//! - [`iif`]: generate IIF journal entries for QuickBooks Desktop.
|
//! - [`iif`]: generate IIF journal entries for QuickBooks Desktop.
|
||||||
|
//! - [`report`]: the statement layout every renderer draws from.
|
||||||
//! - [`xlsx`]: generate XLSX reports.
|
//! - [`xlsx`]: generate XLSX reports.
|
||||||
//! - [`pdf`]: generate PDF financial statements (CPA handoff).
|
//! - [`pdf`]: generate PDF financial statements (CPA handoff).
|
||||||
//!
|
//!
|
||||||
@@ -12,10 +13,12 @@ pub mod bank_csv;
|
|||||||
pub mod chart_csv;
|
pub mod chart_csv;
|
||||||
pub mod iif;
|
pub mod iif;
|
||||||
pub mod pdf;
|
pub mod pdf;
|
||||||
|
pub mod report;
|
||||||
pub mod xlsx;
|
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, 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};
|
pub use xlsx::{XlsxReport, report_to_xlsx};
|
||||||
+19
-158
@@ -3,13 +3,11 @@
|
|||||||
//! 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 crate::report::{ReportMeta, RowKind, Statement, format_amount};
|
||||||
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,
|
||||||
Rgb, TextItem,
|
Rgb, TextItem,
|
||||||
};
|
};
|
||||||
use rust_decimal::Decimal;
|
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
|
|
||||||
/// PDF rendering errors.
|
/// PDF rendering errors.
|
||||||
@@ -20,11 +18,6 @@ pub enum PdfError {
|
|||||||
Save(String),
|
Save(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
fn money(d: Decimal) -> String {
|
|
||||||
// Two decimals, no float anywhere.
|
|
||||||
format!("{:.2}", d)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn text_ops(
|
fn text_ops(
|
||||||
title: &str,
|
title: &str,
|
||||||
subtitle: &str,
|
subtitle: &str,
|
||||||
@@ -103,160 +96,28 @@ fn save_pdf(title: &str, ops: Vec<Op>) -> Result<Vec<u8>, PdfError> {
|
|||||||
Ok(bytes)
|
Ok(bytes)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Render a profit-and-loss statement PDF: revenue and expense accounts with
|
/// Render a laid-out statement (see [`crate::report`]) as a PDF.
|
||||||
/// balances, then totals and net income.
|
pub fn statement_pdf(st: &Statement, meta: &ReportMeta) -> Result<Vec<u8>, PdfError> {
|
||||||
pub fn profit_and_loss_pdf(ledger: &Ledger, start: &str, end: &str) -> Result<Vec<u8>, PdfError> {
|
let mut rows = Vec::new();
|
||||||
let mut revenue_rows: Vec<(String, String)> = Vec::new();
|
let mut totals = Vec::new();
|
||||||
let mut expense_rows: Vec<(String, String)> = Vec::new();
|
for r in &st.rows {
|
||||||
let mut accounts: Vec<_> = ledger.chart().all().collect();
|
let amount = r.amount.map(format_amount).unwrap_or_default();
|
||||||
accounts.sort_by(|a, b| a.code.cmp(&b.code));
|
match r.kind {
|
||||||
for account in accounts {
|
RowKind::Spacer => rows.push((String::new(), String::new())),
|
||||||
if account.placeholder {
|
RowKind::Section => rows.push((r.label.to_uppercase(), String::new())),
|
||||||
continue;
|
RowKind::Line => rows.push((format!(" {}", r.label), amount)),
|
||||||
}
|
RowKind::Subtotal | RowKind::Total => rows.push((r.label.clone(), amount)),
|
||||||
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))),
|
|
||||||
_ => {}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let pl =
|
totals.push((
|
||||||
ledger_core::reports::profit_and_loss(ledger).map_err(|e| PdfError::Save(e.to_string()))?;
|
format!("Prepared from journal through seq {}", meta.journal_seq),
|
||||||
|
String::new(),
|
||||||
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())),
|
|
||||||
];
|
|
||||||
|
|
||||||
let ops = text_ops(
|
let ops = text_ops(
|
||||||
"Profit and Loss",
|
&format!("{} — {}", meta.company, st.title),
|
||||||
&format!("{} to {}", start, end),
|
&st.period,
|
||||||
&rows,
|
&rows,
|
||||||
&totals,
|
&totals,
|
||||||
);
|
);
|
||||||
save_pdf("Profit and Loss", ops)
|
save_pdf(&st.title, 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<Vec<u8>, 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<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)
|
|
||||||
}
|
}
|
||||||
@@ -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<Decimal>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<Row>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn row(kind: RowKind, label: impl Into<String>, amount: Option<Decimal>) -> 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<Item = Row> + '_ {
|
||||||
|
lines.iter().map(|l| {
|
||||||
|
row(
|
||||||
|
RowKind::Line,
|
||||||
|
format!("{} {}", l.code, l.name),
|
||||||
|
Some(l.amount),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn flow_lines(lines: &[CashFlowLine]) -> impl Iterator<Item = Row> + '_ {
|
||||||
|
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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
+120
-158
@@ -2,12 +2,15 @@
|
|||||||
//!
|
//!
|
||||||
//! Money is converted to f64 ONLY at this presentation boundary — never in
|
//! Money is converted to f64 ONLY at this presentation boundary — never in
|
||||||
//! a money path. All accounting math stays `Decimal`; the conversion happens
|
//! 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::Ledger;
|
||||||
use ledger_core::reports::{BalanceSheet, CashFlowStatement, ProfitAndLoss, TrialBalance};
|
use ledger_core::reports::TrialBalance;
|
||||||
use rust_decimal::Decimal;
|
use rust_decimal::Decimal;
|
||||||
use rust_xlsxwriter::{Format, Workbook};
|
use rust_xlsxwriter::{Format, FormatBorder, Workbook, Worksheet};
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
|
|
||||||
/// Which report to render.
|
/// Which report to render.
|
||||||
@@ -15,12 +18,9 @@ use thiserror::Error;
|
|||||||
pub enum XlsxReport<'a> {
|
pub enum XlsxReport<'a> {
|
||||||
/// Trial balance lines.
|
/// Trial balance lines.
|
||||||
TrialBalance(&'a TrialBalance),
|
TrialBalance(&'a TrialBalance),
|
||||||
/// Profit and loss.
|
/// A laid-out financial statement (P&L, balance sheet, cash flow); see
|
||||||
ProfitAndLoss(&'a ProfitAndLoss, String, String),
|
/// [`crate::report`].
|
||||||
/// Balance sheet.
|
Statement(&'a Statement),
|
||||||
BalanceSheet(&'a BalanceSheet, String),
|
|
||||||
/// Cash flow statement.
|
|
||||||
CashFlow(&'a CashFlowStatement),
|
|
||||||
/// General ledger: every posted journal line. Needs the ledger for
|
/// General ledger: every posted journal line. Needs the ledger for
|
||||||
/// account names and class names.
|
/// account names and class names.
|
||||||
GeneralLedger(&'a Ledger),
|
GeneralLedger(&'a Ledger),
|
||||||
@@ -34,155 +34,120 @@ pub enum XlsxError {
|
|||||||
Write(String),
|
Write(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl From<rust_xlsxwriter::XlsxError> 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<u32, XlsxError> {
|
||||||
|
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.
|
/// Render a report to XLSX bytes.
|
||||||
pub fn report_to_xlsx(report: &XlsxReport<'_>) -> Result<Vec<u8>, XlsxError> {
|
pub fn report_to_xlsx(report: &XlsxReport<'_>, meta: &ReportMeta) -> Result<Vec<u8>, XlsxError> {
|
||||||
let mut workbook = Workbook::new();
|
let mut workbook = Workbook::new();
|
||||||
let sheet = workbook.add_worksheet();
|
let sheet = workbook.add_worksheet();
|
||||||
let bold = Format::new().set_bold();
|
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 {
|
match report {
|
||||||
XlsxReport::TrialBalance(tb) => {
|
XlsxReport::TrialBalance(tb) => {
|
||||||
sheet.write_string(row, 0, "Trial Balance").unwrap();
|
sheet.set_name("Trial Balance")?;
|
||||||
row += 1;
|
let mut row = header(sheet, meta, "Trial Balance", None)?;
|
||||||
for (i, h) in ["Code", "Account", "Debit", "Credit"].iter().enumerate() {
|
for (i, h) in ["Code", "Account", "Debit", "Credit"].iter().enumerate() {
|
||||||
sheet
|
sheet.write_string_with_format(row, i as u16, *h, &bold)?;
|
||||||
.write_string_with_format(row, i as u16, *h, &bold)
|
|
||||||
.unwrap();
|
|
||||||
}
|
}
|
||||||
row += 1;
|
row += 1;
|
||||||
for line in &tb.lines {
|
for line in &tb.lines {
|
||||||
sheet.write_string(row, 0, &line.code).unwrap();
|
sheet.write_string(row, 0, &line.code)?;
|
||||||
sheet.write_string(row, 1, &line.name).unwrap();
|
sheet.write_string(row, 1, &line.name)?;
|
||||||
sheet
|
sheet.write_number_with_format(row, 2, dec_to_f64(line.debit), &money)?;
|
||||||
.write_number_with_format(row, 2, dec_to_f64(line.debit), &money_fmt)
|
sheet.write_number_with_format(row, 3, dec_to_f64(line.credit), &money)?;
|
||||||
.unwrap();
|
|
||||||
sheet
|
|
||||||
.write_number_with_format(row, 3, dec_to_f64(line.credit), &money_fmt)
|
|
||||||
.unwrap();
|
|
||||||
row += 1;
|
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) => {
|
XlsxReport::Statement(st) => {
|
||||||
sheet
|
sheet.set_name(&st.title)?;
|
||||||
.write_string(row, 0, format!("Profit and Loss ({start} to {end})"))
|
let mut row = header(sheet, meta, &st.title, Some(&st.period))?;
|
||||||
.unwrap();
|
let line_label = Format::new().set_indent(1);
|
||||||
row += 2;
|
let subtotal = money_bold.clone().set_border_top(FormatBorder::Thin);
|
||||||
sheet.write_string(row, 0, "Revenue").unwrap();
|
let total = money_bold
|
||||||
sheet
|
.clone()
|
||||||
.write_number_with_format(row, 1, dec_to_f64(pl.revenue), &money_fmt)
|
.set_border_top(FormatBorder::Thin)
|
||||||
.unwrap();
|
.set_border_bottom(FormatBorder::Double);
|
||||||
row += 1;
|
for r in &st.rows {
|
||||||
sheet.write_string(row, 0, "Expenses").unwrap();
|
let amount = r.amount.map(dec_to_f64);
|
||||||
sheet
|
match r.kind {
|
||||||
.write_number_with_format(row, 1, dec_to_f64(pl.expenses), &money_fmt)
|
RowKind::Spacer => {}
|
||||||
.unwrap();
|
RowKind::Section => {
|
||||||
row += 1;
|
sheet.write_string_with_format(row, 0, &r.label, &bold)?;
|
||||||
sheet
|
}
|
||||||
.write_string_with_format(row, 0, "Net income", &bold)
|
RowKind::Line => {
|
||||||
.unwrap();
|
sheet.write_string_with_format(row, 0, &r.label, &line_label)?;
|
||||||
let net = pl.revenue - pl.expenses;
|
if let Some(a) = amount {
|
||||||
sheet
|
sheet.write_number_with_format(row, 1, a, &money)?;
|
||||||
.write_number_with_format(row, 1, dec_to_f64(net), &money_fmt)
|
}
|
||||||
.unwrap();
|
}
|
||||||
|
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)?;
|
||||||
}
|
}
|
||||||
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();
|
|
||||||
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;
|
row += 1;
|
||||||
}
|
}
|
||||||
sheet
|
sheet.set_column_width(0, 60)?;
|
||||||
.write_string_with_format(row, 0, "Cash from operations", &bold)
|
sheet.set_column_width(1, 16)?;
|
||||||
.unwrap();
|
footer(sheet, meta, row)?;
|
||||||
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();
|
|
||||||
}
|
}
|
||||||
XlsxReport::GeneralLedger(ledger) => {
|
XlsxReport::GeneralLedger(ledger) => {
|
||||||
sheet.write_string(row, 0, "General Ledger").unwrap();
|
sheet.set_name("General Ledger")?;
|
||||||
row += 1;
|
let mut row = header(sheet, meta, "General Ledger", None)?;
|
||||||
for (i, h) in [
|
for (i, h) in [
|
||||||
"Date",
|
"Date",
|
||||||
"Entry",
|
"Entry",
|
||||||
@@ -196,9 +161,7 @@ pub fn report_to_xlsx(report: &XlsxReport<'_>) -> Result<Vec<u8>, XlsxError> {
|
|||||||
.iter()
|
.iter()
|
||||||
.enumerate()
|
.enumerate()
|
||||||
{
|
{
|
||||||
sheet
|
sheet.write_string_with_format(row, i as u16, *h, &bold)?;
|
||||||
.write_string_with_format(row, i as u16, *h, &bold)
|
|
||||||
.unwrap();
|
|
||||||
}
|
}
|
||||||
row += 1;
|
row += 1;
|
||||||
for entry in ledger.posted_entries() {
|
for entry in ledger.posted_entries() {
|
||||||
@@ -217,29 +180,24 @@ pub fn report_to_xlsx(report: &XlsxReport<'_>) -> Result<Vec<u8>, XlsxError> {
|
|||||||
ledger_core::Side::Debit => (line.amount.amount, Decimal::ZERO),
|
ledger_core::Side::Debit => (line.amount.amount, Decimal::ZERO),
|
||||||
ledger_core::Side::Credit => (Decimal::ZERO, line.amount.amount),
|
ledger_core::Side::Credit => (Decimal::ZERO, line.amount.amount),
|
||||||
};
|
};
|
||||||
sheet
|
sheet.write_string(row, 0, entry.date().to_string())?;
|
||||||
.write_string(row, 0, entry.date().to_string())
|
sheet.write_number(row, 1, entry.id() as f64)?;
|
||||||
.unwrap();
|
sheet.write_string(row, 2, entry.description())?;
|
||||||
sheet.write_number(row, 1, entry.id() as f64).unwrap();
|
sheet.write_string(row, 3, code)?;
|
||||||
sheet.write_string(row, 2, entry.description()).unwrap();
|
sheet.write_string(row, 4, name)?;
|
||||||
sheet.write_string(row, 3, code).unwrap();
|
sheet.write_string(row, 5, class_name)?;
|
||||||
sheet.write_string(row, 4, name).unwrap();
|
sheet.write_number_with_format(row, 6, dec_to_f64(debit), &money)?;
|
||||||
sheet.write_string(row, 5, class_name).unwrap();
|
sheet.write_number_with_format(row, 7, dec_to_f64(credit), &money)?;
|
||||||
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();
|
|
||||||
row += 1;
|
row += 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
sheet.set_column_width(2, 50)?;
|
||||||
|
sheet.set_column_width(4, 32)?;
|
||||||
|
footer(sheet, meta, row)?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
workbook
|
Ok(workbook.save_to_buffer()?)
|
||||||
.save_to_buffer()
|
|
||||||
.map_err(|e| XlsxError::Write(e.to_string()))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Decimal to f64 at the presentation boundary. Rounded to cents first.
|
/// Decimal to f64 at the presentation boundary. Rounded to cents first.
|
||||||
@@ -265,7 +223,11 @@ mod tests {
|
|||||||
total_debits: Decimal::new(10000, 2),
|
total_debits: Decimal::new(10000, 2),
|
||||||
total_credits: 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.
|
// XLSX is a ZIP: starts with PK.
|
||||||
assert_eq!(&bytes[0..2], b"PK");
|
assert_eq!(&bytes[0..2], b"PK");
|
||||||
assert!(bytes.len() > 1000);
|
assert!(bytes.len() > 1000);
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ chrono = "0.4"
|
|||||||
thiserror = "2"
|
thiserror = "2"
|
||||||
tokio = { version = "1", features = ["rt-multi-thread", "macros", "io-util", "sync"] }
|
tokio = { version = "1", features = ["rt-multi-thread", "macros", "io-util", "sync"] }
|
||||||
zip = "9.0.1"
|
zip = "9.0.1"
|
||||||
|
toml = "1"
|
||||||
|
|
||||||
[lints]
|
[lints]
|
||||||
workspace = true
|
workspace = true
|
||||||
@@ -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<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct BooksToml {
|
||||||
|
#[serde(default)]
|
||||||
|
company: CompanyToml,
|
||||||
|
#[serde(default)]
|
||||||
|
books: BooksSection,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default, Deserialize)]
|
||||||
|
struct CompanyToml {
|
||||||
|
name: Option<String>,
|
||||||
|
fiscal_year_start: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default, Deserialize)]
|
||||||
|
struct BooksSection {
|
||||||
|
receipt_threshold: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<BooksSettings, String> {
|
||||||
|
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::<Decimal>()
|
||||||
|
.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<BooksSettings, String> {
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,12 +1,15 @@
|
|||||||
//! `ledger-mcp`: the AI-first bookkeeping interface, over stdio.
|
//! `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 data dir holds `journal.jsonl` (the source of truth), `wanderbooks.db`
|
||||||
//! (the disposable SQLite projection), `blobs/` (content-addressed receipts),
|
//! (the disposable SQLite projection), `blobs/` (content-addressed receipts),
|
||||||
//! and optionally `config.json`. If no store exists, one is created with the
|
//! 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 policy;
|
||||||
mod server;
|
mod server;
|
||||||
mod types;
|
mod types;
|
||||||
@@ -25,9 +28,10 @@ struct Config {
|
|||||||
/// ISO 4217 base currency for a new store. Default "USD".
|
/// ISO 4217 base currency for a new store. Default "USD".
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
base_currency: Option<String>,
|
base_currency: Option<String>,
|
||||||
/// 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)]
|
#[serde(default)]
|
||||||
receipt_policy: ReceiptPolicy,
|
receipt_policy: Option<ReceiptPolicy>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_data_dir() -> PathBuf {
|
fn default_data_dir() -> PathBuf {
|
||||||
@@ -36,15 +40,23 @@ fn default_data_dir() -> PathBuf {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn print_usage() {
|
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!(" --data-dir directory holding journal.jsonl (default ~/.local/share/wanderbooks)");
|
||||||
eprintln!(" --config JSON config file (default <data-dir>/config.json, optional)");
|
eprintln!(" --config JSON config file (default <data-dir>/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 <data-dir>/../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<dyn std::error::Error>> {
|
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
let mut data_dir = default_data_dir();
|
let mut data_dir = default_data_dir();
|
||||||
let mut config_path: Option<PathBuf> = None;
|
let mut config_path: Option<PathBuf> = None;
|
||||||
|
let mut books_toml: Option<PathBuf> = None;
|
||||||
let mut http_addr: Option<String> = None;
|
let mut http_addr: Option<String> = None;
|
||||||
let mut args = std::env::args().skip(1);
|
let mut args = std::env::args().skip(1);
|
||||||
while let Some(arg) = args.next() {
|
while let Some(arg) = args.next() {
|
||||||
@@ -55,6 +67,11 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
"--config" => {
|
"--config" => {
|
||||||
config_path = Some(PathBuf::from(args.next().ok_or("--config needs a path")?));
|
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" => {
|
||||||
http_addr = Some(args.next().ok_or("--http needs an address")?);
|
http_addr = Some(args.next().ok_or("--http needs an address")?);
|
||||||
}
|
}
|
||||||
@@ -79,6 +96,24 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
};
|
};
|
||||||
// Logs go to stderr: stdout is the MCP channel.
|
// Logs go to stderr: stdout is the MCP channel.
|
||||||
eprintln!("ledger-mcp: data dir {}", data_dir.display());
|
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() {
|
let store = if data_dir.join(ledger_store::JOURNAL_FILE).exists() {
|
||||||
Store::open(&data_dir)?
|
Store::open(&data_dir)?
|
||||||
@@ -99,12 +134,12 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
.build()?;
|
.build()?;
|
||||||
if let Some(addr) = http_addr {
|
if let Some(addr) = http_addr {
|
||||||
eprintln!("ledger-mcp: store verified, serving Streamable HTTP on {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(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
eprintln!("ledger-mcp: store verified, serving over stdio");
|
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 {
|
rt.block_on(async {
|
||||||
let running = server.serve(stdio()).await?;
|
let running = server.serve(stdio()).await?;
|
||||||
let _ = running.waiting().await;
|
let _ = running.waiting().await;
|
||||||
@@ -117,6 +152,7 @@ async fn serve_http(
|
|||||||
addr: String,
|
addr: String,
|
||||||
store: Store,
|
store: Store,
|
||||||
policy: ReceiptPolicy,
|
policy: ReceiptPolicy,
|
||||||
|
company: company::Company,
|
||||||
) -> Result<(), Box<dyn std::error::Error>> {
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
use rmcp::transport::streamable_http_server::{
|
use rmcp::transport::streamable_http_server::{
|
||||||
StreamableHttpService, session::local::LocalSessionManager,
|
StreamableHttpService, session::local::LocalSessionManager,
|
||||||
@@ -126,7 +162,8 @@ async fn serve_http(
|
|||||||
let store = Arc::new(std::sync::Mutex::new(store));
|
let store = Arc::new(std::sync::Mutex::new(store));
|
||||||
let service = StreamableHttpService::new(
|
let service = StreamableHttpService::new(
|
||||||
move || {
|
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)
|
Ok(server)
|
||||||
},
|
},
|
||||||
Arc::new(LocalSessionManager::default()),
|
Arc::new(LocalSessionManager::default()),
|
||||||
|
|||||||
+233
-282
@@ -1,14 +1,16 @@
|
|||||||
//! The MCP server: narrow typed tools over the store, with the receipt
|
//! The MCP server: narrow typed tools over the store, with the receipt
|
||||||
//! policy enforced server-side.
|
//! policy enforced server-side.
|
||||||
|
|
||||||
|
use crate::company::Company;
|
||||||
use crate::policy::ReceiptPolicy;
|
use crate::policy::ReceiptPolicy;
|
||||||
use crate::types::*;
|
use crate::types::*;
|
||||||
use chrono::{Datelike, NaiveDate};
|
use chrono::NaiveDate;
|
||||||
use ledger_core::{
|
use ledger_core::{
|
||||||
AccountId, AccountType, AssetStatus, Currency, DocumentLine, DocumentStatus, JournalEntry,
|
AccountId, AccountType, AssetStatus, Currency, DocumentLine, DocumentStatus, JournalEntry,
|
||||||
Ledger, Money, NewAsset, PeriodStatus, Side, accumulated_depreciation, book_value,
|
Ledger, Money, NewAsset, PeriodStatus, Side, accumulated_depreciation, book_value,
|
||||||
depreciation_schedule, reports,
|
depreciation_schedule, reports,
|
||||||
};
|
};
|
||||||
|
use ledger_import::ReportMeta;
|
||||||
use ledger_store::Store;
|
use ledger_store::Store;
|
||||||
use rmcp::handler::server::wrapper::{Json, Parameters};
|
use rmcp::handler::server::wrapper::{Json, Parameters};
|
||||||
use rmcp::model::{
|
use rmcp::model::{
|
||||||
@@ -33,6 +35,7 @@ const MAX_UPLOAD_BYTES: usize = 25 * 1024 * 1024;
|
|||||||
pub struct BooksServer {
|
pub struct BooksServer {
|
||||||
store: Arc<Mutex<Store>>,
|
store: Arc<Mutex<Store>>,
|
||||||
policy: ReceiptPolicy,
|
policy: ReceiptPolicy,
|
||||||
|
company: Company,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl BooksServer {
|
impl BooksServer {
|
||||||
@@ -41,13 +44,32 @@ impl BooksServer {
|
|||||||
Self {
|
Self {
|
||||||
store: Arc::new(Mutex::new(store)),
|
store: Arc::new(Mutex::new(store)),
|
||||||
policy,
|
policy,
|
||||||
|
company: Company::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build the server around a shared store (for HTTP: each connection
|
/// Build the server around a shared store (for HTTP: each connection
|
||||||
/// gets a handle to the same store).
|
/// gets a handle to the same store).
|
||||||
pub fn new_shared(store: Arc<Mutex<Store>>, policy: ReceiptPolicy) -> Self {
|
pub fn new_shared(store: Arc<Mutex<Store>>, 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<std::sync::MutexGuard<'_, Store>, String> {
|
fn lock(&self) -> Result<std::sync::MutexGuard<'_, Store>, String> {
|
||||||
@@ -283,14 +305,7 @@ fn cash_flow_statement(
|
|||||||
return Err("cash_accounts is required for the cash flow statement".to_string());
|
return Err("cash_accounts is required for the cash flow statement".to_string());
|
||||||
}
|
}
|
||||||
let financing_accounts = resolve(financing_codes, "financing")?;
|
let financing_accounts = resolve(financing_codes, "financing")?;
|
||||||
let start = match start {
|
let (start, end) = statement_range(ledger, start, end)?;
|
||||||
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_core::cash_flow(
|
||||||
ledger,
|
ledger,
|
||||||
&ledger_core::CashFlowParams {
|
&ledger_core::CashFlowParams {
|
||||||
@@ -303,50 +318,13 @@ fn cash_flow_statement(
|
|||||||
.map_err(|e| e.to_string())
|
.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.
|
/// Net movement of one account over entries in `[from, to]`, normal side.
|
||||||
fn movement_in(ledger: &Ledger, account: AccountId, from: NaiveDate, to: NaiveDate) -> Decimal {
|
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<u32>,
|
|
||||||
) -> Decimal {
|
|
||||||
let movement: Decimal = ledger
|
let movement: Decimal = ledger
|
||||||
.posted_entries()
|
.posted_entries()
|
||||||
.filter(|e| e.date() >= from && e.date() <= to)
|
.filter(|e| e.date() >= from && e.date() <= to)
|
||||||
.flat_map(|e| e.lines())
|
.flat_map(|e| e.lines())
|
||||||
.filter(|l| l.account == account)
|
.filter(|l| l.account == account)
|
||||||
.filter(|l| match class_id {
|
|
||||||
None => true,
|
|
||||||
Some(cid) => l.class_id == Some(cid),
|
|
||||||
})
|
|
||||||
.map(|l| match l.side {
|
.map(|l| match l.side {
|
||||||
Side::Debit => l.amount.amount,
|
Side::Debit => l.amount.amount,
|
||||||
Side::Credit => -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<ledger_core::ClassId>,
|
||||||
|
) -> Result<ledger_core::ProfitAndLossStatement, String> {
|
||||||
|
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<ledger_core::BalanceSheetStatement, String> {
|
||||||
|
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<StatementLineOut> {
|
||||||
|
lines
|
||||||
|
.iter()
|
||||||
|
.map(|l| StatementLineOut {
|
||||||
|
code: l.code.clone(),
|
||||||
|
name: l.name.clone(),
|
||||||
|
amount: money(l.amount),
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Tools
|
// Tools
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -456,44 +498,24 @@ impl BooksServer {
|
|||||||
) -> Result<Json<ProfitAndLossOut>, String> {
|
) -> Result<Json<ProfitAndLossOut>, String> {
|
||||||
let p = params.0;
|
let p = params.0;
|
||||||
let store = self.lock()?;
|
let store = self.lock()?;
|
||||||
let ledger = store.ledger();
|
let class_id = match &p.class {
|
||||||
let start = match p.start {
|
Some(c) => Some(resolve_class(&store, c)?),
|
||||||
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)?),
|
|
||||||
None => None,
|
None => None,
|
||||||
};
|
};
|
||||||
let mut revenue = Decimal::ZERO;
|
let pl = pnl_statement(
|
||||||
let mut expenses = Decimal::ZERO;
|
store.ledger(),
|
||||||
for a in ledger.chart().all() {
|
p.start.as_deref(),
|
||||||
if a.placeholder {
|
p.end.as_deref(),
|
||||||
continue;
|
class_id,
|
||||||
}
|
)?;
|
||||||
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)
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(Json(ProfitAndLossOut {
|
Ok(Json(ProfitAndLossOut {
|
||||||
start: start.to_string(),
|
start: pl.start.to_string(),
|
||||||
end: end.to_string(),
|
end: pl.end.to_string(),
|
||||||
revenue: money(revenue),
|
revenue: money(pl.total_revenue),
|
||||||
expenses: money(expenses),
|
expenses: money(pl.total_expenses),
|
||||||
net_income: money(revenue - 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<Json<BalanceSheetOut>, String> {
|
) -> Result<Json<BalanceSheetOut>, String> {
|
||||||
let p = params.0;
|
let p = params.0;
|
||||||
let store = self.lock()?;
|
let store = self.lock()?;
|
||||||
let ledger = store.ledger();
|
let bs = bs_statement(store.ledger(), p.as_of.as_deref())?;
|
||||||
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;
|
|
||||||
Ok(Json(BalanceSheetOut {
|
Ok(Json(BalanceSheetOut {
|
||||||
as_of: as_of.to_string(),
|
as_of: bs.as_of.to_string(),
|
||||||
assets: money(assets),
|
assets: money(bs.total_assets),
|
||||||
liabilities: money(liabilities),
|
liabilities: money(bs.total_liabilities),
|
||||||
equity: money(equity),
|
equity: money(bs.total_equity),
|
||||||
current_earnings: money(current_earnings),
|
current_earnings: money(bs.current_earnings),
|
||||||
balanced: assets == liabilities + equity,
|
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.
|
/// Lay out a financial statement for rendering, from export params.
|
||||||
fn export_xlsx(&self, report: &str, p: &ExportParams) -> Result<Vec<u8>, String> {
|
fn layout_statement(
|
||||||
use ledger_import::{XlsxReport, report_to_xlsx};
|
&self,
|
||||||
let store = self.lock()?;
|
ledger: &Ledger,
|
||||||
let ledger = store.ledger();
|
report: &str,
|
||||||
// Build owned report data first (all report structs are owned).
|
p: &ExportParams,
|
||||||
enum Owned {
|
) -> Result<ledger_import::Statement, String> {
|
||||||
Tb(ledger_core::reports::TrialBalance),
|
use ledger_import::report;
|
||||||
Pnl(ledger_core::reports::ProfitAndLoss, String, String),
|
Ok(match report {
|
||||||
Bs(ledger_core::reports::BalanceSheet, String),
|
"profit_and_loss" => report::profit_and_loss(&pnl_statement(
|
||||||
Cf(ledger_core::reports::CashFlowStatement),
|
ledger,
|
||||||
}
|
p.start.as_deref(),
|
||||||
let owned = match report {
|
p.end.as_deref(),
|
||||||
"trial_balance" => Owned::Tb(reports::trial_balance(ledger)),
|
None,
|
||||||
"profit_and_loss" => {
|
)?),
|
||||||
let start = match &p.start {
|
"balance_sheet" => report::balance_sheet(&bs_statement(ledger, p.as_of.as_deref())?),
|
||||||
Some(s) => parse_date(s)?,
|
"cash_flow" => report::cash_flow(&cash_flow_statement(
|
||||||
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(
|
|
||||||
ledger,
|
ledger,
|
||||||
p.start.as_deref(),
|
p.start.as_deref(),
|
||||||
p.end.as_deref(),
|
p.end.as_deref(),
|
||||||
@@ -1487,47 +1463,35 @@ impl BooksServer {
|
|||||||
)?),
|
)?),
|
||||||
_ => {
|
_ => {
|
||||||
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:?}: 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()),
|
/// Render a report to XLSX bytes via ledger-import.
|
||||||
Owned::Bs(bs, a) => XlsxReport::BalanceSheet(bs, a.clone()),
|
fn export_xlsx(&self, report: &str, p: &ExportParams) -> Result<Vec<u8>, String> {
|
||||||
Owned::Cf(cf) => XlsxReport::CashFlow(cf),
|
use ledger_import::{XlsxReport, report_to_xlsx};
|
||||||
};
|
let store = self.lock()?;
|
||||||
report_to_xlsx(&xlsx_report).map_err(|e| e.to_string())
|
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.
|
/// Render a financial statement to PDF bytes via ledger-import.
|
||||||
fn export_pdf(&self, report: &str, p: &ExportParams) -> Result<Vec<u8>, String> {
|
fn export_pdf(&self, report: &str, p: &ExportParams) -> Result<Vec<u8>, String> {
|
||||||
let store = self.lock()?;
|
let store = self.lock()?;
|
||||||
let ledger = store.ledger();
|
let meta = self.report_meta(&store);
|
||||||
let today = || chrono::Utc::now().date_naive().to_string();
|
let st = self
|
||||||
match report {
|
.layout_statement(store.ledger(), report, p)
|
||||||
"profit_and_loss" => ledger_import::profit_and_loss_pdf(
|
.map_err(|e| e.replace("bad report", "bad report for pdf"))?;
|
||||||
ledger,
|
ledger_import::statement_pdf(&st, &meta).map_err(|e| e.to_string())
|
||||||
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.
|
||||||
@@ -2541,44 +2505,42 @@ impl BooksServer {
|
|||||||
&self,
|
&self,
|
||||||
params: Parameters<ExportCpaPackageParams>,
|
params: Parameters<ExportCpaPackageParams>,
|
||||||
) -> Result<Json<ExportCpaPackageOut>, String> {
|
) -> Result<Json<ExportCpaPackageOut>, String> {
|
||||||
use ledger_import::{XlsxReport, report_to_xlsx};
|
use ledger_import::{XlsxReport, report, report_to_xlsx, statement_pdf};
|
||||||
let p = params.0;
|
let p = params.0;
|
||||||
let as_of = match p.as_of {
|
let as_of = match p.as_of {
|
||||||
Some(s) => parse_date(&s)?,
|
Some(s) => parse_date(&s)?,
|
||||||
None => chrono::Utc::now().date_naive(),
|
None => today(),
|
||||||
};
|
};
|
||||||
let store = self.lock()?;
|
let store = self.lock()?;
|
||||||
|
let meta = self.report_meta(&store);
|
||||||
let ledger = store.ledger();
|
let ledger = store.ledger();
|
||||||
|
|
||||||
// Fiscal year to date for the P&L: Jan 1 of the as_of year.
|
// Fiscal year to date, per wanderbooks.toml's fiscal_year_start.
|
||||||
let fy_start = NaiveDate::from_ymd_opt(as_of.year(), 1, 1).unwrap();
|
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_xlsx = xlsx(XlsxReport::TrialBalance(&reports::trial_balance(ledger)))?;
|
||||||
let tb = ledger_core::reports::trial_balance(ledger);
|
let gl_xlsx = xlsx(XlsxReport::GeneralLedger(ledger))?;
|
||||||
let tb_xlsx = report_to_xlsx(&XlsxReport::TrialBalance(&tb)).map_err(|e| e.to_string())?;
|
let pnl_pdf = pdf(report::profit_and_loss(&pnl_statement(
|
||||||
|
|
||||||
// 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(
|
|
||||||
ledger,
|
ledger,
|
||||||
Some(&fy_start.to_string()),
|
Some(&fy_start),
|
||||||
Some(&as_of.to_string()),
|
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.cash_accounts,
|
||||||
&p.financing_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.
|
||||||
@@ -2708,86 +2670,75 @@ impl BooksServer {
|
|||||||
end: Option<&str>,
|
end: Option<&str>,
|
||||||
) -> Result<String, String> {
|
) -> Result<String, String> {
|
||||||
let store = self.lock()?;
|
let store = self.lock()?;
|
||||||
let ledger = store.ledger();
|
let pl = pnl_statement(store.ledger(), start, end, None)?;
|
||||||
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),
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if format == "json" {
|
if format == "json" {
|
||||||
return serde_json::to_string_pretty(&serde_json::json!({
|
return serde_json::to_string_pretty(&serde_json::json!({
|
||||||
"start": start_d.to_string(), "end": end_d.to_string(),
|
"start": pl.start.to_string(), "end": pl.end.to_string(),
|
||||||
"revenue": money(revenue), "expenses": money(expenses),
|
"revenue_accounts": lines_out(&pl.revenue),
|
||||||
"net_income": money(revenue - expenses),
|
"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());
|
.map_err(|e| e.to_string());
|
||||||
}
|
}
|
||||||
Ok(format!(
|
let mut csv = String::from("section,code,name,amount\n");
|
||||||
"metric,amount\nrevenue,{}\nexpenses,{}\nnet_income,{}\n",
|
for (section, lines) in [("revenue", &pl.revenue), ("expenses", &pl.expenses)] {
|
||||||
money(revenue),
|
for l in lines {
|
||||||
money(expenses),
|
csv.push_str(&format!(
|
||||||
money(revenue - expenses)
|
"{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<String, String> {
|
fn export_balance_sheet(&self, format: &str, as_of: Option<&str>) -> Result<String, String> {
|
||||||
let store = self.lock()?;
|
let store = self.lock()?;
|
||||||
let ledger = store.ledger();
|
let bs = bs_statement(store.ledger(), as_of)?;
|
||||||
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;
|
|
||||||
if format == "json" {
|
if format == "json" {
|
||||||
return serde_json::to_string_pretty(&serde_json::json!({
|
return serde_json::to_string_pretty(&serde_json::json!({
|
||||||
"as_of": as_of_d.to_string(),
|
"as_of": bs.as_of.to_string(),
|
||||||
"assets": money(assets), "liabilities": money(liabilities),
|
"asset_accounts": lines_out(&bs.assets),
|
||||||
"equity": money(equity), "current_earnings": money(current_earnings),
|
"liability_accounts": lines_out(&bs.liabilities),
|
||||||
"balanced": assets == liabilities + equity,
|
"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());
|
.map_err(|e| e.to_string());
|
||||||
}
|
}
|
||||||
Ok(format!(
|
let mut csv = String::from("section,code,name,amount\n");
|
||||||
"metric,amount\nassets,{}\nliabilities,{}\nequity,{}\ncurrent_earnings,{}\n",
|
for (section, lines) in [
|
||||||
money(assets),
|
("assets", &bs.assets),
|
||||||
money(liabilities),
|
("liabilities", &bs.liabilities),
|
||||||
money(equity),
|
("equity", &bs.equity),
|
||||||
money(current_earnings)
|
] {
|
||||||
))
|
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<String, String> {
|
fn export_cash_flow(&self, format: &str, p: &ExportParams) -> Result<String, String> {
|
||||||
|
|||||||
@@ -114,6 +114,21 @@ pub struct ProfitAndLossOut {
|
|||||||
pub expenses: String,
|
pub expenses: String,
|
||||||
/// Revenue minus expenses, decimal string. Positive is profit.
|
/// Revenue minus expenses, decimal string. Positive is profit.
|
||||||
pub net_income: String,
|
pub net_income: String,
|
||||||
|
/// Revenue accounts with movement in the period.
|
||||||
|
pub revenue_accounts: Vec<StatementLineOut>,
|
||||||
|
/// Expense accounts with movement in the period.
|
||||||
|
pub expense_accounts: Vec<StatementLineOut>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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`.
|
/// Parameters for `books.cash_flow`.
|
||||||
@@ -285,6 +300,12 @@ pub struct BalanceSheetOut {
|
|||||||
pub current_earnings: String,
|
pub current_earnings: String,
|
||||||
/// True when assets equal liabilities plus equity exactly.
|
/// True when assets equal liabilities plus equity exactly.
|
||||||
pub balanced: bool,
|
pub balanced: bool,
|
||||||
|
/// Asset accounts with a balance.
|
||||||
|
pub asset_accounts: Vec<StatementLineOut>,
|
||||||
|
/// Liability accounts with a balance.
|
||||||
|
pub liability_accounts: Vec<StatementLineOut>,
|
||||||
|
/// Equity accounts with a balance (current earnings is separate).
|
||||||
|
pub equity_accounts: Vec<StatementLineOut>,
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -15,10 +15,15 @@ struct Mcp {
|
|||||||
|
|
||||||
impl Mcp {
|
impl Mcp {
|
||||||
fn spawn(data_dir: &std::path::Path) -> Self {
|
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 bin = env!("CARGO_BIN_EXE_ledger-mcp");
|
||||||
let mut child = Command::new(bin)
|
let mut child = Command::new(bin)
|
||||||
.arg("--data-dir")
|
.arg("--data-dir")
|
||||||
.arg(data_dir)
|
.arg(data_dir)
|
||||||
|
.args(extra)
|
||||||
.stdin(Stdio::piped())
|
.stdin(Stdio::piped())
|
||||||
.stdout(Stdio::piped())
|
.stdout(Stdio::piped())
|
||||||
.stderr(Stdio::null())
|
.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();
|
std::io::Read::read_to_end(&mut zip.by_name("cash-flow.pdf").unwrap(), &mut cf).unwrap();
|
||||||
assert!(cf.starts_with(b"%PDF"));
|
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);
|
||||||
|
}
|
||||||
@@ -1981,6 +1981,12 @@ impl Store {
|
|||||||
// Maintenance: verify and rebuild
|
// 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
|
/// Verify everything: the journal hash chain, a fresh replay's reports
|
||||||
/// against the live ledger, and the SQLite projection against the replay.
|
/// against the live ledger, and the SQLite projection against the replay.
|
||||||
/// Loud on any divergence.
|
/// Loud on any divergence.
|
||||||
|
|||||||
+17
-2
@@ -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
|
core is missing something — fix the core, don't work around it in the MCP
|
||||||
layer.
|
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
|
## Transports
|
||||||
|
|
||||||
1. **stdio** (build first). For Claude Code, Claude Desktop, local agents.
|
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.
|
→ entries with lines, newest first. `limit` caps at 100.
|
||||||
- `books.trial_balance {}` → canonical JSON (same shape as
|
- `books.trial_balance {}` → canonical JSON (same shape as
|
||||||
`reports::snapshot` trial-balance section).
|
`reports::snapshot` trial-balance section).
|
||||||
- `books.balance_sheet {}` → canonical JSON.
|
- `books.balance_sheet { as_of? }` → totals (`assets`, `liabilities`,
|
||||||
- `books.profit_and_loss {}` → canonical JSON.
|
`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? }`
|
- `books.cash_flow { start?, end?, cash_accounts: ["1000"], financing_accounts? }`
|
||||||
→ indirect-method cash flow statement. `cash_accounts` are the reconciling
|
→ indirect-method cash flow statement. `cash_accounts` are the reconciling
|
||||||
accounts (required); the three sections always sum to their net change.
|
accounts (required); the three sections always sum to their net change.
|
||||||
|
|||||||
@@ -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
|
requested label; core needs period-scoped statements); P&L and balance
|
||||||
sheet XLSX are totals-only; PDF amounts don't align (padding in a
|
sheet XLSX are totals-only; PDF amounts don't align (padding in a
|
||||||
proportional font), no company name, negatives as "-x".
|
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".
|
||||||
Reference in new issue
Block a user