Compare commits

..
4 Commits
Author SHA1 Message Date
swaitsandClaude Opus 5.5 8164abb2b8 fix: period close sweeps only through the period end; partnerships close into members' capital
The close zeroed every revenue and expense account's all-time movement,
so closing a period after later entries were posted swept those too:
close January in March and February's and March's income landed in
January's closing entry. The preview had the same bug and never showed
where the net went. Now:

- close_period_to(id, to) zeroes balances as of the period end; entries
  dated later stay open for their own period. Periods must close in
  order (LedgerError::CloseOutOfOrder): a later close has already swept
  the earlier period.
- CloseTo::Members: for a partnership (multi-member LLC) the net goes to
  each member's capital account by share, to the cent, the last member
  absorbing rounding, which is what K-1 capital accounts track.
  CloseTo::RetainedEarnings (3999) stays the default; close_period is
  shorthand for it. books.close_period picks Members when
  wanderbooks.toml lists members.
- The journal records the close target; replay re-derives the closing
  entry and now compares every line with the journal, not just the id,
  so a change to closing math can never silently rewrite history.
- The tool's preview is the real close run on a clone of the ledger
  (Ledger now derives Clone), so preview and post can't disagree.
- Members' equity: closing entries into a capital account are income,
  not contributions. Balance sheet for a partnership: capital accounts
  (with allocated earnings), then only unallocated earnings —
  "Undistributed earnings, prior years" if any, and this year's unclosed
  net income (loss). periods::allocate is shared by the close and the
  statement.

Tests: January closed after February is posted sweeps exactly Dr 4000
2,500 / Cr 6100 800 / Cr 3999 1,700 (mutation-checked: the old code gives
3,500/120/800/2,580); out-of-order close refused; a partnership close
splits 666.67 into 333.34/333.33 with no 3999, and the members' equity
statement reads correctly before and after; store replay of a members
close (reopen, rebuild, verify); partnership and corporate balance sheet
layouts; stdio close preview equals the posted lines.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MtYEaR6M6Ybdhm6X98BWNu
2026-10-11 20:39:17 -07:00
swaitsandClaude Opus 5.5 f591264533 docs: more ledger-gui design references
Records three more dashboard references Stephen shared: the QuickBooks
Online dashboard cards, KPI tiles with period-over-period change, and a
cash-flow dashboard (runway, forecast, A/P and A/R aging, burn).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MtYEaR6M6Ybdhm6X98BWNu
2026-10-11 20:31:08 -07:00
swaitsandClaude Opus 5.5 8f623ba810 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
2026-10-11 20:29:12 -07:00
swaitsandClaude Opus 5.5 02b3b9c053 feat: general ledger by account, trial balance as of, members' equity
Three reports a CPA expects that the engine could not produce:

- general_ledger(start, end): the canonical general ledger, organized by
  account in code order. Each account has its beginning balance, every
  posting in the period (date, entry, description, debit, credit, running
  balance on the normal side) and its ending balance, which ties to the
  trial balance. Each posting names its split: the one account on the
  opposite side of its entry, or "-Split-" when that side has several,
  so a compound entry (one debit, several credits) reads correctly from
  every account it touches. The entry-ordered listing the exports called a
  "general ledger" is really the general journal.
- trial_balance_as_of(as_of): a trial balance is always as of a date.
- members_equity(start, end, members): the statement of changes in
  members' equity for a multi-member LLC taxed as a partnership. Each
  member's capital roll-forward: beginning (capital plus their share of
  undistributed earnings), contributions, allocated net income (loss),
  distributions, ending. Allocation is by ownership share to the cent,
  the last member absorbing rounding, so totals are exact and the ending
  total equals balance-sheet equity; any equity change the roll-forward
  can't explain shows as "other" instead of disappearing. Shares must be
  positive and sum to exactly 1; capital accounts must be distinct
  equity accounts (LedgerError::InvalidMembers).
- CashFlowLine.account: the account a line is about, so renderers can
  name members instead of account codes.

