diff --git a/Cargo.lock b/Cargo.lock index e81f9ed..6873575 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1894,7 +1894,6 @@ dependencies = [ "thiserror", "typst", "typst-as-lib", - "typst-assets", "typst-layout", "typst-pdf", ] diff --git a/crates/ledger-core/src/ledger.rs b/crates/ledger-core/src/ledger.rs index fd1aa8a..d84a1a5 100644 --- a/crates/ledger-core/src/ledger.rs +++ b/crates/ledger-core/src/ledger.rs @@ -642,10 +642,10 @@ impl Ledger { None } else { // Ensure Retained Earnings exists. - let re_id = match self.chart.find_by_code("3999") { + let re_id = match self.chart.find_by_code(periods::RETAINED_EARNINGS_CODE) { Some(a) => a.id, None => self.add_account( - "3999", + periods::RETAINED_EARNINGS_CODE, "Retained Earnings", AccountType::Equity, None, diff --git a/crates/ledger-core/src/lib.rs b/crates/ledger-core/src/lib.rs index 009e428..f02dfdb 100644 --- a/crates/ledger-core/src/lib.rs +++ b/crates/ledger-core/src/lib.rs @@ -43,7 +43,7 @@ pub use journal::{EntryId, EntryStatus, JournalEntry, JournalLine}; pub use ledger::{Ledger, ReconciliationReport, UnclearedLine}; pub use money::{Currency, Money}; pub use payroll::{PayrollLine, PayrollRun, PayrollRunId}; -pub use periods::{Period, PeriodId, PeriodStatus}; +pub use periods::{Period, PeriodId, PeriodStatus, RETAINED_EARNINGS_CODE}; pub use reports::{ BalanceSheet, BalanceSheetStatement, CashFlowLine, CashFlowParams, CashFlowStatement, ClassProfitAndLoss, GeneralLedger, LedgerAccount, LedgerPosting, MemberCapital, diff --git a/crates/ledger-core/src/periods.rs b/crates/ledger-core/src/periods.rs index 59db274..fea364c 100644 --- a/crates/ledger-core/src/periods.rs +++ b/crates/ledger-core/src/periods.rs @@ -29,6 +29,10 @@ pub enum PeriodStatus { Closed, } +/// Code of the Retained Earnings account that closing entries credit (or +/// debit), created on the first close if the chart lacks it. +pub const RETAINED_EARNINGS_CODE: &str = "3999"; + /// A named accounting period: a month, quarter, or year. #[derive(Clone, Debug, Serialize, Deserialize)] pub struct Period { @@ -130,7 +134,7 @@ mod tests { assert_eq!(l.account_balance(rev).unwrap(), rust_decimal::Decimal::ZERO); assert_eq!(l.account_balance(exp).unwrap(), rust_decimal::Decimal::ZERO); // Retained earnings holds the net: 1000 - 400 = 600 (credit-normal). - let re = l.chart().find_by_code("3999").unwrap().id; + let re = l.chart().find_by_code(RETAINED_EARNINGS_CODE).unwrap().id; assert_eq!( l.account_balance(re).unwrap(), "600".parse::().unwrap() diff --git a/crates/ledger-import/Cargo.toml b/crates/ledger-import/Cargo.toml index c1c0a3e..c16f5b2 100644 --- a/crates/ledger-import/Cargo.toml +++ b/crates/ledger-import/Cargo.toml @@ -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" diff --git a/crates/ledger-import/fonts/Inter-Bold.ttf b/crates/ledger-import/fonts/Inter-Bold.ttf new file mode 100644 index 0000000..9fb9b75 Binary files /dev/null and b/crates/ledger-import/fonts/Inter-Bold.ttf differ diff --git a/crates/ledger-import/fonts/Inter-LICENSE.txt b/crates/ledger-import/fonts/Inter-LICENSE.txt new file mode 100644 index 0000000..9b2ca37 --- /dev/null +++ b/crates/ledger-import/fonts/Inter-LICENSE.txt @@ -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. diff --git a/crates/ledger-import/fonts/Inter-Regular.ttf b/crates/ledger-import/fonts/Inter-Regular.ttf new file mode 100644 index 0000000..b7aaca8 Binary files /dev/null and b/crates/ledger-import/fonts/Inter-Regular.ttf differ diff --git a/crates/ledger-import/fonts/Inter-SemiBold.ttf b/crates/ledger-import/fonts/Inter-SemiBold.ttf new file mode 100644 index 0000000..47f8ab1 Binary files /dev/null and b/crates/ledger-import/fonts/Inter-SemiBold.ttf differ diff --git a/crates/ledger-import/src/lib.rs b/crates/ledger-import/src/lib.rs index 27b8fb7..5612ec5 100644 --- a/crates/ledger-import/src/lib.rs +++ b/crates/ledger-import/src/lib.rs @@ -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}; diff --git a/crates/ledger-import/src/pdf.rs b/crates/ledger-import/src/pdf.rs index 76ad0e2..a4ff5b0 100644 --- a/crates/ledger-import/src/pdf.rs +++ b/crates/ledger-import/src/pdf.rs @@ -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 { @@ -34,15 +40,17 @@ fn engine() -> &'static TypstEngine { 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::>(), })).collect::>(), }) } @@ -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$