feat: canonical report set in QuickBooks-style presentation
Every report a CPA expects, laid out the way US small-business books read (QuickBooks as the reference): - Exports: books.export gains general_ledger (by account, running balances, splits), general_journal (the entry-ordered listing, which the CPA package used to mislabel "general ledger"; alias journal), members_equity, and trial_balance as of a date, in xlsx/csv/json, and PDF for all five statements (P&L, balance sheet, cash flow, trial balance, members' equity). books.trial_balance takes as_of. - CPA package: every statement as PDF and XLSX, plus the general ledger and general journal, all for the fiscal year to date. - Layout (ledger-import::report): multi-column statements (Total; Debit/Credit; one column per member plus Total), "$" on subtotals and totals, a rule above every sum (none over an empty section), a double rule under statement totals, zero as 0.00, negatives in parentheses with the closing paren hanging so decimal points align. - Terms: "Net income (loss)"; for a multi-member LLC, "Members' equity", member contributions and distributions named in the cash flow, and the balance sheet splitting retained earnings (the Retained Earnings account plus earnings not yet closed from earlier years) from the fiscal year's net income (loss); the cash flow's "Adjustments to reconcile net income (loss) to net cash from operating activities" and "Net increase (decrease) in cash". - PDF: Inter replaces the typst-assets serif fonts (bundled, SIL OFL 1.1, license in fonts/; from the official v4.1 release), centered title block, column-header row, full-width rules, footer "Accrual basis · Prepared <date> · Journal seq N" with page numbers. Amounts on total rows stay regular weight so bold figures can't leave the decimal column. typst-assets dependency removed. - XLSX: centered title block, header row, Excel accounting formats with "$" attached on sums, general ledger and general journal workbooks. - Config: wanderbooks.toml [[company.members]] (name, share "50%" or "0.5", capital_account; shares must total 100%) and [books] accounting_basis (accrual | cash). - Core: RETAINED_EARNINGS_CODE names the "3999" that period close uses. - Fix: "today" is local time, not UTC; in US evenings UTC is tomorrow, so reports defaulting to "through today" included the next day and footers carried the wrong date. Tests: layout (currency and rule placement, retained earnings vs. this year's net income for a member LLC, footer text); exact template data; deterministic PDFs; config parsing (members, shares, basis); stdio: trial balance as of, February general ledger (beginning 1,004.00, splits, ending 1,003.50), members' equity (contributions 1,000.00, net income 3.50, ending 1,003.50), members' equity PDF, the CPA package's ten files. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MtYEaR6M6Ybdhm6X98BWNu
This commit is contained in:
21 files changed
+1401
-543
No files matched your search
@@ -14,7 +14,6 @@ thiserror = "2"
|
||||
rust_xlsxwriter = "0.90"
|
||||
typst-as-lib = { version = "0.17", default-features = false }
|
||||
typst-pdf = "0.15.1"
|
||||
typst-assets = { version = "0.15.1", features = ["fonts"] }
|
||||
typst = "0.15.1"
|
||||
serde_json = "1"
|
||||
typst-layout = "0.15.1"
|
||||
Binary file not shown.
@@ -0,0 +1,92 @@
|
||||
Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION AND CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
Binary file not shown.
Binary file not shown.
@@ -20,5 +20,5 @@ pub use bank_csv::{BankTransaction, parse_bank_csv};
|
||||
pub use chart_csv::{ChartRow, parse_chart_csv};
|
||||
pub use iif::{IifEntry, journal_to_iif};
|
||||
pub use pdf::{PdfError, statement_pdf};
|
||||
pub use report::{ReportMeta, Row, RowKind, Statement, format_amount};
|
||||
pub use report::{Member, ReportMeta, Row, RowKind, Statement, format_amount};
|
||||
pub use xlsx::{XlsxReport, report_to_xlsx};
|
||||
@@ -4,9 +4,10 @@
|
||||
//! rows to `templates/statement.typ` as JSON data (`sys.inputs.data`) and
|
||||
//! renders the PDF. Amounts arrive already split by
|
||||
//! [`crate::report::amount_parts`] — Typst never does arithmetic on money,
|
||||
//! and labels are data, never markup. Fonts are bundled (`typst-assets`),
|
||||
//! so output doesn't depend on what's installed, and it is deterministic:
|
||||
//! the same statement renders to the same bytes.
|
||||
//! and labels are data, never markup. The font (Inter, SIL OFL 1.1; see
|
||||
//! `fonts/Inter-LICENSE.txt`) is bundled, so output doesn't depend on
|
||||
//! what's installed, and it is deterministic: the same statement and
|
||||
//! report metadata render to the same bytes.
|
||||
|
||||
use crate::report::{ReportMeta, RowKind, Statement, amount_parts};
|
||||
use std::sync::OnceLock;
|
||||
@@ -27,6 +28,11 @@ pub enum PdfError {
|
||||
}
|
||||
|
||||
static TEMPLATE: &str = include_str!("../templates/statement.typ");
|
||||
static INTER: [&[u8]; 3] = [
|
||||
include_bytes!("../fonts/Inter-Regular.ttf"),
|
||||
include_bytes!("../fonts/Inter-SemiBold.ttf"),
|
||||
include_bytes!("../fonts/Inter-Bold.ttf"),
|
||||
];
|
||||
|
||||
/// The engine, built once: parsing the bundled fonts is the expensive part.
|
||||
fn engine() -> &'static TypstEngine<TypstTemplateMainFile> {
|
||||
@@ -34,15 +40,17 @@ fn engine() -> &'static TypstEngine<TypstTemplateMainFile> {
|
||||
ENGINE.get_or_init(|| {
|
||||
TypstEngine::builder()
|
||||
.main_file(TEMPLATE)
|
||||
.fonts(typst_assets::fonts())
|
||||
.fonts(INTER)
|
||||
.build()
|
||||
})
|
||||
}
|
||||
|
||||
/// The template's input: the statement with every amount formatted.
|
||||
/// The template's input: the statement with every amount split for
|
||||
/// alignment.
|
||||
pub fn template_data(st: &Statement, meta: &ReportMeta) -> serde_json::Value {
|
||||
let kind = |k: RowKind| match k {
|
||||
RowKind::Section => "section",
|
||||
RowKind::Subheading => "subheading",
|
||||
RowKind::Line => "line",
|
||||
RowKind::Subtotal => "subtotal",
|
||||
RowKind::Total => "total",
|
||||
@@ -52,20 +60,17 @@ pub fn template_data(st: &Statement, meta: &ReportMeta) -> serde_json::Value {
|
||||
"company": meta.company,
|
||||
"title": st.title,
|
||||
"period": st.period,
|
||||
"footer": format!("Prepared from journal through seq {}", meta.journal_seq),
|
||||
"footer": meta.footer(),
|
||||
"columns": st.columns,
|
||||
"rows": st.rows.iter().map(|r| serde_json::json!({
|
||||
"kind": kind(r.kind),
|
||||
"label": r.label,
|
||||
"rule": r.rule_above,
|
||||
"amount": r.amount.map(|a| {
|
||||
"currency": r.currency,
|
||||
"amounts": r.amounts.iter().map(|a| a.map(|a| {
|
||||
let p = amount_parts(a);
|
||||
serde_json::json!({
|
||||
"digits": p.digits,
|
||||
"negative": p.negative,
|
||||
"zero": p.zero,
|
||||
"currency": r.currency,
|
||||
})
|
||||
}),
|
||||
serde_json::json!({ "digits": p.digits, "negative": p.negative })
|
||||
})).collect::<Vec<_>>(),
|
||||
})).collect::<Vec<_>>(),
|
||||
})
|
||||
}
|
||||
@@ -101,6 +106,8 @@ mod tests {
|
||||
fn meta() -> ReportMeta {
|
||||
ReportMeta {
|
||||
company: "Test Co".to_string(),
|
||||
basis: "Accrual".to_string(),
|
||||
prepared_on: NaiveDate::from_ymd_opt(2026, 10, 11).unwrap(),
|
||||
journal_seq: 42,
|
||||
}
|
||||
}
|
||||
@@ -127,6 +134,7 @@ mod tests {
|
||||
/// The exact data the template typesets: hand-checked, like a golden.
|
||||
#[test]
|
||||
fn template_data_is_exact() {
|
||||
let amt = |digits: &str, negative: bool| serde_json::json!([{"digits": digits, "negative": negative}]);
|
||||
let data = template_data(&profit_and_loss(&pl()), &meta());
|
||||
assert_eq!(
|
||||
data,
|
||||
@@ -134,22 +142,18 @@ mod tests {
|
||||
"company": "Test Co",
|
||||
"title": "Profit and Loss",
|
||||
"period": "October 4 – October 11, 2026",
|
||||
"footer": "Prepared from journal through seq 42",
|
||||
"footer": "Accrual basis · Prepared Sunday, October 11, 2026 · Journal seq 42",
|
||||
"columns": ["Total"],
|
||||
"rows": [
|
||||
{"kind": "section", "label": "Revenue", "rule": false, "amount": null},
|
||||
{"kind": "subtotal", "label": "Total revenue", "rule": false, "amount":
|
||||
{"digits": "", "negative": false, "zero": true, "currency": true}},
|
||||
{"kind": "spacer", "label": "", "rule": false, "amount": null},
|
||||
{"kind": "section", "label": "Expenses", "rule": false, "amount": null},
|
||||
{"kind": "line", "label": "6000 Organizational Costs", "rule": false, "amount":
|
||||
{"digits": "59.00", "negative": false, "zero": false, "currency": true}},
|
||||
{"kind": "line", "label": "6400 Memberships & Licenses", "rule": false, "amount":
|
||||
{"digits": "106.38", "negative": false, "zero": false, "currency": false}},
|
||||
{"kind": "subtotal", "label": "Total expenses", "rule": true, "amount":
|
||||
{"digits": "165.38", "negative": false, "zero": false, "currency": true}},
|
||||
{"kind": "spacer", "label": "", "rule": false, "amount": null},
|
||||
{"kind": "total", "label": "Net income", "rule": true, "amount":
|
||||
{"digits": "165.38", "negative": true, "zero": false, "currency": true}},
|
||||
{"kind": "section", "label": "Revenue", "rule": false, "currency": false, "amounts": []},
|
||||
{"kind": "subtotal", "label": "Total revenue", "rule": false, "currency": true, "amounts": amt("0.00", false)},
|
||||
{"kind": "spacer", "label": "", "rule": false, "currency": false, "amounts": []},
|
||||
{"kind": "section", "label": "Expenses", "rule": false, "currency": false, "amounts": []},
|
||||
{"kind": "line", "label": "6000 Organizational Costs", "rule": false, "currency": false, "amounts": amt("59.00", false)},
|
||||
{"kind": "line", "label": "6400 Memberships & Licenses", "rule": false, "currency": false, "amounts": amt("106.38", false)},
|
||||
{"kind": "subtotal", "label": "Total expenses", "rule": true, "currency": true, "amounts": amt("165.38", false)},
|
||||
{"kind": "spacer", "label": "", "rule": false, "currency": false, "amounts": []},
|
||||
{"kind": "total", "label": "Net income (loss)", "rule": true, "currency": true, "amounts": amt("165.38", true)},
|
||||
],
|
||||
})
|
||||
);
|
||||
@@ -172,7 +176,7 @@ mod tests {
|
||||
.map(|i| Row {
|
||||
kind: RowKind::Line,
|
||||
label: format!("#{i} *not bold* $x$ <label> @ref [link] \\ #panic()"),
|
||||
amount: Some(Decimal::new(i, 0)),
|
||||
amounts: vec![Some(Decimal::new(i, 0))],
|
||||
currency: false,
|
||||
rule_above: false,
|
||||
})
|
||||
|
||||
+335
-187
@@ -1,13 +1,21 @@
|
||||
//! The presentation model every statement renderer (XLSX, PDF) draws from.
|
||||
//!
|
||||
//! Core computes the statements; this module lays them out as rows —
|
||||
//! sections, account lines, subtotals, totals — once, so the formats can't
|
||||
//! drift apart. Amounts stay exact `Decimal`s here; [`format_amount`] is the
|
||||
//! one place they become text ("1,234.56", "(207.68)").
|
||||
//! Core computes the statements; this module lays them out once — title,
|
||||
//! period, column headers, and rows (sections, account lines, subtotals,
|
||||
//! totals) — so the formats can't drift apart. Amounts stay exact
|
||||
//! `Decimal`s here; [`format_amount`] and [`amount_parts`] are the only
|
||||
//! places they become text.
|
||||
//!
|
||||
//! Presentation follows US small-business convention (QuickBooks-style):
|
||||
//! a currency sign on subtotals and totals, a rule above every sum, a
|
||||
//! double rule under statement totals, "Net income (loss)", and — for a
|
||||
//! multi-member LLC — "Members' equity" with each member named.
|
||||
|
||||
use chrono::NaiveDate;
|
||||
use chrono::{Datelike, NaiveDate};
|
||||
use ledger_core::RETAINED_EARNINGS_CODE;
|
||||
use ledger_core::reports::{
|
||||
BalanceSheetStatement, CashFlowLine, CashFlowStatement, ProfitAndLossStatement, StatementLine,
|
||||
BalanceSheetStatement, CashFlowLine, CashFlowStatement, MembersEquityStatement,
|
||||
ProfitAndLossStatement, StatementLine, TrialBalance,
|
||||
};
|
||||
use rust_decimal::Decimal;
|
||||
use serde::Serialize;
|
||||
@@ -17,21 +25,49 @@ use serde::Serialize;
|
||||
pub struct ReportMeta {
|
||||
/// Company name for the header, e.g. "Wanderland Labs LLC".
|
||||
pub company: String,
|
||||
/// Accounting basis for the footer: "Accrual" or "Cash".
|
||||
pub basis: String,
|
||||
/// The day the report was prepared.
|
||||
pub prepared_on: NaiveDate,
|
||||
/// Sequence number of the last journal line the report reflects.
|
||||
pub journal_seq: u64,
|
||||
}
|
||||
|
||||
impl ReportMeta {
|
||||
/// "Accrual basis · Prepared Sunday, October 11, 2026 · Journal seq 114".
|
||||
pub fn footer(&self) -> String {
|
||||
format!(
|
||||
"{} basis · Prepared {}, {} · Journal seq {}",
|
||||
self.basis,
|
||||
self.prepared_on.format("%A"),
|
||||
long_date(self.prepared_on),
|
||||
self.journal_seq
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// A member of a multi-member LLC, for naming capital accounts on reports.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Member {
|
||||
/// The member's name.
|
||||
pub name: String,
|
||||
/// Code of their capital account.
|
||||
pub capital_code: String,
|
||||
}
|
||||
|
||||
/// What a row is, which decides how renderers style it.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum RowKind {
|
||||
/// A section heading ("Revenue"); no amount.
|
||||
/// A section heading ("Revenue"); no amounts.
|
||||
Section,
|
||||
/// One account or one cash-flow line, indented under its section.
|
||||
/// An explanatory heading inside a section; no amounts.
|
||||
Subheading,
|
||||
/// One account or one flow, indented under its section.
|
||||
Line,
|
||||
/// A section total ("Total revenue").
|
||||
Subtotal,
|
||||
/// A statement total ("Net income").
|
||||
/// A statement total ("Net income (loss)"); double rule beneath.
|
||||
Total,
|
||||
/// Vertical space between sections.
|
||||
Spacer,
|
||||
@@ -44,243 +80,289 @@ pub struct Row {
|
||||
pub kind: RowKind,
|
||||
/// Label text.
|
||||
pub label: String,
|
||||
/// Amount, when the row has one.
|
||||
pub amount: Option<Decimal>,
|
||||
/// Print a currency sign with the amount: US accounting style puts "$"
|
||||
/// on the first amount under each section heading and on every
|
||||
/// subtotal and total.
|
||||
/// One amount per column; `None` leaves the cell blank.
|
||||
pub amounts: Vec<Option<Decimal>>,
|
||||
/// Print the currency sign with the amounts: subtotals and totals.
|
||||
pub currency: bool,
|
||||
/// Draw a single rule above the amount: the "sum of the above" line.
|
||||
/// Totals always have one; a subtotal only when amounts sit above it
|
||||
/// (an empty section gets no rule over nothing).
|
||||
/// Draw a rule above the row: the "sum of the above" line. Totals
|
||||
/// always have one; a subtotal only when amounts sit above it.
|
||||
pub rule_above: bool,
|
||||
}
|
||||
|
||||
/// A laid-out financial statement.
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct Statement {
|
||||
/// "Profit and Loss", "Balance Sheet", "Statement of Cash Flows".
|
||||
/// "Profit and Loss", "Balance Sheet", ...
|
||||
pub title: String,
|
||||
/// "October 4 – October 11, 2026" or "As of October 11, 2026".
|
||||
pub period: String,
|
||||
/// Amount column headings ("Total"; "Debit", "Credit"; member names).
|
||||
pub columns: Vec<String>,
|
||||
/// The rows, top to bottom.
|
||||
pub rows: Vec<Row>,
|
||||
}
|
||||
|
||||
fn row(kind: RowKind, label: impl Into<String>, amount: Option<Decimal>) -> Row {
|
||||
fn row(kind: RowKind, label: impl Into<String>, amounts: Vec<Option<Decimal>>) -> Row {
|
||||
Row {
|
||||
kind,
|
||||
label: label.into(),
|
||||
amount,
|
||||
amounts,
|
||||
currency: false,
|
||||
rule_above: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Finish a statement: mark which amounts carry the currency sign and which
|
||||
/// get a rule above.
|
||||
fn statement(title: &str, period: String, mut rows: Vec<Row>) -> Statement {
|
||||
let mut first_in_section = true;
|
||||
fn one(kind: RowKind, label: impl Into<String>, amount: Decimal) -> Row {
|
||||
row(kind, label, vec![Some(amount)])
|
||||
}
|
||||
|
||||
fn section(label: &str) -> Row {
|
||||
row(RowKind::Section, label, vec![])
|
||||
}
|
||||
|
||||
fn subheading(label: &str) -> Row {
|
||||
row(RowKind::Subheading, label, vec![])
|
||||
}
|
||||
|
||||
fn spacer() -> Row {
|
||||
row(RowKind::Spacer, "", vec![])
|
||||
}
|
||||
|
||||
/// Finish a statement: currency signs on sums, rules above them.
|
||||
fn statement(title: &str, period: String, columns: &[&str], mut rows: Vec<Row>) -> Statement {
|
||||
let mut above: Option<RowKind> = None;
|
||||
for r in &mut rows {
|
||||
r.currency = matches!(r.kind, RowKind::Subtotal | RowKind::Total);
|
||||
r.rule_above = match r.kind {
|
||||
RowKind::Total => true,
|
||||
RowKind::Subtotal => matches!(above, Some(RowKind::Line | RowKind::Subtotal)),
|
||||
_ => false,
|
||||
};
|
||||
if r.kind != RowKind::Spacer {
|
||||
if !matches!(r.kind, RowKind::Spacer | RowKind::Subheading) {
|
||||
above = Some(r.kind);
|
||||
}
|
||||
match r.kind {
|
||||
RowKind::Section => first_in_section = true,
|
||||
RowKind::Subtotal | RowKind::Total => r.currency = true,
|
||||
RowKind::Line if r.amount.is_some() && first_in_section => {
|
||||
r.currency = true;
|
||||
first_in_section = false;
|
||||
}
|
||||
RowKind::Line | RowKind::Spacer => {}
|
||||
}
|
||||
}
|
||||
Statement {
|
||||
title: title.to_string(),
|
||||
period,
|
||||
columns: columns.iter().map(|c| c.to_string()).collect(),
|
||||
rows,
|
||||
}
|
||||
}
|
||||
|
||||
/// An amount split for column alignment: the digits, and whether it is a
|
||||
/// negative (printed in parentheses) or zero (printed as a dash). Renderers
|
||||
/// right-align `digits` and reserve the closing parenthesis's width on every
|
||||
/// row, so decimal points line up whether or not a value is negative.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
||||
pub struct AmountParts {
|
||||
/// "1,234.56" — absolute value, grouped, two decimals. Empty for zero.
|
||||
pub digits: String,
|
||||
/// Below zero.
|
||||
pub negative: bool,
|
||||
/// Exactly zero (after rounding to cents).
|
||||
pub zero: bool,
|
||||
}
|
||||
|
||||
/// Split an amount for aligned accounting display. Exact: no floats.
|
||||
pub fn amount_parts(d: Decimal) -> AmountParts {
|
||||
let rounded = d.round_dp(2);
|
||||
if rounded.is_zero() {
|
||||
return AmountParts {
|
||||
digits: String::new(),
|
||||
negative: false,
|
||||
zero: true,
|
||||
};
|
||||
}
|
||||
let text = format_amount(rounded.abs());
|
||||
AmountParts {
|
||||
digits: text,
|
||||
negative: rounded.is_sign_negative(),
|
||||
zero: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn section(label: &str) -> Row {
|
||||
row(RowKind::Section, label, None)
|
||||
}
|
||||
|
||||
fn spacer() -> Row {
|
||||
row(RowKind::Spacer, "", None)
|
||||
}
|
||||
|
||||
fn account_lines(lines: &[StatementLine]) -> impl Iterator<Item = Row> + '_ {
|
||||
lines.iter().map(|l| {
|
||||
row(
|
||||
RowKind::Line,
|
||||
format!("{} {}", l.code, l.name),
|
||||
Some(l.amount),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn flow_lines(lines: &[CashFlowLine]) -> impl Iterator<Item = Row> + '_ {
|
||||
lines
|
||||
.iter()
|
||||
.map(|l| row(RowKind::Line, l.label.clone(), Some(l.amount)))
|
||||
.map(|l| one(RowKind::Line, format!("{} {}", l.code, l.name), l.amount))
|
||||
}
|
||||
|
||||
/// Lay out a profit and loss statement.
|
||||
pub fn profit_and_loss(pl: &ProfitAndLossStatement) -> Statement {
|
||||
let mut rows = vec![section("Revenue")];
|
||||
rows.extend(account_lines(&pl.revenue));
|
||||
rows.push(row(
|
||||
RowKind::Subtotal,
|
||||
"Total revenue",
|
||||
Some(pl.total_revenue),
|
||||
));
|
||||
rows.push(one(RowKind::Subtotal, "Total revenue", pl.total_revenue));
|
||||
rows.push(spacer());
|
||||
rows.push(section("Expenses"));
|
||||
rows.extend(account_lines(&pl.expenses));
|
||||
rows.push(row(
|
||||
RowKind::Subtotal,
|
||||
"Total expenses",
|
||||
Some(pl.total_expenses),
|
||||
));
|
||||
rows.push(one(RowKind::Subtotal, "Total expenses", pl.total_expenses));
|
||||
rows.push(spacer());
|
||||
rows.push(row(RowKind::Total, "Net income", Some(pl.net_income())));
|
||||
statement("Profit and Loss", period_label(pl.start, pl.end), rows)
|
||||
}
|
||||
|
||||
/// Lay out a balance sheet.
|
||||
pub fn balance_sheet(bs: &BalanceSheetStatement) -> Statement {
|
||||
let mut rows = vec![section("Assets")];
|
||||
rows.extend(account_lines(&bs.assets));
|
||||
rows.push(row(
|
||||
RowKind::Subtotal,
|
||||
"Total assets",
|
||||
Some(bs.total_assets),
|
||||
));
|
||||
rows.push(spacer());
|
||||
rows.push(section("Liabilities"));
|
||||
rows.extend(account_lines(&bs.liabilities));
|
||||
rows.push(row(
|
||||
RowKind::Subtotal,
|
||||
"Total liabilities",
|
||||
Some(bs.total_liabilities),
|
||||
));
|
||||
rows.push(spacer());
|
||||
rows.push(section("Equity"));
|
||||
rows.extend(account_lines(&bs.equity));
|
||||
rows.push(row(
|
||||
RowKind::Line,
|
||||
"Current earnings",
|
||||
Some(bs.current_earnings),
|
||||
));
|
||||
rows.push(row(
|
||||
RowKind::Subtotal,
|
||||
"Total equity",
|
||||
Some(bs.total_equity),
|
||||
));
|
||||
rows.push(spacer());
|
||||
rows.push(row(
|
||||
RowKind::Total,
|
||||
"Total liabilities and equity",
|
||||
Some(bs.total_liabilities + bs.total_equity),
|
||||
));
|
||||
rows.push(one(RowKind::Total, "Net income (loss)", pl.net_income()));
|
||||
statement(
|
||||
"Balance Sheet",
|
||||
format!("As of {}", long_date(bs.as_of)),
|
||||
"Profit and Loss",
|
||||
period_label(pl.start, pl.end),
|
||||
&["Total"],
|
||||
rows,
|
||||
)
|
||||
}
|
||||
|
||||
/// Lay out a balance sheet. `net_income_ytd` is the fiscal year to date's
|
||||
/// net income (loss): it shows as its own equity line, and earnings from
|
||||
/// before the fiscal year — the Retained Earnings account plus any not yet
|
||||
/// closed — show as "Retained earnings". With `members`, equity is
|
||||
/// "Members' equity".
|
||||
pub fn balance_sheet(
|
||||
bs: &BalanceSheetStatement,
|
||||
net_income_ytd: Decimal,
|
||||
members: &[Member],
|
||||
) -> Statement {
|
||||
let equity_name = if members.is_empty() {
|
||||
"Equity"
|
||||
} else {
|
||||
"Members' equity"
|
||||
};
|
||||
let mut rows = vec![section("Assets")];
|
||||
rows.extend(account_lines(&bs.assets));
|
||||
rows.push(one(RowKind::Subtotal, "Total assets", bs.total_assets));
|
||||
rows.push(spacer());
|
||||
rows.push(section("Liabilities"));
|
||||
rows.extend(account_lines(&bs.liabilities));
|
||||
rows.push(one(
|
||||
RowKind::Subtotal,
|
||||
"Total liabilities",
|
||||
bs.total_liabilities,
|
||||
));
|
||||
rows.push(spacer());
|
||||
rows.push(section(equity_name));
|
||||
let (retained, other): (Vec<_>, Vec<_>) = bs
|
||||
.equity
|
||||
.iter()
|
||||
.partition(|l| l.code == RETAINED_EARNINGS_CODE);
|
||||
rows.extend(
|
||||
other
|
||||
.iter()
|
||||
.map(|l| one(RowKind::Line, format!("{} {}", l.code, l.name), l.amount)),
|
||||
);
|
||||
let retained: Decimal =
|
||||
retained.iter().map(|l| l.amount).sum::<Decimal>() + bs.current_earnings - net_income_ytd;
|
||||
if !retained.is_zero() {
|
||||
rows.push(one(RowKind::Line, "Retained earnings", retained));
|
||||
}
|
||||
rows.push(one(RowKind::Line, "Net income (loss)", net_income_ytd));
|
||||
rows.push(one(
|
||||
RowKind::Subtotal,
|
||||
format!("Total {}", equity_name.to_lowercase()),
|
||||
bs.total_equity,
|
||||
));
|
||||
rows.push(spacer());
|
||||
rows.push(one(
|
||||
RowKind::Total,
|
||||
format!("Total liabilities and {}", equity_name.to_lowercase()),
|
||||
bs.total_liabilities + bs.total_equity,
|
||||
));
|
||||
statement(
|
||||
"Balance Sheet",
|
||||
format!("As of {}", long_date(bs.as_of)),
|
||||
&["Total"],
|
||||
rows,
|
||||
)
|
||||
}
|
||||
|
||||
/// Cash flow lines, naming members on their capital accounts.
|
||||
fn flow_lines<'a>(
|
||||
lines: &'a [CashFlowLine],
|
||||
members: &'a [Member],
|
||||
) -> impl Iterator<Item = Row> + 'a {
|
||||
lines.iter().map(move |l| {
|
||||
let member = members
|
||||
.iter()
|
||||
.find(|m| l.account.as_deref() == Some(m.capital_code.as_str()));
|
||||
let label = match member {
|
||||
Some(m) if l.amount >= Decimal::ZERO => format!("Member contributions — {}", m.name),
|
||||
Some(m) => format!("Member distributions — {}", m.name),
|
||||
None => l.label.clone(),
|
||||
};
|
||||
one(RowKind::Line, label, l.amount)
|
||||
})
|
||||
}
|
||||
|
||||
/// Lay out a cash flow statement (indirect method).
|
||||
pub fn cash_flow(cf: &CashFlowStatement) -> Statement {
|
||||
pub fn cash_flow(cf: &CashFlowStatement, members: &[Member]) -> Statement {
|
||||
let mut rows = vec![section("Operating activities")];
|
||||
rows.push(row(RowKind::Line, "Net income", Some(cf.net_income)));
|
||||
if !cf.depreciation.is_zero() {
|
||||
rows.push(row(
|
||||
RowKind::Line,
|
||||
"Depreciation (non-cash)",
|
||||
Some(cf.depreciation),
|
||||
rows.push(one(RowKind::Line, "Net income (loss)", cf.net_income));
|
||||
if !cf.depreciation.is_zero() || !cf.working_capital.is_empty() {
|
||||
rows.push(subheading(
|
||||
"Adjustments to reconcile net income (loss) to net cash from operating activities:",
|
||||
));
|
||||
}
|
||||
rows.extend(flow_lines(&cf.working_capital));
|
||||
rows.push(row(
|
||||
if !cf.depreciation.is_zero() {
|
||||
rows.push(one(RowKind::Line, "Depreciation", cf.depreciation));
|
||||
}
|
||||
rows.extend(flow_lines(&cf.working_capital, members));
|
||||
rows.push(one(
|
||||
RowKind::Subtotal,
|
||||
"Net cash from operating activities",
|
||||
Some(cf.cash_from_operations),
|
||||
cf.cash_from_operations,
|
||||
));
|
||||
rows.push(spacer());
|
||||
rows.push(section("Investing activities"));
|
||||
rows.extend(flow_lines(&cf.investing));
|
||||
rows.push(row(
|
||||
rows.extend(flow_lines(&cf.investing, members));
|
||||
rows.push(one(
|
||||
RowKind::Subtotal,
|
||||
"Net cash from investing activities",
|
||||
Some(cf.cash_from_investing),
|
||||
cf.cash_from_investing,
|
||||
));
|
||||
rows.push(spacer());
|
||||
rows.push(section("Financing activities"));
|
||||
rows.extend(flow_lines(&cf.financing));
|
||||
rows.push(row(
|
||||
rows.extend(flow_lines(&cf.financing, members));
|
||||
rows.push(one(
|
||||
RowKind::Subtotal,
|
||||
"Net cash from financing activities",
|
||||
Some(cf.cash_from_financing),
|
||||
cf.cash_from_financing,
|
||||
));
|
||||
rows.push(spacer());
|
||||
rows.push(row(
|
||||
rows.push(one(
|
||||
RowKind::Subtotal,
|
||||
"Net change in cash",
|
||||
Some(cf.net_change_in_cash),
|
||||
"Net increase (decrease) in cash",
|
||||
cf.net_change_in_cash,
|
||||
));
|
||||
rows.push(row(
|
||||
rows.push(one(
|
||||
RowKind::Line,
|
||||
"Cash at beginning of period",
|
||||
Some(cf.cash_beginning),
|
||||
));
|
||||
rows.push(row(
|
||||
RowKind::Total,
|
||||
"Cash at end of period",
|
||||
Some(cf.cash_ending),
|
||||
cf.cash_beginning,
|
||||
));
|
||||
rows.push(one(RowKind::Total, "Cash at end of period", cf.cash_ending));
|
||||
statement(
|
||||
"Statement of Cash Flows",
|
||||
period_label(cf.start, cf.end),
|
||||
&["Total"],
|
||||
rows,
|
||||
)
|
||||
}
|
||||
|
||||
/// Lay out a trial balance as of a date: Debit and Credit columns, a zero
|
||||
/// side left blank.
|
||||
pub fn trial_balance(tb: &TrialBalance, as_of: NaiveDate) -> Statement {
|
||||
let nz = |d: Decimal| if d.is_zero() { None } else { Some(d) };
|
||||
let mut rows: Vec<Row> = tb
|
||||
.lines
|
||||
.iter()
|
||||
.map(|l| {
|
||||
row(
|
||||
RowKind::Line,
|
||||
format!("{} {}", l.code, l.name),
|
||||
vec![nz(l.debit), nz(l.credit)],
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
rows.push(row(
|
||||
RowKind::Total,
|
||||
"Total",
|
||||
vec![Some(tb.total_debits), Some(tb.total_credits)],
|
||||
));
|
||||
statement(
|
||||
"Trial Balance",
|
||||
format!("As of {}", long_date(as_of)),
|
||||
&["Debit", "Credit"],
|
||||
rows,
|
||||
)
|
||||
}
|
||||
|
||||
/// Lay out the statement of changes in members' equity: one column per
|
||||
/// member and a total. Distributions print as reductions.
|
||||
pub fn members_equity(me: &MembersEquityStatement) -> Statement {
|
||||
let cols: Vec<_> = me
|
||||
.members
|
||||
.iter()
|
||||
.chain(std::iter::once(&me.total))
|
||||
.collect();
|
||||
let pick = |f: &dyn Fn(&ledger_core::MemberEquityColumn) -> Decimal| {
|
||||
cols.iter().map(|c| Some(f(c))).collect::<Vec<_>>()
|
||||
};
|
||||
let mut rows = vec![
|
||||
row(RowKind::Line, "Beginning balance", pick(&|c| c.beginning)),
|
||||
row(RowKind::Line, "Contributions", pick(&|c| c.contributions)),
|
||||
row(RowKind::Line, "Net income (loss)", pick(&|c| c.net_income)),
|
||||
row(RowKind::Line, "Distributions", pick(&|c| -c.distributions)),
|
||||
];
|
||||
if !me.total.other.is_zero() {
|
||||
rows.push(row(RowKind::Line, "Other changes", pick(&|c| c.other)));
|
||||
}
|
||||
rows.push(row(RowKind::Total, "Ending balance", pick(&|c| c.ending)));
|
||||
let names: Vec<&str> = cols.iter().map(|c| c.name.as_str()).collect();
|
||||
statement(
|
||||
"Statement of Changes in Members' Equity",
|
||||
period_label(me.start, me.end),
|
||||
&names,
|
||||
rows,
|
||||
)
|
||||
}
|
||||
@@ -292,7 +374,6 @@ pub fn long_date(d: NaiveDate) -> String {
|
||||
|
||||
/// "October 4 – October 11, 2026", or with both years when they differ.
|
||||
pub fn period_label(start: NaiveDate, end: NaiveDate) -> String {
|
||||
use chrono::Datelike;
|
||||
if start.year() == end.year() {
|
||||
format!("{} – {}", start.format("%B %-d"), long_date(end))
|
||||
} else {
|
||||
@@ -320,6 +401,27 @@ pub fn format_amount(d: Decimal) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// An amount split for column alignment: the digits, and whether it is a
|
||||
/// negative (printed in parentheses) or zero (printed as 0.00). Renderers
|
||||
/// right-align `digits` and reserve the closing parenthesis's width on every
|
||||
/// row, so decimal points line up whether or not a value is negative.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
||||
pub struct AmountParts {
|
||||
/// "1,234.56" — absolute value, grouped, two decimals.
|
||||
pub digits: String,
|
||||
/// Below zero.
|
||||
pub negative: bool,
|
||||
}
|
||||
|
||||
/// Split an amount for aligned accounting display. Exact: no floats.
|
||||
pub fn amount_parts(d: Decimal) -> AmountParts {
|
||||
let rounded = d.round_dp(2);
|
||||
AmountParts {
|
||||
digits: format_amount(rounded.abs()),
|
||||
negative: rounded.is_sign_negative() && !rounded.is_zero(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -344,44 +446,80 @@ mod tests {
|
||||
#[test]
|
||||
fn amounts_split_for_alignment() {
|
||||
let p = amount_parts(dec("-1234.5"));
|
||||
assert_eq!(
|
||||
(p.digits.as_str(), p.negative, p.zero),
|
||||
("1,234.50", true, false)
|
||||
);
|
||||
assert!(amount_parts(dec("0.004")).zero);
|
||||
assert_eq!(amount_parts(dec("207.68")).digits, "207.68");
|
||||
assert_eq!((p.digits.as_str(), p.negative), ("1,234.50", true));
|
||||
let z = amount_parts(dec("-0.004"));
|
||||
assert_eq!((z.digits.as_str(), z.negative), ("0.00", false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn currency_signs_go_on_first_lines_and_totals() {
|
||||
fn currency_signs_and_rules_go_on_sums() {
|
||||
let rows = vec![
|
||||
section("Assets"),
|
||||
row(RowKind::Line, "a", Some(dec("1"))),
|
||||
row(RowKind::Line, "b", Some(dec("2"))),
|
||||
row(RowKind::Subtotal, "t", Some(dec("3"))),
|
||||
one(RowKind::Line, "a", dec("1")),
|
||||
one(RowKind::Line, "b", dec("2")),
|
||||
one(RowKind::Subtotal, "t", dec("3")),
|
||||
spacer(),
|
||||
section("Equity"),
|
||||
row(RowKind::Line, "c", Some(dec("3"))),
|
||||
row(RowKind::Total, "T", Some(dec("3"))),
|
||||
section("Investing"),
|
||||
one(RowKind::Subtotal, "empty", dec("0")),
|
||||
one(RowKind::Total, "T", dec("3")),
|
||||
];
|
||||
let st = statement("X", String::new(), rows);
|
||||
let st = statement("X", String::new(), &["Total"], rows);
|
||||
let signs: Vec<bool> = st.rows.iter().map(|r| r.currency).collect();
|
||||
assert_eq!(signs, [false, true, false, true, false, false, true, true]);
|
||||
assert_eq!(signs, [false, false, false, true, false, false, true, true]);
|
||||
let rules: Vec<bool> = st.rows.iter().map(|r| r.rule_above).collect();
|
||||
// No rule over an empty section's subtotal; always one over a total.
|
||||
assert_eq!(
|
||||
rules,
|
||||
[false, false, false, true, false, false, false, true]
|
||||
);
|
||||
// A subtotal straight under its heading (empty section): no rule.
|
||||
let empty = statement(
|
||||
"Y",
|
||||
String::new(),
|
||||
vec![
|
||||
section("Investing"),
|
||||
row(RowKind::Subtotal, "t", Some(dec("0"))),
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn balance_sheet_splits_retained_earnings_from_this_year() {
|
||||
let line = |code: &str, name: &str, a: &str| StatementLine {
|
||||
code: code.into(),
|
||||
name: name.into(),
|
||||
amount: dec(a),
|
||||
};
|
||||
// Prior years closed 1,000 to Retained Earnings; 200 more unclosed
|
||||
// from last year; this year's net income is 300 (current earnings
|
||||
// 500 = 200 + 300).
|
||||
let bs = BalanceSheetStatement {
|
||||
as_of: NaiveDate::from_ymd_opt(2027, 3, 31).unwrap(),
|
||||
assets: vec![line("1000", "Cash", "2500")],
|
||||
liabilities: vec![],
|
||||
equity: vec![
|
||||
line("3000", "Capital — Alice", "1000"),
|
||||
line("3999", "Retained Earnings", "1000"),
|
||||
],
|
||||
current_earnings: dec("500"),
|
||||
total_assets: dec("2500"),
|
||||
total_liabilities: dec("0"),
|
||||
total_equity: dec("2500"),
|
||||
};
|
||||
let members = [Member {
|
||||
name: "Alice".into(),
|
||||
capital_code: "3000".into(),
|
||||
}];
|
||||
let st = balance_sheet(&bs, dec("300"), &members);
|
||||
let eq: Vec<(&str, Option<Decimal>)> = st
|
||||
.rows
|
||||
.iter()
|
||||
.skip_while(|r| r.label != "Members' equity")
|
||||
.map(|r| (r.label.as_str(), r.amounts.first().copied().flatten()))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
eq,
|
||||
[
|
||||
("Members' equity", None),
|
||||
("3000 Capital — Alice", Some(dec("1000"))),
|
||||
("Retained earnings", Some(dec("1200"))),
|
||||
("Net income (loss)", Some(dec("300"))),
|
||||
("Total members' equity", Some(dec("2500"))),
|
||||
("", None),
|
||||
("Total liabilities and members' equity", Some(dec("2500"))),
|
||||
]
|
||||
);
|
||||
assert!(!empty.rows[1].rule_above);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -395,5 +533,15 @@ mod tests {
|
||||
period_label(d(2026, 12, 1), d(2027, 1, 31)),
|
||||
"December 1, 2026 – January 31, 2027"
|
||||
);
|
||||
let meta = ReportMeta {
|
||||
company: "X".into(),
|
||||
basis: "Accrual".into(),
|
||||
prepared_on: d(2026, 10, 11),
|
||||
journal_seq: 114,
|
||||
};
|
||||
assert_eq!(
|
||||
meta.footer(),
|
||||
"Accrual basis · Prepared Sunday, October 11, 2026 · Journal seq 114"
|
||||
);
|
||||
}
|
||||
}
|
||||
+326
-167
@@ -3,27 +3,29 @@
|
||||
//! Money is converted to f64 ONLY at this presentation boundary — never in
|
||||
//! a money path. All accounting math stays `Decimal`; the conversion happens
|
||||
//! when writing a cell, rounded to cents. Cells stay numeric (so a CPA can
|
||||
//! sum them) with an accounting number format: thousands separators,
|
||||
//! negatives in parentheses.
|
||||
//! sum them) with accounting number formats: thousands separators,
|
||||
//! negatives in parentheses with the closing parenthesis hanging (so
|
||||
//! decimal points align), "$" on subtotals and totals.
|
||||
|
||||
use crate::report::{ReportMeta, RowKind, Statement};
|
||||
use chrono::NaiveDate;
|
||||
use ledger_core::Ledger;
|
||||
use ledger_core::reports::TrialBalance;
|
||||
use ledger_core::reports::GeneralLedger;
|
||||
use rust_decimal::Decimal;
|
||||
use rust_xlsxwriter::{Format, FormatBorder, Workbook, Worksheet};
|
||||
use rust_xlsxwriter::{Format, FormatAlign, FormatBorder, Workbook, Worksheet};
|
||||
use thiserror::Error;
|
||||
|
||||
/// Which report to render.
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum XlsxReport<'a> {
|
||||
/// Trial balance lines.
|
||||
TrialBalance(&'a TrialBalance),
|
||||
/// A laid-out financial statement (P&L, balance sheet, cash flow); see
|
||||
/// [`crate::report`].
|
||||
/// A laid-out statement (P&L, balance sheet, cash flow, trial balance,
|
||||
/// members' equity); see [`crate::report`].
|
||||
Statement(&'a Statement),
|
||||
/// General ledger: every posted journal line. Needs the ledger for
|
||||
/// account names and class names.
|
||||
GeneralLedger(&'a Ledger),
|
||||
/// The general ledger: by account, with running balances and splits.
|
||||
GeneralLedger(&'a GeneralLedger),
|
||||
/// The general journal: every posted entry in `[start, end]`, line by
|
||||
/// line, in date order.
|
||||
GeneralJournal(&'a Ledger, NaiveDate, NaiveDate),
|
||||
}
|
||||
|
||||
/// XLSX errors.
|
||||
@@ -40,179 +42,332 @@ impl From<rust_xlsxwriter::XlsxError> for XlsxError {
|
||||
}
|
||||
}
|
||||
|
||||
/// Excel's Accounting format, with and without the currency symbol: "$"
|
||||
/// pinned left, digits right, negatives in parentheses with the closing
|
||||
/// parenthesis hanging (`_)` reserves its width on positives, so decimal
|
||||
/// points line up), zero as a dash.
|
||||
const ACCOUNTING: &str = r#"_(* #,##0.00_);_(* (#,##0.00);_(* "-"??_);_(@_)"#;
|
||||
const ACCOUNTING_USD: &str = r#"_($* #,##0.00_);_($* (#,##0.00);_($* "-"??_);_(@_)"#;
|
||||
/// Accounting number formats: the `_)` reserves a closing parenthesis's
|
||||
/// width on positives so decimal points line up with negatives.
|
||||
const PLAIN: &str = "#,##0.00_);(#,##0.00)";
|
||||
const DOLLARS: &str = "$#,##0.00_);($#,##0.00)";
|
||||
|
||||
/// Company name, title, and period across the top; returns the next row.
|
||||
fn header(
|
||||
sheet: &mut Worksheet,
|
||||
meta: &ReportMeta,
|
||||
title: &str,
|
||||
period: Option<&str>,
|
||||
) -> Result<u32, XlsxError> {
|
||||
let bold = Format::new().set_bold();
|
||||
sheet.write_string_with_format(0, 0, &meta.company, &bold.clone().set_font_size(14))?;
|
||||
sheet.write_string_with_format(1, 0, title, &bold)?;
|
||||
let mut row = 2;
|
||||
if let Some(period) = period {
|
||||
sheet.write_string(row, 0, period)?;
|
||||
row += 1;
|
||||
}
|
||||
Ok(row + 1)
|
||||
struct Formats {
|
||||
bold: Format,
|
||||
company: Format,
|
||||
centered: Format,
|
||||
header: Format,
|
||||
header_right: Format,
|
||||
plain: Format,
|
||||
dollars_sum: Format,
|
||||
dollars_total: Format,
|
||||
line_label: Format,
|
||||
sub_label: Format,
|
||||
note: Format,
|
||||
}
|
||||
|
||||
/// "Prepared from journal through seq N" under the last row.
|
||||
fn footer(sheet: &mut Worksheet, meta: &ReportMeta, row: u32) -> Result<(), XlsxError> {
|
||||
let note = Format::new().set_italic().set_font_color("#666666");
|
||||
sheet.write_string_with_format(
|
||||
impl Formats {
|
||||
fn new() -> Self {
|
||||
let bold = Format::new().set_bold();
|
||||
let ruled = |f: Format| f.set_border_top(FormatBorder::Thin);
|
||||
Self {
|
||||
company: Format::new()
|
||||
.set_font_size(14)
|
||||
.set_align(FormatAlign::Center),
|
||||
centered: Format::new().set_align(FormatAlign::Center),
|
||||
header: bold
|
||||
.clone()
|
||||
.set_font_size(9)
|
||||
.set_border_bottom(FormatBorder::Thin),
|
||||
header_right: bold
|
||||
.clone()
|
||||
.set_font_size(9)
|
||||
.set_border_bottom(FormatBorder::Thin)
|
||||
.set_align(FormatAlign::Right),
|
||||
plain: Format::new().set_num_format(PLAIN),
|
||||
dollars_sum: ruled(bold.clone().set_num_format(DOLLARS)),
|
||||
dollars_total: ruled(bold.clone().set_num_format(DOLLARS))
|
||||
.set_border_bottom(FormatBorder::Double),
|
||||
line_label: Format::new().set_indent(1),
|
||||
sub_label: Format::new().set_italic().set_indent(1),
|
||||
note: Format::new()
|
||||
.set_italic()
|
||||
.set_font_size(9)
|
||||
.set_font_color("#666666")
|
||||
.set_align(FormatAlign::Center),
|
||||
bold,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Company, title, and period centered over `width` columns; returns the
|
||||
/// next free row.
|
||||
fn header(
|
||||
sheet: &mut Worksheet,
|
||||
f: &Formats,
|
||||
meta: &ReportMeta,
|
||||
title: &str,
|
||||
period: &str,
|
||||
width: u16,
|
||||
) -> Result<u32, XlsxError> {
|
||||
let last = width.saturating_sub(1);
|
||||
sheet.merge_range(0, 0, 0, last, &meta.company, &f.company)?;
|
||||
sheet.merge_range(
|
||||
1,
|
||||
0,
|
||||
1,
|
||||
last,
|
||||
title,
|
||||
&f.bold.clone().set_align(FormatAlign::Center),
|
||||
)?;
|
||||
sheet.merge_range(2, 0, 2, last, period, &f.centered)?;
|
||||
Ok(4)
|
||||
}
|
||||
|
||||
/// Basis, preparation date, and journal seq under the last row.
|
||||
fn footer(
|
||||
sheet: &mut Worksheet,
|
||||
f: &Formats,
|
||||
meta: &ReportMeta,
|
||||
row: u32,
|
||||
width: u16,
|
||||
) -> Result<(), XlsxError> {
|
||||
sheet.merge_range(
|
||||
row + 1,
|
||||
0,
|
||||
format!("Prepared from journal through seq {}", meta.journal_seq),
|
||||
¬e,
|
||||
row + 1,
|
||||
width.saturating_sub(1),
|
||||
&meta.footer(),
|
||||
&f.note,
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn number(
|
||||
sheet: &mut Worksheet,
|
||||
row: u32,
|
||||
col: u16,
|
||||
d: Decimal,
|
||||
fmt: &Format,
|
||||
) -> Result<(), XlsxError> {
|
||||
sheet.write_number_with_format(row, col, dec_to_f64(d), fmt)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Render a report to XLSX bytes.
|
||||
pub fn report_to_xlsx(report: &XlsxReport<'_>, meta: &ReportMeta) -> Result<Vec<u8>, XlsxError> {
|
||||
let mut workbook = Workbook::new();
|
||||
let sheet = workbook.add_worksheet();
|
||||
let bold = Format::new().set_bold();
|
||||
let money = Format::new().set_num_format(ACCOUNTING);
|
||||
let usd = Format::new().set_num_format(ACCOUNTING_USD);
|
||||
// Totals are marked by rules, not bold: bold figures are wider and would
|
||||
// break the decimal column.
|
||||
let usd_bold = usd.clone();
|
||||
|
||||
let f = Formats::new();
|
||||
match report {
|
||||
XlsxReport::TrialBalance(tb) => {
|
||||
sheet.set_name("Trial Balance")?;
|
||||
let mut row = header(sheet, meta, "Trial Balance", None)?;
|
||||
for (i, h) in ["Code", "Account", "Debit", "Credit"].iter().enumerate() {
|
||||
sheet.write_string_with_format(row, i as u16, *h, &bold)?;
|
||||
}
|
||||
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)?;
|
||||
XlsxReport::Statement(st) => statement(sheet, &f, meta, st)?,
|
||||
XlsxReport::GeneralLedger(gl) => general_ledger(sheet, &f, meta, gl)?,
|
||||
XlsxReport::GeneralJournal(ledger, start, end) => {
|
||||
general_journal(sheet, &f, meta, ledger, *start, *end)?
|
||||
}
|
||||
}
|
||||
|
||||
Ok(workbook.save_to_buffer()?)
|
||||
}
|
||||
|
||||
fn statement(
|
||||
sheet: &mut Worksheet,
|
||||
f: &Formats,
|
||||
meta: &ReportMeta,
|
||||
st: &Statement,
|
||||
) -> Result<(), XlsxError> {
|
||||
// Excel sheet names: at most 31 characters, no apostrophes at the ends.
|
||||
let name: String = st.title.replace('\'', "").chars().take(31).collect();
|
||||
sheet.set_name(name.trim())?;
|
||||
let width = 1 + st.columns.len() as u16;
|
||||
let mut row = header(sheet, f, meta, &st.title, &st.period, width)?;
|
||||
sheet.write_string_with_format(row, 0, "", &f.header)?;
|
||||
for (i, c) in st.columns.iter().enumerate() {
|
||||
sheet.write_string_with_format(row, 1 + i as u16, c.to_uppercase(), &f.header_right)?;
|
||||
}
|
||||
row += 1;
|
||||
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.
|
||||
fn dec_to_f64(d: Decimal) -> f64 {
|
||||
use rust_decimal::prelude::ToPrimitive;
|
||||
@@ -222,7 +377,8 @@ fn dec_to_f64(d: Decimal) -> f64 {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use ledger_core::reports::TrialBalanceLine;
|
||||
use crate::report;
|
||||
use ledger_core::reports::{TrialBalance, TrialBalanceLine};
|
||||
|
||||
#[test]
|
||||
fn xlsx_is_a_zip() {
|
||||
@@ -238,9 +394,12 @@ mod tests {
|
||||
};
|
||||
let meta = ReportMeta {
|
||||
company: "Test Co".to_string(),
|
||||
basis: "Accrual".to_string(),
|
||||
prepared_on: NaiveDate::from_ymd_opt(2026, 10, 11).unwrap(),
|
||||
journal_seq: 7,
|
||||
};
|
||||
let bytes = report_to_xlsx(&XlsxReport::TrialBalance(&tb), &meta).unwrap();
|
||||
let st = report::trial_balance(&tb, NaiveDate::from_ymd_opt(2026, 10, 11).unwrap());
|
||||
let bytes = report_to_xlsx(&XlsxReport::Statement(&st), &meta).unwrap();
|
||||
// XLSX is a ZIP: starts with PK.
|
||||
assert_eq!(&bytes[0..2], b"PK");
|
||||
assert!(bytes.len() > 1000);
|
||||
|
||||
@@ -1,111 +1,114 @@
|
||||
// A financial statement: P&L, balance sheet, or cash flow.
|
||||
// A financial statement: P&L, balance sheet, cash flow, trial balance, or
|
||||
// members' equity, in US small-business (QuickBooks-style) presentation.
|
||||
//
|
||||
// Everything arrives as data in sys.inputs.data (JSON), already laid out by
|
||||
// ledger-import::report, with amounts already split into exact parts. The
|
||||
// template only typesets: no arithmetic happens here, and no label is ever
|
||||
// parsed as markup.
|
||||
//
|
||||
// Shape: { company, title, period, footer,
|
||||
// rows: [{ kind: "section" | "line" | "subtotal" | "total"
|
||||
// | "spacer", label, rule (single rule above),
|
||||
// amount: null | { digits: "1,234.56", negative, zero,
|
||||
// currency } }] }
|
||||
// Shape: { company, title, period, footer, columns: ["Total"] | [...],
|
||||
// rows: [{ kind: "section" | "subheading" | "line" | "subtotal"
|
||||
// | "total" | "spacer",
|
||||
// label, rule (rule above), currency ("$" on amounts),
|
||||
// amounts: [null | { digits: "1,234.56", negative }] }] }
|
||||
//
|
||||
// Amounts follow US accounting presentation: tabular figures, "$" pinned
|
||||
// to the left of the amount column on the first line of each section and on
|
||||
// totals, negatives in parentheses with the closing parenthesis hanging
|
||||
// (its width is reserved on every row, so decimal points always line up),
|
||||
// and zero as a dash in the ones position. Amounts are never bold: bold
|
||||
// figures are wider, which would knock totals out of the decimal column —
|
||||
// the rules mark subtotals and totals instead.
|
||||
// Amounts: Inter's tabular figures; negatives in parentheses with the
|
||||
// closing parenthesis hanging (its width is reserved on every row, so
|
||||
// decimal points always line up); "$" attached on subtotals and totals.
|
||||
|
||||
#let d = json(bytes(sys.inputs.data))
|
||||
#let ncols = d.columns.len()
|
||||
#let ink = luma(25)
|
||||
#let quiet = luma(110)
|
||||
#let hair = 0.5pt + luma(160)
|
||||
#let firm = 0.6pt + ink
|
||||
|
||||
#set document(title: d.company + " — " + d.title, author: d.company)
|
||||
#set text(
|
||||
font: "Libertinus Serif",
|
||||
size: 10.5pt,
|
||||
number-type: "lining",
|
||||
number-width: "tabular",
|
||||
)
|
||||
#set text(font: "Inter", size: 9pt, fill: ink, number-width: "tabular")
|
||||
#set page(
|
||||
paper: "us-letter",
|
||||
margin: (x: 1in, top: 0.9in, bottom: 0.8in),
|
||||
margin: (x: 0.75in, top: 0.75in, bottom: 0.75in),
|
||||
header: context {
|
||||
if counter(page).get().first() > 1 {
|
||||
set text(8.5pt, fill: luma(110))
|
||||
[#d.company — #d.title #h(1fr) #d.period]
|
||||
set text(7.5pt, fill: quiet)
|
||||
[#d.company · #d.title #h(1fr) #d.period]
|
||||
}
|
||||
},
|
||||
footer: context {
|
||||
set text(8pt, fill: luma(110))
|
||||
[#d.footer #h(1fr) Page #counter(page).display() of #counter(page).final().first()]
|
||||
set text(7.5pt, fill: quiet)
|
||||
grid(
|
||||
columns: (1fr, auto, 1fr),
|
||||
[],
|
||||
d.footer,
|
||||
align(right)[Page #counter(page).display() of #counter(page).final().first()],
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
#align(center)[
|
||||
#text(15pt, weight: "bold", d.company) \
|
||||
#text(15pt, fill: luma(45), d.company) \
|
||||
#v(4pt)
|
||||
#text(10.5pt, weight: "bold", d.title) \
|
||||
#v(2pt)
|
||||
#text(12.5pt, d.title) \
|
||||
#v(1pt)
|
||||
#text(10pt, style: "italic", d.period)
|
||||
#d.period
|
||||
]
|
||||
#v(16pt)
|
||||
#v(12pt)
|
||||
|
||||
// Two lines under a statement total, the accounting double rule, set
|
||||
// clear of the descenders.
|
||||
#let double-rule(body) = stack(
|
||||
body,
|
||||
v(2.4pt),
|
||||
line(length: 100%, stroke: 0.5pt),
|
||||
v(1.4pt),
|
||||
line(length: 100%, stroke: 0.5pt),
|
||||
)
|
||||
|
||||
#let amount(r) = {
|
||||
let a = r.amount
|
||||
#let amount(a, currency) = {
|
||||
if a == none { return [] }
|
||||
let open = if a.negative { [(] } else { [] }
|
||||
let close = if a.negative { [)] } else { hide[)] }
|
||||
let body = if a.zero { [–#hide[.00]] } else { [#open#a.digits] }
|
||||
grid(
|
||||
columns: (auto, 1fr),
|
||||
align: (left, right),
|
||||
if a.currency [\$] else [],
|
||||
[#body#close],
|
||||
let sign = if currency [\$] else []
|
||||
[#open#sign#a.digits#close]
|
||||
}
|
||||
|
||||
#let full(body, ..args) = table.cell(colspan: ncols + 1, ..args, body)
|
||||
|
||||
#let row-cells(r) = {
|
||||
let bold = r.kind == "subtotal" or r.kind == "total"
|
||||
let stroke = if r.rule { (top: if r.kind == "total" { firm } else { hair }) } else { none }
|
||||
let label = if r.kind == "line" { pad(left: 1.4em, r.label) } else { r.label }
|
||||
let label = if bold { text(weight: "bold", label) } else { label }
|
||||
(
|
||||
table.cell(stroke: stroke, label),
|
||||
..r.amounts.map(a => table.cell(stroke: stroke, amount(a, r.currency))),
|
||||
)
|
||||
}
|
||||
|
||||
#table(
|
||||
columns: (1fr, 9em),
|
||||
columns: (1fr,) + (8em,) * ncols,
|
||||
stroke: none,
|
||||
inset: (x: 0pt, y: 3.2pt),
|
||||
align: (left + horizon, right + horizon),
|
||||
inset: (x: 4pt, y: 3.6pt),
|
||||
align: (left + horizon,) + (right + horizon,) * ncols,
|
||||
table.header(
|
||||
table.cell(stroke: (top: firm, bottom: hair), []),
|
||||
..d.columns.map(c => table.cell(
|
||||
stroke: (top: firm, bottom: hair),
|
||||
text(7.5pt, weight: "bold", tracking: 0.04em, upper(c)),
|
||||
)),
|
||||
),
|
||||
..d
|
||||
.rows
|
||||
.map(r => {
|
||||
if r.kind == "spacer" {
|
||||
(table.cell(colspan: 2, v(3pt)),)
|
||||
(full(v(2pt), inset: 2pt),)
|
||||
} else if r.kind == "section" {
|
||||
(table.cell(colspan: 2, text(weight: "bold", r.label)),)
|
||||
} else if r.kind == "line" {
|
||||
(pad(left: 1.4em, r.label), amount(r))
|
||||
} else if r.kind == "subtotal" {
|
||||
(
|
||||
text(weight: "bold", r.label),
|
||||
table.cell(
|
||||
stroke: if r.rule { (top: 0.5pt) } else { none },
|
||||
amount(r),
|
||||
(full(text(weight: "semibold", r.label)),)
|
||||
} else if r.kind == "subheading" {
|
||||
(full(pad(left: 1.4em, emph(r.label))),)
|
||||
} else if r.kind == "total" {
|
||||
// The double rule under a statement total, full width.
|
||||
let double = full(
|
||||
inset: (x: 0pt, y: 0pt),
|
||||
stack(
|
||||
spacing: 1.6pt,
|
||||
line(length: 100%, stroke: firm),
|
||||
line(length: 100%, stroke: firm),
|
||||
),
|
||||
)
|
||||
row-cells(r) + (double,)
|
||||
} else {
|
||||
(
|
||||
align(top, text(weight: "bold", r.label)),
|
||||
table.cell(
|
||||
stroke: (top: 0.5pt),
|
||||
double-rule(amount(r)),
|
||||
),
|
||||
)
|
||||
row-cells(r)
|
||||
}
|
||||
})
|
||||
.flatten(),
|
||||
|
||||
Reference in new issue
Block a user