Tests (every expectation computed by hand): trial balance as of Jan 31;
February general ledger with beginning balances, running balances, a
card that nets to zero, an inactive account still listed, and every
ending balance tied to the trial balance; a compound entry's splits from
all three sides; a two-member partnership where 666.67 and 100.01 split
unevenly (333.34/333.33, 50.01/50.00), with and without closing January,
tying to the balance sheet each month; bad member configuration.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MtYEaR6M6Ybdhm6X98BWNu
2026-10-11 20:16:25 -07:00
28 changed files with 2751 additions and 692 deletions

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",
] ]
+8
View File
@@ -132,6 +132,10 @@ pub enum LedgerError {
#[error("purchase date plus useful life overflows the calendar")] #[error("purchase date plus useful life overflows the calendar")]
AssetDateOverflow, AssetDateOverflow,
/// Partnership members are misconfigured (shares, capital accounts).
#[error("invalid members: {0}")]
InvalidMembers(String),
/// Cash-flow period end is before period start. /// Cash-flow period end is before period start.
#[error("invalid date range: end {end} is before start {start}")] #[error("invalid date range: end {end} is before start {start}")]
InvalidDateRange { InvalidDateRange {
@@ -162,6 +166,10 @@ pub enum LedgerError {
#[error("cannot close period {0}: draft entries exist with dates in the period")] #[error("cannot close period {0}: draft entries exist with dates in the period")]
DraftsInPeriod(u64), DraftsInPeriod(u64),
/// A period can't close while a later period is already closed.
#[error("period {0} can't close: later period {1} is already closed (close in order)")]
CloseOutOfOrder(u64, u64),
/// Entries cannot be created or posted in a closed period. /// Entries cannot be created or posted in a closed period.
#[error("period {0} is closed; entries dated {1} are rejected")] #[error("period {0} is closed; entries dated {1} are rejected")]
ClosedPeriod(u64, String), ClosedPeriod(u64, String),
+142 -86
View File
@@ -57,7 +57,7 @@ pub struct ReconciliationReport {
} }
/// The books. Single-currency (see R-5); owns id allocation. /// The books. Single-currency (see R-5); owns id allocation.
#[derive(Debug)] #[derive(Clone, Debug)]
pub struct Ledger { pub struct Ledger {
base_currency: Currency, base_currency: Currency,
chart: ChartOfAccounts, chart: ChartOfAccounts,
@@ -587,114 +587,161 @@ impl Ledger {
.collect() .collect()
} }
/// Close a period into Retained Earnings: see [`Self::close_period_to`].
pub fn close_period(&mut self, id: PeriodId) -> Result<Option<EntryId>, LedgerError> {
self.close_period_to(id, &periods::CloseTo::RetainedEarnings)
}
/// Close a period. This: /// Close a period. This:
/// 1. Rejects if the period is already closed. /// 1. Rejects if the period is already closed, if any draft is dated
/// 2. Rejects if any draft entries have dates inside the period. /// inside it, or if a later period is already closed (closing out of
/// 3. Posts a closing entry dated at period end: revenue accounts are /// order would sweep the same income twice).
/// debited to zero, expense accounts are credited to zero, and the /// 2. Posts a closing entry dated at period end that zeroes every revenue
/// net goes to Retained Earnings (account `3999`, created if missing). /// and expense account's balance *as of the period end* (entries
/// 4. Marks the period closed. /// dated after it stay open for their own period): revenue debited,
/// expense credited, and the net to `to` — Retained Earnings (account
/// `3999`, created if missing), or members' capital accounts by
/// share for a partnership, to the cent with the last member
/// absorbing rounding.
/// 3. Marks the period closed.
/// ///
/// Returns the closing entry's id, or `None` if there was nothing to /// Returns the closing entry's id, or `None` if there was nothing to
/// close (no revenue/expense movement). /// close (no revenue/expense balance at period end).
pub fn close_period(&mut self, id: PeriodId) -> Result<Option<EntryId>, LedgerError> { pub fn close_period_to(
let period = self &mut self,
.periods id: PeriodId,
.get(&id) to: &periods::CloseTo,
.ok_or(LedgerError::UnknownPeriod(id))? ) -> Result<Option<EntryId>, LedgerError> {
.clone(); self.preview_close_period(id)?;
if period.status == PeriodStatus::Closed { if let periods::CloseTo::Members(members) = to {
return Err(LedgerError::PeriodAlreadyClosed(id)); self.validate_close_members(members)?;
}
// No drafts may have dates inside the period.
let has_drafts = self
.entries
.values()
.any(|e| e.status() == EntryStatus::Draft && period.contains(e.date()));
if has_drafts {
return Err(LedgerError::DraftsInPeriod(id));
} }
let period = self.periods.get(&id).expect("previewed").clone();
// Collect revenue and expense accounts with non-zero movement. // Revenue and expense balances as of the period end.
let mut closings: Vec<(AccountId, Side, Decimal)> = Vec::new(); let mut closings: Vec<(AccountId, Side, Decimal)> = Vec::new();
let mut accounts: Vec<_> = self.chart.all().collect(); let mut accounts: Vec<_> = self.chart.all().collect();
accounts.sort_by(|a, b| a.code.cmp(&b.code)); accounts.sort_by(|a, b| a.code.cmp(&b.code));
for account in accounts { for account in accounts {
match account.account_type { if !matches!(
AccountType::Revenue | AccountType::Expense => { account.account_type,
let movement = self.account_movement(account.id); AccountType::Revenue | AccountType::Expense
if movement.is_zero() { ) {
continue; continue;
}
// To zero the account: post the opposite of its net movement.
let (side, amount) = if movement > Decimal::ZERO {
(Side::Credit, movement)
} else {
(Side::Debit, -movement)
};
closings.push((account.id, side, amount));
}
_ => {}
} }
let movement = self.movement_through(account.id, period.end);
if movement.is_zero() {
continue;
}
// To zero the account: post the opposite of its net movement.
let (side, amount) = if movement > Decimal::ZERO {
(Side::Credit, movement)
} else {
(Side::Debit, -movement)
};
closings.push((account.id, side, amount));
} }
let closing_entry = if closings.is_empty() { let closing_entry = if closings.is_empty() {
None None
} else { } else {
// Ensure Retained Earnings exists. // Net income: the closing debits (revenue) minus credits (expense).
let re_id = match self.chart.find_by_code("3999") { let net: Decimal = closings
Some(a) => a.id, .iter()
None => self.add_account( .map(|(_, side, amount)| match side {
"3999", Side::Debit => *amount,
"Retained Earnings", Side::Credit => -*amount,
AccountType::Equity, })
None, .sum();
false, let targets: Vec<(AccountId, Decimal)> = match to {
)?, periods::CloseTo::RetainedEarnings => {
}; let re_id = match self.chart.find_by_code(periods::RETAINED_EARNINGS_CODE) {
// Sum the closing lines to find the net for RE. Some(a) => a.id,
let mut debits = Decimal::ZERO; None => self.add_account(
let mut credits = Decimal::ZERO; periods::RETAINED_EARNINGS_CODE,
for (_, side, amount) in &closings { "Retained Earnings",
match side { AccountType::Equity,
Side::Debit => debits += *amount, None,
Side::Credit => credits += *amount, false,
)?,
};
vec![(re_id, net)]
} }
} periods::CloseTo::Members(members) => {
let shares: Vec<Decimal> = members.iter().map(|(_, s)| *s).collect();
members
.iter()
.map(|(a, _)| *a)
.zip(periods::allocate(net, &shares))
.collect()
}
};
let entry = self.create_entry(period.end, &format!("Close period {}", period.name)); let entry = self.create_entry(period.end, &format!("Close period {}", period.name));
for (account, side, amount) in closings { for (account, side, amount) in closings {
self.add_line(entry, account, side, Money::new(amount, self.base_currency))?; self.add_line(entry, account, side, Money::new(amount, self.base_currency))?;
} }
// Balance with Retained Earnings. // Profit credits equity; a loss debits it.
if debits > credits { for (account, share) in targets {
// Profit: credit RE. if share.is_zero() {
self.add_line( continue;
entry, }
re_id, let (side, amount) = if share > Decimal::ZERO {
Side::Credit, (Side::Credit, share)
Money::new(debits - credits, self.base_currency), } else {
)?; (Side::Debit, -share)
} else if credits > debits { };
// Loss: debit RE. self.add_line(entry, account, side, Money::new(amount, self.base_currency))?;
self.add_line(
entry,
re_id,
Side::Debit,
Money::new(credits - debits, self.base_currency),
)?;
} }
self.post_entry(entry)?; self.post_entry(entry)?;
Some(entry) Some(entry)
}; };
self.periods.get_mut(&id).expect("period exists").status = PeriodStatus::Closed; let p = self.periods.get_mut(&id).expect("period exists");
self.periods p.status = PeriodStatus::Closed;
.get_mut(&id) p.closing_entry = closing_entry;
.expect("period exists")
.closing_entry = closing_entry;
Ok(closing_entry) Ok(closing_entry)
} }
/// Members for a close: shares positive and summing to 1, capital
/// accounts distinct equity accounts.
fn validate_close_members(&self, members: &[(AccountId, Decimal)]) -> Result<(), LedgerError> {
let total: Decimal = members.iter().map(|(_, s)| *s).sum();
if members.is_empty()
|| total != Decimal::ONE
|| members.iter().any(|(_, s)| *s <= Decimal::ZERO)
{
return Err(LedgerError::InvalidMembers(format!(
"member shares must be positive and sum to 1, got {total}"
)));
}
let mut seen = std::collections::BTreeSet::new();
for (a, _) in members {
let account = self.chart.get(*a).ok_or(LedgerError::UnknownAccount(*a))?;
if account.account_type != AccountType::Equity || !seen.insert(*a) {
return Err(LedgerError::InvalidMembers(format!(
"capital account {} must be a distinct equity account",
account.code
)));
}
}
Ok(())
}
/// Debits minus credits on an account across posted entries dated on or
/// before `end`.
fn movement_through(&self, account: AccountId, end: NaiveDate) -> Decimal {
let mut total = Decimal::ZERO;
for entry in self.posted_entries().filter(|e| e.date() <= end) {
for line in entry.lines().iter().filter(|l| l.account == account) {
match line.side {
Side::Debit => total += line.amount.amount,
Side::Credit => total -= line.amount.amount,
}
}
}
total
}
/// Pure pre-close check for journaling stores: verifies that /// Pure pre-close check for journaling stores: verifies that
/// [`Self::close_period`] would succeed — the period exists and is open, /// [`Self::close_period`] would succeed — the period exists and is open,
/// no draft entries are dated inside it, and the closing entry's date /// no draft entries are dated inside it, and the closing entry's date
@@ -724,14 +771,23 @@ impl Ledger {
{ {
return Err(LedgerError::ClosedPeriod(other, period.end.to_string())); return Err(LedgerError::ClosedPeriod(other, period.end.to_string()));
} }
// Periods close in order: a later closed period already swept this
// one's income, so closing it now would sweep it again.
if let Some(later) = self
.periods
.values()
.find(|p| p.status == PeriodStatus::Closed && p.end > period.end)
{
return Err(LedgerError::CloseOutOfOrder(id, later.id));
}
// The close posts an entry exactly when some revenue or expense // The close posts an entry exactly when some revenue or expense
// account has non-zero movement — the same predicate close_period // account has a balance at period end — the same predicate
// uses to decide. The entry id is the next one the allocator hands // close_period_to uses to decide. The entry id is the next one the
// out; account creation (e.g. Retained Earnings) does not consume // allocator hands out; account creation (e.g. Retained Earnings)
// entry ids. // does not consume entry ids.
let any_movement = self.chart.all().any(|a| { let any_movement = self.chart.all().any(|a| {
matches!(a.account_type, AccountType::Revenue | AccountType::Expense) matches!(a.account_type, AccountType::Revenue | AccountType::Expense)
&& !self.account_movement(a.id).is_zero() && !self.movement_through(a.id, period.end).is_zero()
}); });
Ok(any_movement.then_some(self.next_entry_id)) Ok(any_movement.then_some(self.next_entry_id))
} }
+5 -3
View File
@@ -43,9 +43,11 @@ 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::{CloseTo, Period, PeriodId, PeriodStatus, RETAINED_EARNINGS_CODE};
pub use reports::{ pub use reports::{
BalanceSheet, BalanceSheetStatement, CashFlowLine, CashFlowParams, CashFlowStatement, BalanceSheet, BalanceSheetStatement, CashFlowLine, CashFlowParams, CashFlowStatement,
ClassProfitAndLoss, ProfitAndLoss, ProfitAndLossStatement, StatementLine, TrialBalance, ClassProfitAndLoss, GeneralLedger, LedgerAccount, LedgerPosting, MemberCapital,
TrialBalanceLine, balance_sheet_as_of, cash_flow, profit_and_loss_for_period, MemberEquityColumn, MembersEquityStatement, ProfitAndLoss, ProfitAndLossStatement,
StatementLine, TrialBalance, TrialBalanceLine, balance_sheet_as_of, cash_flow, general_ledger,
members_equity, profit_and_loss_for_period, trial_balance_as_of,
}; };
+34 -1
View File
@@ -29,6 +29,39 @@ pub enum PeriodStatus {
Closed, Closed,
} }
/// Where a period close puts the period's net income.
#[derive(Clone, Debug, PartialEq)]
pub enum CloseTo {
/// One Retained Earnings account ([`RETAINED_EARNINGS_CODE`]): the
/// corporate convention.
RetainedEarnings,
/// Members' capital accounts, by share (shares sum to 1): the
/// partnership convention, where each member's capital account carries
/// their allocated earnings.
Members(Vec<(crate::AccountId, rust_decimal::Decimal)>),
}
/// Split `amount` by `shares` to the cent; the last share absorbs rounding,
/// so the parts always sum to `amount` exactly.
pub fn allocate(
amount: rust_decimal::Decimal,
shares: &[rust_decimal::Decimal],
) -> Vec<rust_decimal::Decimal> {
use rust_decimal::RoundingStrategy::MidpointAwayFromZero;
let mut parts: Vec<_> = shares
.iter()
.map(|s| (amount * s).round_dp_with_strategy(2, MidpointAwayFromZero))
.collect();
if let Some((last, rest)) = parts.split_last_mut() {
*last = amount - rest.iter().copied().sum::<rust_decimal::Decimal>();
}
parts
}
/// 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 +163,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()
+388 -1
View File
@@ -4,6 +4,7 @@
//! (`docs/oracle.md`) diffs: the same fixture books through two engines must //! (`docs/oracle.md`) diffs: the same fixture books through two engines must
//! produce byte-identical [`snapshot`] output. //! produce byte-identical [`snapshot`] output.
use crate::periods::allocate;
use crate::{AccountType, Class, ClassId, JournalEntry, JournalLine, Ledger, LedgerError}; use crate::{AccountType, Class, ClassId, JournalEntry, JournalLine, Ledger, LedgerError};
use chrono::NaiveDate; use chrono::NaiveDate;
use rust_decimal::Decimal; use rust_decimal::Decimal;
@@ -44,6 +45,27 @@ impl TrialBalance {
/// Build the trial balance from all posted entries. /// Build the trial balance from all posted entries.
pub fn trial_balance(ledger: &Ledger) -> TrialBalance { pub fn trial_balance(ledger: &Ledger) -> TrialBalance {
trial_balance_where(ledger, &|_| true)
}
/// The trial balance as of a date: every posted entry dated on or before
/// it (closing entries included, so after a close it is the post-closing
/// trial balance).
pub fn trial_balance_as_of(ledger: &Ledger, as_of: NaiveDate) -> TrialBalance {
trial_balance_where(ledger, &|e| e.date() <= as_of)
}
fn trial_balance_where(ledger: &Ledger, include: &dyn Fn(&JournalEntry) -> bool) -> TrialBalance {
let mut movements: BTreeMap<crate::AccountId, Decimal> = BTreeMap::new();
for entry in ledger.posted_entries().filter(|e| include(e)) {
for line in entry.lines() {
let signed = match line.side {
crate::Side::Debit => line.amount.amount,
crate::Side::Credit => -line.amount.amount,
};
*movements.entry(line.account).or_default() += signed;
}
}
let mut accounts: Vec<_> = ledger.chart().all().collect(); let mut accounts: Vec<_> = ledger.chart().all().collect();
accounts.sort_by(|a, b| a.code.cmp(&b.code)); accounts.sort_by(|a, b| a.code.cmp(&b.code));
@@ -54,7 +76,7 @@ pub fn trial_balance(ledger: &Ledger) -> TrialBalance {
if account.placeholder { if account.placeholder {
continue; continue;
} }
let movement = ledger.account_movement(account.id); let movement = movements.get(&account.id).copied().unwrap_or_default();
if movement.is_zero() { if movement.is_zero() {
continue; continue;
} }
@@ -460,6 +482,363 @@ pub fn balance_sheet_as_of(
}) })
} }
// ---------------------------------------------------------------------------
// General ledger (by account)
// ---------------------------------------------------------------------------
/// One posting in the general ledger: a journal line, seen from its account.
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct LedgerPosting {
/// Entry date.
pub date: NaiveDate,
/// The journal entry: its other lines are the other side.
pub entry_id: crate::EntryId,
/// Entry description.
pub description: String,
/// The other side: `"CODE Name"` of the one account on the entry's
/// opposite side, or `"-Split-"` when that side is several accounts
/// (the QuickBooks convention).
pub split: String,
/// Debit amount, or zero.
pub debit: Decimal,
/// Credit amount, or zero.
pub credit: Decimal,
/// Running balance after this posting, on the account's normal side.
pub balance: Decimal,
}
/// One account's section of the general ledger.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct LedgerAccount {
/// Account code.
pub code: String,
/// Account name.
pub name: String,
/// Account type.
pub account_type: AccountType,
/// Balance before the period, normal side.
pub beginning_balance: Decimal,
/// Postings in the period, by date then entry.
pub postings: Vec<LedgerPosting>,
/// Sum of the period's debits.
pub total_debits: Decimal,
/// Sum of the period's credits.
pub total_credits: Decimal,
/// Balance at period end, normal side. Ties to the trial balance.
pub ending_balance: Decimal,
}
/// The general ledger for a period: every account with a beginning balance
/// or activity, in chart (code) order.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct GeneralLedger {
/// First date, inclusive.
pub start: NaiveDate,
/// Last date, inclusive.
pub end: NaiveDate,
/// Accounts by code.
pub accounts: Vec<LedgerAccount>,
}
/// Build the general ledger for `[start, end]`. Every journal line is a
/// posting to its account, with the entry's other side as the split, and a
/// running balance from the account's balance before `start`. Closing
/// entries are postings like any other.
pub fn general_ledger(
ledger: &Ledger,
start: NaiveDate,
end: NaiveDate,
) -> Result<GeneralLedger, LedgerError> {
if end < start {
return Err(LedgerError::InvalidDateRange {
start: start.to_string(),
end: end.to_string(),
});
}
let display = |id: crate::AccountId| -> Result<String, LedgerError> {
let a = ledger
.chart()
.get(id)
.ok_or(LedgerError::UnknownAccount(id))?;
Ok(format!("{} {}", a.code, a.name))
};
let mut entries: Vec<&JournalEntry> = ledger.posted_entries().collect();
entries.sort_by_key(|e| (e.date(), e.id()));
let mut begin: BTreeMap<crate::AccountId, Decimal> = BTreeMap::new();
let mut postings: BTreeMap<crate::AccountId, Vec<(crate::Side, Decimal, LedgerPosting)>> =
BTreeMap::new();
for entry in &entries {
let lines = entry.lines();
for (i, line) in lines.iter().enumerate() {
let signed = match line.side {
crate::Side::Debit => line.amount.amount,
crate::Side::Credit => -line.amount.amount,
};
if entry.date() < start {
*begin.entry(line.account).or_default() += signed;
continue;
}
if entry.date() > end {
continue;
}
// The other side is the accounts on the opposite side of the
// entry: a credit's split is what was debited, and vice versa.
let others: BTreeSet<crate::AccountId> = lines
.iter()
.enumerate()
.filter(|(j, l)| *j != i && l.side != line.side)
.map(|(_, l)| l.account)
.collect();
let split = match others.len() {
1 => display(*others.iter().next().expect("one"))?,
_ => "-Split-".to_string(),
};
let (debit, credit) = match line.side {
crate::Side::Debit => (line.amount.amount, Decimal::ZERO),
crate::Side::Credit => (Decimal::ZERO, line.amount.amount),
};
postings.entry(line.account).or_default().push((
line.side,
line.amount.amount,
LedgerPosting {
date: entry.date(),
entry_id: entry.id(),
description: entry.description().to_string(),
split,
debit,
credit,
balance: Decimal::ZERO,
},
));
}
}
let mut accounts: Vec<_> = ledger.chart().all().collect();
accounts.sort_by(|a, b| a.code.cmp(&b.code));
let mut out = Vec::new();
for account in accounts {
let begin_movement = begin.get(&account.id).copied().unwrap_or_default();
let lines = postings.remove(&account.id).unwrap_or_default();
if begin_movement.is_zero() && lines.is_empty() {
continue;
}
let normal = account.account_type.normal_side();
let to_normal = |m: Decimal| if normal == crate::Side::Debit { m } else { -m };
let beginning_balance = to_normal(begin_movement) + Decimal::ZERO;
let mut balance = beginning_balance;
let (mut total_debits, mut total_credits) = (Decimal::ZERO, Decimal::ZERO);
let mut posted = Vec::with_capacity(lines.len());
for (side, amount, mut p) in lines {
balance += if side == normal { amount } else { -amount };
total_debits += p.debit;
total_credits += p.credit;
p.balance = balance;
posted.push(p);
}
out.push(LedgerAccount {
code: account.code.clone(),
name: account.name.clone(),
account_type: account.account_type,
beginning_balance: zero_clean(beginning_balance),
postings: posted,
total_debits,
total_credits,
ending_balance: zero_clean(balance),
});
}
Ok(GeneralLedger {
start,
end,
accounts: out,
})
}
/// Never let a negative zero reach a report.
fn zero_clean(d: Decimal) -> Decimal {
if d.is_zero() { Decimal::ZERO } else { d }
}
// ---------------------------------------------------------------------------
// Statement of changes in members' equity (partnership capital accounts)
// ---------------------------------------------------------------------------
/// A member of a partnership (multi-member LLC): their capital account and
/// their share of income.
#[derive(Clone, Debug)]
pub struct MemberCapital {
/// Member's name.
pub name: String,
/// Their capital account (equity): contributions credit it,
/// distributions debit it.
pub capital_account: crate::AccountId,
/// Share of income, e.g. 0.5. The shares must sum to exactly 1.
pub share: Decimal,
}
/// One member's (or the total) capital roll-forward for a period.
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct MemberEquityColumn {
/// Member's name, or "Total".
pub name: String,
/// Capital at the start of the period, including their share of
/// earnings not yet distributed.
pub beginning: Decimal,
/// Contributions during the period.
pub contributions: Decimal,
/// Their share of the period's net income (loss).
pub net_income: Decimal,
/// Distributions during the period (a positive number, subtracted).
pub distributions: Decimal,
/// Any other change in equity, allocated by share. Zero unless equity
/// moved through an account that isn't a member's capital account, retained
/// earnings, or the period's income.
pub other: Decimal,
/// Capital at the end of the period.
pub ending: Decimal,
}
/// The statement of changes in members' equity: each member's capital
/// account roll-forward, plus a total that ties to the balance sheet.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct MembersEquityStatement {
/// First date, inclusive.
pub start: NaiveDate,
/// Last date, inclusive.
pub end: NaiveDate,
/// One column per member, in the order given.
pub members: Vec<MemberEquityColumn>,
/// Sum of the members. `ending` equals total equity on the balance
/// sheet as of `end`.
pub total: MemberEquityColumn,
}
/// Build the statement of changes in members' equity for `[start, end]`.
///
/// Each member's capital account supplies contributions (credits) and
/// distributions (debits). Earnings are partnership earnings: net income
/// for the period, and earnings from before it not yet distributed (retained
/// earnings plus unclosed income), are allocated by share. Allocation is to
/// the cent with the last member absorbing rounding, so every row's total
/// is exact and the ending total equals balance-sheet equity.
pub fn members_equity(
ledger: &Ledger,
start: NaiveDate,
end: NaiveDate,
members: &[MemberCapital],
) -> Result<MembersEquityStatement, LedgerError> {
if members.is_empty() {
return Err(LedgerError::InvalidMembers("no members given".to_string()));
}
let shares: Vec<Decimal> = members.iter().map(|m| m.share).collect();
let total_share: Decimal = shares.iter().sum();
if total_share != Decimal::ONE || shares.iter().any(|s| *s <= Decimal::ZERO) {
return Err(LedgerError::InvalidMembers(format!(
"member shares must be positive and sum to 1, got {total_share}"
)));
}
let mut seen = BTreeSet::new();
for m in members {
let account = ledger
.chart()
.get(m.capital_account)
.ok_or(LedgerError::UnknownAccount(m.capital_account))?;
if account.account_type != AccountType::Equity || !seen.insert(m.capital_account) {
return Err(LedgerError::InvalidMembers(format!(
"{}: capital account {} must be a distinct equity account",
m.name, account.code
)));
}
}
let day_before = start.pred_opt().ok_or(LedgerError::InvalidDateRange {
start: start.to_string(),
end: end.to_string(),
})?;
let pnl = profit_and_loss_for_period(ledger, start, end, None)?;
let begin_bs = balance_sheet_as_of(ledger, day_before)?;
let end_bs = balance_sheet_as_of(ledger, end)?;
// Capital account balances before the period, and their movement in it.
let capital_before = |id: crate::AccountId| -> Decimal {
let mut m = Decimal::ZERO;
for e in ledger.posted_entries().filter(|e| e.date() < start) {
for l in e.lines().iter().filter(|l| l.account == id) {
match l.side {
crate::Side::Credit => m += l.amount.amount,
crate::Side::Debit => m -= l.amount.amount,
}
}
}
m
};
// Contributions and distributions move a capital account; a closing
// entry allocating earnings into it is income, already counted below.
let closings = ledger.closing_entry_ids();
let flows = |id: crate::AccountId| -> (Decimal, Decimal) {
let (mut credits, mut debits) = (Decimal::ZERO, Decimal::ZERO);
for e in ledger
.posted_entries()
.filter(|e| e.date() >= start && e.date() <= end && !closings.contains(&e.id()))
{
for l in e.lines().iter().filter(|l| l.account == id) {
match l.side {
crate::Side::Credit => credits += l.amount.amount,
crate::Side::Debit => debits += l.amount.amount,
}
}
}
(credits, debits)
};
let capital: Vec<Decimal> = members
.iter()
.map(|m| capital_before(m.capital_account))
.collect();
let flow: Vec<(Decimal, Decimal)> = members.iter().map(|m| flows(m.capital_account)).collect();
// Everything in equity that isn't a capital account is partnership
// earnings not yet distributed: allocate it by share.
let undistributed = begin_bs.total_equity - capital.iter().sum::<Decimal>();
let begin_alloc = allocate(undistributed, &shares);
let income_alloc = allocate(pnl.net_income(), &shares);
let explained = flow.iter().map(|(c, d)| c - d).sum::<Decimal>() + pnl.net_income();
let other_alloc = allocate(
end_bs.total_equity - begin_bs.total_equity - explained,
&shares,
);
let mut cols = Vec::with_capacity(members.len());
for (i, m) in members.iter().enumerate() {
let beginning = capital[i] + begin_alloc[i];
let (contributions, distributions) = flow[i];
let ending = beginning + contributions - distributions + income_alloc[i] + other_alloc[i];
cols.push(MemberEquityColumn {
name: m.name.clone(),
beginning: zero_clean(beginning),
contributions,
net_income: zero_clean(income_alloc[i]),
distributions,
other: zero_clean(other_alloc[i]),
ending: zero_clean(ending),
});
}
let sum = |f: fn(&MemberEquityColumn) -> Decimal| cols.iter().map(f).sum::<Decimal>();
let total = MemberEquityColumn {
name: "Total".to_string(),
beginning: sum(|c| c.beginning),
contributions: sum(|c| c.contributions),
net_income: sum(|c| c.net_income),
distributions: sum(|c| c.distributions),
other: sum(|c| c.other),
ending: sum(|c| c.ending),
};
debug_assert_eq!(total.ending, end_bs.total_equity);
Ok(MembersEquityStatement {
start,
end,
members: cols,
total,
})
}
/// Render the three core reports as canonical JSON for oracle diffing. /// Render the three core reports as canonical JSON for oracle diffing.
/// ///
/// Amounts are decimal strings (never floats, never locale-formatted), lines /// Amounts are decimal strings (never floats, never locale-formatted), lines
@@ -507,6 +886,10 @@ pub struct CashFlowLine {
pub label: String, pub label: String,
/// Signed amount: positive = cash in, negative = cash out. /// Signed amount: positive = cash in, negative = cash out.
pub amount: Decimal, pub amount: Decimal,
/// Code of the account the line is about, when it is about one (so a
/// renderer can relabel, e.g. a member's capital account by name).
#[serde(default)]
pub account: Option<String>,
} }
/// Parameters for [`cash_flow`]. /// Parameters for [`cash_flow`].
@@ -734,6 +1117,7 @@ pub fn cash_flow(
target.push(CashFlowLine { target.push(CashFlowLine {
label, label,
amount: line, amount: line,
account: Some(account.code.clone()),
}); });
} }
@@ -796,6 +1180,7 @@ pub fn cash_flow(
investing.push(CashFlowLine { investing.push(CashFlowLine {
label: format!("Sale of fixed assets — {proceeds_label}"), label: format!("Sale of fixed assets — {proceeds_label}"),
amount: proceeds, amount: proceeds,
account: ledger.chart().get(cost_acct).map(|a| a.code.clone()),
}); });
continue; continue;
} }
@@ -805,6 +1190,7 @@ pub fn cash_flow(
investing.push(CashFlowLine { investing.push(CashFlowLine {
label: format!("Purchase of fixed assets — {}", display(line.account)), label: format!("Purchase of fixed assets — {}", display(line.account)),
amount: -line.amount.amount, amount: -line.amount.amount,
account: ledger.chart().get(line.account).map(|a| a.code.clone()),
}); });
} }
} }
@@ -815,6 +1201,7 @@ pub fn cash_flow(
working_capital.push(CashFlowLine { working_capital.push(CashFlowLine {
label: "Less: gain on sale of fixed assets (in investing)".to_string(), label: "Less: gain on sale of fixed assets (in investing)".to_string(),
amount: -disposal_gain_loss, amount: -disposal_gain_loss,
account: None,
}); });
} }
+402 -2
View File
@@ -18,10 +18,11 @@ use ledger_core::reports::{
balance_sheet, balance_sheet_as_of, profit_and_loss, profit_and_loss_for_period, balance_sheet, balance_sheet_as_of, profit_and_loss, profit_and_loss_for_period,
}; };
use ledger_core::{ use ledger_core::{
AccountId, AccountType, CashFlowParams, ClassId, Currency, Ledger, Money, Side, StatementLine, AccountId, AccountType, CashFlowParams, ClassId, Currency, Ledger, LedgerError, MemberCapital,
cash_flow, Money, Side, StatementLine, cash_flow, general_ledger, members_equity, trial_balance_as_of,
}; };
use rust_decimal::Decimal; use rust_decimal::Decimal;
use std::str::FromStr;
fn d(m: u32, day: u32) -> NaiveDate { fn d(m: u32, day: u32) -> NaiveDate {
NaiveDate::from_ymd_opt(2026, m, day).unwrap() NaiveDate::from_ymd_opt(2026, m, day).unwrap()
@@ -265,3 +266,402 @@ fn cash_flow_lines_are_in_code_order() {
); );
} }
} }
fn cents(s: &str) -> Decimal {
Decimal::from_str(s).unwrap()
}
#[test]
fn trial_balance_is_as_of_the_date() {
let b = books(false);
// Jan 31: Dr cash 11,700 and rent 800; Cr capital 10,000 and revenue 2,500.
let tb = trial_balance_as_of(&b.ledger, d(1, 31));
let rows: Vec<(&str, Decimal, Decimal)> = tb
.lines
.iter()
.map(|l| (l.code.as_str(), l.debit, l.credit))
.collect();
assert_eq!(
rows,
[
("1000", dec(11_700), dec(0)),
("3000", dec(0), dec(10_000)),
("4000", dec(0), dec(2_500)),
("6100", dec(800), dec(0)),
]
);
assert_eq!(
(tb.total_debits, tb.total_credits),
(dec(12_500), dec(12_500))
);
}
#[test]
fn general_ledger_runs_balances_by_account() {
let b = books(false);
let gl = general_ledger(&b.ledger, d(2, 1), d(2, 28)).unwrap();
let codes: Vec<&str> = gl.accounts.iter().map(|a| a.code.as_str()).collect();
// Every account with a beginning balance or February activity, by code.
assert_eq!(codes, ["1000", "2000", "3000", "4000", "6000", "6100"]);
let cash = &gl.accounts[0];
assert_eq!(cash.beginning_balance, dec(11_700));
let p: Vec<(Decimal, Decimal, &str, Decimal)> = cash
.postings
.iter()
.map(|p| (p.debit, p.credit, p.split.as_str(), p.balance))
.collect();
assert_eq!(
p,
[
(dec(1_000), dec(0), "4000 Revenue", dec(12_700)),
(dec(0), dec(120), "2000 Card", dec(12_580)),
]
);
assert_eq!(cash.ending_balance, dec(12_580));
// The card: borrowed 120, paid 120, back to zero (credit-normal).
let card = &gl.accounts[1];
assert_eq!(card.beginning_balance, dec(0));
assert_eq!(card.postings[0].balance, dec(120));
assert_eq!(card.ending_balance, dec(0));
// Capital: no February activity, still listed with its balance.
assert!(gl.accounts[2].postings.is_empty());
assert_eq!(gl.accounts[2].ending_balance, dec(10_000));
// Every ending balance ties to the trial balance as of the end.
let tb = trial_balance_as_of(&b.ledger, d(2, 28));
for a in &gl.accounts {
let tb_line = tb.lines.iter().find(|l| l.code == a.code);
let tb_amount = tb_line.map_or(Decimal::ZERO, |l| l.debit - l.credit);
let normal = match a.account_type {
AccountType::Asset | AccountType::Expense => a.ending_balance,
_ => -a.ending_balance,
};
assert_eq!(tb_amount, normal, "account {}", a.code);
}
}
#[test]
fn general_ledger_marks_compound_entries_as_split() {
let mut l = Ledger::new(Currency::USD);
let cash = l
.add_account("1000", "Cash", AccountType::Asset, None, false)
.unwrap();
let card = l
.add_account("2000", "Card", AccountType::Liability, None, false)
.unwrap();
let soft = l
.add_account("6000", "Software", AccountType::Expense, None, false)
.unwrap();
// Dr software 100 / Cr cash 60 / Cr card 40: one debit, split credits.
let e = l.create_entry(d(3, 1), "Annual license, paid two ways");
l.add_line(e, soft, Side::Debit, usd(100)).unwrap();
l.add_line(e, cash, Side::Credit, usd(60)).unwrap();
l.add_line(e, card, Side::Credit, usd(40)).unwrap();
l.post_entry(e).unwrap();
let gl = general_ledger(&l, d(1, 1), d(12, 31)).unwrap();
let split_of = |code: &str| {
let a = gl.accounts.iter().find(|a| a.code == code).unwrap();
(a.postings[0].split.clone(), a.postings[0].entry_id)
};
// The debit side has two other accounts; each credit has one.
assert_eq!(split_of("6000"), ("-Split-".to_string(), e));
assert_eq!(split_of("1000"), ("6000 Software".to_string(), e));
assert_eq!(split_of("2000"), ("6000 Software".to_string(), e));
}
/// Two members, 50/50: Alice and Bob. January: Alice puts in 600, Bob 400;
/// revenue 1,000; expense 333.33, so net income 666.67, which doesn't split
/// evenly. February: Bob takes a 100 distribution; revenue 100.01.
fn partnership(close_january: bool) -> (Ledger, Vec<MemberCapital>) {
let mut l = Ledger::new(Currency::USD);
let cash = l
.add_account("1000", "Cash", AccountType::Asset, None, false)
.unwrap();
let alice = l
.add_account("3000", "Capital — Alice", AccountType::Equity, None, false)
.unwrap();
let bob = l
.add_account("3001", "Capital — Bob", AccountType::Equity, None, false)
.unwrap();
let rev = l
.add_account("4000", "Revenue", AccountType::Revenue, None, false)
.unwrap();
let exp = l
.add_account("6000", "Expense", AccountType::Expense, None, false)
.unwrap();
let money = |s: &str| Money::new(cents(s), Currency::USD);
let post = |l: &mut Ledger, date, dr, cr, amt: &str| {
let e = l.create_entry(date, "x");
l.add_line(e, dr, Side::Debit, money(amt)).unwrap();
l.add_line(e, cr, Side::Credit, money(amt)).unwrap();
l.post_entry(e).unwrap();
};
post(&mut l, d(1, 2), cash, alice, "600");
post(&mut l, d(1, 2), cash, bob, "400");
post(&mut l, d(1, 10), cash, rev, "1000");
post(&mut l, d(1, 20), exp, cash, "333.33");
if close_january {
let jan = l.create_period("2026-01", d(1, 1), d(1, 31)).unwrap();
l.close_period(jan).unwrap();
}
post(&mut l, d(2, 5), bob, cash, "100");
post(&mut l, d(2, 9), cash, rev, "100.01");
let half = cents("0.5");
let members = vec![
MemberCapital {
name: "Alice".into(),
capital_account: alice,
share: half,
},
MemberCapital {
name: "Bob".into(),
capital_account: bob,
share: half,
},
];
(l, members)
}
#[test]
fn members_equity_rolls_capital_forward_and_ties_to_the_balance_sheet() {
for close in [false, true] {
let (l, members) = partnership(close);
// January: 666.67 splits 333.34 / 333.33 (half a cent rounds up for
// Alice; Bob gets the remainder so the total is exact).
let jan = members_equity(&l, d(1, 1), d(1, 31), &members).unwrap();
let row = |c: &ledger_core::MemberEquityColumn| {
(
c.beginning,
c.contributions,
c.net_income,
c.distributions,
c.ending,
)
};
assert_eq!(
row(&jan.members[0]),
(
cents("0"),
cents("600"),
cents("333.34"),
cents("0"),
cents("933.34")
)
);
assert_eq!(
row(&jan.members[1]),
(
cents("0"),
cents("400"),
cents("333.33"),
cents("0"),
cents("733.33")
)
);
assert_eq!(jan.total.ending, cents("1666.67"));
assert_eq!(
jan.total.ending,
balance_sheet_as_of(&l, d(1, 31)).unwrap().total_equity
);
// February begins where January ended, whether or not January was
// closed to retained earnings. 100.01 splits 50.01 / 50.00.
let feb = members_equity(&l, d(2, 1), d(2, 28), &members).unwrap();
assert_eq!(
row(&feb.members[0]),
(
cents("933.34"),
cents("0"),
cents("50.01"),
cents("0"),
cents("983.35")
)
);
assert_eq!(
row(&feb.members[1]),
(
cents("733.33"),
cents("0"),
cents("50.00"),
cents("100"),
cents("683.33")
)
);
assert_eq!(feb.total.ending, cents("1666.68"));
assert_eq!(
feb.total.ending,
balance_sheet_as_of(&l, d(2, 28)).unwrap().total_equity
);
assert!(
feb.members.iter().all(|m| m.other.is_zero()),
"close={close}"
);
}
}
#[test]
fn members_must_be_configured_correctly() {
let (l, mut members) = partnership(false);
members[1].share = cents("0.4");
assert!(matches!(
members_equity(&l, d(1, 1), d(1, 31), &members),
Err(LedgerError::InvalidMembers(_))
));
members[1].share = cents("0.5");
members[1].capital_account = members[0].capital_account;
assert!(matches!(
members_equity(&l, d(1, 1), d(1, 31), &members),
Err(LedgerError::InvalidMembers(_))
));
}
#[test]
fn cash_flow_lines_name_their_account() {
let (l, _) = partnership(false);
let cf = cash_flow(
&l,
&CashFlowParams {
start: d(1, 1),
end: d(12, 31),
cash_accounts: vec![1],
financing_accounts: vec![],
},
)
.unwrap();
let accounts: Vec<Option<&str>> = cf.financing.iter().map(|f| f.account.as_deref()).collect();
assert_eq!(accounts, [Some("3000"), Some("3001")]);
}
#[test]
fn a_close_sweeps_only_through_the_period_end() {
// February is already posted when January closes: the close must take
// January's 2,500 revenue and 800 rent (net 1,700) and leave
// February's 1,000 revenue and 120 software open.
let mut b = books(false);
let jan = b
.ledger
.create_period("2026-01", d(1, 1), d(1, 31))
.unwrap();
let closing = b.ledger.close_period(jan).unwrap().unwrap();
let lines: Vec<(String, Side, Decimal)> = b
.ledger
.get_entry(closing)
.unwrap()
.lines()
.iter()
.map(|l| {
(
b.ledger.chart().get(l.account).unwrap().code.clone(),
l.side,
l.amount.amount,
)
})
.collect();
assert_eq!(
lines,
[
("4000".to_string(), Side::Debit, dec(2_500)),
("6100".to_string(), Side::Credit, dec(800)),
("3999".to_string(), Side::Credit, dec(1_700)),
]
);
let feb = profit_and_loss_for_period(&b.ledger, d(2, 1), d(2, 28), None).unwrap();
assert_eq!(feb.net_income(), dec(880));
let bs = balance_sheet_as_of(&b.ledger, d(2, 28)).unwrap();
assert_eq!(bs.current_earnings, dec(880));
assert_eq!(
bs.equity.last().unwrap(),
&line("3999", "Retained Earnings", 1_700)
);
assert!(bs.balanced());
}
#[test]
fn periods_close_in_order() {
let mut b = books(false);
let jan = b
.ledger
.create_period("2026-01", d(1, 1), d(1, 31))
.unwrap();
let feb = b
.ledger
.create_period("2026-02", d(2, 1), d(2, 28))
.unwrap();
b.ledger.close_period(feb).unwrap();
// February swept everything through Feb 28, January included.
assert!(matches!(
b.ledger.close_period(jan),
Err(LedgerError::CloseOutOfOrder(j, f)) if j == jan && f == feb
));
}
#[test]
fn a_partnership_closes_into_members_capital() {
let (mut l, members) = partnership(false);
let to = ledger_core::CloseTo::Members(
members
.iter()
.map(|m| (m.capital_account, m.share))
.collect(),
);
let jan = l.create_period("2026-01", d(1, 1), d(1, 31)).unwrap();
l.close_period_to(jan, &to).unwrap();
// January earned 666.67: Alice's capital gets 333.34, Bob's 333.33,
// and there is no Retained Earnings account at all.
assert!(l.chart().find_by_code("3999").is_none());
let bs = balance_sheet_as_of(&l, d(1, 31)).unwrap();
let capital: Vec<Decimal> = bs.equity.iter().map(|e| e.amount).collect();
assert_eq!(capital, [cents("933.34"), cents("733.33")]);
assert_eq!(bs.current_earnings, Decimal::ZERO);
// The statement still reads: contributions are contributions (the
// close is income, not a contribution), and February starts where
// January ended.
let row = |c: &ledger_core::MemberEquityColumn| {
(
c.beginning,
c.contributions,
c.net_income,
c.distributions,
c.ending,
)
};
let jan_me = members_equity(&l, d(1, 1), d(1, 31), &members).unwrap();
assert_eq!(
row(&jan_me.members[0]),
(
cents("0"),
cents("600"),
cents("333.34"),
cents("0"),
cents("933.34")
)
);
assert_eq!(
row(&jan_me.members[1]),
(
cents("0"),
cents("400"),
cents("333.33"),
cents("0"),
cents("733.33")
)
);
let feb_me = members_equity(&l, d(2, 1), d(2, 28), &members).unwrap();
assert_eq!(feb_me.members[0].beginning, cents("933.34"));
assert_eq!(feb_me.members[1].ending, cents("683.33"));
assert_eq!(
feb_me.total.ending,
balance_sheet_as_of(&l, d(2, 28)).unwrap().total_equity
);
// Shares must still add up.
let feb = l.create_period("2026-02", d(2, 1), d(2, 28)).unwrap();
let bad = ledger_core::CloseTo::Members(vec![(members[0].capital_account, cents("0.5"))]);
assert!(matches!(
l.close_period_to(feb, &bad),
Err(LedgerError::InvalidMembers(_))
));
}
-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, YearEarnings, 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,
}) })
+424 -188
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,322 @@ 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 cash flow statement (indirect method). /// The fiscal year's earnings, as the balance sheet presents them.
pub fn cash_flow(cf: &CashFlowStatement) -> Statement { #[derive(Clone, Copy, Debug)]
let mut rows = vec![section("Operating activities")]; pub struct YearEarnings {
rows.push(row(RowKind::Line, "Net income", Some(cf.net_income))); /// Net income (loss) for the fiscal year through the as-of date.
if !cf.depreciation.is_zero() { pub net_income_ytd: Decimal,
rows.push(row( /// The part of it not yet closed into equity accounts.
pub unclosed: Decimal,
}
/// Lay out a balance sheet.
///
/// Without members (corporate convention), equity ends with "Retained
/// earnings" — the Retained Earnings account plus earlier years' earnings
/// not yet closed — and the fiscal year's "Net income (loss)".
///
/// With members (a partnership), each member's capital account already
/// carries the earnings closed into it, so equity ends with only what
/// hasn't been allocated: "Undistributed earnings, prior years" (if any)
/// and this year's unclosed "Net income (loss)". Equity is "Members'
/// equity".
pub fn balance_sheet(
bs: &BalanceSheetStatement,
earnings: YearEarnings,
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));
if members.is_empty() {
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
- earnings.net_income_ytd;
if !retained.is_zero() {
rows.push(one(RowKind::Line, "Retained earnings", retained));
}
rows.push(one(
RowKind::Line, RowKind::Line,
"Depreciation (non-cash)", "Net income (loss)",
Some(cf.depreciation), earnings.net_income_ytd,
));
} else {
rows.extend(account_lines(&bs.equity));
let prior = bs.current_earnings - earnings.unclosed;
if !prior.is_zero() {
rows.push(one(
RowKind::Line,
"Undistributed earnings, prior years",
prior,
));
}
rows.push(one(RowKind::Line, "Net income (loss)", earnings.unclosed));
}
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, members: &[Member]) -> Statement {
let mut rows = vec![section("Operating activities")];
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)); 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 +407,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 +434,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 +479,135 @@ 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", fn sl(code: &str, name: &str, a: &str) -> StatementLine {
String::new(), StatementLine {
vec![ code: code.into(),
section("Investing"), name: name.into(),
row(RowKind::Subtotal, "t", Some(dec("0"))), amount: dec(a),
}
}
fn equity_rows(st: &Statement, from: &str) -> Vec<(String, Option<Decimal>)> {
st.rows
.iter()
.skip_while(|r| r.label != from)
.map(|r| (r.label.clone(), r.amounts.first().copied().flatten()))
.collect()
}
#[test]
fn corporate_balance_sheet_splits_retained_earnings_from_this_year() {
// 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![sl("1000", "Cash", "2500")],
liabilities: vec![],
equity: vec![
sl("3000", "Common Stock", "1000"),
sl("3999", "Retained Earnings", "1000"),
], ],
current_earnings: dec("500"),
total_assets: dec("2500"),
total_liabilities: dec("0"),
total_equity: dec("2500"),
};
let e = YearEarnings {
net_income_ytd: dec("300"),
unclosed: dec("300"),
};
let st = balance_sheet(&bs, e, &[]);
let some = |s: &str| Some(dec(s));
assert_eq!(
equity_rows(&st, "Equity"),
[
("Equity".into(), None),
("3000 Common Stock".into(), some("1000")),
("Retained earnings".into(), some("1200")),
("Net income (loss)".into(), some("300")),
("Total equity".into(), some("2500")),
("".into(), None),
("Total liabilities and equity".into(), some("2500")),
]
);
}
#[test]
fn partnership_balance_sheet_shows_only_unallocated_earnings() {
// January (100) was closed into the capital accounts; February and
// March (200) are this year's unclosed income; 50 from last year
// was never closed.
let bs = BalanceSheetStatement {
as_of: NaiveDate::from_ymd_opt(2027, 3, 31).unwrap(),
assets: vec![sl("1000", "Cash", "1350")],
liabilities: vec![],
equity: vec![
sl("3000", "Capital — Alice", "550"),
sl("3001", "Capital — Bob", "550"),
],
current_earnings: dec("250"),
total_assets: dec("1350"),
total_liabilities: dec("0"),
total_equity: dec("1350"),
};
let e = YearEarnings {
net_income_ytd: dec("300"),
unclosed: dec("200"),
};
let members = [
Member {
name: "Alice".into(),
capital_code: "3000".into(),
},
Member {
name: "Bob".into(),
capital_code: "3001".into(),
},
];
let st = balance_sheet(&bs, e, &members);
let some = |s: &str| Some(dec(s));
assert_eq!(
equity_rows(&st, "Members' equity"),
[
("Members' equity".into(), None),
("3000 Capital — Alice".into(), some("550")),
("3001 Capital — Bob".into(), some("550")),
("Undistributed earnings, prior years".into(), some("50")),
("Net income (loss)".into(), some("200")),
("Total members' equity".into(), some("1350")),
("".into(), None),
("Total liabilities and members' equity".into(), some("1350")),
]
); );
assert!(!empty.rows[1].rule_above);
} }
#[test] #[test]
@@ -395,5 +621,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());
+354 -104
View File
@@ -68,10 +68,82 @@ 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()
}
/// The fiscal year's earnings through `as_of`, for the balance sheet:
/// net income (loss) year to date (closing entries excluded), and the
/// part of it still unclosed (closing entries included, so months
/// already closed into equity drop out).
fn year_earnings(
&self,
ledger: &Ledger,
as_of: NaiveDate,
) -> Result<ledger_import::YearEarnings, String> {
let (fy_start, _) = self.company.fiscal_year_containing(as_of);
let net_income_ytd = ledger_core::profit_and_loss_for_period(ledger, fy_start, as_of, None)
.map_err(|e| e.to_string())?
.net_income();
let mut unclosed = Decimal::ZERO;
for a in ledger.chart().all().filter(|a| !a.placeholder) {
match a.account_type {
AccountType::Revenue => unclosed += movement_in(ledger, a.id, fy_start, as_of),
AccountType::Expense => unclosed -= movement_in(ledger, a.id, fy_start, as_of),
_ => {}
}
}
Ok(ledger_import::YearEarnings {
net_income_ytd,
unclosed,
})
}
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 +415,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 +544,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 +650,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 +1243,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 +1467,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 +1506,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 +1536,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 +1544,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 earnings = self.year_earnings(ledger, bs.as_of)?;
p.start.as_deref(), report::balance_sheet(&bs, earnings, &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 +1587,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 +1726,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()
@@ -1853,7 +1969,7 @@ impl BooksServer {
#[tool( #[tool(
name = "books.close_period", name = "books.close_period",
description = "Close an accounting period. Without confirmed=true, returns a preview of the closing entry (revenue/expense zeroed to Retained Earnings) and closes nothing. With confirmed=true, posts the closing entry and marks the period closed. Closed periods reject new entries." description = "Close an accounting period. The closing entry, dated at period end, zeroes revenue and expense balances as of that date into Retained Earnings, or, for a multi-member LLC (members in wanderbooks.toml), into each member's capital account by share. Periods close in order. Without confirmed=true, returns the exact closing entry it would post and closes nothing. With confirmed=true, posts it and marks the period closed. Closed periods reject new entries."
)] )]
async fn close_period( async fn close_period(
&self, &self,
@@ -1865,53 +1981,53 @@ impl BooksServer {
if store.ledger().get_period(p.period_id).is_none() { if store.ledger().get_period(p.period_id).is_none() {
return Err(format!("unknown period {}", p.period_id)); return Err(format!("unknown period {}", p.period_id));
} }
// A partnership closes into its members' capital accounts by share;
// everyone else into Retained Earnings.
let to = if self.company.members.is_empty() {
ledger_core::CloseTo::RetainedEarnings
} else {
ledger_core::CloseTo::Members(
self.member_capitals(store.ledger())?
.into_iter()
.map(|m| (m.capital_account, m.share))
.collect(),
)
};
let describe = |ledger: &Ledger, eid: Option<ledger_core::EntryId>| -> Vec<String> {
let Some(entry) = eid.and_then(|e| ledger.get_entry(e)) else {
return Vec::new();
};
entry
.lines()
.iter()
.map(|line| {
let code = ledger
.chart()
.get(line.account)
.map(|a| a.code.clone())
.unwrap_or_default();
format!("{:?} {} {}", line.side, code, money(line.amount.amount))
})
.collect()
};
if !p.confirmed { if !p.confirmed {
// Preview: compute what the closing entry would look like // Preview: run the real close on a copy of the ledger, so the
// without mutating. We do this by inspecting revenue/expense // preview is exactly what confirming would post.
// balances directly. let mut scratch = store.ledger().clone();
let mut lines = Vec::new(); let closing = scratch
let mut accounts: Vec<_> = store.ledger().chart().all().collect(); .close_period_to(p.period_id, &to)
accounts.sort_by(|a, b| a.code.cmp(&b.code)); .map_err(|e| e.to_string())?;
for account in accounts {
match account.account_type {
AccountType::Revenue | AccountType::Expense => {
let movement = store.ledger().account_movement(account.id);
if movement.is_zero() {
continue;
}
let (side, amount) = if movement > Decimal::ZERO {
("credit", movement)
} else {
("debit", -movement)
};
lines.push(format!("{} {} {} to zero", side, account.code, amount));
}
_ => {}
}
}
return Ok(Json(ClosePeriodOut { return Ok(Json(ClosePeriodOut {
status: "preview".to_string(), status: "preview".to_string(),
period_id: p.period_id, period_id: p.period_id,
closing_entry: None, closing_entry: None,
lines, lines: describe(&scratch, closing),
})); }));
} }
let closing = store.close_period(p.period_id).map_err(|e| e.to_string())?; let closing = store
// Build line descriptions from the posted closing entry. .close_period_to(p.period_id, &to)
let mut lines = Vec::new(); .map_err(|e| e.to_string())?;
if let Some(eid) = closing let lines = describe(store.ledger(), closing);
&& let Some(entry) = store.ledger().get_entry(eid)
{
for line in entry.lines() {
let code = store
.ledger()
.chart()
.get(line.account)
.map(|a| a.code.clone())
.unwrap_or_default();
lines.push(format!("{:?} {} {}", line.side, code, line.amount.amount));
}
}
Ok(Json(ClosePeriodOut { Ok(Json(ClosePeriodOut {
status: "closed".to_string(), status: "closed".to_string(),
period_id: p.period_id, period_id: p.period_id,
@@ -2425,7 +2541,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 +2620,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 +2642,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.year_earnings(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 +2710,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 +2787,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 +2825,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 +3016,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 +3127,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.
+80 -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,74 @@ 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");
// Closing January: a one-member LLC closes into the member's capital
// account, not Retained Earnings. The preview is exactly what posts.
let jan = mcp.call(
"books.create_period",
json!({"name": "2026-01", "start": "2026-01-01", "end": "2026-01-31"}),
);
let jan_id = jan["period_id"].as_u64().unwrap();
let preview = mcp.call(
"books.close_period",
json!({"period_id": jan_id, "confirmed": false}),
);
assert_eq!(
preview["lines"],
json!(["Debit 4000 4.00", "Credit 3000 4.00"])
);
let closed = mcp.call(
"books.close_period",
json!({"period_id": jan_id, "confirmed": true}),
);
assert_eq!(closed["lines"], preview["lines"]);
let jan_bs = mcp.call("books.balance_sheet", json!({"as_of": "2026-01-31"}));
assert_eq!(jan_bs["equity_accounts"][0]["amount"], "1004.00");
assert_eq!(jan_bs["current_earnings"], "0.00");
// The close is income, not a contribution.
let me = mcp.call(
"books.export",
json!({"format": "json", "report": "members_equity", "start": "2026-01-01", "end": "2026-01-31"}),
);
let me: serde_json::Value = serde_json::from_str(me["content"].as_str().unwrap()).unwrap();
assert_eq!(me["total"]["contributions"], "1000.00");
assert_eq!(me["total"]["net_income"], "4.00");
assert_eq!(me["total"]["ending"], "1004.00");
} }
+69 -12
View File
@@ -12,9 +12,9 @@ use crate::journal::{JournalLine, JournalWriter, read_and_verify_journal};
use crate::schema; use crate::schema;
use chrono::{Months, NaiveDate}; use chrono::{Months, NaiveDate};
use ledger_core::{ use ledger_core::{
AccountId, AccountType, AssetId, AssetStatus, BillId, Class, ClassId, Currency, CustomerId, AccountId, AccountType, AssetId, AssetStatus, BillId, Class, ClassId, CloseTo, Currency,
DocumentLine, EntryId, EntryStatus, InvoiceId, Ledger, LedgerError, Money, NewAsset, CustomerId, DocumentLine, EntryId, EntryStatus, InvoiceId, Ledger, LedgerError, Money,
PayrollLine, PayrollRun, PayrollRunId, PeriodId, Side, VendorId, arap, NewAsset, PayrollLine, PayrollRun, PayrollRunId, PeriodId, Side, VendorId, arap,
payroll::{PayrollAccounts, payroll_entry_plan, payroll_remit_plan}, payroll::{PayrollAccounts, payroll_entry_plan, payroll_remit_plan},
reports, reports,
}; };
@@ -955,14 +955,32 @@ impl Store {
/// re-runs the deterministic close logic and checks the closing entry /// re-runs the deterministic close logic and checks the closing entry
/// id against the journal. /// id against the journal.
pub fn close_period(&mut self, id: PeriodId) -> Result<Option<EntryId>, StoreError> { pub fn close_period(&mut self, id: PeriodId) -> Result<Option<EntryId>, StoreError> {
self.close_period_to(id, &CloseTo::RetainedEarnings)
}
/// Close a period with its net income going to `to`: Retained Earnings,
/// or members' capital accounts by share (see
/// [`ledger_core::Ledger::close_period_to`]). The target rides in the
/// journal so replay re-derives the identical closing entry.
pub fn close_period_to(
&mut self,
id: PeriodId,
to: &CloseTo,
) -> Result<Option<EntryId>, StoreError> {
// Pure pre-validation: a doomed close journals nothing, not even // Pure pre-validation: a doomed close journals nothing, not even
// the Retained Earnings account creation below. // the Retained Earnings account creation below.
self.ledger.preview_close_period(id)?; self.ledger.preview_close_period(id)?;
// Retained Earnings must exist before the close so its creation (if if *to == CloseTo::RetainedEarnings {
// needed) is journaled as account.added; the close's own ensure // Retained Earnings must exist before the close so its creation
// then no-ops and replay stays deterministic. // (if needed) is journaled as account.added; the close's own
self.ensure_control_account("3999", "Retained Earnings", AccountType::Equity)?; // ensure then no-ops and replay stays deterministic.
let closing_entry = self.ledger.close_period(id)?; self.ensure_control_account(
ledger_core::RETAINED_EARNINGS_CODE,
"Retained Earnings",
AccountType::Equity,
)?;
}
let closing_entry = self.ledger.close_period_to(id, to)?;
let entry_detail = match closing_entry { let entry_detail = match closing_entry {
Some(eid) => { Some(eid) => {
let e = self let e = self
@@ -978,11 +996,21 @@ impl Store {
} }
None => None, None => None,
}; };
let close_to = match to {
CloseTo::RetainedEarnings => json!("retained_earnings"),
CloseTo::Members(members) => json!({
"members": members
.iter()
.map(|(account, share)| json!({"account": account, "share": share.to_string()}))
.collect::<Vec<_>>(),
}),
};
let line = self.append_journal( let line = self.append_journal(
"period.closed", "period.closed",
json!({ json!({
"id": id, "id": id,
"closing_entry": closing_entry, "closing_entry": closing_entry,
"close_to": close_to,
"entry": entry_detail, "entry": entry_detail,
}), }),
)?; )?;
@@ -2738,15 +2766,44 @@ fn replay_line_into_ledger(
let id = get_u64(p, "id")?; let id = get_u64(p, "id")?;
let want = p.get("closing_entry").and_then(|v| v.as_u64()); let want = p.get("closing_entry").and_then(|v| v.as_u64());
// Deterministic: the close re-derives the closing entry from // Deterministic: the close re-derives the closing entry from
// the replayed state, so it must match the journaled id. The // the replayed state and the journaled target (absent: Retained
// journaled entry detail is for the projection; the ledger // Earnings), so it must match the journal's id and every line.
// re-derives the entry itself. let to = match p.get("close_to").and_then(|v| v.get("members")) {
let got = ledger.close_period(id)?; None => ledger_core::CloseTo::RetainedEarnings,
Some(members) => ledger_core::CloseTo::Members(
members
.as_array()
.ok_or_else(|| StoreError::JournalCorrupt("close_to.members".into()))?
.iter()
.map(|m| {
let share = get_str(m, "share")?.parse().map_err(|_| {
StoreError::JournalCorrupt("close_to member share".into())
})?;
Ok((get_u64(m, "account")?, share))
})
.collect::<Result<_, StoreError>>()?,
),
};
let got = ledger.close_period_to(id, &to)?;
if got != want { if got != want {
return Err(StoreError::ReplayMismatch(format!( return Err(StoreError::ReplayMismatch(format!(
"closing entry: journal says {want:?}, replay got {got:?}" "closing entry: journal says {want:?}, replay got {got:?}"
))); )));
} }
if let Some(eid) = got {
let replayed: Vec<Value> = ledger
.get_entry(eid)
.expect("close posted it")
.lines()
.iter()
.map(journal_line_json)
.collect();
if p["entry"]["lines"].as_array() != Some(&replayed) {
return Err(StoreError::ReplayMismatch(format!(
"closing entry {eid}: replayed lines differ from the journal"
)));
}
}
} }
"line.cleared" => { "line.cleared" => {
ledger.mark_cleared( ledger.mark_cleared(
+69
View File
@@ -457,3 +457,72 @@ fn receipt_corrections_are_journaled_and_survive_rebuild() {
check(&store); check(&store);
store.verify().unwrap(); store.verify().unwrap();
} }
#[test]
fn a_close_into_members_capital_replays_exactly() {
let dir = test_dir("members-close");
let mut store = Store::create(&dir, Currency::USD).unwrap();
let cash = store
.add_account("1000", "Cash", AccountType::Asset, None, false)
.unwrap();
let alice = store
.add_account("3000", "Capital — Alice", AccountType::Equity, None, false)
.unwrap();
let bob = store
.add_account("3001", "Capital — Bob", AccountType::Equity, None, false)
.unwrap();
let revenue = store
.add_account("4000", "Revenue", AccountType::Revenue, None, false)
.unwrap();
let jan15 = NaiveDate::from_ymd_opt(2026, 1, 15).unwrap();
let e = store.create_entry(jan15, "Sale").unwrap();
store
.add_line(
e,
cash,
Side::Debit,
Money::new(Decimal::new(10001, 2), Currency::USD),
)
.unwrap();
store
.add_line(
e,
revenue,
Side::Credit,
Money::new(Decimal::new(10001, 2), Currency::USD),
)
.unwrap();
store.post_entry(e).unwrap();
let jan = store
.create_period(
"2026-01",
NaiveDate::from_ymd_opt(2026, 1, 1).unwrap(),
NaiveDate::from_ymd_opt(2026, 1, 31).unwrap(),
)
.unwrap();
let half = Decimal::new(5, 1);
store
.close_period_to(
jan,
&ledger_core::CloseTo::Members(vec![(alice, half), (bob, half)]),
)
.unwrap();
// 100.01 splits 50.01 / 50.00 into the capital accounts.
let balances = |s: &Store| {
(
s.ledger().account_balance(alice).unwrap(),
s.ledger().account_balance(bob).unwrap(),
)
};
let want = (Decimal::new(5001, 2), Decimal::new(5000, 2));
assert_eq!(balances(&store), want);
let journal = std::fs::read_to_string(dir.join("journal.jsonl")).unwrap();
assert!(journal.contains("\"close_to\":{\"members\""));
drop(store);
let mut store = Store::open(&dir).unwrap();
assert_eq!(balances(&store), want);
store.rebuild().unwrap();
assert_eq!(balances(&store), want);
store.verify().unwrap();
}
+41 -20
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
@@ -260,10 +279,13 @@ R-FUTURE-3 and rides the engine roadmap, not the MCP layer.
- `books.create_period { name, start, end }` → `{ period_id }`. - `books.create_period { name, start, end }` → `{ period_id }`.
Dates are YYYY-MM-DD, inclusive. Dates are YYYY-MM-DD, inclusive.
- `books.close_period { period_id, confirmed? }` → without confirmation: - `books.close_period { period_id, confirmed? }` → without confirmation:
`{ status: "preview", lines: [...] }` showing the closing lines and posts `{ status: "preview", lines: [...] }`: the exact closing entry (the close
nothing; with `confirmed: true`: posts the closing entry (revenue debited run on a copy of the ledger), posting nothing; with `confirmed: true`:
to zero, expenses credited to zero, net to `3999` Retained Earnings) and posts it and marks the period closed. The entry zeroes revenue and
marks the period closed. Closed periods reject new entries. expense balances as of the period end; the net goes to `3999` Retained
Earnings, or, with `[[company.members]]` in `wanderbooks.toml`, to each
member's capital account by share. Periods close in order. Closed periods
reject new entries.
- `books.list_periods {}` → `{ periods: [{ id, name, start, end, status, - `books.list_periods {}` → `{ periods: [{ id, name, start, end, status,
closing_entry }] }`. closing_entry }] }`.
@@ -336,10 +358,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.
+3
View File
@@ -21,6 +21,9 @@ No other statuses.
| Profit & loss | Reports → Income Statement | done | golden-covered | | Profit & loss | Reports → Income Statement | done | golden-covered |
| P&L for a date range, line per account (closing entries excluded) | Reports → Income Statement (custom dates) | partial | hand-verified fixture + property tests; no golden file yet | | P&L for a date range, line per account (closing entries excluded) | Reports → Income Statement (custom dates) | partial | hand-verified fixture + property tests; no golden file yet |
| Balance sheet as of a date, line per account | Reports → Balance Sheet (as of) | partial | hand-verified fixture + property tests; no golden file yet | | Balance sheet as of a date, line per account | Reports → Balance Sheet (as of) | partial | hand-verified fixture + property tests; no golden file yet |
| General ledger by account (running balance, split) | Reports → General Ledger | partial | hand-verified fixture, ties to trial balance; no golden file yet |
| Trial balance as of a date | Reports → Trial Balance (as of) | partial | hand-verified fixture; no golden file yet |
| Statement of changes in members' equity | Reports → Statement of Changes in Equity | partial | hand-verified two-member fixture incl. rounding and a close; no golden file yet |
| Reversing entries | Reverse transaction | done | linked via `reversal_of` | | Reversing entries | Reverse transaction | done | linked via `reversal_of` |
| Canonical report snapshots (JSON) | — (our oracle format) | done | `reports::snapshot` | | Canonical report snapshots (JSON) | — (our oracle format) | done | `reports::snapshot` |
| Fixed-asset register + straight-line depreciation | Fixed Asset register | done | `assets.rs`; golden-covered schedule; idempotent runs; disposal with gain/loss | | Fixed-asset register + straight-line depreciation | Fixed Asset register | done | `assets.rs`; golden-covered schedule; idempotent runs; disposal with gain/loss |
+28 -2
View File
@@ -47,6 +47,24 @@ requirement conflict, the requirement wins.
reason. Period P&Ls partition: split a range anywhere and the halves sum reason. Period P&Ls partition: split a range anywhere and the halves sum
to the whole, and a range's net income equals the change in current to the whole, and a range's net income equals the change in current
earnings across it (property-tested). earnings across it (property-tested).
- **R-9a — General ledger.** `general_ledger(start, end)`: by account in
code order, each with its beginning balance, every posting in the period
(date, entry, description, debit, credit, running balance on the account's
normal side), and its ending balance, which ties to the trial balance as
of `end`. Each posting names its *split*: the one account on the entry's
opposite side, or "-Split-" when that side has several (the QuickBooks
convention). The entry-ordered listing is the *general journal*, a
different report.
- **R-9b — Trial balance as of a date.** `trial_balance_as_of(as_of)`.
- **R-9c — Statement of changes in members' equity.** For a multi-member LLC
(partnership): each member's capital roll-forward — beginning,
contributions (credits to their capital account), allocated net income
(loss), distributions (debits), ending — plus a total that equals
balance-sheet equity. Earnings not yet distributed (retained earnings and
unclosed income) and the period's income are allocated by ownership share
to the cent, the last member absorbing rounding, so every row's total is
exact. Any equity change the roll-forward doesn't explain is shown as
"other", never hidden.
- **R-10 — Canonical snapshots.** `reports::snapshot()` renders the three - **R-10 — Canonical snapshots.** `reports::snapshot()` renders the three
core reports as deterministic JSON (decimal strings, sorted lines, fixed core reports as deterministic JSON (decimal strings, sorted lines, fixed
keys). Two engines fed the same books must produce identical output. This keys). Two engines fed the same books must produce identical output. This
@@ -93,8 +111,16 @@ requirement conflict, the requirement wins.
Reports work in base currency. (Full per-account currencies and translation Reports work in base currency. (Full per-account currencies and translation
remain future.) remain future.)
- **R-? — Periods and close.** Was R-FUTURE-2. - **R-? — Periods and close.** Was R-FUTURE-2.
`ledger-core::periods`: `create_period`, `close_period` (posts closing `ledger-core::periods`: `create_period`, `close_period_to(id, to)`; closed
entry to `3999` Retained Earnings), closed periods reject new entries. periods reject new entries. The closing entry, dated at period end,
zeroes each revenue and expense account's balance **as of the period
end** (later-dated entries belong to later periods) and puts the net in
`to`: `CloseTo::RetainedEarnings` (`3999`, the corporate convention;
`close_period` is shorthand) or `CloseTo::Members` (each member's capital
account by share, to the cent, the last absorbing rounding: the
partnership convention). Periods close in order (`CloseOutOfOrder`). The
journal records the target, and replay re-derives the closing entry and
checks every line against the journal.
- **R-? — A/R and A/P workflows.** Was R-FUTURE-5. - **R-? — A/R and A/P workflows.** Was R-FUTURE-5.
`ledger-core::arap`: customers, vendors, invoices, bills, payments, `ledger-core::arap`: customers, vendors, invoices, bills, payments,
AR/AP aging. MCP tools for the full workflow. AR/AP aging. MCP tools for the full workflow.
+25
View File
@@ -190,3 +190,28 @@ 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).
- 2026-10-11: Period close fixed and made partnership-aware. The close swept
every revenue/expense posting ever made, so a period closed after later
entries existed swallowed them (closing January in March closed February
and March too); it now zeroes balances as of the period end, refuses to
close out of order, and its preview is the real close run on a copy of
the ledger. A multi-member LLC closes into each member's capital account
by share (CloseTo::Members), the partnership convention, so capital
accounts carry allocated earnings; the members'-equity statement treats
those closes as income, not contributions, and the balance sheet shows
only unallocated earnings. Replay now checks closing lines, not just ids.
+35
View File
@@ -38,6 +38,41 @@ 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.
- **QuickBooks Online dashboard**: invoices (unpaid in the last year,
overdue vs. not yet due; paid in the last 30 days, deposited vs. not),
expenses by category (donut, last month), bank account balances (book
vs. bank, items to review), profit and loss (net income, income vs.
expenses bars), sales trend (line, by quarter).
- **KPI tiles with comparison** (QuickBooks accounting dashboard): income,
cost of goods sold, expenses, net profit, each with % change vs. the
previous 30 days; an auto date-range picker; cash balance over time
with a trend line; revenue by month, stacked.
- **Cash-flow dashboard**: bank balance, debtors, burn rate, cash runway
(days) tiles; income vs. expenses for the last six months; a net cash
flow forecast with a confidence band; A/P and A/R aging by days past
due (the engine already computes both agings); total burn over time.
## Phase 5 — Workflow parity ## Phase 5 — Workflow parity