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:
swaitsandClaude Opus 5.5 committed 2026-10-11 20:29:12 -07:00
1 parent 02b3b9c053
commit 8f623ba810
21 files changed
+1401 -543

No files matched your search

Generated
-1
View File
@@ -1894,7 +1894,6 @@ dependencies = [
"thiserror", "thiserror",
"typst", "typst",
"typst-as-lib", "typst-as-lib",
"typst-assets",
"typst-layout", "typst-layout",
"typst-pdf", "typst-pdf",
] ]
+2 -2
View File
@@ -642,10 +642,10 @@ impl Ledger {
None None
} else { } else {
// Ensure Retained Earnings exists. // 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, Some(a) => a.id,
None => self.add_account( None => self.add_account(
"3999", periods::RETAINED_EARNINGS_CODE,
"Retained Earnings", "Retained Earnings",
AccountType::Equity, AccountType::Equity,
None, None,
+1 -1
View File
@@ -43,7 +43,7 @@ pub use journal::{EntryId, EntryStatus, JournalEntry, JournalLine};
pub use ledger::{Ledger, ReconciliationReport, UnclearedLine}; pub use ledger::{Ledger, ReconciliationReport, UnclearedLine};
pub use money::{Currency, Money}; pub use money::{Currency, Money};
pub use payroll::{PayrollLine, PayrollRun, PayrollRunId}; pub use payroll::{PayrollLine, PayrollRun, PayrollRunId};
pub use periods::{Period, PeriodId, PeriodStatus}; pub use periods::{Period, PeriodId, PeriodStatus, RETAINED_EARNINGS_CODE};
pub use reports::{ pub use reports::{
BalanceSheet, BalanceSheetStatement, CashFlowLine, CashFlowParams, CashFlowStatement, BalanceSheet, BalanceSheetStatement, CashFlowLine, CashFlowParams, CashFlowStatement,
ClassProfitAndLoss, GeneralLedger, LedgerAccount, LedgerPosting, MemberCapital, ClassProfitAndLoss, GeneralLedger, LedgerAccount, LedgerPosting, MemberCapital,
+5 -1
View File
@@ -29,6 +29,10 @@ pub enum PeriodStatus {
Closed, 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. /// A named accounting period: a month, quarter, or year.
#[derive(Clone, Debug, Serialize, Deserialize)] #[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Period { 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(rev).unwrap(), rust_decimal::Decimal::ZERO);
assert_eq!(l.account_balance(exp).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). // 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!( assert_eq!(
l.account_balance(re).unwrap(), l.account_balance(re).unwrap(),
"600".parse::<rust_decimal::Decimal>().unwrap() "600".parse::<rust_decimal::Decimal>().unwrap()
-1
View File
@@ -14,7 +14,6 @@ thiserror = "2"
rust_xlsxwriter = "0.90" rust_xlsxwriter = "0.90"
typst-as-lib = { version = "0.17", default-features = false } typst-as-lib = { version = "0.17", default-features = false }
typst-pdf = "0.15.1" typst-pdf = "0.15.1"
typst-assets = { version = "0.15.1", features = ["fonts"] }
typst = "0.15.1" typst = "0.15.1"
serde_json = "1" serde_json = "1"
typst-layout = "0.15.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.
+1 -1
View File
@@ -20,5 +20,5 @@ pub use bank_csv::{BankTransaction, parse_bank_csv};
pub use chart_csv::{ChartRow, parse_chart_csv}; pub use chart_csv::{ChartRow, parse_chart_csv};
pub use iif::{IifEntry, journal_to_iif}; pub use iif::{IifEntry, journal_to_iif};
pub use pdf::{PdfError, statement_pdf}; 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}; pub use xlsx::{XlsxReport, report_to_xlsx};
+34 -30
View File
@@ -4,9 +4,10 @@
//! rows to `templates/statement.typ` as JSON data (`sys.inputs.data`) and //! rows to `templates/statement.typ` as JSON data (`sys.inputs.data`) and
//! renders the PDF. Amounts arrive already split by //! renders the PDF. Amounts arrive already split by
//! [`crate::report::amount_parts`] — Typst never does arithmetic on money, //! [`crate::report::amount_parts`] — Typst never does arithmetic on money,
//! and labels are data, never markup. Fonts are bundled (`typst-assets`), //! and labels are data, never markup. The font (Inter, SIL OFL 1.1; see
//! so output doesn't depend on what's installed, and it is deterministic: //! `fonts/Inter-LICENSE.txt`) is bundled, so output doesn't depend on
//! the same statement renders to the same bytes. //! 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 crate::report::{ReportMeta, RowKind, Statement, amount_parts};
use std::sync::OnceLock; use std::sync::OnceLock;
@@ -27,6 +28,11 @@ pub enum PdfError {
} }
static TEMPLATE: &str = include_str!("../templates/statement.typ"); 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. /// The engine, built once: parsing the bundled fonts is the expensive part.
fn engine() -> &'static TypstEngine<TypstTemplateMainFile> { fn engine() -> &'static TypstEngine<TypstTemplateMainFile> {
@@ -34,15 +40,17 @@ fn engine() -> &'static TypstEngine<TypstTemplateMainFile> {
ENGINE.get_or_init(|| { ENGINE.get_or_init(|| {
TypstEngine::builder() TypstEngine::builder()
.main_file(TEMPLATE) .main_file(TEMPLATE)
.fonts(typst_assets::fonts()) .fonts(INTER)
.build() .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 { pub fn template_data(st: &Statement, meta: &ReportMeta) -> serde_json::Value {
let kind = |k: RowKind| match k { let kind = |k: RowKind| match k {
RowKind::Section => "section", RowKind::Section => "section",
RowKind::Subheading => "subheading",
RowKind::Line => "line", RowKind::Line => "line",
RowKind::Subtotal => "subtotal", RowKind::Subtotal => "subtotal",
RowKind::Total => "total", RowKind::Total => "total",
@@ -52,20 +60,17 @@ pub fn template_data(st: &Statement, meta: &ReportMeta) -> serde_json::Value {
"company": meta.company, "company": meta.company,
"title": st.title, "title": st.title,
"period": st.period, "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!({ "rows": st.rows.iter().map(|r| serde_json::json!({
"kind": kind(r.kind), "kind": kind(r.kind),
"label": r.label, "label": r.label,
"rule": r.rule_above, "rule": r.rule_above,
"amount": r.amount.map(|a| { "currency": r.currency,
"amounts": r.amounts.iter().map(|a| a.map(|a| {
let p = amount_parts(a); let p = amount_parts(a);
serde_json::json!({ serde_json::json!({ "digits": p.digits, "negative": p.negative })
"digits": p.digits, })).collect::<Vec<_>>(),
"negative": p.negative,
"zero": p.zero,
"currency": r.currency,
})
}),
})).collect::<Vec<_>>(), })).collect::<Vec<_>>(),
}) })
} }
@@ -101,6 +106,8 @@ mod tests {
fn meta() -> ReportMeta { fn meta() -> ReportMeta {
ReportMeta { ReportMeta {
company: "Test Co".to_string(), company: "Test Co".to_string(),
basis: "Accrual".to_string(),
prepared_on: NaiveDate::from_ymd_opt(2026, 10, 11).unwrap(),
journal_seq: 42, journal_seq: 42,
} }
} }
@@ -127,6 +134,7 @@ mod tests {
/// The exact data the template typesets: hand-checked, like a golden. /// The exact data the template typesets: hand-checked, like a golden.
#[test] #[test]
fn template_data_is_exact() { 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()); let data = template_data(&profit_and_loss(&pl()), &meta());
assert_eq!( assert_eq!(
data, data,
@@ -134,22 +142,18 @@ mod tests {
"company": "Test Co", "company": "Test Co",
"title": "Profit and Loss", "title": "Profit and Loss",
"period": "October 4 – October 11, 2026", "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": [ "rows": [
{"kind": "section", "label": "Revenue", "rule": false, "amount": null}, {"kind": "section", "label": "Revenue", "rule": false, "currency": false, "amounts": []},
{"kind": "subtotal", "label": "Total revenue", "rule": false, "amount": {"kind": "subtotal", "label": "Total revenue", "rule": false, "currency": true, "amounts": amt("0.00", false)},
{"digits": "", "negative": false, "zero": true, "currency": true}}, {"kind": "spacer", "label": "", "rule": false, "currency": false, "amounts": []},
{"kind": "spacer", "label": "", "rule": false, "amount": null}, {"kind": "section", "label": "Expenses", "rule": false, "currency": false, "amounts": []},
{"kind": "section", "label": "Expenses", "rule": false, "amount": null}, {"kind": "line", "label": "6000 Organizational Costs", "rule": false, "currency": false, "amounts": amt("59.00", false)},
{"kind": "line", "label": "6000 Organizational Costs", "rule": false, "amount": {"kind": "line", "label": "6400 Memberships & Licenses", "rule": false, "currency": false, "amounts": amt("106.38", false)},
{"digits": "59.00", "negative": false, "zero": false, "currency": true}}, {"kind": "subtotal", "label": "Total expenses", "rule": true, "currency": true, "amounts": amt("165.38", false)},
{"kind": "line", "label": "6400 Memberships & Licenses", "rule": false, "amount": {"kind": "spacer", "label": "", "rule": false, "currency": false, "amounts": []},
{"digits": "106.38", "negative": false, "zero": false, "currency": false}}, {"kind": "total", "label": "Net income (loss)", "rule": true, "currency": true, "amounts": amt("165.38", true)},
{"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}},
], ],
}) })
); );
@@ -172,7 +176,7 @@ mod tests {
.map(|i| Row { .map(|i| Row {
kind: RowKind::Line, kind: RowKind::Line,
label: format!("#{i} *not bold* $x$ <label> @ref [link] \\ #panic()"), 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, currency: false,
rule_above: false, rule_above: false,
}) })
+335 -187
View File
@@ -1,13 +1,21 @@
//! The presentation model every statement renderer (XLSX, PDF) draws from. //! The presentation model every statement renderer (XLSX, PDF) draws from.
//! //!
//! Core computes the statements; this module lays them out as rows — //! Core computes the statements; this module lays them out once — title,
//! sections, account lines, subtotals, totals — once, so the formats can't //! period, column headers, and rows (sections, account lines, subtotals,
//! drift apart. Amounts stay exact `Decimal`s here; [`format_amount`] is the //! totals) — so the formats can't drift apart. Amounts stay exact
//! one place they become text ("1,234.56", "(207.68)"). //! `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::{ use ledger_core::reports::{
BalanceSheetStatement, CashFlowLine, CashFlowStatement, ProfitAndLossStatement, StatementLine, BalanceSheetStatement, CashFlowLine, CashFlowStatement, MembersEquityStatement,
ProfitAndLossStatement, StatementLine, TrialBalance,
}; };
use rust_decimal::Decimal; use rust_decimal::Decimal;
use serde::Serialize; use serde::Serialize;
@@ -17,21 +25,49 @@ use serde::Serialize;
pub struct ReportMeta { pub struct ReportMeta {
/// Company name for the header, e.g. "Wanderland Labs LLC". /// Company name for the header, e.g. "Wanderland Labs LLC".
pub company: String, 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. /// Sequence number of the last journal line the report reflects.
pub journal_seq: u64, 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. /// What a row is, which decides how renderers style it.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
pub enum RowKind { pub enum RowKind {
/// A section heading ("Revenue"); no amount. /// A section heading ("Revenue"); no amounts.
Section, 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, Line,
/// A section total ("Total revenue"). /// A section total ("Total revenue").
Subtotal, Subtotal,
/// A statement total ("Net income"). /// A statement total ("Net income (loss)"); double rule beneath.
Total, Total,
/// Vertical space between sections. /// Vertical space between sections.
Spacer, Spacer,
@@ -44,243 +80,289 @@ pub struct Row {
pub kind: RowKind, pub kind: RowKind,
/// Label text. /// Label text.
pub label: String, pub label: String,
/// Amount, when the row has one. /// One amount per column; `None` leaves the cell blank.
pub amount: Option<Decimal>, pub amounts: Vec<Option<Decimal>>,
/// Print a currency sign with the amount: US accounting style puts "$" /// Print the currency sign with the amounts: subtotals and totals.
/// on the first amount under each section heading and on every
/// subtotal and total.
pub currency: bool, pub currency: bool,
/// Draw a single rule above the amount: the "sum of the above" line. /// Draw a rule above the row: the "sum of the above" line. Totals
/// Totals always have one; a subtotal only when amounts sit above it /// always have one; a subtotal only when amounts sit above it.
/// (an empty section gets no rule over nothing).
pub rule_above: bool, pub rule_above: bool,
} }
/// A laid-out financial statement. /// A laid-out financial statement.
#[derive(Clone, Debug, Serialize)] #[derive(Clone, Debug, Serialize)]
pub struct Statement { pub struct Statement {
/// "Profit and Loss", "Balance Sheet", "Statement of Cash Flows". /// "Profit and Loss", "Balance Sheet", ...
pub title: String, pub title: String,
/// "October 4 – October 11, 2026" or "As of October 11, 2026". /// "October 4 – October 11, 2026" or "As of October 11, 2026".
pub period: String, pub period: String,
/// Amount column headings ("Total"; "Debit", "Credit"; member names).
pub columns: Vec<String>,
/// The rows, top to bottom. /// The rows, top to bottom.
pub rows: Vec<Row>, 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 { Row {
kind, kind,
label: label.into(), label: label.into(),
amount, amounts,
currency: false, currency: false,
rule_above: false, rule_above: false,
} }
} }
/// Finish a statement: mark which amounts carry the currency sign and which fn one(kind: RowKind, label: impl Into<String>, amount: Decimal) -> Row {
/// get a rule above. row(kind, label, vec![Some(amount)])
fn statement(title: &str, period: String, mut rows: Vec<Row>) -> Statement { }
let mut first_in_section = true;
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; let mut above: Option<RowKind> = None;
for r in &mut rows { for r in &mut rows {
r.currency = matches!(r.kind, RowKind::Subtotal | RowKind::Total);
r.rule_above = match r.kind { r.rule_above = match r.kind {
RowKind::Total => true, RowKind::Total => true,
RowKind::Subtotal => matches!(above, Some(RowKind::Line | RowKind::Subtotal)), RowKind::Subtotal => matches!(above, Some(RowKind::Line | RowKind::Subtotal)),
_ => false, _ => false,
}; };
if r.kind != RowKind::Spacer { if !matches!(r.kind, RowKind::Spacer | RowKind::Subheading) {
above = Some(r.kind); 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 { Statement {
title: title.to_string(), title: title.to_string(),
period, period,
columns: columns.iter().map(|c| c.to_string()).collect(),
rows, 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> + '_ { 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 lines
.iter() .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. /// Lay out a profit and loss statement.
pub fn profit_and_loss(pl: &ProfitAndLossStatement) -> Statement { pub fn profit_and_loss(pl: &ProfitAndLossStatement) -> Statement {
let mut rows = vec![section("Revenue")]; let mut rows = vec![section("Revenue")];
rows.extend(account_lines(&pl.revenue)); rows.extend(account_lines(&pl.revenue));
rows.push(row( rows.push(one(RowKind::Subtotal, "Total revenue", pl.total_revenue));
RowKind::Subtotal,
"Total revenue",
Some(pl.total_revenue),
));
rows.push(spacer()); rows.push(spacer());
rows.push(section("Expenses")); rows.push(section("Expenses"));
rows.extend(account_lines(&pl.expenses)); rows.extend(account_lines(&pl.expenses));
rows.push(row( rows.push(one(RowKind::Subtotal, "Total expenses", pl.total_expenses));
RowKind::Subtotal,
"Total expenses",
Some(pl.total_expenses),
));
rows.push(spacer()); rows.push(spacer());
rows.push(row(RowKind::Total, "Net income", Some(pl.net_income()))); rows.push(one(RowKind::Total, "Net income (loss)", 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),
));
statement( statement(
"Balance Sheet", "Profit and Loss",
format!("As of {}", long_date(bs.as_of)), period_label(pl.start, pl.end),
&["Total"],
rows, 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). /// 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")]; let mut rows = vec![section("Operating activities")];
rows.push(row(RowKind::Line, "Net income", Some(cf.net_income))); rows.push(one(RowKind::Line, "Net income (loss)", cf.net_income));
if !cf.depreciation.is_zero() { if !cf.depreciation.is_zero() || !cf.working_capital.is_empty() {
rows.push(row( rows.push(subheading(
RowKind::Line, "Adjustments to reconcile net income (loss) to net cash from operating activities:",
"Depreciation (non-cash)",
Some(cf.depreciation),
)); ));
} }
rows.extend(flow_lines(&cf.working_capital)); if !cf.depreciation.is_zero() {
rows.push(row( rows.push(one(RowKind::Line, "Depreciation", cf.depreciation));
}
rows.extend(flow_lines(&cf.working_capital, members));
rows.push(one(
RowKind::Subtotal, RowKind::Subtotal,
"Net cash from operating activities", "Net cash from operating activities",
Some(cf.cash_from_operations), cf.cash_from_operations,
)); ));
rows.push(spacer()); rows.push(spacer());
rows.push(section("Investing activities")); rows.push(section("Investing activities"));
rows.extend(flow_lines(&cf.investing)); rows.extend(flow_lines(&cf.investing, members));
rows.push(row( rows.push(one(
RowKind::Subtotal, RowKind::Subtotal,
"Net cash from investing activities", "Net cash from investing activities",
Some(cf.cash_from_investing), cf.cash_from_investing,
)); ));
rows.push(spacer()); rows.push(spacer());
rows.push(section("Financing activities")); rows.push(section("Financing activities"));
rows.extend(flow_lines(&cf.financing)); rows.extend(flow_lines(&cf.financing, members));
rows.push(row( rows.push(one(
RowKind::Subtotal, RowKind::Subtotal,
"Net cash from financing activities", "Net cash from financing activities",
Some(cf.cash_from_financing), cf.cash_from_financing,
)); ));
rows.push(spacer()); rows.push(spacer());
rows.push(row( rows.push(one(
RowKind::Subtotal, RowKind::Subtotal,
"Net change in cash", "Net increase (decrease) in cash",
Some(cf.net_change_in_cash), cf.net_change_in_cash,
)); ));
rows.push(row( rows.push(one(
RowKind::Line, RowKind::Line,
"Cash at beginning of period", "Cash at beginning of period",
Some(cf.cash_beginning), cf.cash_beginning,
));
rows.push(row(
RowKind::Total,
"Cash at end of period",
Some(cf.cash_ending),
)); ));
rows.push(one(RowKind::Total, "Cash at end of period", cf.cash_ending));
statement( statement(
"Statement of Cash Flows", "Statement of Cash Flows",
period_label(cf.start, cf.end), 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, rows,
) )
} }
@@ -292,7 +374,6 @@ pub fn long_date(d: NaiveDate) -> String {
/// "October 4 – October 11, 2026", or with both years when they differ. /// "October 4 – October 11, 2026", or with both years when they differ.
pub fn period_label(start: NaiveDate, end: NaiveDate) -> String { pub fn period_label(start: NaiveDate, end: NaiveDate) -> String {
use chrono::Datelike;
if start.year() == end.year() { if start.year() == end.year() {
format!("{} – {}", start.format("%B %-d"), long_date(end)) format!("{} – {}", start.format("%B %-d"), long_date(end))
} else { } 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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -344,44 +446,80 @@ mod tests {
#[test] #[test]
fn amounts_split_for_alignment() { fn amounts_split_for_alignment() {
let p = amount_parts(dec("-1234.5")); let p = amount_parts(dec("-1234.5"));
assert_eq!( assert_eq!((p.digits.as_str(), p.negative), ("1,234.50", true));
(p.digits.as_str(), p.negative, p.zero), let z = amount_parts(dec("-0.004"));
("1,234.50", true, false) assert_eq!((z.digits.as_str(), z.negative), ("0.00", false));
);
assert!(amount_parts(dec("0.004")).zero);
assert_eq!(amount_parts(dec("207.68")).digits, "207.68");
} }
#[test] #[test]
fn currency_signs_go_on_first_lines_and_totals() { fn currency_signs_and_rules_go_on_sums() {
let rows = vec![ let rows = vec![
section("Assets"), section("Assets"),
row(RowKind::Line, "a", Some(dec("1"))), one(RowKind::Line, "a", dec("1")),
row(RowKind::Line, "b", Some(dec("2"))), one(RowKind::Line, "b", dec("2")),
row(RowKind::Subtotal, "t", Some(dec("3"))), one(RowKind::Subtotal, "t", dec("3")),
spacer(), spacer(),
section("Equity"), section("Investing"),
row(RowKind::Line, "c", Some(dec("3"))), one(RowKind::Subtotal, "empty", dec("0")),
row(RowKind::Total, "T", Some(dec("3"))), 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(); 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(); 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!( assert_eq!(
rules, rules,
[false, false, false, true, false, false, false, true] [false, false, false, true, false, false, false, true]
); );
// A subtotal straight under its heading (empty section): no rule. }
let empty = statement(
"Y", #[test]
String::new(), fn balance_sheet_splits_retained_earnings_from_this_year() {
vec![ let line = |code: &str, name: &str, a: &str| StatementLine {
section("Investing"), code: code.into(),
row(RowKind::Subtotal, "t", Some(dec("0"))), 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] #[test]
@@ -395,5 +533,15 @@ mod tests {
period_label(d(2026, 12, 1), d(2027, 1, 31)), period_label(d(2026, 12, 1), d(2027, 1, 31)),
"December 1, 2026 – January 31, 2027" "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"
);
} }
} }
+326 -167
View File
@@ -3,27 +3,29 @@
//! Money is converted to f64 ONLY at this presentation boundary — never in //! Money is converted to f64 ONLY at this presentation boundary — never in
//! a money path. All accounting math stays `Decimal`; the conversion happens //! a money path. All accounting math stays `Decimal`; the conversion happens
//! when writing a cell, rounded to cents. Cells stay numeric (so a CPA can //! when writing a cell, rounded to cents. Cells stay numeric (so a CPA can
//! sum them) with an accounting number format: thousands separators, //! sum them) with accounting number formats: thousands separators,
//! negatives in parentheses. //! negatives in parentheses with the closing parenthesis hanging (so
//! decimal points align), "$" on subtotals and totals.
use crate::report::{ReportMeta, RowKind, Statement}; use crate::report::{ReportMeta, RowKind, Statement};
use chrono::NaiveDate;
use ledger_core::Ledger; use ledger_core::Ledger;
use ledger_core::reports::TrialBalance; use ledger_core::reports::GeneralLedger;
use rust_decimal::Decimal; use rust_decimal::Decimal;
use rust_xlsxwriter::{Format, FormatBorder, Workbook, Worksheet}; use rust_xlsxwriter::{Format, FormatAlign, FormatBorder, Workbook, Worksheet};
use thiserror::Error; use thiserror::Error;
/// Which report to render. /// Which report to render.
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub enum XlsxReport<'a> { pub enum XlsxReport<'a> {
/// Trial balance lines. /// A laid-out statement (P&L, balance sheet, cash flow, trial balance,
TrialBalance(&'a TrialBalance), /// members' equity); see [`crate::report`].
/// A laid-out financial statement (P&L, balance sheet, cash flow); see
/// [`crate::report`].
Statement(&'a Statement), Statement(&'a Statement),
/// General ledger: every posted journal line. Needs the ledger for /// The general ledger: by account, with running balances and splits.
/// account names and class names. GeneralLedger(&'a GeneralLedger),
GeneralLedger(&'a Ledger), /// The general journal: every posted entry in `[start, end]`, line by
/// line, in date order.
GeneralJournal(&'a Ledger, NaiveDate, NaiveDate),
} }
/// XLSX errors. /// XLSX errors.
@@ -40,179 +42,332 @@ impl From<rust_xlsxwriter::XlsxError> for XlsxError {
} }
} }
/// Excel's Accounting format, with and without the currency symbol: "$" /// Accounting number formats: the `_)` reserves a closing parenthesis's
/// pinned left, digits right, negatives in parentheses with the closing /// width on positives so decimal points line up with negatives.
/// parenthesis hanging (`_)` reserves its width on positives, so decimal const PLAIN: &str = "#,##0.00_);(#,##0.00)";
/// points line up), zero as a dash. const DOLLARS: &str = "$#,##0.00_);($#,##0.00)";
const ACCOUNTING: &str = r#"_(* #,##0.00_);_(* (#,##0.00);_(* "-"??_);_(@_)"#;
const ACCOUNTING_USD: &str = r#"_($* #,##0.00_);_($* (#,##0.00);_($* "-"??_);_(@_)"#;
/// Company name, title, and period across the top; returns the next row. struct Formats {
fn header( bold: Format,
sheet: &mut Worksheet, company: Format,
meta: &ReportMeta, centered: Format,
title: &str, header: Format,
period: Option<&str>, header_right: Format,
) -> Result<u32, XlsxError> { plain: Format,
let bold = Format::new().set_bold(); dollars_sum: Format,
sheet.write_string_with_format(0, 0, &meta.company, &bold.clone().set_font_size(14))?; dollars_total: Format,
sheet.write_string_with_format(1, 0, title, &bold)?; line_label: Format,
let mut row = 2; sub_label: Format,
if let Some(period) = period { note: Format,
sheet.write_string(row, 0, period)?;
row += 1;
}
Ok(row + 1)
} }
/// "Prepared from journal through seq N" under the last row. impl Formats {
fn footer(sheet: &mut Worksheet, meta: &ReportMeta, row: u32) -> Result<(), XlsxError> { fn new() -> Self {
let note = Format::new().set_italic().set_font_color("#666666"); let bold = Format::new().set_bold();
sheet.write_string_with_format( 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, row + 1,
0, 0,
format!("Prepared from journal through seq {}", meta.journal_seq), row + 1,
&note, width.saturating_sub(1),
&meta.footer(),
&f.note,
)?; )?;
Ok(()) 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. /// Render a report to XLSX bytes.
pub fn report_to_xlsx(report: &XlsxReport<'_>, meta: &ReportMeta) -> Result<Vec<u8>, XlsxError> { pub fn report_to_xlsx(report: &XlsxReport<'_>, meta: &ReportMeta) -> Result<Vec<u8>, XlsxError> {
let mut workbook = Workbook::new(); let mut workbook = Workbook::new();
let sheet = workbook.add_worksheet(); let sheet = workbook.add_worksheet();
let bold = Format::new().set_bold(); let f = Formats::new();
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();
match report { match report {
XlsxReport::TrialBalance(tb) => { XlsxReport::Statement(st) => statement(sheet, &f, meta, st)?,
sheet.set_name("Trial Balance")?; XlsxReport::GeneralLedger(gl) => general_ledger(sheet, &f, meta, gl)?,
let mut row = header(sheet, meta, "Trial Balance", None)?; XlsxReport::GeneralJournal(ledger, start, end) => {
for (i, h) in ["Code", "Account", "Debit", "Credit"].iter().enumerate() { general_journal(sheet, &f, meta, ledger, *start, *end)?
sheet.write_string_with_format(row, i as u16, *h, &bold)?;
}
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);
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)?;
}
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)?;
}
}
}
row += 1;
}
sheet.set_column_width(0, 60)?;
sheet.set_column_width(1, 16)?;
footer(sheet, meta, row)?;
}
XlsxReport::GeneralLedger(ledger) => {
sheet.set_name("General Ledger")?;
let mut row = header(sheet, meta, "General Ledger", None)?;
for (i, h) in [
"Date",
"Entry",
"Description",
"Account Code",
"Account Name",
"Class",
"Debit",
"Credit",
]
.iter()
.enumerate()
{
sheet.write_string_with_format(row, i as u16, *h, &bold)?;
}
row += 1;
for entry in ledger.posted_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
.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)?;
row += 1;
}
}
sheet.set_column_width(2, 50)?;
sheet.set_column_width(4, 32)?;
footer(sheet, meta, row)?;
} }
} }
Ok(workbook.save_to_buffer()?) 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;
let dollars_unruled = f.bold.clone().set_num_format(DOLLARS);
for r in &st.rows {
let (label_fmt, amount_fmt) = match r.kind {
RowKind::Spacer => {
row += 1;
continue;
}
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, 58)?;
for i in 0..st.columns.len() as u16 {
sheet.set_column_width(1 + i, 16)?;
}
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 period = crate::report::period_label(gl.start, gl.end);
let cols = [
"Date",
"Entry",
"Description",
"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",
];
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;
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)
.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("");
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, &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.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)?;
}
footer(sheet, f, meta, row + 1, cols.len() as u16)
}
/// Decimal to f64 at the presentation boundary. Rounded to cents first. /// Decimal to f64 at the presentation boundary. Rounded to cents first.
fn dec_to_f64(d: Decimal) -> f64 { fn dec_to_f64(d: Decimal) -> f64 {
use rust_decimal::prelude::ToPrimitive; use rust_decimal::prelude::ToPrimitive;
@@ -222,7 +377,8 @@ fn dec_to_f64(d: Decimal) -> f64 {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use ledger_core::reports::TrialBalanceLine; use crate::report;
use ledger_core::reports::{TrialBalance, TrialBalanceLine};
#[test] #[test]
fn xlsx_is_a_zip() { fn xlsx_is_a_zip() {
@@ -238,9 +394,12 @@ mod tests {
}; };
let meta = ReportMeta { let meta = ReportMeta {
company: "Test Co".to_string(), company: "Test Co".to_string(),
basis: "Accrual".to_string(),
prepared_on: NaiveDate::from_ymd_opt(2026, 10, 11).unwrap(),
journal_seq: 7, 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. // XLSX is a ZIP: starts with PK.
assert_eq!(&bytes[0..2], b"PK"); assert_eq!(&bytes[0..2], b"PK");
assert!(bytes.len() > 1000); assert!(bytes.len() > 1000);
+70 -67
View File
@@ -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 // Everything arrives as data in sys.inputs.data (JSON), already laid out by
// ledger-import::report, with amounts already split into exact parts. The // ledger-import::report, with amounts already split into exact parts. The
// template only typesets: no arithmetic happens here, and no label is ever // template only typesets: no arithmetic happens here, and no label is ever
// parsed as markup. // parsed as markup.
// //
// Shape: { company, title, period, footer, // Shape: { company, title, period, footer, columns: ["Total"] | [...],
// rows: [{ kind: "section" | "line" | "subtotal" | "total" // rows: [{ kind: "section" | "subheading" | "line" | "subtotal"
// | "spacer", label, rule (single rule above), // | "total" | "spacer",
// amount: null | { digits: "1,234.56", negative, zero, // label, rule (rule above), currency ("$" on amounts),
// currency } }] } // amounts: [null | { digits: "1,234.56", negative }] }] }
// //
// Amounts follow US accounting presentation: tabular figures, "$" pinned // Amounts: Inter's tabular figures; negatives in parentheses with the
// to the left of the amount column on the first line of each section and on // closing parenthesis hanging (its width is reserved on every row, so
// totals, negatives in parentheses with the closing parenthesis hanging // decimal points always line up); "$" attached on subtotals and totals.
// (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.
#let d = json(bytes(sys.inputs.data)) #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 document(title: d.company + " — " + d.title, author: d.company)
#set text( #set text(font: "Inter", size: 9pt, fill: ink, number-width: "tabular")
font: "Libertinus Serif",
size: 10.5pt,
number-type: "lining",
number-width: "tabular",
)
#set page( #set page(
paper: "us-letter", paper: "us-letter",
margin: (x: 1in, top: 0.9in, bottom: 0.8in), margin: (x: 0.75in, top: 0.75in, bottom: 0.75in),
header: context { header: context {
if counter(page).get().first() > 1 { if counter(page).get().first() > 1 {
set text(8.5pt, fill: luma(110)) set text(7.5pt, fill: quiet)
[#d.company — #d.title #h(1fr) #d.period] [#d.company · #d.title #h(1fr) #d.period]
} }
}, },
footer: context { footer: context {
set text(8pt, fill: luma(110)) set text(7.5pt, fill: quiet)
[#d.footer #h(1fr) Page #counter(page).display() of #counter(page).final().first()] grid(
columns: (1fr, auto, 1fr),
[],
d.footer,
align(right)[Page #counter(page).display() of #counter(page).final().first()],
)
}, },
) )
#align(center)[ #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) #v(2pt)
#text(12.5pt, d.title) \ #d.period
#v(1pt)
#text(10pt, style: "italic", d.period)
] ]
#v(16pt) #v(12pt)
// Two lines under a statement total, the accounting double rule, set #let amount(a, currency) = {
// 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
if a == none { return [] } if a == none { return [] }
let open = if a.negative { [(] } else { [] } let open = if a.negative { [(] } else { [] }
let close = if a.negative { [)] } else { hide[)] } let close = if a.negative { [)] } else { hide[)] }
let body = if a.zero { [–#hide[.00]] } else { [#open#a.digits] } let sign = if currency [\$] else []
grid( [#open#sign#a.digits#close]
columns: (auto, 1fr), }
align: (left, right),
if a.currency [\$] else [], #let full(body, ..args) = table.cell(colspan: ncols + 1, ..args, body)
[#body#close],
#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( #table(
columns: (1fr, 9em), columns: (1fr,) + (8em,) * ncols,
stroke: none, stroke: none,
inset: (x: 0pt, y: 3.2pt), inset: (x: 4pt, y: 3.6pt),
align: (left + horizon, right + horizon), 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 ..d
.rows .rows
.map(r => { .map(r => {
if r.kind == "spacer" { if r.kind == "spacer" {
(table.cell(colspan: 2, v(3pt)),) (full(v(2pt), inset: 2pt),)
} else if r.kind == "section" { } else if r.kind == "section" {
(table.cell(colspan: 2, text(weight: "bold", r.label)),) (full(text(weight: "semibold", r.label)),)
} else if r.kind == "line" { } else if r.kind == "subheading" {
(pad(left: 1.4em, r.label), amount(r)) (full(pad(left: 1.4em, emph(r.label))),)
} else if r.kind == "subtotal" { } else if r.kind == "total" {
( // The double rule under a statement total, full width.
text(weight: "bold", r.label), let double = full(
table.cell( inset: (x: 0pt, y: 0pt),
stroke: if r.rule { (top: 0.5pt) } else { none }, stack(
amount(r), spacing: 1.6pt,
line(length: 100%, stroke: firm),
line(length: 100%, stroke: firm),
), ),
) )
row-cells(r) + (double,)
} else { } else {
( row-cells(r)
align(top, text(weight: "bold", r.label)),
table.cell(
stroke: (top: 0.5pt),
double-rule(amount(r)),
),
)
} }
}) })
.flatten(), .flatten(),
+111 -3
View File
@@ -2,9 +2,11 @@
//! //!
//! `wanderbooks init` writes that file next to `books-data/`; onboarding //! `wanderbooks init` writes that file next to `books-data/`; onboarding
//! fills in `[company]`. The server reads the company name (report headers), //! fills in `[company]`. The server reads the company name (report headers),
//! the fiscal year start (fiscal-year-to-date reports), and //! the fiscal year start (fiscal-year-to-date reports), the members of a
//! `[books] receipt_threshold` (the receipt policy, unless `config.json` //! multi-member LLC (`[[company.members]]`: name, share, capital account —
//! sets one). The fiscal year *end* is never stored: it is the day before //! 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. //! the next start, so the two can't disagree.
use chrono::{Datelike, NaiveDate}; use chrono::{Datelike, NaiveDate};
@@ -19,6 +21,21 @@ pub struct Company {
pub name: String, pub name: String,
/// Month and day the fiscal year starts. Default January 1. /// Month and day the fiscal year starts. Default January 1.
pub fiscal_year_start: (u32, u32), 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 { impl Default for Company {
@@ -26,6 +43,8 @@ impl Default for Company {
Self { Self {
name: "Unnamed company".to_string(), name: "Unnamed company".to_string(),
fiscal_year_start: (1, 1), fiscal_year_start: (1, 1),
members: Vec::new(),
basis: "Accrual".to_string(),
} }
} }
} }
@@ -68,11 +87,35 @@ struct BooksToml {
struct CompanyToml { struct CompanyToml {
name: Option<String>, name: Option<String>,
fiscal_year_start: 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)] #[derive(Default, Deserialize)]
struct BooksSection { struct BooksSection {
receipt_threshold: Option<String>, 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 /// 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 { if let Some(fy) = raw.company.fiscal_year_start {
company.fiscal_year_start = parse_month_day(fy.trim())?; 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 { let receipt_threshold = match raw.books.receipt_threshold {
Some(t) => { Some(t) => {
t.trim() 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] #[test]
fn rejects_bad_settings() { fn rejects_bad_settings() {
assert!(parse("[company]\nfiscal_year_start = \"02-29\"\n").is_err()); assert!(parse("[company]\nfiscal_year_start = \"02-29\"\n").is_err());
+296 -63
View File
@@ -68,10 +68,65 @@ impl BooksServer {
fn report_meta(&self, store: &Store) -> ReportMeta { fn report_meta(&self, store: &Store) -> ReportMeta {
ReportMeta { ReportMeta {
company: self.company.name.clone(), company: self.company.name.clone(),
basis: self.company.basis.clone(),
prepared_on: today(),
journal_seq: store.journal_seq(), 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> { fn lock(&self) -> Result<std::sync::MutexGuard<'_, Store>, String> {
self.store self.store
.lock() .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 { 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 /// Resolve a statement's date range: `end` defaults to today and `start` to
@@ -470,13 +527,21 @@ impl BooksServer {
#[tool( #[tool(
name = "books.trial_balance", 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) 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 store = self.lock()?;
let tb = reports::trial_balance(store.ledger()); let as_of = match &params.0.as_of {
Some(s) => parse_date(s)?,
None => today(),
};
let tb = reports::trial_balance_as_of(store.ledger(), as_of);
Ok(Json(TrialBalanceOut { Ok(Json(TrialBalanceOut {
as_of: as_of.to_string(),
lines: tb lines: tb
.lines .lines
.iter() .iter()
@@ -568,7 +633,7 @@ impl BooksServer {
}; };
let end = match p.end { let end = match p.end {
Some(s) => parse_date(&s)?, Some(s) => parse_date(&s)?,
None => chrono::Utc::now().date_naive(), None => today(),
}; };
// Resolve cash and financing account codes to ids. // Resolve cash and financing account codes to ids.
let mut cash_accounts = Vec::new(); let mut cash_accounts = Vec::new();
@@ -1161,7 +1226,7 @@ impl BooksServer {
async fn digest(&self, params: Parameters<DigestParams>) -> Result<Json<DigestOut>, String> { async fn digest(&self, params: Parameters<DigestParams>) -> Result<Json<DigestOut>, String> {
let p = params.0; let p = params.0;
let since = parse_date(&p.since)?; let since = parse_date(&p.since)?;
let today = chrono::Utc::now().date_naive(); let today = today();
let store = self.lock()?; let store = self.lock()?;
let ledger = store.ledger(); let ledger = store.ledger();
let mut entries = Vec::new(); let mut entries = Vec::new();
@@ -1385,7 +1450,7 @@ impl BooksServer {
#[tool( #[tool(
name = "books.export", 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) annotations(read_only_hint = true)
)] )]
async fn export(&self, params: Parameters<ExportParams>) -> Result<Json<ExportOut>, String> { async fn export(&self, params: Parameters<ExportParams>) -> Result<Json<ExportOut>, String> {
@@ -1424,14 +1489,16 @@ impl BooksServer {
})); }));
} }
let content = match report.as_str() { let content = match report.as_str() {
"journal" => self.export_journal(&format)?, "journal" | "general_journal" => self.export_journal(&format)?,
"trial_balance" => self.export_trial_balance(&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())?, "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())?, "balance_sheet" => self.export_balance_sheet(&format, p.as_of.as_deref())?,
"cash_flow" => self.export_cash_flow(&format, &p)?, "cash_flow" => self.export_cash_flow(&format, &p)?,
_ => { _ => {
return Err(format!( 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 p.report
)); ));
} }
@@ -1452,6 +1519,7 @@ impl BooksServer {
p: &ExportParams, p: &ExportParams,
) -> Result<ledger_import::Statement, String> { ) -> Result<ledger_import::Statement, String> {
use ledger_import::report; use ledger_import::report;
let members = self.report_members();
Ok(match report { Ok(match report {
"profit_and_loss" => report::profit_and_loss(&pnl_statement( "profit_and_loss" => report::profit_and_loss(&pnl_statement(
ledger, ledger,
@@ -1459,17 +1527,38 @@ impl BooksServer {
p.end.as_deref(), p.end.as_deref(),
None, None,
)?), )?),
"balance_sheet" => report::balance_sheet(&bs_statement(ledger, p.as_of.as_deref())?), "balance_sheet" => {
"cash_flow" => report::cash_flow(&cash_flow_statement( let bs = bs_statement(ledger, p.as_of.as_deref())?;
ledger, let ytd = self.net_income_ytd(ledger, bs.as_of)?;
p.start.as_deref(), report::balance_sheet(&bs, ytd, &members)
p.end.as_deref(), }
&p.cash_accounts, "cash_flow" => report::cash_flow(
&p.financing_accounts, &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!( 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 store = self.lock()?;
let meta = self.report_meta(&store); let meta = self.report_meta(&store);
let ledger = store.ledger(); let ledger = store.ledger();
if report == "trial_balance" { let xlsx = |r: XlsxReport<'_>| report_to_xlsx(&r, &meta).map_err(|e| e.to_string());
let tb = reports::trial_balance(ledger); match report {
return report_to_xlsx(&XlsxReport::TrialBalance(&tb), &meta) "general_ledger" => {
.map_err(|e| e.to_string()); 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. /// Render a financial statement to PDF bytes via ledger-import.
fn export_pdf(&self, report: &str, p: &ExportParams) -> Result<Vec<u8>, String> { fn export_pdf(&self, report: &str, p: &ExportParams) -> Result<Vec<u8>, String> {
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 store = self.lock()?;
let meta = self.report_meta(&store); let meta = self.report_meta(&store);
let st = self let st = self.layout_statement(store.ledger(), report, p)?;
.layout_statement(store.ledger(), report, p)
.map_err(|e| e.replace("bad report", "bad report for pdf"))?;
ledger_import::statement_pdf(&st, &meta).map_err(|e| e.to_string()) 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> { async fn list_assets(&self) -> Result<Json<ListAssetsOut>, String> {
let store = self.lock()?; let store = self.lock()?;
let today = chrono::Utc::now().date_naive(); let today = today();
let assets = store let assets = store
.ledger() .ledger()
.registered_assets() .registered_assets()
@@ -2425,7 +2524,7 @@ impl BooksServer {
}; };
let end = match p.end { let end = match p.end {
Some(s) => parse_date(&s)?, Some(s) => parse_date(&s)?,
None => chrono::Utc::now().date_naive(), None => today(),
}; };
if end < start { if end < start {
return Err("end is before start".to_string()); return Err("end is before start".to_string());
@@ -2504,7 +2603,7 @@ impl BooksServer {
#[tool( #[tool(
name = "books.export_cpa_package", name = "books.export_cpa_package",
description = "Build the CPA handoff package as a zip (base64): trial-balance.xlsx, general-ledger.xlsx (every posted line), profit-and-loss.pdf, balance-sheet.pdf, 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) annotations(read_only_hint = true)
)] )]
async fn export_cpa_package( 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 (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 xlsx = |r: XlsxReport<'_>| report_to_xlsx(&r, &meta).map_err(|e| e.to_string());
let pdf = 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)))?; // The financial statements, each as PDF and XLSX.
let gl_xlsx = xlsx(XlsxReport::GeneralLedger(ledger))?; let mut statements: Vec<(&str, ledger_import::Statement)> = vec![
let pnl_pdf = pdf(report::profit_and_loss(&pnl_statement( (
ledger, "profit-and-loss",
Some(&fy_start), report::profit_and_loss(&pnl_statement(
Some(&as_of_s), ledger,
None, Some(&fy_start),
)?))?; Some(&as_of_s),
let bs_pdf = pdf(report::balance_sheet(&bs_statement( None,
ledger, )?),
Some(&as_of_s), ),
)?))?; (
let cf_pdf = pdf(report::cash_flow(&cash_flow_statement( "balance-sheet",
ledger, report::balance_sheet(
Some(&fy_start), &bs_statement(ledger, Some(&as_of_s))?,
Some(&as_of_s), self.net_income_ytd(ledger, as_of)?,
&p.cash_accounts, &members,
&p.financing_accounts, ),
)?))?; ),
(
"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); drop(store);
// Zip it. // Zip it.
@@ -2554,14 +2693,9 @@ impl BooksServer {
{ {
let mut zip = zip::ZipWriter::new(std::io::Cursor::new(&mut zip_buf)); let mut zip = zip::ZipWriter::new(std::io::Cursor::new(&mut zip_buf));
let opts = zip::write::SimpleFileOptions::default(); let opts = zip::write::SimpleFileOptions::default();
for (name, bytes) in [ for (name, bytes) in &files {
("trial-balance.xlsx", tb_xlsx.as_slice()), zip.start_file(name.as_str(), opts)
("general-ledger.xlsx", gl_xlsx.as_slice()), .map_err(|e| e.to_string())?;
("profit-and-loss.pdf", pnl_pdf.as_slice()),
("balance-sheet.pdf", bs_pdf.as_slice()),
("cash-flow.pdf", cf_pdf.as_slice()),
] {
zip.start_file(name, opts).map_err(|e| e.to_string())?;
use std::io::Write; use std::io::Write;
zip.write_all(bytes).map_err(|e| e.to_string())?; zip.write_all(bytes).map_err(|e| e.to_string())?;
} }
@@ -2636,11 +2770,16 @@ impl BooksServer {
Ok(csv) 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 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" { if format == "json" {
return serde_json::to_string_pretty(&serde_json::json!({ return serde_json::to_string_pretty(&serde_json::json!({
"as_of": as_of.to_string(),
"lines": tb.lines.iter().map(|l| serde_json::json!({ "lines": tb.lines.iter().map(|l| serde_json::json!({
"code": l.code, "name": l.name, "code": l.code, "name": l.name,
"debit": money(l.debit), "credit": money(l.credit), "debit": money(l.debit), "credit": money(l.credit),
@@ -2669,6 +2808,98 @@ impl BooksServer {
Ok(csv) 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( fn export_pnl(
&self, &self,
format: &str, format: &str,
@@ -2768,7 +2999,7 @@ impl BooksServer {
}; };
let end = match &p.end { let end = match &p.end {
Some(s) => parse_date(s)?, Some(s) => parse_date(s)?,
None => chrono::Utc::now().date_naive(), None => today(),
}; };
let stmt = ledger_core::cash_flow( let stmt = ledger_core::cash_flow(
ledger, ledger,
@@ -2879,7 +3110,9 @@ impl BooksServer {
serde_json::to_string_pretty(&out).map_err(|e| e.to_string()) serde_json::to_string_pretty(&out).map_err(|e| e.to_string())
} }
"books://reports/trial-balance" => { "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()) serde_json::to_string_pretty(&out).map_err(|e| e.to_string())
} }
"books://reports/balance-sheet" => { "books://reports/balance-sheet" => {
+10
View File
@@ -76,9 +76,19 @@ pub struct TrialBalanceLineOut {
pub credit: String, 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`. /// Result of `books.trial_balance`.
#[derive(Debug, Serialize, JsonSchema)] #[derive(Debug, Serialize, JsonSchema)]
pub struct TrialBalanceOut { pub struct TrialBalanceOut {
/// The as-of date, YYYY-MM-DD.
pub as_of: String,
/// Lines in code order. /// Lines in code order.
pub lines: Vec<TrialBalanceLineOut>, pub lines: Vec<TrialBalanceLineOut>,
/// Sum of the debit column, decimal string. /// Sum of the debit column, decimal string.
+47 -3
View File
@@ -834,6 +834,7 @@ fn mcp_statement_pdfs_and_cpa_package_over_stdio() {
("profit_and_loss", json!({})), ("profit_and_loss", json!({})),
("balance_sheet", json!({})), ("balance_sheet", json!({})),
("cash_flow", json!({"cash_accounts": ["1000"]})), ("cash_flow", json!({"cash_accounts": ["1000"]})),
("trial_balance", json!({"as_of": "2026-10-31"})),
] { ] {
let mut args = json!({"format": "pdf", "report": report}); let mut args = json!({"format": "pdf", "report": report});
args.as_object_mut() args.as_object_mut()
@@ -850,11 +851,12 @@ fn mcp_statement_pdfs_and_cpa_package_over_stdio() {
json!({"format": "pdf", "report": "cash_flow"}), json!({"format": "pdf", "report": "cash_flow"}),
); );
assert!(err.contains("cash_accounts"), "{err}"); assert!(err.contains("cash_accounts"), "{err}");
// The ledger and journal are workbooks, not printable statements.
let err = mcp.call_err( let err = mcp.call_err(
"books.export", "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. // The CPA package carries all three statements.
let err = mcp.call_err("books.export_cpa_package", json!({"as_of": "2026-10-31"})); 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, names,
[ [
"balance-sheet.pdf", "balance-sheet.pdf",
"balance-sheet.xlsx",
"cash-flow.pdf", "cash-flow.pdf",
"cash-flow.xlsx",
"general-journal.xlsx",
"general-ledger.xlsx", "general-ledger.xlsx",
"profit-and-loss.pdf", "profit-and-loss.pdf",
"profit-and-loss.xlsx",
"trial-balance.pdf",
"trial-balance.xlsx" "trial-balance.xlsx"
] ]
); );
@@ -892,7 +899,7 @@ fn mcp_period_statements_over_stdio() {
let toml = dir.join("wanderbooks.toml"); let toml = dir.join("wanderbooks.toml");
std::fs::write( std::fs::write(
&toml, &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(); .unwrap();
let data = dir.join("books-data"); 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["current_earnings"], "4.00");
assert_eq!(jan["equity_accounts"][0]["amount"], "1000.00"); assert_eq!(jan["equity_accounts"][0]["amount"], "1000.00");
assert_eq!(jan["balanced"], true); 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
View File
@@ -27,7 +27,11 @@ layer.
`--data-dir`; override with `--books-toml PATH`): `[company] name` heads `--data-dir`; override with `--books-toml PATH`): `[company] name` heads
every report, `[company] fiscal_year_start` ("MM-DD", default "01-01"; 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 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`. `config.json` sets `receipt_policy`.
## Transports ## Transports
@@ -85,8 +89,8 @@ below.
- `books.get_entry { entry_id }` → full entry with lines. - `books.get_entry { entry_id }` → full entry with lines.
- `books.search_entries { account?, date_from?, date_to?, text?, limit?, offset? }` - `books.search_entries { account?, date_from?, date_to?, text?, limit?, offset? }`
→ entries with lines, newest first. `limit` caps at 100. → entries with lines, newest first. `limit` caps at 100.
- `books.trial_balance {}` → canonical JSON (same shape as - `books.trial_balance { as_of? }` → `as_of`, lines, totals, `balanced`
`reports::snapshot` trial-balance section). (the trial balance as of the date, default today).
- `books.balance_sheet { as_of? }` → totals (`assets`, `liabilities`, - `books.balance_sheet { as_of? }` → totals (`assets`, `liabilities`,
`equity`, `current_earnings`, `balanced`) plus `asset_accounts`, `equity`, `current_earnings`, `balanced`) plus `asset_accounts`,
`liability_accounts`, `equity_accounts` (`{code, name, amount}`), as of `liability_accounts`, `equity_accounts` (`{code, name, amount}`), as of
@@ -192,12 +196,27 @@ posting.
## Tools — export ## Tools — export
- `books.export { format: "csv" | "json" | "xlsx" | "pdf" | "iif", report: - `books.export { format, report, start?, end?, as_of?, cash_accounts?,
"journal" | "trial_balance" | "profit_and_loss" | "balance_sheet" | financing_accounts? }` → `{ content, content_encoding? }`.
"cash_flow", start?, end?, as_of?, cash_accounts?, financing_accounts? }` → - Reports: `profit_and_loss`, `balance_sheet`, `cash_flow` (needs
`{ content, content_encoding? }`. CPA-ready output. `xlsx` and `pdf` are `cash_accounts`), `trial_balance`, `members_equity` (statement of
base64-encoded; `pdf` covers the three financial statements; `iif` is changes in members' equity; members from `wanderbooks.toml`),
journal-only (QuickBooks Desktop). Cash flow needs `cash_accounts`. `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 ## Tools — import
@@ -336,10 +355,9 @@ entries.
## Tools — CPA handoff ## Tools — CPA handoff
- `books.export_cpa_package { as_of?, cash_accounts, financing_accounts? }` - `books.export_cpa_package { as_of?, cash_accounts, financing_accounts? }`
→ `{ file_name, content_base64 }`. A zip with `trial-balance.xlsx`, → `{ file_name, content_base64 }`. A zip with the full set, each
`general-ledger.xlsx` (every posted line: date, entry, description, statement as PDF and XLSX: `profit-and-loss`, `balance-sheet`,
account, class, debit, credit), `profit-and-loss.pdf` (fiscal year to `cash-flow` (reconciled to `cash_accounts`), `members-equity`
date, per-account detail), `balance-sheet.pdf` (per-account detail), and (multi-member LLCs), `trial-balance`; plus `general-ledger.xlsx` and
`cash-flow.pdf` (fiscal year to date, indirect method, reconciled to `general-journal.xlsx`. Period reports cover the fiscal year to date.
`cash_accounts`). What the CPA needs; they never What the CPA needs; they never log into the system.
log into the system.
+15
View File
@@ -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 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 lines by account code (it followed HashMap order, so line order could
change between runs). 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).
+22
View File
@@ -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. - egui or web; a dumb client holding no accounting logic.
- This is the trust layer for humans (Stephen, Kat, the CPA), not the - This is the trust layer for humans (Stephen, Kat, the CPA), not the
primary interface. Read-only at first; editing stays with the agent. 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 ## Phase 5 — Workflow parity