feat: canonical report set in QuickBooks-style presentation
Every report a CPA expects, laid out the way US small-business books read (QuickBooks as the reference): - Exports: books.export gains general_ledger (by account, running balances, splits), general_journal (the entry-ordered listing, which the CPA package used to mislabel "general ledger"; alias journal), members_equity, and trial_balance as of a date, in xlsx/csv/json, and PDF for all five statements (P&L, balance sheet, cash flow, trial balance, members' equity). books.trial_balance takes as_of. - CPA package: every statement as PDF and XLSX, plus the general ledger and general journal, all for the fiscal year to date. - Layout (ledger-import::report): multi-column statements (Total; Debit/Credit; one column per member plus Total), "$" on subtotals and totals, a rule above every sum (none over an empty section), a double rule under statement totals, zero as 0.00, negatives in parentheses with the closing paren hanging so decimal points align. - Terms: "Net income (loss)"; for a multi-member LLC, "Members' equity", member contributions and distributions named in the cash flow, and the balance sheet splitting retained earnings (the Retained Earnings account plus earnings not yet closed from earlier years) from the fiscal year's net income (loss); the cash flow's "Adjustments to reconcile net income (loss) to net cash from operating activities" and "Net increase (decrease) in cash". - PDF: Inter replaces the typst-assets serif fonts (bundled, SIL OFL 1.1, license in fonts/; from the official v4.1 release), centered title block, column-header row, full-width rules, footer "Accrual basis · Prepared <date> · Journal seq N" with page numbers. Amounts on total rows stay regular weight so bold figures can't leave the decimal column. typst-assets dependency removed. - XLSX: centered title block, header row, Excel accounting formats with "$" attached on sums, general ledger and general journal workbooks. - Config: wanderbooks.toml [[company.members]] (name, share "50%" or "0.5", capital_account; shares must total 100%) and [books] accounting_basis (accrual | cash). - Core: RETAINED_EARNINGS_CODE names the "3999" that period close uses. - Fix: "today" is local time, not UTC; in US evenings UTC is tomorrow, so reports defaulting to "through today" included the next day and footers carried the wrong date. Tests: layout (currency and rule placement, retained earnings vs. this year's net income for a member LLC, footer text); exact template data; deterministic PDFs; config parsing (members, shares, basis); stdio: trial balance as of, February general ledger (beginning 1,004.00, splits, ending 1,003.50), members' equity (contributions 1,000.00, net income 3.50, ending 1,003.50), members' equity PDF, the CPA package's ten files. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MtYEaR6M6Ybdhm6X98BWNu
This commit is contained in:
21 files changed
+1358
-500
No files matched your search
Generated
-1
@@ -1894,7 +1894,6 @@ dependencies = [
|
||||
"thiserror",
|
||||
"typst",
|
||||
"typst-as-lib",
|
||||
"typst-assets",
|
||||
"typst-layout",
|
||||
"typst-pdf",
|
||||
]
|
||||
|
||||
@@ -642,10 +642,10 @@ impl Ledger {
|
||||
None
|
||||
} else {
|
||||
// Ensure Retained Earnings exists.
|
||||
let re_id = match self.chart.find_by_code("3999") {
|
||||
let re_id = match self.chart.find_by_code(periods::RETAINED_EARNINGS_CODE) {
|
||||
Some(a) => a.id,
|
||||
None => self.add_account(
|
||||
"3999",
|
||||
periods::RETAINED_EARNINGS_CODE,
|
||||
"Retained Earnings",
|
||||
AccountType::Equity,
|
||||
None,
|
||||
|
||||
@@ -43,7 +43,7 @@ pub use journal::{EntryId, EntryStatus, JournalEntry, JournalLine};
|
||||
pub use ledger::{Ledger, ReconciliationReport, UnclearedLine};
|
||||
pub use money::{Currency, Money};
|
||||
pub use payroll::{PayrollLine, PayrollRun, PayrollRunId};
|
||||
pub use periods::{Period, PeriodId, PeriodStatus};
|
||||
pub use periods::{Period, PeriodId, PeriodStatus, RETAINED_EARNINGS_CODE};
|
||||
pub use reports::{
|
||||
BalanceSheet, BalanceSheetStatement, CashFlowLine, CashFlowParams, CashFlowStatement,
|
||||
ClassProfitAndLoss, GeneralLedger, LedgerAccount, LedgerPosting, MemberCapital,
|
||||
|
||||
@@ -29,6 +29,10 @@ pub enum PeriodStatus {
|
||||
Closed,
|
||||
}
|
||||
|
||||
/// Code of the Retained Earnings account that closing entries credit (or
|
||||
/// debit), created on the first close if the chart lacks it.
|
||||
pub const RETAINED_EARNINGS_CODE: &str = "3999";
|
||||
|
||||
/// A named accounting period: a month, quarter, or year.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct Period {
|
||||
@@ -130,7 +134,7 @@ mod tests {
|
||||
assert_eq!(l.account_balance(rev).unwrap(), rust_decimal::Decimal::ZERO);
|
||||
assert_eq!(l.account_balance(exp).unwrap(), rust_decimal::Decimal::ZERO);
|
||||
// Retained earnings holds the net: 1000 - 400 = 600 (credit-normal).
|
||||
let re = l.chart().find_by_code("3999").unwrap().id;
|
||||
let re = l.chart().find_by_code(RETAINED_EARNINGS_CODE).unwrap().id;
|
||||
assert_eq!(
|
||||
l.account_balance(re).unwrap(),
|
||||
"600".parse::<rust_decimal::Decimal>().unwrap()
|
||||
|
||||
@@ -14,7 +14,6 @@ thiserror = "2"
|
||||
rust_xlsxwriter = "0.90"
|
||||
typst-as-lib = { version = "0.17", default-features = false }
|
||||
typst-pdf = "0.15.1"
|
||||
typst-assets = { version = "0.15.1", features = ["fonts"] }
|
||||
typst = "0.15.1"
|
||||
serde_json = "1"
|
||||
typst-layout = "0.15.1"
|
||||
Binary file not shown.
@@ -0,0 +1,92 @@
|
||||
Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION AND CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
Binary file not shown.
Binary file not shown.
@@ -20,5 +20,5 @@ pub use bank_csv::{BankTransaction, parse_bank_csv};
|
||||
pub use chart_csv::{ChartRow, parse_chart_csv};
|
||||
pub use iif::{IifEntry, journal_to_iif};
|
||||
pub use pdf::{PdfError, statement_pdf};
|
||||
pub use report::{ReportMeta, Row, RowKind, Statement, format_amount};
|
||||
pub use report::{Member, ReportMeta, Row, RowKind, Statement, format_amount};
|
||||
pub use xlsx::{XlsxReport, report_to_xlsx};
|
||||
@@ -4,9 +4,10 @@
|
||||
//! rows to `templates/statement.typ` as JSON data (`sys.inputs.data`) and
|
||||
//! renders the PDF. Amounts arrive already split by
|
||||
//! [`crate::report::amount_parts`] — Typst never does arithmetic on money,
|
||||
//! and labels are data, never markup. Fonts are bundled (`typst-assets`),
|
||||
//! so output doesn't depend on what's installed, and it is deterministic:
|
||||
//! the same statement renders to the same bytes.
|
||||
//! and labels are data, never markup. The font (Inter, SIL OFL 1.1; see
|
||||
//! `fonts/Inter-LICENSE.txt`) is bundled, so output doesn't depend on
|
||||
//! what's installed, and it is deterministic: the same statement and
|
||||
//! report metadata render to the same bytes.
|
||||
|
||||
use crate::report::{ReportMeta, RowKind, Statement, amount_parts};
|
||||
use std::sync::OnceLock;
|
||||
@@ -27,6 +28,11 @@ pub enum PdfError {
|
||||
}
|
||||
|
||||
static TEMPLATE: &str = include_str!("../templates/statement.typ");
|
||||
static INTER: [&[u8]; 3] = [
|
||||
include_bytes!("../fonts/Inter-Regular.ttf"),
|
||||
include_bytes!("../fonts/Inter-SemiBold.ttf"),
|
||||
include_bytes!("../fonts/Inter-Bold.ttf"),
|
||||
];
|
||||
|
||||
/// The engine, built once: parsing the bundled fonts is the expensive part.
|
||||
fn engine() -> &'static TypstEngine<TypstTemplateMainFile> {
|
||||
@@ -34,15 +40,17 @@ fn engine() -> &'static TypstEngine<TypstTemplateMainFile> {
|
||||
ENGINE.get_or_init(|| {
|
||||
TypstEngine::builder()
|
||||
.main_file(TEMPLATE)
|
||||
.fonts(typst_assets::fonts())
|
||||
.fonts(INTER)
|
||||
.build()
|
||||
})
|
||||
}
|
||||
|
||||
/// The template's input: the statement with every amount formatted.
|
||||
/// The template's input: the statement with every amount split for
|
||||
/// alignment.
|
||||
pub fn template_data(st: &Statement, meta: &ReportMeta) -> serde_json::Value {
|
||||
let kind = |k: RowKind| match k {
|
||||
RowKind::Section => "section",
|
||||
RowKind::Subheading => "subheading",
|
||||
RowKind::Line => "line",
|
||||
RowKind::Subtotal => "subtotal",
|
||||
RowKind::Total => "total",
|
||||
@@ -52,20 +60,17 @@ pub fn template_data(st: &Statement, meta: &ReportMeta) -> serde_json::Value {
|
||||
"company": meta.company,
|
||||
"title": st.title,
|
||||
"period": st.period,
|
||||
"footer": format!("Prepared from journal through seq {}", meta.journal_seq),
|
||||
"footer": meta.footer(),
|
||||
"columns": st.columns,
|
||||
"rows": st.rows.iter().map(|r| serde_json::json!({
|
||||
"kind": kind(r.kind),
|
||||
"label": r.label,
|
||||
"rule": r.rule_above,
|
||||
"amount": r.amount.map(|a| {
|
||||
let p = amount_parts(a);
|
||||
serde_json::json!({
|
||||
"digits": p.digits,
|
||||
"negative": p.negative,
|
||||
"zero": p.zero,
|
||||
"currency": r.currency,
|
||||
})
|
||||
}),
|
||||
"amounts": r.amounts.iter().map(|a| a.map(|a| {
|
||||
let p = amount_parts(a);
|
||||
serde_json::json!({ "digits": p.digits, "negative": p.negative })
|
||||
})).collect::<Vec<_>>(),
|
||||
})).collect::<Vec<_>>(),
|
||||
})
|
||||
}
|
||||
@@ -101,6 +106,8 @@ mod tests {
|
||||
fn meta() -> ReportMeta {
|
||||
ReportMeta {
|
||||
company: "Test Co".to_string(),
|
||||
basis: "Accrual".to_string(),
|
||||
prepared_on: NaiveDate::from_ymd_opt(2026, 10, 11).unwrap(),
|
||||
journal_seq: 42,
|
||||
}
|
||||
}
|
||||
@@ -127,6 +134,7 @@ mod tests {
|
||||
/// The exact data the template typesets: hand-checked, like a golden.
|
||||
#[test]
|
||||
fn template_data_is_exact() {
|
||||
let amt = |digits: &str, negative: bool| serde_json::json!([{"digits": digits, "negative": negative}]);
|
||||
let data = template_data(&profit_and_loss(&pl()), &meta());
|
||||
assert_eq!(
|
||||
data,
|
||||
@@ -134,22 +142,18 @@ mod tests {
|
||||
"company": "Test Co",
|
||||
"title": "Profit and Loss",
|
||||
"period": "October 4 – October 11, 2026",
|
||||
"footer": "Prepared from journal through seq 42",
|
||||
"footer": "Accrual basis · Prepared Sunday, October 11, 2026 · Journal seq 42",
|
||||
"columns": ["Total"],
|
||||
"rows": [
|
||||
{"kind": "section", "label": "Revenue", "rule": false, "amount": null},
|
||||
{"kind": "subtotal", "label": "Total revenue", "rule": false, "amount":
|
||||
{"digits": "", "negative": false, "zero": true, "currency": true}},
|
||||
{"kind": "spacer", "label": "", "rule": false, "amount": null},
|
||||
{"kind": "section", "label": "Expenses", "rule": false, "amount": null},
|
||||
{"kind": "line", "label": "6000 Organizational Costs", "rule": false, "amount":
|
||||
{"digits": "59.00", "negative": false, "zero": false, "currency": true}},
|
||||
{"kind": "line", "label": "6400 Memberships & Licenses", "rule": false, "amount":
|
||||
{"digits": "106.38", "negative": false, "zero": false, "currency": false}},
|
||||
{"kind": "subtotal", "label": "Total expenses", "rule": true, "amount":
|
||||
{"digits": "165.38", "negative": false, "zero": false, "currency": true}},
|
||||
{"kind": "spacer", "label": "", "rule": false, "amount": null},
|
||||
{"kind": "total", "label": "Net income", "rule": true, "amount":
|
||||
{"digits": "165.38", "negative": true, "zero": false, "currency": true}},
|
||||
{"kind": "section", "label": "Revenue", "rule": false, "currency": false, "amounts": []},
|
||||
{"kind": "subtotal", "label": "Total revenue", "rule": false, "currency": true, "amounts": amt("0.00", false)},
|
||||
{"kind": "spacer", "label": "", "rule": false, "currency": false, "amounts": []},
|
||||
{"kind": "section", "label": "Expenses", "rule": false, "currency": false, "amounts": []},
|
||||
{"kind": "line", "label": "6000 Organizational Costs", "rule": false, "currency": false, "amounts": amt("59.00", false)},
|
||||
{"kind": "line", "label": "6400 Memberships & Licenses", "rule": false, "currency": false, "amounts": amt("106.38", false)},
|
||||
{"kind": "subtotal", "label": "Total expenses", "rule": true, "currency": true, "amounts": amt("165.38", false)},
|
||||
{"kind": "spacer", "label": "", "rule": false, "currency": false, "amounts": []},
|
||||
{"kind": "total", "label": "Net income (loss)", "rule": true, "currency": true, "amounts": amt("165.38", true)},
|
||||
],
|
||||
})
|
||||
);
|
||||
@@ -172,7 +176,7 @@ mod tests {
|
||||
.map(|i| Row {
|
||||
kind: RowKind::Line,
|
||||
label: format!("#{i} *not bold* $x$ <label> @ref [link] \\ #panic()"),
|
||||
amount: Some(Decimal::new(i, 0)),
|
||||
amounts: vec![Some(Decimal::new(i, 0))],
|
||||
currency: false,
|
||||
rule_above: false,
|
||||
})
|
||||
|
||||
+335
-187
@@ -1,13 +1,21 @@
|
||||
//! 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)").
|
||||
//! Core computes the statements; this module lays them out once — title,
|
||||
//! period, column headers, and rows (sections, account lines, subtotals,
|
||||
//! totals) — so the formats can't drift apart. Amounts stay exact
|
||||
//! `Decimal`s here; [`format_amount`] and [`amount_parts`] are the only
|
||||
//! places they become text.
|
||||
//!
|
||||
//! Presentation follows US small-business convention (QuickBooks-style):
|
||||
//! a currency sign on subtotals and totals, a rule above every sum, a
|
||||
//! double rule under statement totals, "Net income (loss)", and — for a
|
||||
//! multi-member LLC — "Members' equity" with each member named.
|
||||
|
||||
use chrono::NaiveDate;
|
||||
use chrono::{Datelike, NaiveDate};
|
||||
use ledger_core::RETAINED_EARNINGS_CODE;
|
||||
use ledger_core::reports::{
|
||||
BalanceSheetStatement, CashFlowLine, CashFlowStatement, ProfitAndLossStatement, StatementLine,
|
||||
BalanceSheetStatement, CashFlowLine, CashFlowStatement, MembersEquityStatement,
|
||||
ProfitAndLossStatement, StatementLine, TrialBalance,
|
||||
};
|
||||
use rust_decimal::Decimal;
|
||||
use serde::Serialize;
|
||||
@@ -17,21 +25,49 @@ use serde::Serialize;
|
||||
pub struct ReportMeta {
|
||||
/// Company name for the header, e.g. "Wanderland Labs LLC".
|
||||
pub company: String,
|
||||
/// Accounting basis for the footer: "Accrual" or "Cash".
|
||||
pub basis: String,
|
||||
/// The day the report was prepared.
|
||||
pub prepared_on: NaiveDate,
|
||||
/// Sequence number of the last journal line the report reflects.
|
||||
pub journal_seq: u64,
|
||||
}
|
||||
|
||||
impl ReportMeta {
|
||||
/// "Accrual basis · Prepared Sunday, October 11, 2026 · Journal seq 114".
|
||||
pub fn footer(&self) -> String {
|
||||
format!(
|
||||
"{} basis · Prepared {}, {} · Journal seq {}",
|
||||
self.basis,
|
||||
self.prepared_on.format("%A"),
|
||||
long_date(self.prepared_on),
|
||||
self.journal_seq
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// A member of a multi-member LLC, for naming capital accounts on reports.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Member {
|
||||
/// The member's name.
|
||||
pub name: String,
|
||||
/// Code of their capital account.
|
||||
pub capital_code: String,
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// A section heading ("Revenue"); no amounts.
|
||||
Section,
|
||||
/// One account or one cash-flow line, indented under its section.
|
||||
/// An explanatory heading inside a section; no amounts.
|
||||
Subheading,
|
||||
/// One account or one flow, indented under its section.
|
||||
Line,
|
||||
/// A section total ("Total revenue").
|
||||
Subtotal,
|
||||
/// A statement total ("Net income").
|
||||
/// A statement total ("Net income (loss)"); double rule beneath.
|
||||
Total,
|
||||
/// Vertical space between sections.
|
||||
Spacer,
|
||||
@@ -44,243 +80,289 @@ pub struct Row {
|
||||
pub kind: RowKind,
|
||||
/// Label text.
|
||||
pub label: String,
|
||||
/// Amount, when the row has one.
|
||||
pub amount: Option<Decimal>,
|
||||
/// Print a currency sign with the amount: US accounting style puts "$"
|
||||
/// on the first amount under each section heading and on every
|
||||
/// subtotal and total.
|
||||
/// One amount per column; `None` leaves the cell blank.
|
||||
pub amounts: Vec<Option<Decimal>>,
|
||||
/// Print the currency sign with the amounts: subtotals and totals.
|
||||
pub currency: bool,
|
||||
/// Draw a single rule above the amount: the "sum of the above" line.
|
||||
/// Totals always have one; a subtotal only when amounts sit above it
|
||||
/// (an empty section gets no rule over nothing).
|
||||
/// Draw a rule above the row: the "sum of the above" line. Totals
|
||||
/// always have one; a subtotal only when amounts sit above it.
|
||||
pub rule_above: bool,
|
||||
}
|
||||
|
||||
/// A laid-out financial statement.
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct Statement {
|
||||
/// "Profit and Loss", "Balance Sheet", "Statement of Cash Flows".
|
||||
/// "Profit and Loss", "Balance Sheet", ...
|
||||
pub title: String,
|
||||
/// "October 4 – October 11, 2026" or "As of October 11, 2026".
|
||||
pub period: String,
|
||||
/// Amount column headings ("Total"; "Debit", "Credit"; member names).
|
||||
pub columns: Vec<String>,
|
||||
/// The rows, top to bottom.
|
||||
pub rows: Vec<Row>,
|
||||
}
|
||||
|
||||
fn row(kind: RowKind, label: impl Into<String>, amount: Option<Decimal>) -> Row {
|
||||
fn row(kind: RowKind, label: impl Into<String>, amounts: Vec<Option<Decimal>>) -> Row {
|
||||
Row {
|
||||
kind,
|
||||
label: label.into(),
|
||||
amount,
|
||||
amounts,
|
||||
currency: false,
|
||||
rule_above: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Finish a statement: mark which amounts carry the currency sign and which
|
||||
/// get a rule above.
|
||||
fn statement(title: &str, period: String, mut rows: Vec<Row>) -> Statement {
|
||||
let mut first_in_section = true;
|
||||
fn one(kind: RowKind, label: impl Into<String>, amount: Decimal) -> Row {
|
||||
row(kind, label, vec![Some(amount)])
|
||||
}
|
||||
|
||||
fn section(label: &str) -> Row {
|
||||
row(RowKind::Section, label, vec![])
|
||||
}
|
||||
|
||||
fn subheading(label: &str) -> Row {
|
||||
row(RowKind::Subheading, label, vec![])
|
||||
}
|
||||
|
||||
fn spacer() -> Row {
|
||||
row(RowKind::Spacer, "", vec![])
|
||||
}
|
||||
|
||||
/// Finish a statement: currency signs on sums, rules above them.
|
||||
fn statement(title: &str, period: String, columns: &[&str], mut rows: Vec<Row>) -> Statement {
|
||||
let mut above: Option<RowKind> = None;
|
||||
for r in &mut rows {
|
||||
r.currency = matches!(r.kind, RowKind::Subtotal | RowKind::Total);
|
||||
r.rule_above = match r.kind {
|
||||
RowKind::Total => true,
|
||||
RowKind::Subtotal => matches!(above, Some(RowKind::Line | RowKind::Subtotal)),
|
||||
_ => false,
|
||||
};
|
||||
if r.kind != RowKind::Spacer {
|
||||
if !matches!(r.kind, RowKind::Spacer | RowKind::Subheading) {
|
||||
above = Some(r.kind);
|
||||
}
|
||||
match r.kind {
|
||||
RowKind::Section => first_in_section = true,
|
||||
RowKind::Subtotal | RowKind::Total => r.currency = true,
|
||||
RowKind::Line if r.amount.is_some() && first_in_section => {
|
||||
r.currency = true;
|
||||
first_in_section = false;
|
||||
}
|
||||
RowKind::Line | RowKind::Spacer => {}
|
||||
}
|
||||
}
|
||||
Statement {
|
||||
title: title.to_string(),
|
||||
period,
|
||||
columns: columns.iter().map(|c| c.to_string()).collect(),
|
||||
rows,
|
||||
}
|
||||
}
|
||||
|
||||
/// An amount split for column alignment: the digits, and whether it is a
|
||||
/// negative (printed in parentheses) or zero (printed as a dash). Renderers
|
||||
/// right-align `digits` and reserve the closing parenthesis's width on every
|
||||
/// row, so decimal points line up whether or not a value is negative.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
||||
pub struct AmountParts {
|
||||
/// "1,234.56" — absolute value, grouped, two decimals. Empty for zero.
|
||||
pub digits: String,
|
||||
/// Below zero.
|
||||
pub negative: bool,
|
||||
/// Exactly zero (after rounding to cents).
|
||||
pub zero: bool,
|
||||
}
|
||||
|
||||
/// Split an amount for aligned accounting display. Exact: no floats.
|
||||
pub fn amount_parts(d: Decimal) -> AmountParts {
|
||||
let rounded = d.round_dp(2);
|
||||
if rounded.is_zero() {
|
||||
return AmountParts {
|
||||
digits: String::new(),
|
||||
negative: false,
|
||||
zero: true,
|
||||
};
|
||||
}
|
||||
let text = format_amount(rounded.abs());
|
||||
AmountParts {
|
||||
digits: text,
|
||||
negative: rounded.is_sign_negative(),
|
||||
zero: false,
|
||||
}
|
||||
}
|
||||
|
||||
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)))
|
||||
.map(|l| one(RowKind::Line, format!("{} {}", l.code, l.name), 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(one(RowKind::Subtotal, "Total revenue", 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(one(RowKind::Subtotal, "Total expenses", pl.total_expenses));
|
||||
rows.push(spacer());
|
||||
rows.push(row(RowKind::Total, "Net income", Some(pl.net_income())));
|
||||
statement("Profit and Loss", 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),
|
||||
));
|
||||
rows.push(one(RowKind::Total, "Net income (loss)", pl.net_income()));
|
||||
statement(
|
||||
"Balance Sheet",
|
||||
format!("As of {}", long_date(bs.as_of)),
|
||||
"Profit and Loss",
|
||||
period_label(pl.start, pl.end),
|
||||
&["Total"],
|
||||
rows,
|
||||
)
|
||||
}
|
||||
|
||||
/// Lay out a balance sheet. `net_income_ytd` is the fiscal year to date's
|
||||
/// net income (loss): it shows as its own equity line, and earnings from
|
||||
/// before the fiscal year — the Retained Earnings account plus any not yet
|
||||
/// closed — show as "Retained earnings". With `members`, equity is
|
||||
/// "Members' equity".
|
||||
pub fn balance_sheet(
|
||||
bs: &BalanceSheetStatement,
|
||||
net_income_ytd: Decimal,
|
||||
members: &[Member],
|
||||
) -> Statement {
|
||||
let equity_name = if members.is_empty() {
|
||||
"Equity"
|
||||
} else {
|
||||
"Members' equity"
|
||||
};
|
||||
let mut rows = vec![section("Assets")];
|
||||
rows.extend(account_lines(&bs.assets));
|
||||
rows.push(one(RowKind::Subtotal, "Total assets", bs.total_assets));
|
||||
rows.push(spacer());
|
||||
rows.push(section("Liabilities"));
|
||||
rows.extend(account_lines(&bs.liabilities));
|
||||
rows.push(one(
|
||||
RowKind::Subtotal,
|
||||
"Total liabilities",
|
||||
bs.total_liabilities,
|
||||
));
|
||||
rows.push(spacer());
|
||||
rows.push(section(equity_name));
|
||||
let (retained, other): (Vec<_>, Vec<_>) = bs
|
||||
.equity
|
||||
.iter()
|
||||
.partition(|l| l.code == RETAINED_EARNINGS_CODE);
|
||||
rows.extend(
|
||||
other
|
||||
.iter()
|
||||
.map(|l| one(RowKind::Line, format!("{} {}", l.code, l.name), l.amount)),
|
||||
);
|
||||
let retained: Decimal =
|
||||
retained.iter().map(|l| l.amount).sum::<Decimal>() + bs.current_earnings - net_income_ytd;
|
||||
if !retained.is_zero() {
|
||||
rows.push(one(RowKind::Line, "Retained earnings", retained));
|
||||
}
|
||||
rows.push(one(RowKind::Line, "Net income (loss)", net_income_ytd));
|
||||
rows.push(one(
|
||||
RowKind::Subtotal,
|
||||
format!("Total {}", equity_name.to_lowercase()),
|
||||
bs.total_equity,
|
||||
));
|
||||
rows.push(spacer());
|
||||
rows.push(one(
|
||||
RowKind::Total,
|
||||
format!("Total liabilities and {}", equity_name.to_lowercase()),
|
||||
bs.total_liabilities + bs.total_equity,
|
||||
));
|
||||
statement(
|
||||
"Balance Sheet",
|
||||
format!("As of {}", long_date(bs.as_of)),
|
||||
&["Total"],
|
||||
rows,
|
||||
)
|
||||
}
|
||||
|
||||
/// Cash flow lines, naming members on their capital accounts.
|
||||
fn flow_lines<'a>(
|
||||
lines: &'a [CashFlowLine],
|
||||
members: &'a [Member],
|
||||
) -> impl Iterator<Item = Row> + 'a {
|
||||
lines.iter().map(move |l| {
|
||||
let member = members
|
||||
.iter()
|
||||
.find(|m| l.account.as_deref() == Some(m.capital_code.as_str()));
|
||||
let label = match member {
|
||||
Some(m) if l.amount >= Decimal::ZERO => format!("Member contributions — {}", m.name),
|
||||
Some(m) => format!("Member distributions — {}", m.name),
|
||||
None => l.label.clone(),
|
||||
};
|
||||
one(RowKind::Line, label, l.amount)
|
||||
})
|
||||
}
|
||||
|
||||
/// Lay out a cash flow statement (indirect method).
|
||||
pub fn cash_flow(cf: &CashFlowStatement) -> Statement {
|
||||
pub fn cash_flow(cf: &CashFlowStatement, members: &[Member]) -> 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.push(one(RowKind::Line, "Net income (loss)", cf.net_income));
|
||||
if !cf.depreciation.is_zero() || !cf.working_capital.is_empty() {
|
||||
rows.push(subheading(
|
||||
"Adjustments to reconcile net income (loss) to net cash from operating activities:",
|
||||
));
|
||||
}
|
||||
rows.extend(flow_lines(&cf.working_capital));
|
||||
rows.push(row(
|
||||
if !cf.depreciation.is_zero() {
|
||||
rows.push(one(RowKind::Line, "Depreciation", cf.depreciation));
|
||||
}
|
||||
rows.extend(flow_lines(&cf.working_capital, members));
|
||||
rows.push(one(
|
||||
RowKind::Subtotal,
|
||||
"Net cash from operating activities",
|
||||
Some(cf.cash_from_operations),
|
||||
cf.cash_from_operations,
|
||||
));
|
||||
rows.push(spacer());
|
||||
rows.push(section("Investing activities"));
|
||||
rows.extend(flow_lines(&cf.investing));
|
||||
rows.push(row(
|
||||
rows.extend(flow_lines(&cf.investing, members));
|
||||
rows.push(one(
|
||||
RowKind::Subtotal,
|
||||
"Net cash from investing activities",
|
||||
Some(cf.cash_from_investing),
|
||||
cf.cash_from_investing,
|
||||
));
|
||||
rows.push(spacer());
|
||||
rows.push(section("Financing activities"));
|
||||
rows.extend(flow_lines(&cf.financing));
|
||||
rows.push(row(
|
||||
rows.extend(flow_lines(&cf.financing, members));
|
||||
rows.push(one(
|
||||
RowKind::Subtotal,
|
||||
"Net cash from financing activities",
|
||||
Some(cf.cash_from_financing),
|
||||
cf.cash_from_financing,
|
||||
));
|
||||
rows.push(spacer());
|
||||
rows.push(row(
|
||||
rows.push(one(
|
||||
RowKind::Subtotal,
|
||||
"Net change in cash",
|
||||
Some(cf.net_change_in_cash),
|
||||
"Net increase (decrease) in cash",
|
||||
cf.net_change_in_cash,
|
||||
));
|
||||
rows.push(row(
|
||||
rows.push(one(
|
||||
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),
|
||||
cf.cash_beginning,
|
||||
));
|
||||
rows.push(one(RowKind::Total, "Cash at end of period", cf.cash_ending));
|
||||
statement(
|
||||
"Statement of Cash Flows",
|
||||
period_label(cf.start, cf.end),
|
||||
&["Total"],
|
||||
rows,
|
||||
)
|
||||
}
|
||||
|
||||
/// Lay out a trial balance as of a date: Debit and Credit columns, a zero
|
||||
/// side left blank.
|
||||
pub fn trial_balance(tb: &TrialBalance, as_of: NaiveDate) -> Statement {
|
||||
let nz = |d: Decimal| if d.is_zero() { None } else { Some(d) };
|
||||
let mut rows: Vec<Row> = tb
|
||||
.lines
|
||||
.iter()
|
||||
.map(|l| {
|
||||
row(
|
||||
RowKind::Line,
|
||||
format!("{} {}", l.code, l.name),
|
||||
vec![nz(l.debit), nz(l.credit)],
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
rows.push(row(
|
||||
RowKind::Total,
|
||||
"Total",
|
||||
vec![Some(tb.total_debits), Some(tb.total_credits)],
|
||||
));
|
||||
statement(
|
||||
"Trial Balance",
|
||||
format!("As of {}", long_date(as_of)),
|
||||
&["Debit", "Credit"],
|
||||
rows,
|
||||
)
|
||||
}
|
||||
|
||||
/// Lay out the statement of changes in members' equity: one column per
|
||||
/// member and a total. Distributions print as reductions.
|
||||
pub fn members_equity(me: &MembersEquityStatement) -> Statement {
|
||||
let cols: Vec<_> = me
|
||||
.members
|
||||
.iter()
|
||||
.chain(std::iter::once(&me.total))
|
||||
.collect();
|
||||
let pick = |f: &dyn Fn(&ledger_core::MemberEquityColumn) -> Decimal| {
|
||||
cols.iter().map(|c| Some(f(c))).collect::<Vec<_>>()
|
||||
};
|
||||
let mut rows = vec![
|
||||
row(RowKind::Line, "Beginning balance", pick(&|c| c.beginning)),
|
||||
row(RowKind::Line, "Contributions", pick(&|c| c.contributions)),
|
||||
row(RowKind::Line, "Net income (loss)", pick(&|c| c.net_income)),
|
||||
row(RowKind::Line, "Distributions", pick(&|c| -c.distributions)),
|
||||
];
|
||||
if !me.total.other.is_zero() {
|
||||
rows.push(row(RowKind::Line, "Other changes", pick(&|c| c.other)));
|
||||
}
|
||||
rows.push(row(RowKind::Total, "Ending balance", pick(&|c| c.ending)));
|
||||
let names: Vec<&str> = cols.iter().map(|c| c.name.as_str()).collect();
|
||||
statement(
|
||||
"Statement of Changes in Members' Equity",
|
||||
period_label(me.start, me.end),
|
||||
&names,
|
||||
rows,
|
||||
)
|
||||
}
|
||||
@@ -292,7 +374,6 @@ pub fn long_date(d: NaiveDate) -> 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 {
|
||||
@@ -320,6 +401,27 @@ pub fn format_amount(d: Decimal) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// An amount split for column alignment: the digits, and whether it is a
|
||||
/// negative (printed in parentheses) or zero (printed as 0.00). Renderers
|
||||
/// right-align `digits` and reserve the closing parenthesis's width on every
|
||||
/// row, so decimal points line up whether or not a value is negative.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
||||
pub struct AmountParts {
|
||||
/// "1,234.56" — absolute value, grouped, two decimals.
|
||||
pub digits: String,
|
||||
/// Below zero.
|
||||
pub negative: bool,
|
||||
}
|
||||
|
||||
/// Split an amount for aligned accounting display. Exact: no floats.
|
||||
pub fn amount_parts(d: Decimal) -> AmountParts {
|
||||
let rounded = d.round_dp(2);
|
||||
AmountParts {
|
||||
digits: format_amount(rounded.abs()),
|
||||
negative: rounded.is_sign_negative() && !rounded.is_zero(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -344,44 +446,80 @@ mod tests {
|
||||
#[test]
|
||||
fn amounts_split_for_alignment() {
|
||||
let p = amount_parts(dec("-1234.5"));
|
||||
assert_eq!(
|
||||
(p.digits.as_str(), p.negative, p.zero),
|
||||
("1,234.50", true, false)
|
||||
);
|
||||
assert!(amount_parts(dec("0.004")).zero);
|
||||
assert_eq!(amount_parts(dec("207.68")).digits, "207.68");
|
||||
assert_eq!((p.digits.as_str(), p.negative), ("1,234.50", true));
|
||||
let z = amount_parts(dec("-0.004"));
|
||||
assert_eq!((z.digits.as_str(), z.negative), ("0.00", false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn currency_signs_go_on_first_lines_and_totals() {
|
||||
fn currency_signs_and_rules_go_on_sums() {
|
||||
let rows = vec![
|
||||
section("Assets"),
|
||||
row(RowKind::Line, "a", Some(dec("1"))),
|
||||
row(RowKind::Line, "b", Some(dec("2"))),
|
||||
row(RowKind::Subtotal, "t", Some(dec("3"))),
|
||||
one(RowKind::Line, "a", dec("1")),
|
||||
one(RowKind::Line, "b", dec("2")),
|
||||
one(RowKind::Subtotal, "t", dec("3")),
|
||||
spacer(),
|
||||
section("Equity"),
|
||||
row(RowKind::Line, "c", Some(dec("3"))),
|
||||
row(RowKind::Total, "T", Some(dec("3"))),
|
||||
section("Investing"),
|
||||
one(RowKind::Subtotal, "empty", dec("0")),
|
||||
one(RowKind::Total, "T", dec("3")),
|
||||
];
|
||||
let st = statement("X", String::new(), rows);
|
||||
let st = statement("X", String::new(), &["Total"], rows);
|
||||
let signs: Vec<bool> = st.rows.iter().map(|r| r.currency).collect();
|
||||
assert_eq!(signs, [false, true, false, true, false, false, true, true]);
|
||||
assert_eq!(signs, [false, false, false, true, false, false, true, true]);
|
||||
let rules: Vec<bool> = st.rows.iter().map(|r| r.rule_above).collect();
|
||||
// No rule over an empty section's subtotal; always one over a total.
|
||||
assert_eq!(
|
||||
rules,
|
||||
[false, false, false, true, false, false, false, true]
|
||||
);
|
||||
// A subtotal straight under its heading (empty section): no rule.
|
||||
let empty = statement(
|
||||
"Y",
|
||||
String::new(),
|
||||
vec![
|
||||
section("Investing"),
|
||||
row(RowKind::Subtotal, "t", Some(dec("0"))),
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn balance_sheet_splits_retained_earnings_from_this_year() {
|
||||
let line = |code: &str, name: &str, a: &str| StatementLine {
|
||||
code: code.into(),
|
||||
name: name.into(),
|
||||
amount: dec(a),
|
||||
};
|
||||
// Prior years closed 1,000 to Retained Earnings; 200 more unclosed
|
||||
// from last year; this year's net income is 300 (current earnings
|
||||
// 500 = 200 + 300).
|
||||
let bs = BalanceSheetStatement {
|
||||
as_of: NaiveDate::from_ymd_opt(2027, 3, 31).unwrap(),
|
||||
assets: vec![line("1000", "Cash", "2500")],
|
||||
liabilities: vec![],
|
||||
equity: vec![
|
||||
line("3000", "Capital — Alice", "1000"),
|
||||
line("3999", "Retained Earnings", "1000"),
|
||||
],
|
||||
current_earnings: dec("500"),
|
||||
total_assets: dec("2500"),
|
||||
total_liabilities: dec("0"),
|
||||
total_equity: dec("2500"),
|
||||
};
|
||||
let members = [Member {
|
||||
name: "Alice".into(),
|
||||
capital_code: "3000".into(),
|
||||
}];
|
||||
let st = balance_sheet(&bs, dec("300"), &members);
|
||||
let eq: Vec<(&str, Option<Decimal>)> = st
|
||||
.rows
|
||||
.iter()
|
||||
.skip_while(|r| r.label != "Members' equity")
|
||||
.map(|r| (r.label.as_str(), r.amounts.first().copied().flatten()))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
eq,
|
||||
[
|
||||
("Members' equity", None),
|
||||
("3000 Capital — Alice", Some(dec("1000"))),
|
||||
("Retained earnings", Some(dec("1200"))),
|
||||
("Net income (loss)", Some(dec("300"))),
|
||||
("Total members' equity", Some(dec("2500"))),
|
||||
("", None),
|
||||
("Total liabilities and members' equity", Some(dec("2500"))),
|
||||
]
|
||||
);
|
||||
assert!(!empty.rows[1].rule_above);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -395,5 +533,15 @@ mod tests {
|
||||
period_label(d(2026, 12, 1), d(2027, 1, 31)),
|
||||
"December 1, 2026 – January 31, 2027"
|
||||
);
|
||||
let meta = ReportMeta {
|
||||
company: "X".into(),
|
||||
basis: "Accrual".into(),
|
||||
prepared_on: d(2026, 10, 11),
|
||||
journal_seq: 114,
|
||||
};
|
||||
assert_eq!(
|
||||
meta.footer(),
|
||||
"Accrual basis · Prepared Sunday, October 11, 2026 · Journal seq 114"
|
||||
);
|
||||
}
|
||||
}
|
||||
+297
-138
@@ -3,27 +3,29 @@
|
||||
//! Money is converted to f64 ONLY at this presentation boundary — never in
|
||||
//! a money path. All accounting math stays `Decimal`; the conversion happens
|
||||
//! 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.
|
||||
//! sum them) with accounting number formats: thousands separators,
|
||||
//! negatives in parentheses with the closing parenthesis hanging (so
|
||||
//! decimal points align), "$" on subtotals and totals.
|
||||
|
||||
use crate::report::{ReportMeta, RowKind, Statement};
|
||||
use chrono::NaiveDate;
|
||||
use ledger_core::Ledger;
|
||||
use ledger_core::reports::TrialBalance;
|
||||
use ledger_core::reports::GeneralLedger;
|
||||
use rust_decimal::Decimal;
|
||||
use rust_xlsxwriter::{Format, FormatBorder, Workbook, Worksheet};
|
||||
use rust_xlsxwriter::{Format, FormatAlign, FormatBorder, Workbook, Worksheet};
|
||||
use thiserror::Error;
|
||||
|
||||
/// Which report to render.
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum XlsxReport<'a> {
|
||||
/// Trial balance lines.
|
||||
TrialBalance(&'a TrialBalance),
|
||||
/// A laid-out financial statement (P&L, balance sheet, cash flow); see
|
||||
/// [`crate::report`].
|
||||
/// A laid-out statement (P&L, balance sheet, cash flow, trial balance,
|
||||
/// members' equity); see [`crate::report`].
|
||||
Statement(&'a Statement),
|
||||
/// General ledger: every posted journal line. Needs the ledger for
|
||||
/// account names and class names.
|
||||
GeneralLedger(&'a Ledger),
|
||||
/// The general ledger: by account, with running balances and splits.
|
||||
GeneralLedger(&'a GeneralLedger),
|
||||
/// The general journal: every posted entry in `[start, end]`, line by
|
||||
/// line, in date order.
|
||||
GeneralJournal(&'a Ledger, NaiveDate, NaiveDate),
|
||||
}
|
||||
|
||||
/// XLSX errors.
|
||||
@@ -40,177 +42,330 @@ impl From<rust_xlsxwriter::XlsxError> for XlsxError {
|
||||
}
|
||||
}
|
||||
|
||||
/// Excel's Accounting format, with and without the currency symbol: "$"
|
||||
/// pinned left, digits right, negatives in parentheses with the closing
|
||||
/// parenthesis hanging (`_)` reserves its width on positives, so decimal
|
||||
/// points line up), zero as a dash.
|
||||
const ACCOUNTING: &str = r#"_(* #,##0.00_);_(* (#,##0.00);_(* "-"??_);_(@_)"#;
|
||||
const ACCOUNTING_USD: &str = r#"_($* #,##0.00_);_($* (#,##0.00);_($* "-"??_);_(@_)"#;
|
||||
/// Accounting number formats: the `_)` reserves a closing parenthesis's
|
||||
/// width on positives so decimal points line up with negatives.
|
||||
const PLAIN: &str = "#,##0.00_);(#,##0.00)";
|
||||
const DOLLARS: &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)
|
||||
struct Formats {
|
||||
bold: Format,
|
||||
company: Format,
|
||||
centered: Format,
|
||||
header: Format,
|
||||
header_right: Format,
|
||||
plain: Format,
|
||||
dollars_sum: Format,
|
||||
dollars_total: Format,
|
||||
line_label: Format,
|
||||
sub_label: Format,
|
||||
note: Format,
|
||||
}
|
||||
|
||||
/// "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(
|
||||
impl Formats {
|
||||
fn new() -> Self {
|
||||
let bold = Format::new().set_bold();
|
||||
let ruled = |f: Format| f.set_border_top(FormatBorder::Thin);
|
||||
Self {
|
||||
company: Format::new()
|
||||
.set_font_size(14)
|
||||
.set_align(FormatAlign::Center),
|
||||
centered: Format::new().set_align(FormatAlign::Center),
|
||||
header: bold
|
||||
.clone()
|
||||
.set_font_size(9)
|
||||
.set_border_bottom(FormatBorder::Thin),
|
||||
header_right: bold
|
||||
.clone()
|
||||
.set_font_size(9)
|
||||
.set_border_bottom(FormatBorder::Thin)
|
||||
.set_align(FormatAlign::Right),
|
||||
plain: Format::new().set_num_format(PLAIN),
|
||||
dollars_sum: ruled(bold.clone().set_num_format(DOLLARS)),
|
||||
dollars_total: ruled(bold.clone().set_num_format(DOLLARS))
|
||||
.set_border_bottom(FormatBorder::Double),
|
||||
line_label: Format::new().set_indent(1),
|
||||
sub_label: Format::new().set_italic().set_indent(1),
|
||||
note: Format::new()
|
||||
.set_italic()
|
||||
.set_font_size(9)
|
||||
.set_font_color("#666666")
|
||||
.set_align(FormatAlign::Center),
|
||||
bold,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Company, title, and period centered over `width` columns; returns the
|
||||
/// next free row.
|
||||
fn header(
|
||||
sheet: &mut Worksheet,
|
||||
f: &Formats,
|
||||
meta: &ReportMeta,
|
||||
title: &str,
|
||||
period: &str,
|
||||
width: u16,
|
||||
) -> Result<u32, XlsxError> {
|
||||
let last = width.saturating_sub(1);
|
||||
sheet.merge_range(0, 0, 0, last, &meta.company, &f.company)?;
|
||||
sheet.merge_range(
|
||||
1,
|
||||
0,
|
||||
1,
|
||||
last,
|
||||
title,
|
||||
&f.bold.clone().set_align(FormatAlign::Center),
|
||||
)?;
|
||||
sheet.merge_range(2, 0, 2, last, period, &f.centered)?;
|
||||
Ok(4)
|
||||
}
|
||||
|
||||
/// Basis, preparation date, and journal seq under the last row.
|
||||
fn footer(
|
||||
sheet: &mut Worksheet,
|
||||
f: &Formats,
|
||||
meta: &ReportMeta,
|
||||
row: u32,
|
||||
width: u16,
|
||||
) -> Result<(), XlsxError> {
|
||||
sheet.merge_range(
|
||||
row + 1,
|
||||
0,
|
||||
format!("Prepared from journal through seq {}", meta.journal_seq),
|
||||
¬e,
|
||||
row + 1,
|
||||
width.saturating_sub(1),
|
||||
&meta.footer(),
|
||||
&f.note,
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn number(
|
||||
sheet: &mut Worksheet,
|
||||
row: u32,
|
||||
col: u16,
|
||||
d: Decimal,
|
||||
fmt: &Format,
|
||||
) -> Result<(), XlsxError> {
|
||||
sheet.write_number_with_format(row, col, dec_to_f64(d), fmt)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Render a report to XLSX bytes.
|
||||
pub fn report_to_xlsx(report: &XlsxReport<'_>, meta: &ReportMeta) -> Result<Vec<u8>, XlsxError> {
|
||||
let mut workbook = Workbook::new();
|
||||
let sheet = workbook.add_worksheet();
|
||||
let bold = Format::new().set_bold();
|
||||
let money = Format::new().set_num_format(ACCOUNTING);
|
||||
let usd = Format::new().set_num_format(ACCOUNTING_USD);
|
||||
// Totals are marked by rules, not bold: bold figures are wider and would
|
||||
// break the decimal column.
|
||||
let usd_bold = usd.clone();
|
||||
|
||||
let f = Formats::new();
|
||||
match report {
|
||||
XlsxReport::TrialBalance(tb) => {
|
||||
sheet.set_name("Trial Balance")?;
|
||||
let mut row = header(sheet, meta, "Trial Balance", None)?;
|
||||
for (i, h) in ["Code", "Account", "Debit", "Credit"].iter().enumerate() {
|
||||
sheet.write_string_with_format(row, i as u16, *h, &bold)?;
|
||||
XlsxReport::Statement(st) => statement(sheet, &f, meta, st)?,
|
||||
XlsxReport::GeneralLedger(gl) => general_ledger(sheet, &f, meta, gl)?,
|
||||
XlsxReport::GeneralJournal(ledger, start, end) => {
|
||||
general_journal(sheet, &f, meta, ledger, *start, *end)?
|
||||
}
|
||||
}
|
||||
Ok(workbook.save_to_buffer()?)
|
||||
}
|
||||
|
||||
fn statement(
|
||||
sheet: &mut Worksheet,
|
||||
f: &Formats,
|
||||
meta: &ReportMeta,
|
||||
st: &Statement,
|
||||
) -> Result<(), XlsxError> {
|
||||
// Excel sheet names: at most 31 characters, no apostrophes at the ends.
|
||||
let name: String = st.title.replace('\'', "").chars().take(31).collect();
|
||||
sheet.set_name(name.trim())?;
|
||||
let width = 1 + st.columns.len() as u16;
|
||||
let mut row = header(sheet, f, meta, &st.title, &st.period, width)?;
|
||||
sheet.write_string_with_format(row, 0, "", &f.header)?;
|
||||
for (i, c) in st.columns.iter().enumerate() {
|
||||
sheet.write_string_with_format(row, 1 + i as u16, c.to_uppercase(), &f.header_right)?;
|
||||
}
|
||||
row += 1;
|
||||
for line in &tb.lines {
|
||||
sheet.write_string(row, 0, &line.code)?;
|
||||
sheet.write_string(row, 1, &line.name)?;
|
||||
sheet.write_number_with_format(row, 2, dec_to_f64(line.debit), &money)?;
|
||||
sheet.write_number_with_format(row, 3, dec_to_f64(line.credit), &money)?;
|
||||
row += 1;
|
||||
}
|
||||
let total = usd_bold
|
||||
.clone()
|
||||
.set_border_top(FormatBorder::Thin)
|
||||
.set_border_bottom(FormatBorder::Double);
|
||||
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::Statement(st) => {
|
||||
sheet.set_name(&st.title)?;
|
||||
let mut row = header(sheet, meta, &st.title, Some(&st.period))?;
|
||||
let line_label = Format::new().set_indent(1);
|
||||
let subtotal = usd_bold.clone().set_border_top(FormatBorder::Thin);
|
||||
let total = usd_bold
|
||||
.clone()
|
||||
.set_border_top(FormatBorder::Thin)
|
||||
.set_border_bottom(FormatBorder::Double);
|
||||
let dollars_unruled = f.bold.clone().set_num_format(DOLLARS);
|
||||
for r in &st.rows {
|
||||
let amount = r.amount.map(dec_to_f64);
|
||||
match r.kind {
|
||||
RowKind::Spacer => {}
|
||||
RowKind::Section => {
|
||||
sheet.write_string_with_format(row, 0, &r.label, &bold)?;
|
||||
let (label_fmt, amount_fmt) = match r.kind {
|
||||
RowKind::Spacer => {
|
||||
row += 1;
|
||||
continue;
|
||||
}
|
||||
RowKind::Line => {
|
||||
sheet.write_string_with_format(row, 0, &r.label, &line_label)?;
|
||||
if let Some(a) = amount {
|
||||
let fmt = if r.currency { &usd } else { &money };
|
||||
sheet.write_number_with_format(row, 1, a, fmt)?;
|
||||
}
|
||||
}
|
||||
RowKind::Subtotal => {
|
||||
sheet.write_string_with_format(row, 0, &r.label, &bold)?;
|
||||
if let Some(a) = amount {
|
||||
let fmt = if r.rule_above { &subtotal } else { &usd_bold };
|
||||
sheet.write_number_with_format(row, 1, a, fmt)?;
|
||||
}
|
||||
}
|
||||
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)?;
|
||||
RowKind::Section => (&f.bold, &f.plain),
|
||||
RowKind::Subheading => (&f.sub_label, &f.plain),
|
||||
RowKind::Line => (&f.line_label, &f.plain),
|
||||
RowKind::Subtotal if r.rule_above => (&f.bold, &f.dollars_sum),
|
||||
RowKind::Subtotal => (&f.bold, &dollars_unruled),
|
||||
RowKind::Total => (&f.bold, &f.dollars_total),
|
||||
};
|
||||
sheet.write_string_with_format(row, 0, &r.label, label_fmt)?;
|
||||
for (i, a) in r.amounts.iter().enumerate() {
|
||||
match a {
|
||||
Some(d) => number(sheet, row, 1 + i as u16, *d, amount_fmt)?,
|
||||
None if r.rule_above => {
|
||||
sheet.write_blank(row, 1 + i as u16, amount_fmt)?;
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
row += 1;
|
||||
}
|
||||
sheet.set_column_width(0, 60)?;
|
||||
sheet.set_column_width(1, 16)?;
|
||||
footer(sheet, meta, row)?;
|
||||
sheet.set_column_width(0, 58)?;
|
||||
for i in 0..st.columns.len() as u16 {
|
||||
sheet.set_column_width(1 + i, 16)?;
|
||||
}
|
||||
XlsxReport::GeneralLedger(ledger) => {
|
||||
footer(sheet, f, meta, row, width)
|
||||
}
|
||||
|
||||
fn general_ledger(
|
||||
sheet: &mut Worksheet,
|
||||
f: &Formats,
|
||||
meta: &ReportMeta,
|
||||
gl: &GeneralLedger,
|
||||
) -> Result<(), XlsxError> {
|
||||
sheet.set_name("General Ledger")?;
|
||||
let mut row = header(sheet, meta, "General Ledger", None)?;
|
||||
for (i, h) in [
|
||||
let period = crate::report::period_label(gl.start, gl.end);
|
||||
let cols = [
|
||||
"Date",
|
||||
"Entry",
|
||||
"Description",
|
||||
"Account Code",
|
||||
"Account Name",
|
||||
"Split",
|
||||
"Debit",
|
||||
"Credit",
|
||||
"Balance",
|
||||
];
|
||||
let mut row = header(sheet, f, meta, "General Ledger", &period, cols.len() as u16)?;
|
||||
for (i, h) in cols.iter().enumerate() {
|
||||
let fmt = if i >= 4 { &f.header_right } else { &f.header };
|
||||
sheet.write_string_with_format(row, i as u16, h.to_uppercase(), fmt)?;
|
||||
}
|
||||
row += 1;
|
||||
for a in &gl.accounts {
|
||||
let name = format!("{} {}", a.code, a.name);
|
||||
sheet.write_string_with_format(row, 0, &name, &f.bold)?;
|
||||
row += 1;
|
||||
sheet.write_string_with_format(row, 2, "Beginning balance", &f.line_label)?;
|
||||
number(sheet, row, 6, a.beginning_balance, &f.plain)?;
|
||||
row += 1;
|
||||
for p in &a.postings {
|
||||
sheet.write_string(row, 0, p.date.to_string())?;
|
||||
sheet.write_number(row, 1, p.entry_id as f64)?;
|
||||
sheet.write_string(row, 2, &p.description)?;
|
||||
sheet.write_string(row, 3, &p.split)?;
|
||||
if !p.debit.is_zero() {
|
||||
number(sheet, row, 4, p.debit, &f.plain)?;
|
||||
}
|
||||
if !p.credit.is_zero() {
|
||||
number(sheet, row, 5, p.credit, &f.plain)?;
|
||||
}
|
||||
number(sheet, row, 6, p.balance, &f.plain)?;
|
||||
row += 1;
|
||||
}
|
||||
sheet.write_string_with_format(row, 0, format!("Total for {name}"), &f.bold)?;
|
||||
for c in 1..4 {
|
||||
sheet.write_blank(row, c, &f.dollars_sum)?;
|
||||
}
|
||||
number(sheet, row, 4, a.total_debits, &f.dollars_sum)?;
|
||||
number(sheet, row, 5, a.total_credits, &f.dollars_sum)?;
|
||||
number(sheet, row, 6, a.ending_balance, &f.dollars_sum)?;
|
||||
row += 2;
|
||||
}
|
||||
for (c, w) in [
|
||||
(0, 12.0),
|
||||
(1, 7.0),
|
||||
(2, 46.0),
|
||||
(3, 34.0),
|
||||
(4, 13.0),
|
||||
(5, 13.0),
|
||||
(6, 13.0),
|
||||
] {
|
||||
sheet.set_column_width(c, w)?;
|
||||
}
|
||||
footer(sheet, f, meta, row, cols.len() as u16)
|
||||
}
|
||||
|
||||
fn general_journal(
|
||||
sheet: &mut Worksheet,
|
||||
f: &Formats,
|
||||
meta: &ReportMeta,
|
||||
ledger: &Ledger,
|
||||
start: NaiveDate,
|
||||
end: NaiveDate,
|
||||
) -> Result<(), XlsxError> {
|
||||
sheet.set_name("General Journal")?;
|
||||
let period = crate::report::period_label(start, end);
|
||||
let cols = [
|
||||
"Date",
|
||||
"Entry",
|
||||
"Description",
|
||||
"Account",
|
||||
"Class",
|
||||
"Debit",
|
||||
"Credit",
|
||||
]
|
||||
.iter()
|
||||
.enumerate()
|
||||
{
|
||||
sheet.write_string_with_format(row, i as u16, *h, &bold)?;
|
||||
];
|
||||
let mut row = header(
|
||||
sheet,
|
||||
f,
|
||||
meta,
|
||||
"General Journal",
|
||||
&period,
|
||||
cols.len() as u16,
|
||||
)?;
|
||||
for (i, h) in cols.iter().enumerate() {
|
||||
let fmt = if i >= 5 { &f.header_right } else { &f.header };
|
||||
sheet.write_string_with_format(row, i as u16, h.to_uppercase(), fmt)?;
|
||||
}
|
||||
row += 1;
|
||||
for entry in ledger.posted_entries() {
|
||||
let mut entries: Vec<_> = ledger
|
||||
.posted_entries()
|
||||
.filter(|e| e.date() >= start && e.date() <= end)
|
||||
.collect();
|
||||
entries.sort_by_key(|e| (e.date(), e.id()));
|
||||
let (mut debits, mut credits) = (Decimal::ZERO, Decimal::ZERO);
|
||||
for entry in entries {
|
||||
for line in entry.lines() {
|
||||
let account = ledger.chart().get(line.account);
|
||||
let (code, name) = match account {
|
||||
Some(a) => (a.code.as_str(), a.name.as_str()),
|
||||
None => ("?", "?"),
|
||||
};
|
||||
let class_name = line
|
||||
let account = ledger
|
||||
.chart()
|
||||
.get(line.account)
|
||||
.map(|a| format!("{} {}", a.code, a.name))
|
||||
.unwrap_or_else(|| "?".to_string());
|
||||
let class = line
|
||||
.class_id
|
||||
.and_then(|cid| ledger.get_class(cid))
|
||||
.map(|c| c.name.as_str())
|
||||
.unwrap_or("");
|
||||
let (debit, credit) = match line.side {
|
||||
ledger_core::Side::Debit => (line.amount.amount, Decimal::ZERO),
|
||||
ledger_core::Side::Credit => (Decimal::ZERO, line.amount.amount),
|
||||
};
|
||||
sheet.write_string(row, 0, entry.date().to_string())?;
|
||||
sheet.write_number(row, 1, entry.id() as f64)?;
|
||||
sheet.write_string(row, 2, entry.description())?;
|
||||
sheet.write_string(row, 3, code)?;
|
||||
sheet.write_string(row, 4, name)?;
|
||||
sheet.write_string(row, 5, class_name)?;
|
||||
sheet.write_number_with_format(row, 6, dec_to_f64(debit), &money)?;
|
||||
sheet.write_number_with_format(row, 7, dec_to_f64(credit), &money)?;
|
||||
sheet.write_string(row, 3, &account)?;
|
||||
sheet.write_string(row, 4, class)?;
|
||||
match line.side {
|
||||
ledger_core::Side::Debit => {
|
||||
number(sheet, row, 5, line.amount.amount, &f.plain)?;
|
||||
debits += line.amount.amount;
|
||||
}
|
||||
ledger_core::Side::Credit => {
|
||||
number(sheet, row, 6, line.amount.amount, &f.plain)?;
|
||||
credits += line.amount.amount;
|
||||
}
|
||||
}
|
||||
row += 1;
|
||||
}
|
||||
}
|
||||
sheet.set_column_width(2, 50)?;
|
||||
sheet.set_column_width(4, 32)?;
|
||||
footer(sheet, meta, row)?;
|
||||
sheet.write_string_with_format(row, 0, "Total", &f.bold)?;
|
||||
for c in 1..5 {
|
||||
sheet.write_blank(row, c, &f.dollars_total)?;
|
||||
}
|
||||
number(sheet, row, 5, debits, &f.dollars_total)?;
|
||||
number(sheet, row, 6, credits, &f.dollars_total)?;
|
||||
for (c, w) in [
|
||||
(0, 12.0),
|
||||
(1, 7.0),
|
||||
(2, 50.0),
|
||||
(3, 38.0),
|
||||
(4, 12.0),
|
||||
(5, 13.0),
|
||||
(6, 13.0),
|
||||
] {
|
||||
sheet.set_column_width(c, w)?;
|
||||
}
|
||||
|
||||
Ok(workbook.save_to_buffer()?)
|
||||
footer(sheet, f, meta, row + 1, cols.len() as u16)
|
||||
}
|
||||
|
||||
/// Decimal to f64 at the presentation boundary. Rounded to cents first.
|
||||
@@ -222,7 +377,8 @@ fn dec_to_f64(d: Decimal) -> f64 {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use ledger_core::reports::TrialBalanceLine;
|
||||
use crate::report;
|
||||
use ledger_core::reports::{TrialBalance, TrialBalanceLine};
|
||||
|
||||
#[test]
|
||||
fn xlsx_is_a_zip() {
|
||||
@@ -238,9 +394,12 @@ mod tests {
|
||||
};
|
||||
let meta = ReportMeta {
|
||||
company: "Test Co".to_string(),
|
||||
basis: "Accrual".to_string(),
|
||||
prepared_on: NaiveDate::from_ymd_opt(2026, 10, 11).unwrap(),
|
||||
journal_seq: 7,
|
||||
};
|
||||
let bytes = report_to_xlsx(&XlsxReport::TrialBalance(&tb), &meta).unwrap();
|
||||
let st = report::trial_balance(&tb, NaiveDate::from_ymd_opt(2026, 10, 11).unwrap());
|
||||
let bytes = report_to_xlsx(&XlsxReport::Statement(&st), &meta).unwrap();
|
||||
// XLSX is a ZIP: starts with PK.
|
||||
assert_eq!(&bytes[0..2], b"PK");
|
||||
assert!(bytes.len() > 1000);
|
||||
|
||||
@@ -1,111 +1,114 @@
|
||||
// A financial statement: P&L, balance sheet, or cash flow.
|
||||
// A financial statement: P&L, balance sheet, cash flow, trial balance, or
|
||||
// members' equity, in US small-business (QuickBooks-style) presentation.
|
||||
//
|
||||
// Everything arrives as data in sys.inputs.data (JSON), already laid out by
|
||||
// ledger-import::report, with amounts already split into exact parts. The
|
||||
// template only typesets: no arithmetic happens here, and no label is ever
|
||||
// parsed as markup.
|
||||
//
|
||||
// Shape: { company, title, period, footer,
|
||||
// rows: [{ kind: "section" | "line" | "subtotal" | "total"
|
||||
// | "spacer", label, rule (single rule above),
|
||||
// amount: null | { digits: "1,234.56", negative, zero,
|
||||
// currency } }] }
|
||||
// Shape: { company, title, period, footer, columns: ["Total"] | [...],
|
||||
// rows: [{ kind: "section" | "subheading" | "line" | "subtotal"
|
||||
// | "total" | "spacer",
|
||||
// label, rule (rule above), currency ("$" on amounts),
|
||||
// amounts: [null | { digits: "1,234.56", negative }] }] }
|
||||
//
|
||||
// Amounts follow US accounting presentation: tabular figures, "$" pinned
|
||||
// to the left of the amount column on the first line of each section and on
|
||||
// totals, negatives in parentheses with the closing parenthesis hanging
|
||||
// (its width is reserved on every row, so decimal points always line up),
|
||||
// and zero as a dash in the ones position. Amounts are never bold: bold
|
||||
// figures are wider, which would knock totals out of the decimal column —
|
||||
// the rules mark subtotals and totals instead.
|
||||
// Amounts: Inter's tabular figures; negatives in parentheses with the
|
||||
// closing parenthesis hanging (its width is reserved on every row, so
|
||||
// decimal points always line up); "$" attached on subtotals and totals.
|
||||
|
||||
#let d = json(bytes(sys.inputs.data))
|
||||
#let ncols = d.columns.len()
|
||||
#let ink = luma(25)
|
||||
#let quiet = luma(110)
|
||||
#let hair = 0.5pt + luma(160)
|
||||
#let firm = 0.6pt + ink
|
||||
|
||||
#set document(title: d.company + " — " + d.title, author: d.company)
|
||||
#set text(
|
||||
font: "Libertinus Serif",
|
||||
size: 10.5pt,
|
||||
number-type: "lining",
|
||||
number-width: "tabular",
|
||||
)
|
||||
#set text(font: "Inter", size: 9pt, fill: ink, number-width: "tabular")
|
||||
#set page(
|
||||
paper: "us-letter",
|
||||
margin: (x: 1in, top: 0.9in, bottom: 0.8in),
|
||||
margin: (x: 0.75in, top: 0.75in, bottom: 0.75in),
|
||||
header: context {
|
||||
if counter(page).get().first() > 1 {
|
||||
set text(8.5pt, fill: luma(110))
|
||||
[#d.company — #d.title #h(1fr) #d.period]
|
||||
set text(7.5pt, fill: quiet)
|
||||
[#d.company · #d.title #h(1fr) #d.period]
|
||||
}
|
||||
},
|
||||
footer: context {
|
||||
set text(8pt, fill: luma(110))
|
||||
[#d.footer #h(1fr) Page #counter(page).display() of #counter(page).final().first()]
|
||||
set text(7.5pt, fill: quiet)
|
||||
grid(
|
||||
columns: (1fr, auto, 1fr),
|
||||
[],
|
||||
d.footer,
|
||||
align(right)[Page #counter(page).display() of #counter(page).final().first()],
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
#align(center)[
|
||||
#text(15pt, weight: "bold", d.company) \
|
||||
#text(15pt, fill: luma(45), d.company) \
|
||||
#v(4pt)
|
||||
#text(10.5pt, weight: "bold", d.title) \
|
||||
#v(2pt)
|
||||
#text(12.5pt, d.title) \
|
||||
#v(1pt)
|
||||
#text(10pt, style: "italic", d.period)
|
||||
#d.period
|
||||
]
|
||||
#v(16pt)
|
||||
#v(12pt)
|
||||
|
||||
// Two lines under a statement total, the accounting double rule, set
|
||||
// clear of the descenders.
|
||||
#let double-rule(body) = stack(
|
||||
body,
|
||||
v(2.4pt),
|
||||
line(length: 100%, stroke: 0.5pt),
|
||||
v(1.4pt),
|
||||
line(length: 100%, stroke: 0.5pt),
|
||||
)
|
||||
|
||||
#let amount(r) = {
|
||||
let a = r.amount
|
||||
#let amount(a, currency) = {
|
||||
if a == none { return [] }
|
||||
let open = if a.negative { [(] } else { [] }
|
||||
let close = if a.negative { [)] } else { hide[)] }
|
||||
let body = if a.zero { [–#hide[.00]] } else { [#open#a.digits] }
|
||||
grid(
|
||||
columns: (auto, 1fr),
|
||||
align: (left, right),
|
||||
if a.currency [\$] else [],
|
||||
[#body#close],
|
||||
let sign = if currency [\$] else []
|
||||
[#open#sign#a.digits#close]
|
||||
}
|
||||
|
||||
#let full(body, ..args) = table.cell(colspan: ncols + 1, ..args, body)
|
||||
|
||||
#let row-cells(r) = {
|
||||
let bold = r.kind == "subtotal" or r.kind == "total"
|
||||
let stroke = if r.rule { (top: if r.kind == "total" { firm } else { hair }) } else { none }
|
||||
let label = if r.kind == "line" { pad(left: 1.4em, r.label) } else { r.label }
|
||||
let label = if bold { text(weight: "bold", label) } else { label }
|
||||
(
|
||||
table.cell(stroke: stroke, label),
|
||||
..r.amounts.map(a => table.cell(stroke: stroke, amount(a, r.currency))),
|
||||
)
|
||||
}
|
||||
|
||||
#table(
|
||||
columns: (1fr, 9em),
|
||||
columns: (1fr,) + (8em,) * ncols,
|
||||
stroke: none,
|
||||
inset: (x: 0pt, y: 3.2pt),
|
||||
align: (left + horizon, right + horizon),
|
||||
inset: (x: 4pt, y: 3.6pt),
|
||||
align: (left + horizon,) + (right + horizon,) * ncols,
|
||||
table.header(
|
||||
table.cell(stroke: (top: firm, bottom: hair), []),
|
||||
..d.columns.map(c => table.cell(
|
||||
stroke: (top: firm, bottom: hair),
|
||||
text(7.5pt, weight: "bold", tracking: 0.04em, upper(c)),
|
||||
)),
|
||||
),
|
||||
..d
|
||||
.rows
|
||||
.map(r => {
|
||||
if r.kind == "spacer" {
|
||||
(table.cell(colspan: 2, v(3pt)),)
|
||||
(full(v(2pt), inset: 2pt),)
|
||||
} else if r.kind == "section" {
|
||||
(table.cell(colspan: 2, text(weight: "bold", r.label)),)
|
||||
} else if r.kind == "line" {
|
||||
(pad(left: 1.4em, r.label), amount(r))
|
||||
} else if r.kind == "subtotal" {
|
||||
(
|
||||
text(weight: "bold", r.label),
|
||||
table.cell(
|
||||
stroke: if r.rule { (top: 0.5pt) } else { none },
|
||||
amount(r),
|
||||
(full(text(weight: "semibold", r.label)),)
|
||||
} else if r.kind == "subheading" {
|
||||
(full(pad(left: 1.4em, emph(r.label))),)
|
||||
} else if r.kind == "total" {
|
||||
// The double rule under a statement total, full width.
|
||||
let double = full(
|
||||
inset: (x: 0pt, y: 0pt),
|
||||
stack(
|
||||
spacing: 1.6pt,
|
||||
line(length: 100%, stroke: firm),
|
||||
line(length: 100%, stroke: firm),
|
||||
),
|
||||
)
|
||||
row-cells(r) + (double,)
|
||||
} else {
|
||||
(
|
||||
align(top, text(weight: "bold", r.label)),
|
||||
table.cell(
|
||||
stroke: (top: 0.5pt),
|
||||
double-rule(amount(r)),
|
||||
),
|
||||
)
|
||||
row-cells(r)
|
||||
}
|
||||
})
|
||||
.flatten(),
|
||||
|
||||
@@ -2,9 +2,11 @@
|
||||
//!
|
||||
//! `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 fiscal year start (fiscal-year-to-date reports), the members of a
|
||||
//! multi-member LLC (`[[company.members]]`: name, share, capital account —
|
||||
//! for the statement of members' equity), `[books] accounting_basis`
|
||||
//! (report footers), 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};
|
||||
@@ -19,6 +21,21 @@ pub struct Company {
|
||||
pub name: String,
|
||||
/// Month and day the fiscal year starts. Default January 1.
|
||||
pub fiscal_year_start: (u32, u32),
|
||||
/// Members of a multi-member LLC; empty for other entities.
|
||||
pub members: Vec<Member>,
|
||||
/// "Accrual" or "Cash", for report footers. Default "Accrual".
|
||||
pub basis: String,
|
||||
}
|
||||
|
||||
/// One member of a multi-member LLC.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Member {
|
||||
/// Name, as it appears on reports.
|
||||
pub name: String,
|
||||
/// Share of income, e.g. 0.50.
|
||||
pub share: Decimal,
|
||||
/// Code of their capital account in the chart.
|
||||
pub capital_account: String,
|
||||
}
|
||||
|
||||
impl Default for Company {
|
||||
@@ -26,6 +43,8 @@ impl Default for Company {
|
||||
Self {
|
||||
name: "Unnamed company".to_string(),
|
||||
fiscal_year_start: (1, 1),
|
||||
members: Vec::new(),
|
||||
basis: "Accrual".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -68,11 +87,35 @@ struct BooksToml {
|
||||
struct CompanyToml {
|
||||
name: Option<String>,
|
||||
fiscal_year_start: Option<String>,
|
||||
#[serde(default)]
|
||||
members: Vec<MemberToml>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct MemberToml {
|
||||
name: String,
|
||||
share: String,
|
||||
capital_account: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Default, Deserialize)]
|
||||
struct BooksSection {
|
||||
receipt_threshold: Option<String>,
|
||||
accounting_basis: Option<String>,
|
||||
}
|
||||
|
||||
/// Parse a share: "50%" or "0.5".
|
||||
fn parse_share(name: &str, s: &str) -> Result<Decimal, String> {
|
||||
let s = s.trim();
|
||||
let bad = || format!("member {name}: share {s:?} must be like \"50%\" or \"0.5\"");
|
||||
let share = match s.strip_suffix('%') {
|
||||
Some(pct) => pct.trim().parse::<Decimal>().map_err(|_| bad())? / Decimal::from(100),
|
||||
None => s.parse::<Decimal>().map_err(|_| bad())?,
|
||||
};
|
||||
if share <= Decimal::ZERO || share > Decimal::ONE {
|
||||
return Err(bad());
|
||||
}
|
||||
Ok(share)
|
||||
}
|
||||
|
||||
/// Parse `"MM-DD"`. February 29 is rejected: a fiscal year must start on a
|
||||
@@ -97,6 +140,35 @@ pub fn parse(text: &str) -> Result<BooksSettings, String> {
|
||||
if let Some(fy) = raw.company.fiscal_year_start {
|
||||
company.fiscal_year_start = parse_month_day(fy.trim())?;
|
||||
}
|
||||
for m in raw.company.members {
|
||||
let share = parse_share(&m.name, &m.share)?;
|
||||
let capital_account = m
|
||||
.capital_account
|
||||
.filter(|c| !c.trim().is_empty())
|
||||
.ok_or_else(|| format!("member {}: capital_account is required", m.name))?;
|
||||
company.members.push(Member {
|
||||
name: m.name.trim().to_string(),
|
||||
share,
|
||||
capital_account: capital_account.trim().to_string(),
|
||||
});
|
||||
}
|
||||
if !company.members.is_empty() {
|
||||
let total: Decimal = company.members.iter().map(|m| m.share).sum();
|
||||
if total != Decimal::ONE {
|
||||
return Err(format!("member shares sum to {total}, not 100%"));
|
||||
}
|
||||
}
|
||||
if let Some(basis) = raw.books.accounting_basis {
|
||||
company.basis = match basis.trim().to_lowercase().as_str() {
|
||||
"accrual" => "Accrual".to_string(),
|
||||
"cash" => "Cash".to_string(),
|
||||
other => {
|
||||
return Err(format!(
|
||||
"accounting_basis {other:?}: expected accrual or cash"
|
||||
));
|
||||
}
|
||||
};
|
||||
}
|
||||
let receipt_threshold = match raw.books.receipt_threshold {
|
||||
Some(t) => {
|
||||
t.trim()
|
||||
@@ -174,6 +246,42 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_members_and_basis() {
|
||||
let s = parse(
|
||||
"[company]\n[[company.members]]\nname = \"Stephen Waits\"\nshare = \"50%\"\ncapital_account = \"3000\"\n[[company.members]]\nname = \"Katherine Pendergast\"\nshare = \"0.5\"\ncapital_account = \"3001\"\n[books]\naccounting_basis = \"cash\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
let half = Decimal::new(5, 1);
|
||||
assert_eq!(
|
||||
s.company.members,
|
||||
vec![
|
||||
Member {
|
||||
name: "Stephen Waits".into(),
|
||||
share: half,
|
||||
capital_account: "3000".into()
|
||||
},
|
||||
Member {
|
||||
name: "Katherine Pendergast".into(),
|
||||
share: half,
|
||||
capital_account: "3001".into()
|
||||
},
|
||||
]
|
||||
);
|
||||
assert_eq!(s.company.basis, "Cash");
|
||||
assert_eq!(parse("").unwrap().company.basis, "Accrual");
|
||||
// Shares must add up; every member needs a capital account.
|
||||
let two = |a: &str, b: &str| {
|
||||
format!(
|
||||
"[company]\n[[company.members]]\nname = \"A\"\nshare = \"{a}\"\ncapital_account = \"3000\"\n[[company.members]]\nname = \"B\"\nshare = \"{b}\"\ncapital_account = \"3001\"\n"
|
||||
)
|
||||
};
|
||||
assert!(parse(&two("50%", "40%")).is_err());
|
||||
assert!(parse(&two("60%", "40%")).is_ok());
|
||||
assert!(parse("[company]\n[[company.members]]\nname = \"A\"\nshare = \"100%\"\n").is_err());
|
||||
assert!(parse("[books]\naccounting_basis = \"modified\"\n").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_bad_settings() {
|
||||
assert!(parse("[company]\nfiscal_year_start = \"02-29\"\n").is_err());
|
||||
|
||||
+282
-49
@@ -68,10 +68,65 @@ impl BooksServer {
|
||||
fn report_meta(&self, store: &Store) -> ReportMeta {
|
||||
ReportMeta {
|
||||
company: self.company.name.clone(),
|
||||
basis: self.company.basis.clone(),
|
||||
prepared_on: today(),
|
||||
journal_seq: store.journal_seq(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Members as reports name them (empty unless a multi-member LLC).
|
||||
fn report_members(&self) -> Vec<ledger_import::Member> {
|
||||
self.company
|
||||
.members
|
||||
.iter()
|
||||
.map(|m| ledger_import::Member {
|
||||
name: m.name.clone(),
|
||||
capital_code: m.capital_account.clone(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Members with their capital accounts resolved in the chart, for the
|
||||
/// statement of members' equity.
|
||||
fn member_capitals(&self, ledger: &Ledger) -> Result<Vec<ledger_core::MemberCapital>, String> {
|
||||
if self.company.members.is_empty() {
|
||||
return Err(
|
||||
"no members configured: add [[company.members]] (name, share, capital_account) to wanderbooks.toml"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
self.company
|
||||
.members
|
||||
.iter()
|
||||
.map(|m| {
|
||||
let account = ledger
|
||||
.chart()
|
||||
.find_by_code(&m.capital_account)
|
||||
.ok_or_else(|| {
|
||||
format!(
|
||||
"member {}: unknown capital account {}",
|
||||
m.name, m.capital_account
|
||||
)
|
||||
})?;
|
||||
Ok(ledger_core::MemberCapital {
|
||||
name: m.name.clone(),
|
||||
capital_account: account.id,
|
||||
share: m.share,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Net income (loss) for the fiscal year containing `as_of`, through it.
|
||||
fn net_income_ytd(&self, ledger: &Ledger, as_of: NaiveDate) -> Result<Decimal, String> {
|
||||
let (fy_start, _) = self.company.fiscal_year_containing(as_of);
|
||||
Ok(
|
||||
ledger_core::profit_and_loss_for_period(ledger, fy_start, as_of, None)
|
||||
.map_err(|e| e.to_string())?
|
||||
.net_income(),
|
||||
)
|
||||
}
|
||||
|
||||
fn lock(&self) -> Result<std::sync::MutexGuard<'_, Store>, String> {
|
||||
self.store
|
||||
.lock()
|
||||
@@ -343,8 +398,10 @@ fn movement_in(ledger: &Ledger, account: AccountId, from: NaiveDate, to: NaiveDa
|
||||
}
|
||||
}
|
||||
|
||||
/// Today in the books' local time zone. Not UTC: in the evening (US
|
||||
/// time) UTC is already tomorrow, and "through today" must not include it.
|
||||
fn today() -> NaiveDate {
|
||||
chrono::Utc::now().date_naive()
|
||||
chrono::Local::now().date_naive()
|
||||
}
|
||||
|
||||
/// Resolve a statement's date range: `end` defaults to today and `start` to
|
||||
@@ -470,13 +527,21 @@ impl BooksServer {
|
||||
|
||||
#[tool(
|
||||
name = "books.trial_balance",
|
||||
description = "The trial balance: every account with a non-zero balance, debit and credit columns and totals. Debits must equal credits.",
|
||||
description = "The trial balance as of a date (default today): every account with a non-zero balance, debit and credit columns and totals. Debits must equal credits.",
|
||||
annotations(read_only_hint = true)
|
||||
)]
|
||||
async fn trial_balance(&self) -> Result<Json<TrialBalanceOut>, String> {
|
||||
async fn trial_balance(
|
||||
&self,
|
||||
params: Parameters<TrialBalanceParams>,
|
||||
) -> Result<Json<TrialBalanceOut>, String> {
|
||||
let store = self.lock()?;
|
||||
let tb = reports::trial_balance(store.ledger());
|
||||
let as_of = match ¶ms.0.as_of {
|
||||
Some(s) => parse_date(s)?,
|
||||
None => today(),
|
||||
};
|
||||
let tb = reports::trial_balance_as_of(store.ledger(), as_of);
|
||||
Ok(Json(TrialBalanceOut {
|
||||
as_of: as_of.to_string(),
|
||||
lines: tb
|
||||
.lines
|
||||
.iter()
|
||||
@@ -568,7 +633,7 @@ impl BooksServer {
|
||||
};
|
||||
let end = match p.end {
|
||||
Some(s) => parse_date(&s)?,
|
||||
None => chrono::Utc::now().date_naive(),
|
||||
None => today(),
|
||||
};
|
||||
// Resolve cash and financing account codes to ids.
|
||||
let mut cash_accounts = Vec::new();
|
||||
@@ -1161,7 +1226,7 @@ impl BooksServer {
|
||||
async fn digest(&self, params: Parameters<DigestParams>) -> Result<Json<DigestOut>, String> {
|
||||
let p = params.0;
|
||||
let since = parse_date(&p.since)?;
|
||||
let today = chrono::Utc::now().date_naive();
|
||||
let today = today();
|
||||
let store = self.lock()?;
|
||||
let ledger = store.ledger();
|
||||
let mut entries = Vec::new();
|
||||
@@ -1385,7 +1450,7 @@ impl BooksServer {
|
||||
|
||||
#[tool(
|
||||
name = "books.export",
|
||||
description = "Export a report. Formats: csv, json, xlsx (base64), pdf (base64; profit_and_loss, balance_sheet, cash_flow), iif (journal only, for QuickBooks Desktop). Reports: journal, trial_balance, profit_and_loss, balance_sheet, cash_flow. cash_flow needs cash_accounts.",
|
||||
description = "Export a report. Reports: profit_and_loss, balance_sheet, cash_flow (needs cash_accounts), trial_balance (as_of), members_equity (multi-member LLC; members from wanderbooks.toml), general_ledger (by account: running balances, splits), general_journal (every entry in date order; alias journal). Formats: pdf and xlsx (base64) for the five statements; xlsx, csv, json for every report; iif for general_journal (QuickBooks Desktop). Dates: start/end for period reports (start defaults to the first entry, end to today), as_of for balance_sheet and trial_balance.",
|
||||
annotations(read_only_hint = true)
|
||||
)]
|
||||
async fn export(&self, params: Parameters<ExportParams>) -> Result<Json<ExportOut>, String> {
|
||||
@@ -1424,14 +1489,16 @@ impl BooksServer {
|
||||
}));
|
||||
}
|
||||
let content = match report.as_str() {
|
||||
"journal" => self.export_journal(&format)?,
|
||||
"trial_balance" => self.export_trial_balance(&format)?,
|
||||
"journal" | "general_journal" => self.export_journal(&format)?,
|
||||
"trial_balance" => self.export_trial_balance(&format, p.as_of.as_deref())?,
|
||||
"general_ledger" => self.export_general_ledger(&format, &p)?,
|
||||
"members_equity" => self.export_members_equity(&format, &p)?,
|
||||
"profit_and_loss" => self.export_pnl(&format, p.start.as_deref(), p.end.as_deref())?,
|
||||
"balance_sheet" => self.export_balance_sheet(&format, p.as_of.as_deref())?,
|
||||
"cash_flow" => self.export_cash_flow(&format, &p)?,
|
||||
_ => {
|
||||
return Err(format!(
|
||||
"bad report {:?}: expected journal, trial_balance, profit_and_loss, balance_sheet, or cash_flow",
|
||||
"bad report {:?}: expected general_journal, general_ledger, trial_balance, profit_and_loss, balance_sheet, cash_flow, or members_equity",
|
||||
p.report
|
||||
));
|
||||
}
|
||||
@@ -1452,6 +1519,7 @@ impl BooksServer {
|
||||
p: &ExportParams,
|
||||
) -> Result<ledger_import::Statement, String> {
|
||||
use ledger_import::report;
|
||||
let members = self.report_members();
|
||||
Ok(match report {
|
||||
"profit_and_loss" => report::profit_and_loss(&pnl_statement(
|
||||
ledger,
|
||||
@@ -1459,17 +1527,38 @@ impl BooksServer {
|
||||
p.end.as_deref(),
|
||||
None,
|
||||
)?),
|
||||
"balance_sheet" => report::balance_sheet(&bs_statement(ledger, p.as_of.as_deref())?),
|
||||
"cash_flow" => report::cash_flow(&cash_flow_statement(
|
||||
"balance_sheet" => {
|
||||
let bs = bs_statement(ledger, p.as_of.as_deref())?;
|
||||
let ytd = self.net_income_ytd(ledger, bs.as_of)?;
|
||||
report::balance_sheet(&bs, ytd, &members)
|
||||
}
|
||||
"cash_flow" => report::cash_flow(
|
||||
&cash_flow_statement(
|
||||
ledger,
|
||||
p.start.as_deref(),
|
||||
p.end.as_deref(),
|
||||
&p.cash_accounts,
|
||||
&p.financing_accounts,
|
||||
)?),
|
||||
)?,
|
||||
&members,
|
||||
),
|
||||
"trial_balance" => {
|
||||
let as_of = match &p.as_of {
|
||||
Some(s) => parse_date(s)?,
|
||||
None => today(),
|
||||
};
|
||||
report::trial_balance(&reports::trial_balance_as_of(ledger, as_of), as_of)
|
||||
}
|
||||
"members_equity" => {
|
||||
let (start, end) = statement_range(ledger, p.start.as_deref(), p.end.as_deref())?;
|
||||
let me =
|
||||
ledger_core::members_equity(ledger, start, end, &self.member_capitals(ledger)?)
|
||||
.map_err(|e| e.to_string())?;
|
||||
report::members_equity(&me)
|
||||
}
|
||||
_ => {
|
||||
return Err(format!(
|
||||
"bad report {report:?}: expected profit_and_loss, balance_sheet, or cash_flow"
|
||||
"bad report {report:?}: expected profit_and_loss, balance_sheet, cash_flow, trial_balance, or members_equity"
|
||||
));
|
||||
}
|
||||
})
|
||||
@@ -1481,22 +1570,32 @@ impl BooksServer {
|
||||
let store = self.lock()?;
|
||||
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 xlsx = |r: XlsxReport<'_>| report_to_xlsx(&r, &meta).map_err(|e| e.to_string());
|
||||
match report {
|
||||
"general_ledger" => {
|
||||
let (start, end) = statement_range(ledger, p.start.as_deref(), p.end.as_deref())?;
|
||||
let gl =
|
||||
ledger_core::general_ledger(ledger, start, end).map_err(|e| e.to_string())?;
|
||||
xlsx(XlsxReport::GeneralLedger(&gl))
|
||||
}
|
||||
"general_journal" | "journal" => {
|
||||
let (start, end) = statement_range(ledger, p.start.as_deref(), p.end.as_deref())?;
|
||||
xlsx(XlsxReport::GeneralJournal(ledger, start, end))
|
||||
}
|
||||
_ => xlsx(XlsxReport::Statement(
|
||||
&self.layout_statement(ledger, report, p)?,
|
||||
)),
|
||||
}
|
||||
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.
|
||||
fn export_pdf(&self, report: &str, p: &ExportParams) -> Result<Vec<u8>, String> {
|
||||
if matches!(report, "general_ledger" | "general_journal" | "journal") {
|
||||
return Err(format!("{report} exports as xlsx, csv, or json, not pdf"));
|
||||
}
|
||||
let store = self.lock()?;
|
||||
let meta = self.report_meta(&store);
|
||||
let st = self
|
||||
.layout_statement(store.ledger(), report, p)
|
||||
.map_err(|e| e.replace("bad report", "bad report for pdf"))?;
|
||||
let st = self.layout_statement(store.ledger(), report, p)?;
|
||||
ledger_import::statement_pdf(&st, &meta).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
@@ -1610,7 +1709,7 @@ impl BooksServer {
|
||||
)]
|
||||
async fn list_assets(&self) -> Result<Json<ListAssetsOut>, String> {
|
||||
let store = self.lock()?;
|
||||
let today = chrono::Utc::now().date_naive();
|
||||
let today = today();
|
||||
let assets = store
|
||||
.ledger()
|
||||
.registered_assets()
|
||||
@@ -2425,7 +2524,7 @@ impl BooksServer {
|
||||
};
|
||||
let end = match p.end {
|
||||
Some(s) => parse_date(&s)?,
|
||||
None => chrono::Utc::now().date_naive(),
|
||||
None => today(),
|
||||
};
|
||||
if end < start {
|
||||
return Err("end is before start".to_string());
|
||||
@@ -2504,7 +2603,7 @@ impl BooksServer {
|
||||
|
||||
#[tool(
|
||||
name = "books.export_cpa_package",
|
||||
description = "Build the CPA handoff package as a zip (base64): trial-balance.xlsx, general-ledger.xlsx (every posted line), profit-and-loss.pdf, balance-sheet.pdf, cash-flow.pdf. cash_accounts (e.g. [\"1000\"]) is required: the cash flow statement reconciles to them. The as_of date defaults to today; the P&L and cash flow cover the fiscal year to date.",
|
||||
description = "Build the CPA handoff package as a zip (base64): the profit and loss, balance sheet, cash flow, statement of changes in members' equity (multi-member LLCs), and trial balance, each as PDF and XLSX; plus the general ledger (by account) and general journal (entry by entry) as XLSX. cash_accounts (e.g. [\"1000\"]) is required: the cash flow statement reconciles to them. as_of defaults to today; period reports cover the fiscal year to date.",
|
||||
annotations(read_only_hint = true)
|
||||
)]
|
||||
async fn export_cpa_package(
|
||||
@@ -2526,27 +2625,67 @@ impl BooksServer {
|
||||
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());
|
||||
|st: &ledger_import::Statement| statement_pdf(st, &meta).map_err(|e| e.to_string());
|
||||
let fy = NaiveDate::parse_from_str(&fy_start, "%Y-%m-%d").map_err(|e| e.to_string())?;
|
||||
let members = self.report_members();
|
||||
|
||||
let tb_xlsx = xlsx(XlsxReport::TrialBalance(&reports::trial_balance(ledger)))?;
|
||||
let gl_xlsx = xlsx(XlsxReport::GeneralLedger(ledger))?;
|
||||
let pnl_pdf = pdf(report::profit_and_loss(&pnl_statement(
|
||||
// The financial statements, each as PDF and XLSX.
|
||||
let mut statements: Vec<(&str, ledger_import::Statement)> = vec![
|
||||
(
|
||||
"profit-and-loss",
|
||||
report::profit_and_loss(&pnl_statement(
|
||||
ledger,
|
||||
Some(&fy_start),
|
||||
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(
|
||||
)?),
|
||||
),
|
||||
(
|
||||
"balance-sheet",
|
||||
report::balance_sheet(
|
||||
&bs_statement(ledger, Some(&as_of_s))?,
|
||||
self.net_income_ytd(ledger, as_of)?,
|
||||
&members,
|
||||
),
|
||||
),
|
||||
(
|
||||
"cash-flow",
|
||||
report::cash_flow(
|
||||
&cash_flow_statement(
|
||||
ledger,
|
||||
Some(&fy_start),
|
||||
Some(&as_of_s),
|
||||
&p.cash_accounts,
|
||||
&p.financing_accounts,
|
||||
)?))?;
|
||||
)?,
|
||||
&members,
|
||||
),
|
||||
),
|
||||
];
|
||||
if !self.company.members.is_empty() {
|
||||
let me = ledger_core::members_equity(ledger, fy, as_of, &self.member_capitals(ledger)?)
|
||||
.map_err(|e| e.to_string())?;
|
||||
statements.push(("members-equity", report::members_equity(&me)));
|
||||
}
|
||||
statements.push((
|
||||
"trial-balance",
|
||||
report::trial_balance(&reports::trial_balance_as_of(ledger, as_of), as_of),
|
||||
));
|
||||
let mut files: Vec<(String, Vec<u8>)> = Vec::new();
|
||||
for (name, st) in &statements {
|
||||
files.push((format!("{name}.pdf"), pdf(st)?));
|
||||
files.push((format!("{name}.xlsx"), xlsx(XlsxReport::Statement(st))?));
|
||||
}
|
||||
// The books behind them: by account, and entry by entry.
|
||||
let gl = ledger_core::general_ledger(ledger, fy, as_of).map_err(|e| e.to_string())?;
|
||||
files.push((
|
||||
"general-ledger.xlsx".into(),
|
||||
xlsx(XlsxReport::GeneralLedger(&gl))?,
|
||||
));
|
||||
files.push((
|
||||
"general-journal.xlsx".into(),
|
||||
xlsx(XlsxReport::GeneralJournal(ledger, fy, as_of))?,
|
||||
));
|
||||
drop(store);
|
||||
|
||||
// Zip it.
|
||||
@@ -2554,14 +2693,9 @@ impl BooksServer {
|
||||
{
|
||||
let mut zip = zip::ZipWriter::new(std::io::Cursor::new(&mut zip_buf));
|
||||
let opts = zip::write::SimpleFileOptions::default();
|
||||
for (name, bytes) in [
|
||||
("trial-balance.xlsx", tb_xlsx.as_slice()),
|
||||
("general-ledger.xlsx", gl_xlsx.as_slice()),
|
||||
("profit-and-loss.pdf", pnl_pdf.as_slice()),
|
||||
("balance-sheet.pdf", bs_pdf.as_slice()),
|
||||
("cash-flow.pdf", cf_pdf.as_slice()),
|
||||
] {
|
||||
zip.start_file(name, opts).map_err(|e| e.to_string())?;
|
||||
for (name, bytes) in &files {
|
||||
zip.start_file(name.as_str(), opts)
|
||||
.map_err(|e| e.to_string())?;
|
||||
use std::io::Write;
|
||||
zip.write_all(bytes).map_err(|e| e.to_string())?;
|
||||
}
|
||||
@@ -2636,11 +2770,16 @@ impl BooksServer {
|
||||
Ok(csv)
|
||||
}
|
||||
|
||||
fn export_trial_balance(&self, format: &str) -> Result<String, String> {
|
||||
fn export_trial_balance(&self, format: &str, as_of: Option<&str>) -> Result<String, String> {
|
||||
let store = self.lock()?;
|
||||
let tb = reports::trial_balance(store.ledger());
|
||||
let as_of = match as_of {
|
||||
Some(s) => parse_date(s)?,
|
||||
None => today(),
|
||||
};
|
||||
let tb = reports::trial_balance_as_of(store.ledger(), as_of);
|
||||
if format == "json" {
|
||||
return serde_json::to_string_pretty(&serde_json::json!({
|
||||
"as_of": as_of.to_string(),
|
||||
"lines": tb.lines.iter().map(|l| serde_json::json!({
|
||||
"code": l.code, "name": l.name,
|
||||
"debit": money(l.debit), "credit": money(l.credit),
|
||||
@@ -2669,6 +2808,98 @@ impl BooksServer {
|
||||
Ok(csv)
|
||||
}
|
||||
|
||||
fn export_general_ledger(&self, format: &str, p: &ExportParams) -> Result<String, String> {
|
||||
let store = self.lock()?;
|
||||
let ledger = store.ledger();
|
||||
let (start, end) = statement_range(ledger, p.start.as_deref(), p.end.as_deref())?;
|
||||
let gl = ledger_core::general_ledger(ledger, start, end).map_err(|e| e.to_string())?;
|
||||
if format == "json" {
|
||||
return serde_json::to_string_pretty(&serde_json::json!({
|
||||
"start": gl.start.to_string(), "end": gl.end.to_string(),
|
||||
"accounts": gl.accounts.iter().map(|a| serde_json::json!({
|
||||
"code": a.code, "name": a.name,
|
||||
"beginning_balance": money(a.beginning_balance),
|
||||
"postings": a.postings.iter().map(|p| serde_json::json!({
|
||||
"date": p.date.to_string(), "entry_id": p.entry_id,
|
||||
"description": p.description, "split": p.split,
|
||||
"debit": money(p.debit), "credit": money(p.credit),
|
||||
"balance": money(p.balance),
|
||||
})).collect::<Vec<_>>(),
|
||||
"total_debits": money(a.total_debits),
|
||||
"total_credits": money(a.total_credits),
|
||||
"ending_balance": money(a.ending_balance),
|
||||
})).collect::<Vec<_>>(),
|
||||
}))
|
||||
.map_err(|e| e.to_string());
|
||||
}
|
||||
let mut csv = String::from("account,date,entry,description,split,debit,credit,balance\n");
|
||||
for a in &gl.accounts {
|
||||
let account = csv_escape(&format!("{} {}", a.code, a.name));
|
||||
csv.push_str(&format!(
|
||||
"{account},,,Beginning balance,,,,{}\n",
|
||||
money(a.beginning_balance)
|
||||
));
|
||||
for p in &a.postings {
|
||||
csv.push_str(&format!(
|
||||
"{account},{},{},{},{},{},{},{}\n",
|
||||
p.date,
|
||||
p.entry_id,
|
||||
csv_escape(&p.description),
|
||||
csv_escape(&p.split),
|
||||
money(p.debit),
|
||||
money(p.credit),
|
||||
money(p.balance)
|
||||
));
|
||||
}
|
||||
csv.push_str(&format!(
|
||||
"{account},,,Total,,{},{},{}\n",
|
||||
money(a.total_debits),
|
||||
money(a.total_credits),
|
||||
money(a.ending_balance)
|
||||
));
|
||||
}
|
||||
Ok(csv)
|
||||
}
|
||||
|
||||
fn export_members_equity(&self, format: &str, p: &ExportParams) -> Result<String, String> {
|
||||
let store = self.lock()?;
|
||||
let ledger = store.ledger();
|
||||
let (start, end) = statement_range(ledger, p.start.as_deref(), p.end.as_deref())?;
|
||||
let me = ledger_core::members_equity(ledger, start, end, &self.member_capitals(ledger)?)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let col = |c: &ledger_core::MemberEquityColumn| {
|
||||
serde_json::json!({
|
||||
"name": c.name, "beginning": money(c.beginning),
|
||||
"contributions": money(c.contributions), "net_income": money(c.net_income),
|
||||
"distributions": money(c.distributions), "other": money(c.other),
|
||||
"ending": money(c.ending),
|
||||
})
|
||||
};
|
||||
if format == "json" {
|
||||
return serde_json::to_string_pretty(&serde_json::json!({
|
||||
"start": me.start.to_string(), "end": me.end.to_string(),
|
||||
"members": me.members.iter().map(col).collect::<Vec<_>>(),
|
||||
"total": col(&me.total),
|
||||
}))
|
||||
.map_err(|e| e.to_string());
|
||||
}
|
||||
let mut csv =
|
||||
String::from("member,beginning,contributions,net_income,distributions,other,ending\n");
|
||||
for c in me.members.iter().chain(std::iter::once(&me.total)) {
|
||||
csv.push_str(&format!(
|
||||
"{},{},{},{},{},{},{}\n",
|
||||
csv_escape(&c.name),
|
||||
money(c.beginning),
|
||||
money(c.contributions),
|
||||
money(c.net_income),
|
||||
money(c.distributions),
|
||||
money(c.other),
|
||||
money(c.ending)
|
||||
));
|
||||
}
|
||||
Ok(csv)
|
||||
}
|
||||
|
||||
fn export_pnl(
|
||||
&self,
|
||||
format: &str,
|
||||
@@ -2768,7 +2999,7 @@ impl BooksServer {
|
||||
};
|
||||
let end = match &p.end {
|
||||
Some(s) => parse_date(s)?,
|
||||
None => chrono::Utc::now().date_naive(),
|
||||
None => today(),
|
||||
};
|
||||
let stmt = ledger_core::cash_flow(
|
||||
ledger,
|
||||
@@ -2879,7 +3110,9 @@ impl BooksServer {
|
||||
serde_json::to_string_pretty(&out).map_err(|e| e.to_string())
|
||||
}
|
||||
"books://reports/trial-balance" => {
|
||||
let Json(out) = self.trial_balance().await?;
|
||||
let Json(out) = self
|
||||
.trial_balance(Parameters(TrialBalanceParams::default()))
|
||||
.await?;
|
||||
serde_json::to_string_pretty(&out).map_err(|e| e.to_string())
|
||||
}
|
||||
"books://reports/balance-sheet" => {
|
||||
|
||||
@@ -76,9 +76,19 @@ pub struct TrialBalanceLineOut {
|
||||
pub credit: String,
|
||||
}
|
||||
|
||||
/// Parameters for `books.trial_balance`.
|
||||
#[derive(Debug, Default, Deserialize, JsonSchema)]
|
||||
pub struct TrialBalanceParams {
|
||||
/// As of this date, YYYY-MM-DD. Defaults to today.
|
||||
#[serde(default)]
|
||||
pub as_of: Option<String>,
|
||||
}
|
||||
|
||||
/// Result of `books.trial_balance`.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct TrialBalanceOut {
|
||||
/// The as-of date, YYYY-MM-DD.
|
||||
pub as_of: String,
|
||||
/// Lines in code order.
|
||||
pub lines: Vec<TrialBalanceLineOut>,
|
||||
/// Sum of the debit column, decimal string.
|
||||
|
||||
@@ -834,6 +834,7 @@ fn mcp_statement_pdfs_and_cpa_package_over_stdio() {
|
||||
("profit_and_loss", json!({})),
|
||||
("balance_sheet", json!({})),
|
||||
("cash_flow", json!({"cash_accounts": ["1000"]})),
|
||||
("trial_balance", json!({"as_of": "2026-10-31"})),
|
||||
] {
|
||||
let mut args = json!({"format": "pdf", "report": report});
|
||||
args.as_object_mut()
|
||||
@@ -850,11 +851,12 @@ fn mcp_statement_pdfs_and_cpa_package_over_stdio() {
|
||||
json!({"format": "pdf", "report": "cash_flow"}),
|
||||
);
|
||||
assert!(err.contains("cash_accounts"), "{err}");
|
||||
// The ledger and journal are workbooks, not printable statements.
|
||||
let err = mcp.call_err(
|
||||
"books.export",
|
||||
json!({"format": "pdf", "report": "trial_balance"}),
|
||||
json!({"format": "pdf", "report": "general_ledger"}),
|
||||
);
|
||||
assert!(err.contains("for pdf"), "{err}");
|
||||
assert!(err.contains("not pdf"), "{err}");
|
||||
|
||||
// The CPA package carries all three statements.
|
||||
let err = mcp.call_err("books.export_cpa_package", json!({"as_of": "2026-10-31"}));
|
||||
@@ -873,9 +875,14 @@ fn mcp_statement_pdfs_and_cpa_package_over_stdio() {
|
||||
names,
|
||||
[
|
||||
"balance-sheet.pdf",
|
||||
"balance-sheet.xlsx",
|
||||
"cash-flow.pdf",
|
||||
"cash-flow.xlsx",
|
||||
"general-journal.xlsx",
|
||||
"general-ledger.xlsx",
|
||||
"profit-and-loss.pdf",
|
||||
"profit-and-loss.xlsx",
|
||||
"trial-balance.pdf",
|
||||
"trial-balance.xlsx"
|
||||
]
|
||||
);
|
||||
@@ -892,7 +899,7 @@ fn mcp_period_statements_over_stdio() {
|
||||
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",
|
||||
"[company]\nname = \"Test Co\"\nfiscal_year_start = \"02-01\"\n[[company.members]]\nname = \"Owner\"\nshare = \"100%\"\ncapital_account = \"3000\"\n[books]\nreceipt_threshold = \"5.00\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
let data = dir.join("books-data");
|
||||
@@ -978,4 +985,41 @@ fn mcp_period_statements_over_stdio() {
|
||||
assert_eq!(jan["current_earnings"], "4.00");
|
||||
assert_eq!(jan["equity_accounts"][0]["amount"], "1000.00");
|
||||
assert_eq!(jan["balanced"], true);
|
||||
|
||||
// Trial balance as of Jan 31: Dr cash 1,004.00 = Cr capital + sales.
|
||||
let tb = mcp.call("books.trial_balance", json!({"as_of": "2026-01-31"}));
|
||||
assert_eq!(tb["as_of"], "2026-01-31");
|
||||
assert_eq!(tb["total_debits"], "1004.00");
|
||||
|
||||
// General ledger for February: cash starts at 1,004.00, pays 3.00 for
|
||||
// software, takes in 2.50 of sales, ends at 1,003.50.
|
||||
let gl = mcp.call(
|
||||
"books.export",
|
||||
json!({"format": "json", "report": "general_ledger", "start": "2026-02-01", "end": "2026-02-28"}),
|
||||
);
|
||||
let gl: serde_json::Value = serde_json::from_str(gl["content"].as_str().unwrap()).unwrap();
|
||||
let cash = &gl["accounts"][0];
|
||||
assert_eq!(cash["beginning_balance"], "1004.00");
|
||||
assert_eq!(cash["postings"][0]["split"], "6000 Software");
|
||||
assert_eq!(cash["postings"][0]["balance"], "1001.00");
|
||||
assert_eq!(cash["postings"][1]["split"], "4000 Sales");
|
||||
assert_eq!(cash["ending_balance"], "1003.50");
|
||||
|
||||
// Members' equity, Jan-Feb: the one member contributed 1,000.00 and
|
||||
// earned 3.50 (6.50 sales - 3.00 software), ending at balance-sheet
|
||||
// equity, 1,003.50.
|
||||
let me = mcp.call(
|
||||
"books.export",
|
||||
json!({"format": "json", "report": "members_equity", "start": "2026-01-01", "end": "2026-02-28"}),
|
||||
);
|
||||
let me: serde_json::Value = serde_json::from_str(me["content"].as_str().unwrap()).unwrap();
|
||||
assert_eq!(me["members"][0]["name"], "Owner");
|
||||
assert_eq!(me["total"]["contributions"], "1000.00");
|
||||
assert_eq!(me["total"]["net_income"], "3.50");
|
||||
assert_eq!(me["total"]["ending"], "1003.50");
|
||||
let pdf = mcp.call(
|
||||
"books.export",
|
||||
json!({"format": "pdf", "report": "members_equity", "end": "2026-02-28"}),
|
||||
);
|
||||
assert_eq!(pdf["content_encoding"], "base64");
|
||||
}
|
||||
+34
-16
@@ -27,7 +27,11 @@ layer.
|
||||
`--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
|
||||
CPA package and the balance sheet's net income line, `[[company.members]]`
|
||||
(`name`, `share` as "50%" or "0.5", `capital_account` code; shares must sum
|
||||
to 100%) drives the statement of members' equity, `[books]
|
||||
accounting_basis` ("accrual" or "cash", default accrual) labels report
|
||||
footers, and `[books] receipt_threshold` sets the receipt policy unless
|
||||
`config.json` sets `receipt_policy`.
|
||||
|
||||
## Transports
|
||||
@@ -85,8 +89,8 @@ below.
|
||||
- `books.get_entry { entry_id }` → full entry with lines.
|
||||
- `books.search_entries { account?, date_from?, date_to?, text?, limit?, offset? }`
|
||||
→ entries with lines, newest first. `limit` caps at 100.
|
||||
- `books.trial_balance {}` → canonical JSON (same shape as
|
||||
`reports::snapshot` trial-balance section).
|
||||
- `books.trial_balance { as_of? }` → `as_of`, lines, totals, `balanced`
|
||||
(the trial balance as of the date, default today).
|
||||
- `books.balance_sheet { as_of? }` → totals (`assets`, `liabilities`,
|
||||
`equity`, `current_earnings`, `balanced`) plus `asset_accounts`,
|
||||
`liability_accounts`, `equity_accounts` (`{code, name, amount}`), as of
|
||||
@@ -192,12 +196,27 @@ posting.
|
||||
|
||||
## Tools — export
|
||||
|
||||
- `books.export { format: "csv" | "json" | "xlsx" | "pdf" | "iif", report:
|
||||
"journal" | "trial_balance" | "profit_and_loss" | "balance_sheet" |
|
||||
"cash_flow", start?, end?, as_of?, cash_accounts?, financing_accounts? }` →
|
||||
`{ content, content_encoding? }`. CPA-ready output. `xlsx` and `pdf` are
|
||||
base64-encoded; `pdf` covers the three financial statements; `iif` is
|
||||
journal-only (QuickBooks Desktop). Cash flow needs `cash_accounts`.
|
||||
- `books.export { format, report, start?, end?, as_of?, cash_accounts?,
|
||||
financing_accounts? }` → `{ content, content_encoding? }`.
|
||||
- Reports: `profit_and_loss`, `balance_sheet`, `cash_flow` (needs
|
||||
`cash_accounts`), `trial_balance`, `members_equity` (statement of
|
||||
changes in members' equity; members from `wanderbooks.toml`),
|
||||
`general_ledger` (by account: beginning balance, postings with running
|
||||
balance and split, ending balance), `general_journal` (alias
|
||||
`journal`: every entry, line by line, in date order).
|
||||
- Formats: `pdf` and `xlsx` (base64) for the five statements; `xlsx`,
|
||||
`csv`, `json` for every report; `iif` for the journal (QuickBooks
|
||||
Desktop).
|
||||
- Dates: period reports take `start` (default: first entry) and `end`
|
||||
(default: today, local time); `balance_sheet` and `trial_balance`
|
||||
take `as_of`.
|
||||
- Presentation (PDF and XLSX alike, QuickBooks-style): centered company,
|
||||
title, and period; a column-header row; "$" on subtotals and totals;
|
||||
negatives in parentheses with decimal points aligned; a rule above every
|
||||
sum and a double rule under statement totals; a footer with the
|
||||
accounting basis, preparation date, and journal seq. Multi-member LLCs
|
||||
get "Members' equity" with members named; the balance sheet separates
|
||||
retained earnings from the fiscal year's net income (loss).
|
||||
|
||||
## Tools — import
|
||||
|
||||
@@ -336,10 +355,9 @@ entries.
|
||||
## Tools — CPA handoff
|
||||
|
||||
- `books.export_cpa_package { as_of?, cash_accounts, financing_accounts? }`
|
||||
→ `{ file_name, content_base64 }`. A zip with `trial-balance.xlsx`,
|
||||
`general-ledger.xlsx` (every posted line: date, entry, description,
|
||||
account, class, debit, credit), `profit-and-loss.pdf` (fiscal year to
|
||||
date, per-account detail), `balance-sheet.pdf` (per-account detail), and
|
||||
`cash-flow.pdf` (fiscal year to date, indirect method, reconciled to
|
||||
`cash_accounts`). What the CPA needs; they never
|
||||
log into the system.
|
||||
→ `{ file_name, content_base64 }`. A zip with the full set, each
|
||||
statement as PDF and XLSX: `profit-and-loss`, `balance-sheet`,
|
||||
`cash-flow` (reconciled to `cash_accounts`), `members-equity`
|
||||
(multi-member LLCs), `trial-balance`; plus `general-ledger.xlsx` and
|
||||
`general-journal.xlsx`. Period reports cover the fiscal year to date.
|
||||
What the CPA needs; they never log into the system.
|
||||
@@ -190,3 +190,18 @@ interface is MCP, not a cloned GUI. The first storage + MCP slice is done
|
||||
with a repeated header and "Page n of m". Core's cash flow now orders
|
||||
lines by account code (it followed HashMap order, so line order could
|
||||
change between runs).
|
||||
- 2026-10-11: The canonical report set. Core gained the general ledger by
|
||||
account (running balances; QuickBooks' "split" = the opposite side of the
|
||||
entry, "-Split-" when several accounts), the trial balance as of a date,
|
||||
and the statement of changes in members' equity (capital roll-forward per
|
||||
member, allocation by share to the cent, ties to balance-sheet equity).
|
||||
The entry-ordered listing is now correctly called the general journal.
|
||||
Reports render QuickBooks-style (Stephen's reference): Inter (bundled,
|
||||
OFL) instead of the typst-assets serif fonts, centered title block, a
|
||||
column-header row, multi-column statements (trial balance Debit/Credit,
|
||||
one column per member), "$" on sums, rules above sums, a double rule
|
||||
under totals, zero as 0.00, entity terms (Members' equity, Net income
|
||||
(loss), standard cash-flow wording) and an "Accrual basis · Prepared …"
|
||||
footer. "Today" is local time, not UTC (evening exports were dated
|
||||
tomorrow).
|
||||
|
||||
@@ -38,6 +38,28 @@ The primary interface is MCP, not a cloned GUI (Stephen's call, 2026-10-10).
|
||||
- egui or web; a dumb client holding no accounting logic.
|
||||
- This is the trust layer for humans (Stephen, Kat, the CPA), not the
|
||||
primary interface. Read-only at first; editing stays with the agent.
|
||||
- Design references (Stephen, 2026-10-11; screenshots shared in session,
|
||||
described here):
|
||||
- **Reports with a date range and a column interval** (QuickBooks
|
||||
Desktop's P&L: From/To plus Columns = Year/Quarter/Month/...): any
|
||||
statement split into one column per interval plus TOTAL, to see the
|
||||
books over time. The multi-column statement layout in
|
||||
`ledger-import::report` already renders this shape; the GUI (and the
|
||||
exports) need a per-interval builder over the period-scoped core
|
||||
statements.
|
||||
- **Financial dashboard** (Coupler.io's QuickBooks dashboard): P&L,
|
||||
balance sheet, and cash flow overview tables with Last month / Month to
|
||||
date / Year to date columns, each beside charts — income vs. COGS +
|
||||
expenses vs. earnings bars; stacked assets vs. liabilities + equity;
|
||||
cash at end of period; net cash change.
|
||||
- **Business overview** (QuickBooks Online home): cards for cash balance
|
||||
with a trend, expenses by category (donut), profit and loss (income
|
||||
vs. expenses), invoices (unpaid, overdue, paid), sales trend, and bank
|
||||
account balances.
|
||||
- **Revenue dashboard** (Coupler.io): KPI tiles (paid, overdue, open
|
||||
invoices this month), an invoice table (number, customer, dates,
|
||||
currency, home and foreign amounts, balance, totals), expenses by
|
||||
category by month, invoices by status per month.
|
||||
|
||||
## Phase 5 — Workflow parity
|
||||
|
||||
|
||||
Reference in new issue
Block a user