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",
"typst",
"typst-as-lib",
"typst-assets",
"typst-layout",
"typst-pdf",
]
+8
View File
@@ -132,6 +132,10 @@ pub enum LedgerError {
#[error("purchase date plus useful life overflows the calendar")]
AssetDateOverflow,
/// Partnership members are misconfigured (shares, capital accounts).
#[error("invalid members: {0}")]
InvalidMembers(String),
/// Cash-flow period end is before period start.
#[error("invalid date range: end {end} is before start {start}")]
InvalidDateRange {
@@ -162,6 +166,10 @@ pub enum LedgerError {
#[error("cannot close period {0}: draft entries exist with dates in the period")]
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.
#[error("period {0} is closed; entries dated {1} are rejected")]
ClosedPeriod(u64, String),
+142 -86
View File
@@ -57,7 +57,7 @@ pub struct ReconciliationReport {
}
/// The books. Single-currency (see R-5); owns id allocation.
#[derive(Debug)]
#[derive(Clone, Debug)]
pub struct Ledger {
base_currency: Currency,
chart: ChartOfAccounts,
@@ -587,114 +587,161 @@ impl Ledger {
.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:
/// 1. Rejects if the period is already closed.
/// 2. Rejects if any draft entries have dates inside the period.
/// 3. Posts a closing entry dated at period end: revenue accounts are
/// debited to zero, expense accounts are credited to zero, and the
/// net goes to Retained Earnings (account `3999`, created if missing).
/// 4. Marks the period closed.
/// 1. Rejects if the period is already closed, if any draft is dated
/// inside it, or if a later period is already closed (closing out of
/// order would sweep the same income twice).
/// 2. Posts a closing entry dated at period end that zeroes every revenue
/// and expense account's balance *as of the period end* (entries
/// 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
/// close (no revenue/expense movement).
pub fn close_period(&mut self, id: PeriodId) -> Result<Option<EntryId>, LedgerError> {
let period = self
.periods
.get(&id)
.ok_or(LedgerError::UnknownPeriod(id))?
.clone();
if period.status == PeriodStatus::Closed {
return Err(LedgerError::PeriodAlreadyClosed(id));
}
// 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));
/// close (no revenue/expense balance at period end).
pub fn close_period_to(
&mut self,
id: PeriodId,
to: &periods::CloseTo,
) -> Result<Option<EntryId>, LedgerError> {
self.preview_close_period(id)?;
if let periods::CloseTo::Members(members) = to {
self.validate_close_members(members)?;
}
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 accounts: Vec<_> = self.chart.all().collect();
accounts.sort_by(|a, b| a.code.cmp(&b.code));
for account in accounts {
match account.account_type {
AccountType::Revenue | AccountType::Expense => {
let movement = self.account_movement(account.id);
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));
}
_ => {}
if !matches!(
account.account_type,
AccountType::Revenue | AccountType::Expense
) {
continue;
}
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() {
None
} else {
// Ensure Retained Earnings exists.
let re_id = match self.chart.find_by_code("3999") {
Some(a) => a.id,
None => self.add_account(
"3999",
"Retained Earnings",
AccountType::Equity,
None,
false,
)?,
};
// Sum the closing lines to find the net for RE.
let mut debits = Decimal::ZERO;
let mut credits = Decimal::ZERO;
for (_, side, amount) in &closings {
match side {
Side::Debit => debits += *amount,
Side::Credit => credits += *amount,
// Net income: the closing debits (revenue) minus credits (expense).
let net: Decimal = closings
.iter()
.map(|(_, side, amount)| match side {
Side::Debit => *amount,
Side::Credit => -*amount,
})
.sum();
let targets: Vec<(AccountId, Decimal)> = match to {
periods::CloseTo::RetainedEarnings => {
let re_id = match self.chart.find_by_code(periods::RETAINED_EARNINGS_CODE) {
Some(a) => a.id,
None => self.add_account(
periods::RETAINED_EARNINGS_CODE,
"Retained Earnings",
AccountType::Equity,
None,
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));
for (account, side, amount) in closings {
self.add_line(entry, account, side, Money::new(amount, self.base_currency))?;
}
// Balance with Retained Earnings.
if debits > credits {
// Profit: credit RE.
self.add_line(
entry,
re_id,
Side::Credit,
Money::new(debits - credits, self.base_currency),
)?;
} else if credits > debits {
// Loss: debit RE.
self.add_line(
entry,
re_id,
Side::Debit,
Money::new(credits - debits, self.base_currency),
)?;
// Profit credits equity; a loss debits it.
for (account, share) in targets {
if share.is_zero() {
continue;
}
let (side, amount) = if share > Decimal::ZERO {
(Side::Credit, share)
} else {
(Side::Debit, -share)
};
self.add_line(entry, account, side, Money::new(amount, self.base_currency))?;
}
self.post_entry(entry)?;
Some(entry)
};
self.periods.get_mut(&id).expect("period exists").status = PeriodStatus::Closed;
self.periods
.get_mut(&id)
.expect("period exists")
.closing_entry = closing_entry;
let p = self.periods.get_mut(&id).expect("period exists");
p.status = PeriodStatus::Closed;
p.closing_entry = 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
/// [`Self::close_period`] would succeed — the period exists and is open,
/// 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()));
}
// 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
// account has non-zero movement — the same predicate close_period
// uses to decide. The entry id is the next one the allocator hands
// out; account creation (e.g. Retained Earnings) does not consume
// entry ids.
// account has a balance at period end — the same predicate
// close_period_to uses to decide. The entry id is the next one the
// allocator hands out; account creation (e.g. Retained Earnings)
// does not consume entry ids.
let any_movement = self.chart.all().any(|a| {
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))
}
+5 -3
View File
@@ -43,9 +43,11 @@ pub use journal::{EntryId, EntryStatus, JournalEntry, JournalLine};
pub use ledger::{Ledger, ReconciliationReport, UnclearedLine};
pub use money::{Currency, Money};
pub use payroll::{PayrollLine, PayrollRun, PayrollRunId};
pub use periods::{Period, PeriodId, PeriodStatus};
pub use periods::{CloseTo, Period, PeriodId, PeriodStatus, RETAINED_EARNINGS_CODE};
pub use reports::{
BalanceSheet, BalanceSheetStatement, CashFlowLine, CashFlowParams, CashFlowStatement,
ClassProfitAndLoss, ProfitAndLoss, ProfitAndLossStatement, StatementLine, TrialBalance,
TrialBalanceLine, balance_sheet_as_of, cash_flow, profit_and_loss_for_period,
ClassProfitAndLoss, GeneralLedger, LedgerAccount, LedgerPosting, MemberCapital,
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,
}
/// 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.
#[derive(Clone, Debug, Serialize, Deserialize)]
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(exp).unwrap(), rust_decimal::Decimal::ZERO);
// Retained earnings holds the net: 1000 - 400 = 600 (credit-normal).
let re = l.chart().find_by_code("3999").unwrap().id;
let re = l.chart().find_by_code(RETAINED_EARNINGS_CODE).unwrap().id;
assert_eq!(
l.account_balance(re).unwrap(),
"600".parse::<rust_decimal::Decimal>().unwrap()
+388 -1
View File
@@ -4,6 +4,7 @@
//! (`docs/oracle.md`) diffs: the same fixture books through two engines must
//! produce byte-identical [`snapshot`] output.
use crate::periods::allocate;
use crate::{AccountType, Class, ClassId, JournalEntry, JournalLine, Ledger, LedgerError};
use chrono::NaiveDate;
use rust_decimal::Decimal;
@@ -44,6 +45,27 @@ impl TrialBalance {
/// Build the trial balance from all posted entries.
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();
accounts.sort_by(|a, b| a.code.cmp(&b.code));
@@ -54,7 +76,7 @@ pub fn trial_balance(ledger: &Ledger) -> TrialBalance {
if account.placeholder {
continue;
}
let movement = ledger.account_movement(account.id);
let movement = movements.get(&account.id).copied().unwrap_or_default();
if movement.is_zero() {
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.
///
/// Amounts are decimal strings (never floats, never locale-formatted), lines
@@ -507,6 +886,10 @@ pub struct CashFlowLine {
pub label: String,
/// Signed amount: positive = cash in, negative = cash out.
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`].
@@ -734,6 +1117,7 @@ pub fn cash_flow(
target.push(CashFlowLine {
label,
amount: line,
account: Some(account.code.clone()),
});
}
@@ -796,6 +1180,7 @@ pub fn cash_flow(
investing.push(CashFlowLine {
label: format!("Sale of fixed assets — {proceeds_label}"),
amount: proceeds,
account: ledger.chart().get(cost_acct).map(|a| a.code.clone()),
});
continue;
}
@@ -805,6 +1190,7 @@ pub fn cash_flow(
investing.push(CashFlowLine {
label: format!("Purchase of fixed assets — {}", display(line.account)),
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 {
label: "Less: gain on sale of fixed assets (in investing)".to_string(),
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,
};
use ledger_core::{
AccountId, AccountType, CashFlowParams, ClassId, Currency, Ledger, Money, Side, StatementLine,
cash_flow,
AccountId, AccountType, CashFlowParams, ClassId, Currency, Ledger, LedgerError, MemberCapital,
Money, Side, StatementLine, cash_flow, general_ledger, members_equity, trial_balance_as_of,
};
use rust_decimal::Decimal;
use std::str::FromStr;
fn d(m: u32, day: u32) -> NaiveDate {
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"
typst-as-lib = { version = "0.17", default-features = false }
typst-pdf = "0.15.1"
typst-assets = { version = "0.15.1", features = ["fonts"] }
typst = "0.15.1"
serde_json = "1"
typst-layout = "0.15.1"
Binary file not shown.
@@ -0,0 +1,92 @@
Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION AND CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.
Binary file not shown.
+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 iif::{IifEntry, journal_to_iif};
pub use pdf::{PdfError, statement_pdf};
pub use report::{ReportMeta, Row, RowKind, Statement, format_amount};
pub use report::{Member, ReportMeta, Row, RowKind, Statement, YearEarnings, format_amount};
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
//! renders the PDF. Amounts arrive already split by
//! [`crate::report::amount_parts`] — Typst never does arithmetic on money,
//! and labels are data, never markup. Fonts are bundled (`typst-assets`),
//! so output doesn't depend on what's installed, and it is deterministic:
//! the same statement renders to the same bytes.
//! and labels are data, never markup. The font (Inter, SIL OFL 1.1; see
//! `fonts/Inter-LICENSE.txt`) is bundled, so output doesn't depend on
//! what's installed, and it is deterministic: the same statement and
//! report metadata render to the same bytes.
use crate::report::{ReportMeta, RowKind, Statement, amount_parts};
use std::sync::OnceLock;
@@ -27,6 +28,11 @@ pub enum PdfError {
}
static TEMPLATE: &str = include_str!("../templates/statement.typ");
static INTER: [&[u8]; 3] = [
include_bytes!("../fonts/Inter-Regular.ttf"),
include_bytes!("../fonts/Inter-SemiBold.ttf"),
include_bytes!("../fonts/Inter-Bold.ttf"),
];
/// The engine, built once: parsing the bundled fonts is the expensive part.
fn engine() -> &'static TypstEngine<TypstTemplateMainFile> {
@@ -34,15 +40,17 @@ fn engine() -> &'static TypstEngine<TypstTemplateMainFile> {
ENGINE.get_or_init(|| {
TypstEngine::builder()
.main_file(TEMPLATE)
.fonts(typst_assets::fonts())
.fonts(INTER)
.build()
})
}
/// The template's input: the statement with every amount formatted.
/// The template's input: the statement with every amount split for
/// alignment.
pub fn template_data(st: &Statement, meta: &ReportMeta) -> serde_json::Value {
let kind = |k: RowKind| match k {
RowKind::Section => "section",
RowKind::Subheading => "subheading",
RowKind::Line => "line",
RowKind::Subtotal => "subtotal",
RowKind::Total => "total",
@@ -52,20 +60,17 @@ pub fn template_data(st: &Statement, meta: &ReportMeta) -> serde_json::Value {
"company": meta.company,
"title": st.title,
"period": st.period,
"footer": format!("Prepared from journal through seq {}", meta.journal_seq),
"footer": meta.footer(),
"columns": st.columns,
"rows": st.rows.iter().map(|r| serde_json::json!({
"kind": kind(r.kind),
"label": r.label,
"rule": r.rule_above,
"amount": r.amount.map(|a| {
"currency": r.currency,
"amounts": r.amounts.iter().map(|a| a.map(|a| {
let p = amount_parts(a);
serde_json::json!({
"digits": p.digits,
"negative": p.negative,
"zero": p.zero,
"currency": r.currency,
})
}),
serde_json::json!({ "digits": p.digits, "negative": p.negative })
})).collect::<Vec<_>>(),
})).collect::<Vec<_>>(),
})
}
@@ -101,6 +106,8 @@ mod tests {
fn meta() -> ReportMeta {
ReportMeta {
company: "Test Co".to_string(),
basis: "Accrual".to_string(),
prepared_on: NaiveDate::from_ymd_opt(2026, 10, 11).unwrap(),
journal_seq: 42,
}
}
@@ -127,6 +134,7 @@ mod tests {
/// The exact data the template typesets: hand-checked, like a golden.
#[test]
fn template_data_is_exact() {
let amt = |digits: &str, negative: bool| serde_json::json!([{"digits": digits, "negative": negative}]);
let data = template_data(&profit_and_loss(&pl()), &meta());
assert_eq!(
data,
@@ -134,22 +142,18 @@ mod tests {
"company": "Test Co",
"title": "Profit and Loss",
"period": "October 4 – October 11, 2026",
"footer": "Prepared from journal through seq 42",
"footer": "Accrual basis · Prepared Sunday, October 11, 2026 · Journal seq 42",
"columns": ["Total"],
"rows": [
{"kind": "section", "label": "Revenue", "rule": false, "amount": null},
{"kind": "subtotal", "label": "Total revenue", "rule": false, "amount":
{"digits": "", "negative": false, "zero": true, "currency": true}},
{"kind": "spacer", "label": "", "rule": false, "amount": null},
{"kind": "section", "label": "Expenses", "rule": false, "amount": null},
{"kind": "line", "label": "6000 Organizational Costs", "rule": false, "amount":
{"digits": "59.00", "negative": false, "zero": false, "currency": true}},
{"kind": "line", "label": "6400 Memberships & Licenses", "rule": false, "amount":
{"digits": "106.38", "negative": false, "zero": false, "currency": false}},
{"kind": "subtotal", "label": "Total expenses", "rule": true, "amount":
{"digits": "165.38", "negative": false, "zero": false, "currency": true}},
{"kind": "spacer", "label": "", "rule": false, "amount": null},
{"kind": "total", "label": "Net income", "rule": true, "amount":
{"digits": "165.38", "negative": true, "zero": false, "currency": true}},
{"kind": "section", "label": "Revenue", "rule": false, "currency": false, "amounts": []},
{"kind": "subtotal", "label": "Total revenue", "rule": false, "currency": true, "amounts": amt("0.00", false)},
{"kind": "spacer", "label": "", "rule": false, "currency": false, "amounts": []},
{"kind": "section", "label": "Expenses", "rule": false, "currency": false, "amounts": []},
{"kind": "line", "label": "6000 Organizational Costs", "rule": false, "currency": false, "amounts": amt("59.00", false)},
{"kind": "line", "label": "6400 Memberships & Licenses", "rule": false, "currency": false, "amounts": amt("106.38", false)},
{"kind": "subtotal", "label": "Total expenses", "rule": true, "currency": true, "amounts": amt("165.38", false)},
{"kind": "spacer", "label": "", "rule": false, "currency": false, "amounts": []},
{"kind": "total", "label": "Net income (loss)", "rule": true, "currency": true, "amounts": amt("165.38", true)},
],
})
);
@@ -172,7 +176,7 @@ mod tests {
.map(|i| Row {
kind: RowKind::Line,
label: format!("#{i} *not bold* $x$ <label> @ref [link] \\ #panic()"),
amount: Some(Decimal::new(i, 0)),
amounts: vec![Some(Decimal::new(i, 0))],
currency: false,
rule_above: false,
})
+424 -188
View File
@@ -1,13 +1,21 @@
//! The presentation model every statement renderer (XLSX, PDF) draws from.
//!
//! Core computes the statements; this module lays them out as rows —
//! sections, account lines, subtotals, totals — once, so the formats can't
//! drift apart. Amounts stay exact `Decimal`s here; [`format_amount`] is the
//! one place they become text ("1,234.56", "(207.68)").
//! Core computes the statements; this module lays them out once — title,
//! period, column headers, and rows (sections, account lines, subtotals,
//! totals) — so the formats can't drift apart. Amounts stay exact
//! `Decimal`s here; [`format_amount`] and [`amount_parts`] are the only
//! places they become text.
//!
//! Presentation follows US small-business convention (QuickBooks-style):
//! a currency sign on subtotals and totals, a rule above every sum, a
//! double rule under statement totals, "Net income (loss)", and — for a
//! multi-member LLC — "Members' equity" with each member named.
use chrono::NaiveDate;
use chrono::{Datelike, NaiveDate};
use ledger_core::RETAINED_EARNINGS_CODE;
use ledger_core::reports::{
BalanceSheetStatement, CashFlowLine, CashFlowStatement, ProfitAndLossStatement, StatementLine,
BalanceSheetStatement, CashFlowLine, CashFlowStatement, MembersEquityStatement,
ProfitAndLossStatement, StatementLine, TrialBalance,
};
use rust_decimal::Decimal;
use serde::Serialize;
@@ -17,21 +25,49 @@ use serde::Serialize;
pub struct ReportMeta {
/// Company name for the header, e.g. "Wanderland Labs LLC".
pub company: String,
/// Accounting basis for the footer: "Accrual" or "Cash".
pub basis: String,
/// The day the report was prepared.
pub prepared_on: NaiveDate,
/// Sequence number of the last journal line the report reflects.
pub journal_seq: u64,
}
impl ReportMeta {
/// "Accrual basis · Prepared Sunday, October 11, 2026 · Journal seq 114".
pub fn footer(&self) -> String {
format!(
"{} basis · Prepared {}, {} · Journal seq {}",
self.basis,
self.prepared_on.format("%A"),
long_date(self.prepared_on),
self.journal_seq
)
}
}
/// A member of a multi-member LLC, for naming capital accounts on reports.
#[derive(Clone, Debug)]
pub struct Member {
/// The member's name.
pub name: String,
/// Code of their capital account.
pub capital_code: String,
}
/// What a row is, which decides how renderers style it.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum RowKind {
/// A section heading ("Revenue"); no amount.
/// A section heading ("Revenue"); no amounts.
Section,
/// One account or one cash-flow line, indented under its section.
/// An explanatory heading inside a section; no amounts.
Subheading,
/// One account or one flow, indented under its section.
Line,
/// A section total ("Total revenue").
Subtotal,
/// A statement total ("Net income").
/// A statement total ("Net income (loss)"); double rule beneath.
Total,
/// Vertical space between sections.
Spacer,
@@ -44,243 +80,322 @@ pub struct Row {
pub kind: RowKind,
/// Label text.
pub label: String,
/// Amount, when the row has one.
pub amount: Option<Decimal>,
/// Print a currency sign with the amount: US accounting style puts "$"
/// on the first amount under each section heading and on every
/// subtotal and total.
/// One amount per column; `None` leaves the cell blank.
pub amounts: Vec<Option<Decimal>>,
/// Print the currency sign with the amounts: subtotals and totals.
pub currency: bool,
/// Draw a single rule above the amount: the "sum of the above" line.
/// Totals always have one; a subtotal only when amounts sit above it
/// (an empty section gets no rule over nothing).
/// Draw a rule above the row: the "sum of the above" line. Totals
/// always have one; a subtotal only when amounts sit above it.
pub rule_above: bool,
}
/// A laid-out financial statement.
#[derive(Clone, Debug, Serialize)]
pub struct Statement {
/// "Profit and Loss", "Balance Sheet", "Statement of Cash Flows".
/// "Profit and Loss", "Balance Sheet", ...
pub title: String,
/// "October 4 – October 11, 2026" or "As of October 11, 2026".
pub period: String,
/// Amount column headings ("Total"; "Debit", "Credit"; member names).
pub columns: Vec<String>,
/// The rows, top to bottom.
pub rows: Vec<Row>,
}
fn row(kind: RowKind, label: impl Into<String>, amount: Option<Decimal>) -> Row {
fn row(kind: RowKind, label: impl Into<String>, amounts: Vec<Option<Decimal>>) -> Row {
Row {
kind,
label: label.into(),
amount,
amounts,
currency: false,
rule_above: false,
}
}
/// Finish a statement: mark which amounts carry the currency sign and which
/// get a rule above.
fn statement(title: &str, period: String, mut rows: Vec<Row>) -> Statement {
let mut first_in_section = true;
fn one(kind: RowKind, label: impl Into<String>, amount: Decimal) -> Row {
row(kind, label, vec![Some(amount)])
}
fn section(label: &str) -> Row {
row(RowKind::Section, label, vec![])
}
fn subheading(label: &str) -> Row {
row(RowKind::Subheading, label, vec![])
}
fn spacer() -> Row {
row(RowKind::Spacer, "", vec![])
}
/// Finish a statement: currency signs on sums, rules above them.
fn statement(title: &str, period: String, columns: &[&str], mut rows: Vec<Row>) -> Statement {
let mut above: Option<RowKind> = None;
for r in &mut rows {
r.currency = matches!(r.kind, RowKind::Subtotal | RowKind::Total);
r.rule_above = match r.kind {
RowKind::Total => true,
RowKind::Subtotal => matches!(above, Some(RowKind::Line | RowKind::Subtotal)),
_ => false,
};
if r.kind != RowKind::Spacer {
if !matches!(r.kind, RowKind::Spacer | RowKind::Subheading) {
above = Some(r.kind);
}
match r.kind {
RowKind::Section => first_in_section = true,
RowKind::Subtotal | RowKind::Total => r.currency = true,
RowKind::Line if r.amount.is_some() && first_in_section => {
r.currency = true;
first_in_section = false;
}
RowKind::Line | RowKind::Spacer => {}
}
}
Statement {
title: title.to_string(),
period,
columns: columns.iter().map(|c| c.to_string()).collect(),
rows,
}
}
/// An amount split for column alignment: the digits, and whether it is a
/// negative (printed in parentheses) or zero (printed as a dash). Renderers
/// right-align `digits` and reserve the closing parenthesis's width on every
/// row, so decimal points line up whether or not a value is negative.
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct AmountParts {
/// "1,234.56" — absolute value, grouped, two decimals. Empty for zero.
pub digits: String,
/// Below zero.
pub negative: bool,
/// Exactly zero (after rounding to cents).
pub zero: bool,
}
/// Split an amount for aligned accounting display. Exact: no floats.
pub fn amount_parts(d: Decimal) -> AmountParts {
let rounded = d.round_dp(2);
if rounded.is_zero() {
return AmountParts {
digits: String::new(),
negative: false,
zero: true,
};
}
let text = format_amount(rounded.abs());
AmountParts {
digits: text,
negative: rounded.is_sign_negative(),
zero: false,
}
}
fn section(label: &str) -> Row {
row(RowKind::Section, label, None)
}
fn spacer() -> Row {
row(RowKind::Spacer, "", None)
}
fn account_lines(lines: &[StatementLine]) -> impl Iterator<Item = Row> + '_ {
lines.iter().map(|l| {
row(
RowKind::Line,
format!("{} {}", l.code, l.name),
Some(l.amount),
)
})
}
fn flow_lines(lines: &[CashFlowLine]) -> impl Iterator<Item = Row> + '_ {
lines
.iter()
.map(|l| row(RowKind::Line, l.label.clone(), Some(l.amount)))
.map(|l| one(RowKind::Line, format!("{} {}", l.code, l.name), l.amount))
}
/// Lay out a profit and loss statement.
pub fn profit_and_loss(pl: &ProfitAndLossStatement) -> Statement {
let mut rows = vec![section("Revenue")];
rows.extend(account_lines(&pl.revenue));
rows.push(row(
RowKind::Subtotal,
"Total revenue",
Some(pl.total_revenue),
));
rows.push(one(RowKind::Subtotal, "Total revenue", pl.total_revenue));
rows.push(spacer());
rows.push(section("Expenses"));
rows.extend(account_lines(&pl.expenses));
rows.push(row(
RowKind::Subtotal,
"Total expenses",
Some(pl.total_expenses),
));
rows.push(one(RowKind::Subtotal, "Total expenses", pl.total_expenses));
rows.push(spacer());
rows.push(row(RowKind::Total, "Net income", Some(pl.net_income())));
statement("Profit and Loss", period_label(pl.start, pl.end), rows)
}
/// Lay out a balance sheet.
pub fn balance_sheet(bs: &BalanceSheetStatement) -> Statement {
let mut rows = vec![section("Assets")];
rows.extend(account_lines(&bs.assets));
rows.push(row(
RowKind::Subtotal,
"Total assets",
Some(bs.total_assets),
));
rows.push(spacer());
rows.push(section("Liabilities"));
rows.extend(account_lines(&bs.liabilities));
rows.push(row(
RowKind::Subtotal,
"Total liabilities",
Some(bs.total_liabilities),
));
rows.push(spacer());
rows.push(section("Equity"));
rows.extend(account_lines(&bs.equity));
rows.push(row(
RowKind::Line,
"Current earnings",
Some(bs.current_earnings),
));
rows.push(row(
RowKind::Subtotal,
"Total equity",
Some(bs.total_equity),
));
rows.push(spacer());
rows.push(row(
RowKind::Total,
"Total liabilities and equity",
Some(bs.total_liabilities + bs.total_equity),
));
rows.push(one(RowKind::Total, "Net income (loss)", pl.net_income()));
statement(
"Balance Sheet",
format!("As of {}", long_date(bs.as_of)),
"Profit and Loss",
period_label(pl.start, pl.end),
&["Total"],
rows,
)
}
/// Lay out a cash flow statement (indirect method).
pub fn cash_flow(cf: &CashFlowStatement) -> Statement {
let mut rows = vec![section("Operating activities")];
rows.push(row(RowKind::Line, "Net income", Some(cf.net_income)));
if !cf.depreciation.is_zero() {
rows.push(row(
/// The fiscal year's earnings, as the balance sheet presents them.
#[derive(Clone, Copy, Debug)]
pub struct YearEarnings {
/// Net income (loss) for the fiscal year through the as-of date.
pub net_income_ytd: Decimal,
/// 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,
"Depreciation (non-cash)",
Some(cf.depreciation),
"Net income (loss)",
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));
rows.push(row(
if !cf.depreciation.is_zero() {
rows.push(one(RowKind::Line, "Depreciation", cf.depreciation));
}
rows.extend(flow_lines(&cf.working_capital, members));
rows.push(one(
RowKind::Subtotal,
"Net cash from operating activities",
Some(cf.cash_from_operations),
cf.cash_from_operations,
));
rows.push(spacer());
rows.push(section("Investing activities"));
rows.extend(flow_lines(&cf.investing));
rows.push(row(
rows.extend(flow_lines(&cf.investing, members));
rows.push(one(
RowKind::Subtotal,
"Net cash from investing activities",
Some(cf.cash_from_investing),
cf.cash_from_investing,
));
rows.push(spacer());
rows.push(section("Financing activities"));
rows.extend(flow_lines(&cf.financing));
rows.push(row(
rows.extend(flow_lines(&cf.financing, members));
rows.push(one(
RowKind::Subtotal,
"Net cash from financing activities",
Some(cf.cash_from_financing),
cf.cash_from_financing,
));
rows.push(spacer());
rows.push(row(
rows.push(one(
RowKind::Subtotal,
"Net change in cash",
Some(cf.net_change_in_cash),
"Net increase (decrease) in cash",
cf.net_change_in_cash,
));
rows.push(row(
rows.push(one(
RowKind::Line,
"Cash at beginning of period",
Some(cf.cash_beginning),
));
rows.push(row(
RowKind::Total,
"Cash at end of period",
Some(cf.cash_ending),
cf.cash_beginning,
));
rows.push(one(RowKind::Total, "Cash at end of period", cf.cash_ending));
statement(
"Statement of Cash Flows",
period_label(cf.start, cf.end),
&["Total"],
rows,
)
}
/// Lay out a trial balance as of a date: Debit and Credit columns, a zero
/// side left blank.
pub fn trial_balance(tb: &TrialBalance, as_of: NaiveDate) -> Statement {
let nz = |d: Decimal| if d.is_zero() { None } else { Some(d) };
let mut rows: Vec<Row> = tb
.lines
.iter()
.map(|l| {
row(
RowKind::Line,
format!("{} {}", l.code, l.name),
vec![nz(l.debit), nz(l.credit)],
)
})
.collect();
rows.push(row(
RowKind::Total,
"Total",
vec![Some(tb.total_debits), Some(tb.total_credits)],
));
statement(
"Trial Balance",
format!("As of {}", long_date(as_of)),
&["Debit", "Credit"],
rows,
)
}
/// Lay out the statement of changes in members' equity: one column per
/// member and a total. Distributions print as reductions.
pub fn members_equity(me: &MembersEquityStatement) -> Statement {
let cols: Vec<_> = me
.members
.iter()
.chain(std::iter::once(&me.total))
.collect();
let pick = |f: &dyn Fn(&ledger_core::MemberEquityColumn) -> Decimal| {
cols.iter().map(|c| Some(f(c))).collect::<Vec<_>>()
};
let mut rows = vec![
row(RowKind::Line, "Beginning balance", pick(&|c| c.beginning)),
row(RowKind::Line, "Contributions", pick(&|c| c.contributions)),
row(RowKind::Line, "Net income (loss)", pick(&|c| c.net_income)),
row(RowKind::Line, "Distributions", pick(&|c| -c.distributions)),
];
if !me.total.other.is_zero() {
rows.push(row(RowKind::Line, "Other changes", pick(&|c| c.other)));
}
rows.push(row(RowKind::Total, "Ending balance", pick(&|c| c.ending)));
let names: Vec<&str> = cols.iter().map(|c| c.name.as_str()).collect();
statement(
"Statement of Changes in Members' Equity",
period_label(me.start, me.end),
&names,
rows,
)
}
@@ -292,7 +407,6 @@ pub fn long_date(d: NaiveDate) -> String {
/// "October 4 – October 11, 2026", or with both years when they differ.
pub fn period_label(start: NaiveDate, end: NaiveDate) -> String {
use chrono::Datelike;
if start.year() == end.year() {
format!("{} – {}", start.format("%B %-d"), long_date(end))
} else {
@@ -320,6 +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)]
mod tests {
use super::*;
@@ -344,44 +479,135 @@ mod tests {
#[test]
fn amounts_split_for_alignment() {
let p = amount_parts(dec("-1234.5"));
assert_eq!(
(p.digits.as_str(), p.negative, p.zero),
("1,234.50", true, false)
);
assert!(amount_parts(dec("0.004")).zero);
assert_eq!(amount_parts(dec("207.68")).digits, "207.68");
assert_eq!((p.digits.as_str(), p.negative), ("1,234.50", true));
let z = amount_parts(dec("-0.004"));
assert_eq!((z.digits.as_str(), z.negative), ("0.00", false));
}
#[test]
fn currency_signs_go_on_first_lines_and_totals() {
fn currency_signs_and_rules_go_on_sums() {
let rows = vec![
section("Assets"),
row(RowKind::Line, "a", Some(dec("1"))),
row(RowKind::Line, "b", Some(dec("2"))),
row(RowKind::Subtotal, "t", Some(dec("3"))),
one(RowKind::Line, "a", dec("1")),
one(RowKind::Line, "b", dec("2")),
one(RowKind::Subtotal, "t", dec("3")),
spacer(),
section("Equity"),
row(RowKind::Line, "c", Some(dec("3"))),
row(RowKind::Total, "T", Some(dec("3"))),
section("Investing"),
one(RowKind::Subtotal, "empty", dec("0")),
one(RowKind::Total, "T", dec("3")),
];
let st = statement("X", String::new(), rows);
let st = statement("X", String::new(), &["Total"], rows);
let signs: Vec<bool> = st.rows.iter().map(|r| r.currency).collect();
assert_eq!(signs, [false, true, false, true, false, false, true, true]);
assert_eq!(signs, [false, false, false, true, false, false, true, true]);
let rules: Vec<bool> = st.rows.iter().map(|r| r.rule_above).collect();
// No rule over an empty section's subtotal; always one over a total.
assert_eq!(
rules,
[false, false, false, true, false, false, false, true]
);
// A subtotal straight under its heading (empty section): no rule.
let empty = statement(
"Y",
String::new(),
vec![
section("Investing"),
row(RowKind::Subtotal, "t", Some(dec("0"))),
}
fn sl(code: &str, name: &str, a: &str) -> StatementLine {
StatementLine {
code: code.into(),
name: name.into(),
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]
@@ -395,5 +621,15 @@ mod tests {
period_label(d(2026, 12, 1), d(2027, 1, 31)),
"December 1, 2026 – January 31, 2027"
);
let meta = ReportMeta {
company: "X".into(),
basis: "Accrual".into(),
prepared_on: d(2026, 10, 11),
journal_seq: 114,
};
assert_eq!(
meta.footer(),
"Accrual basis · Prepared Sunday, October 11, 2026 · Journal seq 114"
);
}
}
+326 -167
View File
@@ -3,27 +3,29 @@
//! Money is converted to f64 ONLY at this presentation boundary — never in
//! a money path. All accounting math stays `Decimal`; the conversion happens
//! when writing a cell, rounded to cents. Cells stay numeric (so a CPA can
//! sum them) with an accounting number format: thousands separators,
//! negatives in parentheses.
//! sum them) with accounting number formats: thousands separators,
//! negatives in parentheses with the closing parenthesis hanging (so
//! decimal points align), "$" on subtotals and totals.
use crate::report::{ReportMeta, RowKind, Statement};
use chrono::NaiveDate;
use ledger_core::Ledger;
use ledger_core::reports::TrialBalance;
use ledger_core::reports::GeneralLedger;
use rust_decimal::Decimal;
use rust_xlsxwriter::{Format, FormatBorder, Workbook, Worksheet};
use rust_xlsxwriter::{Format, FormatAlign, FormatBorder, Workbook, Worksheet};
use thiserror::Error;
/// Which report to render.
#[derive(Clone, Debug)]
pub enum XlsxReport<'a> {
/// Trial balance lines.
TrialBalance(&'a TrialBalance),
/// A laid-out financial statement (P&L, balance sheet, cash flow); see
/// [`crate::report`].
/// A laid-out statement (P&L, balance sheet, cash flow, trial balance,
/// members' equity); see [`crate::report`].
Statement(&'a Statement),
/// General ledger: every posted journal line. Needs the ledger for
/// account names and class names.
GeneralLedger(&'a Ledger),
/// The general ledger: by account, with running balances and splits.
GeneralLedger(&'a GeneralLedger),
/// The general journal: every posted entry in `[start, end]`, line by
/// line, in date order.
GeneralJournal(&'a Ledger, NaiveDate, NaiveDate),
}
/// XLSX errors.
@@ -40,179 +42,332 @@ impl From<rust_xlsxwriter::XlsxError> for XlsxError {
}
}
/// Excel's Accounting format, with and without the currency symbol: "$"
/// pinned left, digits right, negatives in parentheses with the closing
/// parenthesis hanging (`_)` reserves its width on positives, so decimal
/// points line up), zero as a dash.
const ACCOUNTING: &str = r#"_(* #,##0.00_);_(* (#,##0.00);_(* "-"??_);_(@_)"#;
const ACCOUNTING_USD: &str = r#"_($* #,##0.00_);_($* (#,##0.00);_($* "-"??_);_(@_)"#;
/// Accounting number formats: the `_)` reserves a closing parenthesis's
/// width on positives so decimal points line up with negatives.
const PLAIN: &str = "#,##0.00_);(#,##0.00)";
const DOLLARS: &str = "$#,##0.00_);($#,##0.00)";
/// Company name, title, and period across the top; returns the next row.
fn header(
sheet: &mut Worksheet,
meta: &ReportMeta,
title: &str,
period: Option<&str>,
) -> Result<u32, XlsxError> {
let bold = Format::new().set_bold();
sheet.write_string_with_format(0, 0, &meta.company, &bold.clone().set_font_size(14))?;
sheet.write_string_with_format(1, 0, title, &bold)?;
let mut row = 2;
if let Some(period) = period {
sheet.write_string(row, 0, period)?;
row += 1;
}
Ok(row + 1)
struct Formats {
bold: Format,
company: Format,
centered: Format,
header: Format,
header_right: Format,
plain: Format,
dollars_sum: Format,
dollars_total: Format,
line_label: Format,
sub_label: Format,
note: Format,
}
/// "Prepared from journal through seq N" under the last row.
fn footer(sheet: &mut Worksheet, meta: &ReportMeta, row: u32) -> Result<(), XlsxError> {
let note = Format::new().set_italic().set_font_color("#666666");
sheet.write_string_with_format(
impl Formats {
fn new() -> Self {
let bold = Format::new().set_bold();
let ruled = |f: Format| f.set_border_top(FormatBorder::Thin);
Self {
company: Format::new()
.set_font_size(14)
.set_align(FormatAlign::Center),
centered: Format::new().set_align(FormatAlign::Center),
header: bold
.clone()
.set_font_size(9)
.set_border_bottom(FormatBorder::Thin),
header_right: bold
.clone()
.set_font_size(9)
.set_border_bottom(FormatBorder::Thin)
.set_align(FormatAlign::Right),
plain: Format::new().set_num_format(PLAIN),
dollars_sum: ruled(bold.clone().set_num_format(DOLLARS)),
dollars_total: ruled(bold.clone().set_num_format(DOLLARS))
.set_border_bottom(FormatBorder::Double),
line_label: Format::new().set_indent(1),
sub_label: Format::new().set_italic().set_indent(1),
note: Format::new()
.set_italic()
.set_font_size(9)
.set_font_color("#666666")
.set_align(FormatAlign::Center),
bold,
}
}
}
/// Company, title, and period centered over `width` columns; returns the
/// next free row.
fn header(
sheet: &mut Worksheet,
f: &Formats,
meta: &ReportMeta,
title: &str,
period: &str,
width: u16,
) -> Result<u32, XlsxError> {
let last = width.saturating_sub(1);
sheet.merge_range(0, 0, 0, last, &meta.company, &f.company)?;
sheet.merge_range(
1,
0,
1,
last,
title,
&f.bold.clone().set_align(FormatAlign::Center),
)?;
sheet.merge_range(2, 0, 2, last, period, &f.centered)?;
Ok(4)
}
/// Basis, preparation date, and journal seq under the last row.
fn footer(
sheet: &mut Worksheet,
f: &Formats,
meta: &ReportMeta,
row: u32,
width: u16,
) -> Result<(), XlsxError> {
sheet.merge_range(
row + 1,
0,
format!("Prepared from journal through seq {}", meta.journal_seq),
&note,
row + 1,
width.saturating_sub(1),
&meta.footer(),
&f.note,
)?;
Ok(())
}
fn number(
sheet: &mut Worksheet,
row: u32,
col: u16,
d: Decimal,
fmt: &Format,
) -> Result<(), XlsxError> {
sheet.write_number_with_format(row, col, dec_to_f64(d), fmt)?;
Ok(())
}
/// Render a report to XLSX bytes.
pub fn report_to_xlsx(report: &XlsxReport<'_>, meta: &ReportMeta) -> Result<Vec<u8>, XlsxError> {
let mut workbook = Workbook::new();
let sheet = workbook.add_worksheet();
let bold = Format::new().set_bold();
let money = Format::new().set_num_format(ACCOUNTING);
let usd = Format::new().set_num_format(ACCOUNTING_USD);
// Totals are marked by rules, not bold: bold figures are wider and would
// break the decimal column.
let usd_bold = usd.clone();
let f = Formats::new();
match report {
XlsxReport::TrialBalance(tb) => {
sheet.set_name("Trial Balance")?;
let mut row = header(sheet, meta, "Trial Balance", None)?;
for (i, h) in ["Code", "Account", "Debit", "Credit"].iter().enumerate() {
sheet.write_string_with_format(row, i as u16, *h, &bold)?;
}
row += 1;
for line in &tb.lines {
sheet.write_string(row, 0, &line.code)?;
sheet.write_string(row, 1, &line.name)?;
sheet.write_number_with_format(row, 2, dec_to_f64(line.debit), &money)?;
sheet.write_number_with_format(row, 3, dec_to_f64(line.credit), &money)?;
row += 1;
}
let total = usd_bold
.clone()
.set_border_top(FormatBorder::Thin)
.set_border_bottom(FormatBorder::Double);
sheet.write_string_with_format(row, 1, "Total", &bold)?;
sheet.write_number_with_format(row, 2, dec_to_f64(tb.total_debits), &total)?;
sheet.write_number_with_format(row, 3, dec_to_f64(tb.total_credits), &total)?;
sheet.set_column_width(1, 40)?;
sheet.set_column_width(2, 14)?;
sheet.set_column_width(3, 14)?;
footer(sheet, meta, row + 1)?;
}
XlsxReport::Statement(st) => {
sheet.set_name(&st.title)?;
let mut row = header(sheet, meta, &st.title, Some(&st.period))?;
let line_label = Format::new().set_indent(1);
let subtotal = usd_bold.clone().set_border_top(FormatBorder::Thin);
let total = usd_bold
.clone()
.set_border_top(FormatBorder::Thin)
.set_border_bottom(FormatBorder::Double);
for r in &st.rows {
let amount = r.amount.map(dec_to_f64);
match r.kind {
RowKind::Spacer => {}
RowKind::Section => {
sheet.write_string_with_format(row, 0, &r.label, &bold)?;
}
RowKind::Line => {
sheet.write_string_with_format(row, 0, &r.label, &line_label)?;
if let Some(a) = amount {
let fmt = if r.currency { &usd } else { &money };
sheet.write_number_with_format(row, 1, a, fmt)?;
}
}
RowKind::Subtotal => {
sheet.write_string_with_format(row, 0, &r.label, &bold)?;
if let Some(a) = amount {
let fmt = if r.rule_above { &subtotal } else { &usd_bold };
sheet.write_number_with_format(row, 1, a, fmt)?;
}
}
RowKind::Total => {
sheet.write_string_with_format(row, 0, &r.label, &bold)?;
if let Some(a) = amount {
sheet.write_number_with_format(row, 1, a, &total)?;
}
}
}
row += 1;
}
sheet.set_column_width(0, 60)?;
sheet.set_column_width(1, 16)?;
footer(sheet, meta, row)?;
}
XlsxReport::GeneralLedger(ledger) => {
sheet.set_name("General Ledger")?;
let mut row = header(sheet, meta, "General Ledger", None)?;
for (i, h) in [
"Date",
"Entry",
"Description",
"Account Code",
"Account Name",
"Class",
"Debit",
"Credit",
]
.iter()
.enumerate()
{
sheet.write_string_with_format(row, i as u16, *h, &bold)?;
}
row += 1;
for entry in ledger.posted_entries() {
for line in entry.lines() {
let account = ledger.chart().get(line.account);
let (code, name) = match account {
Some(a) => (a.code.as_str(), a.name.as_str()),
None => ("?", "?"),
};
let class_name = line
.class_id
.and_then(|cid| ledger.get_class(cid))
.map(|c| c.name.as_str())
.unwrap_or("");
let (debit, credit) = match line.side {
ledger_core::Side::Debit => (line.amount.amount, Decimal::ZERO),
ledger_core::Side::Credit => (Decimal::ZERO, line.amount.amount),
};
sheet.write_string(row, 0, entry.date().to_string())?;
sheet.write_number(row, 1, entry.id() as f64)?;
sheet.write_string(row, 2, entry.description())?;
sheet.write_string(row, 3, code)?;
sheet.write_string(row, 4, name)?;
sheet.write_string(row, 5, class_name)?;
sheet.write_number_with_format(row, 6, dec_to_f64(debit), &money)?;
sheet.write_number_with_format(row, 7, dec_to_f64(credit), &money)?;
row += 1;
}
}
sheet.set_column_width(2, 50)?;
sheet.set_column_width(4, 32)?;
footer(sheet, meta, row)?;
XlsxReport::Statement(st) => statement(sheet, &f, meta, st)?,
XlsxReport::GeneralLedger(gl) => general_ledger(sheet, &f, meta, gl)?,
XlsxReport::GeneralJournal(ledger, start, end) => {
general_journal(sheet, &f, meta, ledger, *start, *end)?
}
}
Ok(workbook.save_to_buffer()?)
}
fn statement(
sheet: &mut Worksheet,
f: &Formats,
meta: &ReportMeta,
st: &Statement,
) -> Result<(), XlsxError> {
// Excel sheet names: at most 31 characters, no apostrophes at the ends.
let name: String = st.title.replace('\'', "").chars().take(31).collect();
sheet.set_name(name.trim())?;
let width = 1 + st.columns.len() as u16;
let mut row = header(sheet, f, meta, &st.title, &st.period, width)?;
sheet.write_string_with_format(row, 0, "", &f.header)?;
for (i, c) in st.columns.iter().enumerate() {
sheet.write_string_with_format(row, 1 + i as u16, c.to_uppercase(), &f.header_right)?;
}
row += 1;
let dollars_unruled = f.bold.clone().set_num_format(DOLLARS);
for r in &st.rows {
let (label_fmt, amount_fmt) = match r.kind {
RowKind::Spacer => {
row += 1;
continue;
}
RowKind::Section => (&f.bold, &f.plain),
RowKind::Subheading => (&f.sub_label, &f.plain),
RowKind::Line => (&f.line_label, &f.plain),
RowKind::Subtotal if r.rule_above => (&f.bold, &f.dollars_sum),
RowKind::Subtotal => (&f.bold, &dollars_unruled),
RowKind::Total => (&f.bold, &f.dollars_total),
};
sheet.write_string_with_format(row, 0, &r.label, label_fmt)?;
for (i, a) in r.amounts.iter().enumerate() {
match a {
Some(d) => number(sheet, row, 1 + i as u16, *d, amount_fmt)?,
None if r.rule_above => {
sheet.write_blank(row, 1 + i as u16, amount_fmt)?;
}
None => {}
}
}
row += 1;
}
sheet.set_column_width(0, 58)?;
for i in 0..st.columns.len() as u16 {
sheet.set_column_width(1 + i, 16)?;
}
footer(sheet, f, meta, row, width)
}
fn general_ledger(
sheet: &mut Worksheet,
f: &Formats,
meta: &ReportMeta,
gl: &GeneralLedger,
) -> Result<(), XlsxError> {
sheet.set_name("General Ledger")?;
let period = crate::report::period_label(gl.start, gl.end);
let cols = [
"Date",
"Entry",
"Description",
"Split",
"Debit",
"Credit",
"Balance",
];
let mut row = header(sheet, f, meta, "General Ledger", &period, cols.len() as u16)?;
for (i, h) in cols.iter().enumerate() {
let fmt = if i >= 4 { &f.header_right } else { &f.header };
sheet.write_string_with_format(row, i as u16, h.to_uppercase(), fmt)?;
}
row += 1;
for a in &gl.accounts {
let name = format!("{} {}", a.code, a.name);
sheet.write_string_with_format(row, 0, &name, &f.bold)?;
row += 1;
sheet.write_string_with_format(row, 2, "Beginning balance", &f.line_label)?;
number(sheet, row, 6, a.beginning_balance, &f.plain)?;
row += 1;
for p in &a.postings {
sheet.write_string(row, 0, p.date.to_string())?;
sheet.write_number(row, 1, p.entry_id as f64)?;
sheet.write_string(row, 2, &p.description)?;
sheet.write_string(row, 3, &p.split)?;
if !p.debit.is_zero() {
number(sheet, row, 4, p.debit, &f.plain)?;
}
if !p.credit.is_zero() {
number(sheet, row, 5, p.credit, &f.plain)?;
}
number(sheet, row, 6, p.balance, &f.plain)?;
row += 1;
}
sheet.write_string_with_format(row, 0, format!("Total for {name}"), &f.bold)?;
for c in 1..4 {
sheet.write_blank(row, c, &f.dollars_sum)?;
}
number(sheet, row, 4, a.total_debits, &f.dollars_sum)?;
number(sheet, row, 5, a.total_credits, &f.dollars_sum)?;
number(sheet, row, 6, a.ending_balance, &f.dollars_sum)?;
row += 2;
}
for (c, w) in [
(0, 12.0),
(1, 7.0),
(2, 46.0),
(3, 34.0),
(4, 13.0),
(5, 13.0),
(6, 13.0),
] {
sheet.set_column_width(c, w)?;
}
footer(sheet, f, meta, row, cols.len() as u16)
}
fn general_journal(
sheet: &mut Worksheet,
f: &Formats,
meta: &ReportMeta,
ledger: &Ledger,
start: NaiveDate,
end: NaiveDate,
) -> Result<(), XlsxError> {
sheet.set_name("General Journal")?;
let period = crate::report::period_label(start, end);
let cols = [
"Date",
"Entry",
"Description",
"Account",
"Class",
"Debit",
"Credit",
];
let mut row = header(
sheet,
f,
meta,
"General Journal",
&period,
cols.len() as u16,
)?;
for (i, h) in cols.iter().enumerate() {
let fmt = if i >= 5 { &f.header_right } else { &f.header };
sheet.write_string_with_format(row, i as u16, h.to_uppercase(), fmt)?;
}
row += 1;
let mut entries: Vec<_> = ledger
.posted_entries()
.filter(|e| e.date() >= start && e.date() <= end)
.collect();
entries.sort_by_key(|e| (e.date(), e.id()));
let (mut debits, mut credits) = (Decimal::ZERO, Decimal::ZERO);
for entry in entries {
for line in entry.lines() {
let account = ledger
.chart()
.get(line.account)
.map(|a| format!("{} {}", a.code, a.name))
.unwrap_or_else(|| "?".to_string());
let class = line
.class_id
.and_then(|cid| ledger.get_class(cid))
.map(|c| c.name.as_str())
.unwrap_or("");
sheet.write_string(row, 0, entry.date().to_string())?;
sheet.write_number(row, 1, entry.id() as f64)?;
sheet.write_string(row, 2, entry.description())?;
sheet.write_string(row, 3, &account)?;
sheet.write_string(row, 4, class)?;
match line.side {
ledger_core::Side::Debit => {
number(sheet, row, 5, line.amount.amount, &f.plain)?;
debits += line.amount.amount;
}
ledger_core::Side::Credit => {
number(sheet, row, 6, line.amount.amount, &f.plain)?;
credits += line.amount.amount;
}
}
row += 1;
}
}
sheet.write_string_with_format(row, 0, "Total", &f.bold)?;
for c in 1..5 {
sheet.write_blank(row, c, &f.dollars_total)?;
}
number(sheet, row, 5, debits, &f.dollars_total)?;
number(sheet, row, 6, credits, &f.dollars_total)?;
for (c, w) in [
(0, 12.0),
(1, 7.0),
(2, 50.0),
(3, 38.0),
(4, 12.0),
(5, 13.0),
(6, 13.0),
] {
sheet.set_column_width(c, w)?;
}
footer(sheet, f, meta, row + 1, cols.len() as u16)
}
/// Decimal to f64 at the presentation boundary. Rounded to cents first.
fn dec_to_f64(d: Decimal) -> f64 {
use rust_decimal::prelude::ToPrimitive;
@@ -222,7 +377,8 @@ fn dec_to_f64(d: Decimal) -> f64 {
#[cfg(test)]
mod tests {
use super::*;
use ledger_core::reports::TrialBalanceLine;
use crate::report;
use ledger_core::reports::{TrialBalance, TrialBalanceLine};
#[test]
fn xlsx_is_a_zip() {
@@ -238,9 +394,12 @@ mod tests {
};
let meta = ReportMeta {
company: "Test Co".to_string(),
basis: "Accrual".to_string(),
prepared_on: NaiveDate::from_ymd_opt(2026, 10, 11).unwrap(),
journal_seq: 7,
};
let bytes = report_to_xlsx(&XlsxReport::TrialBalance(&tb), &meta).unwrap();
let st = report::trial_balance(&tb, NaiveDate::from_ymd_opt(2026, 10, 11).unwrap());
let bytes = report_to_xlsx(&XlsxReport::Statement(&st), &meta).unwrap();
// XLSX is a ZIP: starts with PK.
assert_eq!(&bytes[0..2], b"PK");
assert!(bytes.len() > 1000);
+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
// ledger-import::report, with amounts already split into exact parts. The
// template only typesets: no arithmetic happens here, and no label is ever
// parsed as markup.
//
// Shape: { company, title, period, footer,
// rows: [{ kind: "section" | "line" | "subtotal" | "total"
// | "spacer", label, rule (single rule above),
// amount: null | { digits: "1,234.56", negative, zero,
// currency } }] }
// Shape: { company, title, period, footer, columns: ["Total"] | [...],
// rows: [{ kind: "section" | "subheading" | "line" | "subtotal"
// | "total" | "spacer",
// label, rule (rule above), currency ("$" on amounts),
// amounts: [null | { digits: "1,234.56", negative }] }] }
//
// Amounts follow US accounting presentation: tabular figures, "$" pinned
// to the left of the amount column on the first line of each section and on
// totals, negatives in parentheses with the closing parenthesis hanging
// (its width is reserved on every row, so decimal points always line up),
// and zero as a dash in the ones position. Amounts are never bold: bold
// figures are wider, which would knock totals out of the decimal column —
// the rules mark subtotals and totals instead.
// Amounts: Inter's tabular figures; negatives in parentheses with the
// closing parenthesis hanging (its width is reserved on every row, so
// decimal points always line up); "$" attached on subtotals and totals.
#let d = json(bytes(sys.inputs.data))
#let ncols = d.columns.len()
#let ink = luma(25)
#let quiet = luma(110)
#let hair = 0.5pt + luma(160)
#let firm = 0.6pt + ink
#set document(title: d.company + " — " + d.title, author: d.company)
#set text(
font: "Libertinus Serif",
size: 10.5pt,
number-type: "lining",
number-width: "tabular",
)
#set text(font: "Inter", size: 9pt, fill: ink, number-width: "tabular")
#set page(
paper: "us-letter",
margin: (x: 1in, top: 0.9in, bottom: 0.8in),
margin: (x: 0.75in, top: 0.75in, bottom: 0.75in),
header: context {
if counter(page).get().first() > 1 {
set text(8.5pt, fill: luma(110))
[#d.company — #d.title #h(1fr) #d.period]
set text(7.5pt, fill: quiet)
[#d.company · #d.title #h(1fr) #d.period]
}
},
footer: context {
set text(8pt, fill: luma(110))
[#d.footer #h(1fr) Page #counter(page).display() of #counter(page).final().first()]
set text(7.5pt, fill: quiet)
grid(
columns: (1fr, auto, 1fr),
[],
d.footer,
align(right)[Page #counter(page).display() of #counter(page).final().first()],
)
},
)
#align(center)[
#text(15pt, weight: "bold", d.company) \
#text(15pt, fill: luma(45), d.company) \
#v(4pt)
#text(10.5pt, weight: "bold", d.title) \
#v(2pt)
#text(12.5pt, d.title) \
#v(1pt)
#text(10pt, style: "italic", d.period)
#d.period
]
#v(16pt)
#v(12pt)
// Two lines under a statement total, the accounting double rule, set
// clear of the descenders.
#let double-rule(body) = stack(
body,
v(2.4pt),
line(length: 100%, stroke: 0.5pt),
v(1.4pt),
line(length: 100%, stroke: 0.5pt),
)
#let amount(r) = {
let a = r.amount
#let amount(a, currency) = {
if a == none { return [] }
let open = if a.negative { [(] } else { [] }
let close = if a.negative { [)] } else { hide[)] }
let body = if a.zero { [–#hide[.00]] } else { [#open#a.digits] }
grid(
columns: (auto, 1fr),
align: (left, right),
if a.currency [\$] else [],
[#body#close],
let sign = if currency [\$] else []
[#open#sign#a.digits#close]
}
#let full(body, ..args) = table.cell(colspan: ncols + 1, ..args, body)
#let row-cells(r) = {
let bold = r.kind == "subtotal" or r.kind == "total"
let stroke = if r.rule { (top: if r.kind == "total" { firm } else { hair }) } else { none }
let label = if r.kind == "line" { pad(left: 1.4em, r.label) } else { r.label }
let label = if bold { text(weight: "bold", label) } else { label }
(
table.cell(stroke: stroke, label),
..r.amounts.map(a => table.cell(stroke: stroke, amount(a, r.currency))),
)
}
#table(
columns: (1fr, 9em),
columns: (1fr,) + (8em,) * ncols,
stroke: none,
inset: (x: 0pt, y: 3.2pt),
align: (left + horizon, right + horizon),
inset: (x: 4pt, y: 3.6pt),
align: (left + horizon,) + (right + horizon,) * ncols,
table.header(
table.cell(stroke: (top: firm, bottom: hair), []),
..d.columns.map(c => table.cell(
stroke: (top: firm, bottom: hair),
text(7.5pt, weight: "bold", tracking: 0.04em, upper(c)),
)),
),
..d
.rows
.map(r => {
if r.kind == "spacer" {
(table.cell(colspan: 2, v(3pt)),)
(full(v(2pt), inset: 2pt),)
} else if r.kind == "section" {
(table.cell(colspan: 2, text(weight: "bold", r.label)),)
} else if r.kind == "line" {
(pad(left: 1.4em, r.label), amount(r))
} else if r.kind == "subtotal" {
(
text(weight: "bold", r.label),
table.cell(
stroke: if r.rule { (top: 0.5pt) } else { none },
amount(r),
(full(text(weight: "semibold", r.label)),)
} else if r.kind == "subheading" {
(full(pad(left: 1.4em, emph(r.label))),)
} else if r.kind == "total" {
// The double rule under a statement total, full width.
let double = full(
inset: (x: 0pt, y: 0pt),
stack(
spacing: 1.6pt,
line(length: 100%, stroke: firm),
line(length: 100%, stroke: firm),
),
)
row-cells(r) + (double,)
} else {
(
align(top, text(weight: "bold", r.label)),
table.cell(
stroke: (top: 0.5pt),
double-rule(amount(r)),
),
)
row-cells(r)
}
})
.flatten(),
+111 -3
View File
@@ -2,9 +2,11 @@
//!
//! `wanderbooks init` writes that file next to `books-data/`; onboarding
//! fills in `[company]`. The server reads the company name (report headers),
//! the fiscal year start (fiscal-year-to-date reports), and
//! `[books] receipt_threshold` (the receipt policy, unless `config.json`
//! sets one). The fiscal year *end* is never stored: it is the day before
//! the fiscal year start (fiscal-year-to-date reports), the members of a
//! multi-member LLC (`[[company.members]]`: name, share, capital account —
//! for the statement of members' equity), `[books] accounting_basis`
//! (report footers), and `[books] receipt_threshold` (the receipt policy,
//! unless `config.json` sets one). The fiscal year *end* is never stored: it is the day before
//! the next start, so the two can't disagree.
use chrono::{Datelike, NaiveDate};
@@ -19,6 +21,21 @@ pub struct Company {
pub name: String,
/// Month and day the fiscal year starts. Default January 1.
pub fiscal_year_start: (u32, u32),
/// Members of a multi-member LLC; empty for other entities.
pub members: Vec<Member>,
/// "Accrual" or "Cash", for report footers. Default "Accrual".
pub basis: String,
}
/// One member of a multi-member LLC.
#[derive(Clone, Debug, PartialEq)]
pub struct Member {
/// Name, as it appears on reports.
pub name: String,
/// Share of income, e.g. 0.50.
pub share: Decimal,
/// Code of their capital account in the chart.
pub capital_account: String,
}
impl Default for Company {
@@ -26,6 +43,8 @@ impl Default for Company {
Self {
name: "Unnamed company".to_string(),
fiscal_year_start: (1, 1),
members: Vec::new(),
basis: "Accrual".to_string(),
}
}
}
@@ -68,11 +87,35 @@ struct BooksToml {
struct CompanyToml {
name: Option<String>,
fiscal_year_start: Option<String>,
#[serde(default)]
members: Vec<MemberToml>,
}
#[derive(Deserialize)]
struct MemberToml {
name: String,
share: String,
capital_account: Option<String>,
}
#[derive(Default, Deserialize)]
struct BooksSection {
receipt_threshold: Option<String>,
accounting_basis: Option<String>,
}
/// Parse a share: "50%" or "0.5".
fn parse_share(name: &str, s: &str) -> Result<Decimal, String> {
let s = s.trim();
let bad = || format!("member {name}: share {s:?} must be like \"50%\" or \"0.5\"");
let share = match s.strip_suffix('%') {
Some(pct) => pct.trim().parse::<Decimal>().map_err(|_| bad())? / Decimal::from(100),
None => s.parse::<Decimal>().map_err(|_| bad())?,
};
if share <= Decimal::ZERO || share > Decimal::ONE {
return Err(bad());
}
Ok(share)
}
/// Parse `"MM-DD"`. February 29 is rejected: a fiscal year must start on a
@@ -97,6 +140,35 @@ pub fn parse(text: &str) -> Result<BooksSettings, String> {
if let Some(fy) = raw.company.fiscal_year_start {
company.fiscal_year_start = parse_month_day(fy.trim())?;
}
for m in raw.company.members {
let share = parse_share(&m.name, &m.share)?;
let capital_account = m
.capital_account
.filter(|c| !c.trim().is_empty())
.ok_or_else(|| format!("member {}: capital_account is required", m.name))?;
company.members.push(Member {
name: m.name.trim().to_string(),
share,
capital_account: capital_account.trim().to_string(),
});
}
if !company.members.is_empty() {
let total: Decimal = company.members.iter().map(|m| m.share).sum();
if total != Decimal::ONE {
return Err(format!("member shares sum to {total}, not 100%"));
}
}
if let Some(basis) = raw.books.accounting_basis {
company.basis = match basis.trim().to_lowercase().as_str() {
"accrual" => "Accrual".to_string(),
"cash" => "Cash".to_string(),
other => {
return Err(format!(
"accounting_basis {other:?}: expected accrual or cash"
));
}
};
}
let receipt_threshold = match raw.books.receipt_threshold {
Some(t) => {
t.trim()
@@ -174,6 +246,42 @@ mod tests {
);
}
#[test]
fn reads_members_and_basis() {
let s = parse(
"[company]\n[[company.members]]\nname = \"Stephen Waits\"\nshare = \"50%\"\ncapital_account = \"3000\"\n[[company.members]]\nname = \"Katherine Pendergast\"\nshare = \"0.5\"\ncapital_account = \"3001\"\n[books]\naccounting_basis = \"cash\"\n",
)
.unwrap();
let half = Decimal::new(5, 1);
assert_eq!(
s.company.members,
vec![
Member {
name: "Stephen Waits".into(),
share: half,
capital_account: "3000".into()
},
Member {
name: "Katherine Pendergast".into(),
share: half,
capital_account: "3001".into()
},
]
);
assert_eq!(s.company.basis, "Cash");
assert_eq!(parse("").unwrap().company.basis, "Accrual");
// Shares must add up; every member needs a capital account.
let two = |a: &str, b: &str| {
format!(
"[company]\n[[company.members]]\nname = \"A\"\nshare = \"{a}\"\ncapital_account = \"3000\"\n[[company.members]]\nname = \"B\"\nshare = \"{b}\"\ncapital_account = \"3001\"\n"
)
};
assert!(parse(&two("50%", "40%")).is_err());
assert!(parse(&two("60%", "40%")).is_ok());
assert!(parse("[company]\n[[company.members]]\nname = \"A\"\nshare = \"100%\"\n").is_err());
assert!(parse("[books]\naccounting_basis = \"modified\"\n").is_err());
}
#[test]
fn rejects_bad_settings() {
assert!(parse("[company]\nfiscal_year_start = \"02-29\"\n").is_err());
+354 -104
View File
@@ -68,10 +68,82 @@ impl BooksServer {
fn report_meta(&self, store: &Store) -> ReportMeta {
ReportMeta {
company: self.company.name.clone(),
basis: self.company.basis.clone(),
prepared_on: today(),
journal_seq: store.journal_seq(),
}
}
/// Members as reports name them (empty unless a multi-member LLC).
fn report_members(&self) -> Vec<ledger_import::Member> {
self.company
.members
.iter()
.map(|m| ledger_import::Member {
name: m.name.clone(),
capital_code: m.capital_account.clone(),
})
.collect()
}
/// Members with their capital accounts resolved in the chart, for the
/// statement of members' equity.
fn member_capitals(&self, ledger: &Ledger) -> Result<Vec<ledger_core::MemberCapital>, String> {
if self.company.members.is_empty() {
return Err(
"no members configured: add [[company.members]] (name, share, capital_account) to wanderbooks.toml"
.to_string(),
);
}
self.company
.members
.iter()
.map(|m| {
let account = ledger
.chart()
.find_by_code(&m.capital_account)
.ok_or_else(|| {
format!(
"member {}: unknown capital account {}",
m.name, m.capital_account
)
})?;
Ok(ledger_core::MemberCapital {
name: m.name.clone(),
capital_account: account.id,
share: m.share,
})
})
.collect()
}
/// 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> {
self.store
.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 {
chrono::Utc::now().date_naive()
chrono::Local::now().date_naive()
}
/// Resolve a statement's date range: `end` defaults to today and `start` to
@@ -470,13 +544,21 @@ impl BooksServer {
#[tool(
name = "books.trial_balance",
description = "The trial balance: every account with a non-zero balance, debit and credit columns and totals. Debits must equal credits.",
description = "The trial balance as of a date (default today): every account with a non-zero balance, debit and credit columns and totals. Debits must equal credits.",
annotations(read_only_hint = true)
)]
async fn trial_balance(&self) -> Result<Json<TrialBalanceOut>, String> {
async fn trial_balance(
&self,
params: Parameters<TrialBalanceParams>,
) -> Result<Json<TrialBalanceOut>, String> {
let store = self.lock()?;
let tb = reports::trial_balance(store.ledger());
let as_of = match &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 {
as_of: as_of.to_string(),
lines: tb
.lines
.iter()
@@ -568,7 +650,7 @@ impl BooksServer {
};
let end = match p.end {
Some(s) => parse_date(&s)?,
None => chrono::Utc::now().date_naive(),
None => today(),
};
// Resolve cash and financing account codes to ids.
let mut cash_accounts = Vec::new();
@@ -1161,7 +1243,7 @@ impl BooksServer {
async fn digest(&self, params: Parameters<DigestParams>) -> Result<Json<DigestOut>, String> {
let p = params.0;
let since = parse_date(&p.since)?;
let today = chrono::Utc::now().date_naive();
let today = today();
let store = self.lock()?;
let ledger = store.ledger();
let mut entries = Vec::new();
@@ -1385,7 +1467,7 @@ impl BooksServer {
#[tool(
name = "books.export",
description = "Export a report. Formats: csv, json, xlsx (base64), pdf (base64; profit_and_loss, balance_sheet, cash_flow), iif (journal only, for QuickBooks Desktop). Reports: journal, trial_balance, profit_and_loss, balance_sheet, cash_flow. cash_flow needs cash_accounts.",
description = "Export a report. Reports: profit_and_loss, balance_sheet, cash_flow (needs cash_accounts), trial_balance (as_of), members_equity (multi-member LLC; members from wanderbooks.toml), general_ledger (by account: running balances, splits), general_journal (every entry in date order; alias journal). Formats: pdf and xlsx (base64) for the five statements; xlsx, csv, json for every report; iif for general_journal (QuickBooks Desktop). Dates: start/end for period reports (start defaults to the first entry, end to today), as_of for balance_sheet and trial_balance.",
annotations(read_only_hint = true)
)]
async fn export(&self, params: Parameters<ExportParams>) -> Result<Json<ExportOut>, String> {
@@ -1424,14 +1506,16 @@ impl BooksServer {
}));
}
let content = match report.as_str() {
"journal" => self.export_journal(&format)?,
"trial_balance" => self.export_trial_balance(&format)?,
"journal" | "general_journal" => self.export_journal(&format)?,
"trial_balance" => self.export_trial_balance(&format, p.as_of.as_deref())?,
"general_ledger" => self.export_general_ledger(&format, &p)?,
"members_equity" => self.export_members_equity(&format, &p)?,
"profit_and_loss" => self.export_pnl(&format, p.start.as_deref(), p.end.as_deref())?,
"balance_sheet" => self.export_balance_sheet(&format, p.as_of.as_deref())?,
"cash_flow" => self.export_cash_flow(&format, &p)?,
_ => {
return Err(format!(
"bad report {:?}: expected journal, trial_balance, profit_and_loss, balance_sheet, or cash_flow",
"bad report {:?}: expected general_journal, general_ledger, trial_balance, profit_and_loss, balance_sheet, cash_flow, or members_equity",
p.report
));
}
@@ -1452,6 +1536,7 @@ impl BooksServer {
p: &ExportParams,
) -> Result<ledger_import::Statement, String> {
use ledger_import::report;
let members = self.report_members();
Ok(match report {
"profit_and_loss" => report::profit_and_loss(&pnl_statement(
ledger,
@@ -1459,17 +1544,38 @@ impl BooksServer {
p.end.as_deref(),
None,
)?),
"balance_sheet" => report::balance_sheet(&bs_statement(ledger, p.as_of.as_deref())?),
"cash_flow" => report::cash_flow(&cash_flow_statement(
ledger,
p.start.as_deref(),
p.end.as_deref(),
&p.cash_accounts,
&p.financing_accounts,
)?),
"balance_sheet" => {
let bs = bs_statement(ledger, p.as_of.as_deref())?;
let earnings = self.year_earnings(ledger, bs.as_of)?;
report::balance_sheet(&bs, earnings, &members)
}
"cash_flow" => report::cash_flow(
&cash_flow_statement(
ledger,
p.start.as_deref(),
p.end.as_deref(),
&p.cash_accounts,
&p.financing_accounts,
)?,
&members,
),
"trial_balance" => {
let as_of = match &p.as_of {
Some(s) => parse_date(s)?,
None => today(),
};
report::trial_balance(&reports::trial_balance_as_of(ledger, as_of), as_of)
}
"members_equity" => {
let (start, end) = statement_range(ledger, p.start.as_deref(), p.end.as_deref())?;
let me =
ledger_core::members_equity(ledger, start, end, &self.member_capitals(ledger)?)
.map_err(|e| e.to_string())?;
report::members_equity(&me)
}
_ => {
return Err(format!(
"bad report {report:?}: expected profit_and_loss, balance_sheet, or cash_flow"
"bad report {report:?}: expected profit_and_loss, balance_sheet, cash_flow, trial_balance, or members_equity"
));
}
})
@@ -1481,22 +1587,32 @@ impl BooksServer {
let store = self.lock()?;
let meta = self.report_meta(&store);
let ledger = store.ledger();
if report == "trial_balance" {
let tb = reports::trial_balance(ledger);
return report_to_xlsx(&XlsxReport::TrialBalance(&tb), &meta)
.map_err(|e| e.to_string());
let xlsx = |r: XlsxReport<'_>| report_to_xlsx(&r, &meta).map_err(|e| e.to_string());
match report {
"general_ledger" => {
let (start, end) = statement_range(ledger, p.start.as_deref(), p.end.as_deref())?;
let gl =
ledger_core::general_ledger(ledger, start, end).map_err(|e| e.to_string())?;
xlsx(XlsxReport::GeneralLedger(&gl))
}
"general_journal" | "journal" => {
let (start, end) = statement_range(ledger, p.start.as_deref(), p.end.as_deref())?;
xlsx(XlsxReport::GeneralJournal(ledger, start, end))
}
_ => xlsx(XlsxReport::Statement(
&self.layout_statement(ledger, report, p)?,
)),
}
let st = self.layout_statement(ledger, report, p)?;
report_to_xlsx(&XlsxReport::Statement(&st), &meta).map_err(|e| e.to_string())
}
/// Render a financial statement to PDF bytes via ledger-import.
fn export_pdf(&self, report: &str, p: &ExportParams) -> Result<Vec<u8>, String> {
if matches!(report, "general_ledger" | "general_journal" | "journal") {
return Err(format!("{report} exports as xlsx, csv, or json, not pdf"));
}
let store = self.lock()?;
let meta = self.report_meta(&store);
let st = self
.layout_statement(store.ledger(), report, p)
.map_err(|e| e.replace("bad report", "bad report for pdf"))?;
let st = self.layout_statement(store.ledger(), report, p)?;
ledger_import::statement_pdf(&st, &meta).map_err(|e| e.to_string())
}
@@ -1610,7 +1726,7 @@ impl BooksServer {
)]
async fn list_assets(&self) -> Result<Json<ListAssetsOut>, String> {
let store = self.lock()?;
let today = chrono::Utc::now().date_naive();
let today = today();
let assets = store
.ledger()
.registered_assets()
@@ -1853,7 +1969,7 @@ impl BooksServer {
#[tool(
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(
&self,
@@ -1865,53 +1981,53 @@ impl BooksServer {
if store.ledger().get_period(p.period_id).is_none() {
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 {
// Preview: compute what the closing entry would look like
// without mutating. We do this by inspecting revenue/expense
// balances directly.
let mut lines = Vec::new();
let mut accounts: Vec<_> = store.ledger().chart().all().collect();
accounts.sort_by(|a, b| a.code.cmp(&b.code));
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));
}
_ => {}
}
}
// Preview: run the real close on a copy of the ledger, so the
// preview is exactly what confirming would post.
let mut scratch = store.ledger().clone();
let closing = scratch
.close_period_to(p.period_id, &to)
.map_err(|e| e.to_string())?;
return Ok(Json(ClosePeriodOut {
status: "preview".to_string(),
period_id: p.period_id,
closing_entry: None,
lines,
lines: describe(&scratch, closing),
}));
}
let closing = store.close_period(p.period_id).map_err(|e| e.to_string())?;
// Build line descriptions from the posted closing entry.
let mut lines = Vec::new();
if let Some(eid) = 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));
}
}
let closing = store
.close_period_to(p.period_id, &to)
.map_err(|e| e.to_string())?;
let lines = describe(store.ledger(), closing);
Ok(Json(ClosePeriodOut {
status: "closed".to_string(),
period_id: p.period_id,
@@ -2425,7 +2541,7 @@ impl BooksServer {
};
let end = match p.end {
Some(s) => parse_date(&s)?,
None => chrono::Utc::now().date_naive(),
None => today(),
};
if end < start {
return Err("end is before start".to_string());
@@ -2504,7 +2620,7 @@ impl BooksServer {
#[tool(
name = "books.export_cpa_package",
description = "Build the CPA handoff package as a zip (base64): trial-balance.xlsx, general-ledger.xlsx (every posted line), profit-and-loss.pdf, balance-sheet.pdf, cash-flow.pdf. cash_accounts (e.g. [\"1000\"]) is required: the cash flow statement reconciles to them. The as_of date defaults to today; the P&L and cash flow cover the fiscal year to date.",
description = "Build the CPA handoff package as a zip (base64): the profit and loss, balance sheet, cash flow, statement of changes in members' equity (multi-member LLCs), and trial balance, each as PDF and XLSX; plus the general ledger (by account) and general journal (entry by entry) as XLSX. cash_accounts (e.g. [\"1000\"]) is required: the cash flow statement reconciles to them. as_of defaults to today; period reports cover the fiscal year to date.",
annotations(read_only_hint = true)
)]
async fn export_cpa_package(
@@ -2526,27 +2642,67 @@ impl BooksServer {
let (fy_start, as_of_s) = (fy_start.to_string(), as_of.to_string());
let xlsx = |r: XlsxReport<'_>| report_to_xlsx(&r, &meta).map_err(|e| e.to_string());
let pdf =
|st: ledger_import::Statement| statement_pdf(&st, &meta).map_err(|e| e.to_string());
|st: &ledger_import::Statement| statement_pdf(st, &meta).map_err(|e| e.to_string());
let fy = NaiveDate::parse_from_str(&fy_start, "%Y-%m-%d").map_err(|e| e.to_string())?;
let members = self.report_members();
let tb_xlsx = xlsx(XlsxReport::TrialBalance(&reports::trial_balance(ledger)))?;
let gl_xlsx = xlsx(XlsxReport::GeneralLedger(ledger))?;
let pnl_pdf = pdf(report::profit_and_loss(&pnl_statement(
ledger,
Some(&fy_start),
Some(&as_of_s),
None,
)?))?;
let bs_pdf = pdf(report::balance_sheet(&bs_statement(
ledger,
Some(&as_of_s),
)?))?;
let cf_pdf = pdf(report::cash_flow(&cash_flow_statement(
ledger,
Some(&fy_start),
Some(&as_of_s),
&p.cash_accounts,
&p.financing_accounts,
)?))?;
// The financial statements, each as PDF and XLSX.
let mut statements: Vec<(&str, ledger_import::Statement)> = vec![
(
"profit-and-loss",
report::profit_and_loss(&pnl_statement(
ledger,
Some(&fy_start),
Some(&as_of_s),
None,
)?),
),
(
"balance-sheet",
report::balance_sheet(
&bs_statement(ledger, Some(&as_of_s))?,
self.year_earnings(ledger, as_of)?,
&members,
),
),
(
"cash-flow",
report::cash_flow(
&cash_flow_statement(
ledger,
Some(&fy_start),
Some(&as_of_s),
&p.cash_accounts,
&p.financing_accounts,
)?,
&members,
),
),
];
if !self.company.members.is_empty() {
let me = ledger_core::members_equity(ledger, fy, as_of, &self.member_capitals(ledger)?)
.map_err(|e| e.to_string())?;
statements.push(("members-equity", report::members_equity(&me)));
}
statements.push((
"trial-balance",
report::trial_balance(&reports::trial_balance_as_of(ledger, as_of), as_of),
));
let mut files: Vec<(String, Vec<u8>)> = Vec::new();
for (name, st) in &statements {
files.push((format!("{name}.pdf"), pdf(st)?));
files.push((format!("{name}.xlsx"), xlsx(XlsxReport::Statement(st))?));
}
// The books behind them: by account, and entry by entry.
let gl = ledger_core::general_ledger(ledger, fy, as_of).map_err(|e| e.to_string())?;
files.push((
"general-ledger.xlsx".into(),
xlsx(XlsxReport::GeneralLedger(&gl))?,
));
files.push((
"general-journal.xlsx".into(),
xlsx(XlsxReport::GeneralJournal(ledger, fy, as_of))?,
));
drop(store);
// Zip it.
@@ -2554,14 +2710,9 @@ impl BooksServer {
{
let mut zip = zip::ZipWriter::new(std::io::Cursor::new(&mut zip_buf));
let opts = zip::write::SimpleFileOptions::default();
for (name, bytes) in [
("trial-balance.xlsx", tb_xlsx.as_slice()),
("general-ledger.xlsx", gl_xlsx.as_slice()),
("profit-and-loss.pdf", pnl_pdf.as_slice()),
("balance-sheet.pdf", bs_pdf.as_slice()),
("cash-flow.pdf", cf_pdf.as_slice()),
] {
zip.start_file(name, opts).map_err(|e| e.to_string())?;
for (name, bytes) in &files {
zip.start_file(name.as_str(), opts)
.map_err(|e| e.to_string())?;
use std::io::Write;
zip.write_all(bytes).map_err(|e| e.to_string())?;
}
@@ -2636,11 +2787,16 @@ impl BooksServer {
Ok(csv)
}
fn export_trial_balance(&self, format: &str) -> Result<String, String> {
fn export_trial_balance(&self, format: &str, as_of: Option<&str>) -> Result<String, String> {
let store = self.lock()?;
let tb = reports::trial_balance(store.ledger());
let as_of = match as_of {
Some(s) => parse_date(s)?,
None => today(),
};
let tb = reports::trial_balance_as_of(store.ledger(), as_of);
if format == "json" {
return serde_json::to_string_pretty(&serde_json::json!({
"as_of": as_of.to_string(),
"lines": tb.lines.iter().map(|l| serde_json::json!({
"code": l.code, "name": l.name,
"debit": money(l.debit), "credit": money(l.credit),
@@ -2669,6 +2825,98 @@ impl BooksServer {
Ok(csv)
}
fn export_general_ledger(&self, format: &str, p: &ExportParams) -> Result<String, String> {
let store = self.lock()?;
let ledger = store.ledger();
let (start, end) = statement_range(ledger, p.start.as_deref(), p.end.as_deref())?;
let gl = ledger_core::general_ledger(ledger, start, end).map_err(|e| e.to_string())?;
if format == "json" {
return serde_json::to_string_pretty(&serde_json::json!({
"start": gl.start.to_string(), "end": gl.end.to_string(),
"accounts": gl.accounts.iter().map(|a| serde_json::json!({
"code": a.code, "name": a.name,
"beginning_balance": money(a.beginning_balance),
"postings": a.postings.iter().map(|p| serde_json::json!({
"date": p.date.to_string(), "entry_id": p.entry_id,
"description": p.description, "split": p.split,
"debit": money(p.debit), "credit": money(p.credit),
"balance": money(p.balance),
})).collect::<Vec<_>>(),
"total_debits": money(a.total_debits),
"total_credits": money(a.total_credits),
"ending_balance": money(a.ending_balance),
})).collect::<Vec<_>>(),
}))
.map_err(|e| e.to_string());
}
let mut csv = String::from("account,date,entry,description,split,debit,credit,balance\n");
for a in &gl.accounts {
let account = csv_escape(&format!("{} {}", a.code, a.name));
csv.push_str(&format!(
"{account},,,Beginning balance,,,,{}\n",
money(a.beginning_balance)
));
for p in &a.postings {
csv.push_str(&format!(
"{account},{},{},{},{},{},{},{}\n",
p.date,
p.entry_id,
csv_escape(&p.description),
csv_escape(&p.split),
money(p.debit),
money(p.credit),
money(p.balance)
));
}
csv.push_str(&format!(
"{account},,,Total,,{},{},{}\n",
money(a.total_debits),
money(a.total_credits),
money(a.ending_balance)
));
}
Ok(csv)
}
fn export_members_equity(&self, format: &str, p: &ExportParams) -> Result<String, String> {
let store = self.lock()?;
let ledger = store.ledger();
let (start, end) = statement_range(ledger, p.start.as_deref(), p.end.as_deref())?;
let me = ledger_core::members_equity(ledger, start, end, &self.member_capitals(ledger)?)
.map_err(|e| e.to_string())?;
let col = |c: &ledger_core::MemberEquityColumn| {
serde_json::json!({
"name": c.name, "beginning": money(c.beginning),
"contributions": money(c.contributions), "net_income": money(c.net_income),
"distributions": money(c.distributions), "other": money(c.other),
"ending": money(c.ending),
})
};
if format == "json" {
return serde_json::to_string_pretty(&serde_json::json!({
"start": me.start.to_string(), "end": me.end.to_string(),
"members": me.members.iter().map(col).collect::<Vec<_>>(),
"total": col(&me.total),
}))
.map_err(|e| e.to_string());
}
let mut csv =
String::from("member,beginning,contributions,net_income,distributions,other,ending\n");
for c in me.members.iter().chain(std::iter::once(&me.total)) {
csv.push_str(&format!(
"{},{},{},{},{},{},{}\n",
csv_escape(&c.name),
money(c.beginning),
money(c.contributions),
money(c.net_income),
money(c.distributions),
money(c.other),
money(c.ending)
));
}
Ok(csv)
}
fn export_pnl(
&self,
format: &str,
@@ -2768,7 +3016,7 @@ impl BooksServer {
};
let end = match &p.end {
Some(s) => parse_date(s)?,
None => chrono::Utc::now().date_naive(),
None => today(),
};
let stmt = ledger_core::cash_flow(
ledger,
@@ -2879,7 +3127,9 @@ impl BooksServer {
serde_json::to_string_pretty(&out).map_err(|e| e.to_string())
}
"books://reports/trial-balance" => {
let Json(out) = self.trial_balance().await?;
let Json(out) = self
.trial_balance(Parameters(TrialBalanceParams::default()))
.await?;
serde_json::to_string_pretty(&out).map_err(|e| e.to_string())
}
"books://reports/balance-sheet" => {
+10
View File
@@ -76,9 +76,19 @@ pub struct TrialBalanceLineOut {
pub credit: String,
}
/// Parameters for `books.trial_balance`.
#[derive(Debug, Default, Deserialize, JsonSchema)]
pub struct TrialBalanceParams {
/// As of this date, YYYY-MM-DD. Defaults to today.
#[serde(default)]
pub as_of: Option<String>,
}
/// Result of `books.trial_balance`.
#[derive(Debug, Serialize, JsonSchema)]
pub struct TrialBalanceOut {
/// The as-of date, YYYY-MM-DD.
pub as_of: String,
/// Lines in code order.
pub lines: Vec<TrialBalanceLineOut>,
/// Sum of the debit column, decimal string.
+80 -3
View File
@@ -834,6 +834,7 @@ fn mcp_statement_pdfs_and_cpa_package_over_stdio() {
("profit_and_loss", json!({})),
("balance_sheet", json!({})),
("cash_flow", json!({"cash_accounts": ["1000"]})),
("trial_balance", json!({"as_of": "2026-10-31"})),
] {
let mut args = json!({"format": "pdf", "report": report});
args.as_object_mut()
@@ -850,11 +851,12 @@ fn mcp_statement_pdfs_and_cpa_package_over_stdio() {
json!({"format": "pdf", "report": "cash_flow"}),
);
assert!(err.contains("cash_accounts"), "{err}");
// The ledger and journal are workbooks, not printable statements.
let err = mcp.call_err(
"books.export",
json!({"format": "pdf", "report": "trial_balance"}),
json!({"format": "pdf", "report": "general_ledger"}),
);
assert!(err.contains("for pdf"), "{err}");
assert!(err.contains("not pdf"), "{err}");
// The CPA package carries all three statements.
let err = mcp.call_err("books.export_cpa_package", json!({"as_of": "2026-10-31"}));
@@ -873,9 +875,14 @@ fn mcp_statement_pdfs_and_cpa_package_over_stdio() {
names,
[
"balance-sheet.pdf",
"balance-sheet.xlsx",
"cash-flow.pdf",
"cash-flow.xlsx",
"general-journal.xlsx",
"general-ledger.xlsx",
"profit-and-loss.pdf",
"profit-and-loss.xlsx",
"trial-balance.pdf",
"trial-balance.xlsx"
]
);
@@ -892,7 +899,7 @@ fn mcp_period_statements_over_stdio() {
let toml = dir.join("wanderbooks.toml");
std::fs::write(
&toml,
"[company]\nname = \"Test Co\"\nfiscal_year_start = \"02-01\"\n[books]\nreceipt_threshold = \"5.00\"\n",
"[company]\nname = \"Test Co\"\nfiscal_year_start = \"02-01\"\n[[company.members]]\nname = \"Owner\"\nshare = \"100%\"\ncapital_account = \"3000\"\n[books]\nreceipt_threshold = \"5.00\"\n",
)
.unwrap();
let data = dir.join("books-data");
@@ -978,4 +985,74 @@ fn mcp_period_statements_over_stdio() {
assert_eq!(jan["current_earnings"], "4.00");
assert_eq!(jan["equity_accounts"][0]["amount"], "1000.00");
assert_eq!(jan["balanced"], true);
// Trial balance as of Jan 31: Dr cash 1,004.00 = Cr capital + sales.
let tb = mcp.call("books.trial_balance", json!({"as_of": "2026-01-31"}));
assert_eq!(tb["as_of"], "2026-01-31");
assert_eq!(tb["total_debits"], "1004.00");
// General ledger for February: cash starts at 1,004.00, pays 3.00 for
// software, takes in 2.50 of sales, ends at 1,003.50.
let gl = mcp.call(
"books.export",
json!({"format": "json", "report": "general_ledger", "start": "2026-02-01", "end": "2026-02-28"}),
);
let gl: serde_json::Value = serde_json::from_str(gl["content"].as_str().unwrap()).unwrap();
let cash = &gl["accounts"][0];
assert_eq!(cash["beginning_balance"], "1004.00");
assert_eq!(cash["postings"][0]["split"], "6000 Software");
assert_eq!(cash["postings"][0]["balance"], "1001.00");
assert_eq!(cash["postings"][1]["split"], "4000 Sales");
assert_eq!(cash["ending_balance"], "1003.50");
// Members' equity, Jan-Feb: the one member contributed 1,000.00 and
// earned 3.50 (6.50 sales - 3.00 software), ending at balance-sheet
// equity, 1,003.50.
let me = mcp.call(
"books.export",
json!({"format": "json", "report": "members_equity", "start": "2026-01-01", "end": "2026-02-28"}),
);
let me: serde_json::Value = serde_json::from_str(me["content"].as_str().unwrap()).unwrap();
assert_eq!(me["members"][0]["name"], "Owner");
assert_eq!(me["total"]["contributions"], "1000.00");
assert_eq!(me["total"]["net_income"], "3.50");
assert_eq!(me["total"]["ending"], "1003.50");
let pdf = mcp.call(
"books.export",
json!({"format": "pdf", "report": "members_equity", "end": "2026-02-28"}),
);
assert_eq!(pdf["content_encoding"], "base64");
// 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 chrono::{Months, NaiveDate};
use ledger_core::{
AccountId, AccountType, AssetId, AssetStatus, BillId, Class, ClassId, Currency, CustomerId,
DocumentLine, EntryId, EntryStatus, InvoiceId, Ledger, LedgerError, Money, NewAsset,
PayrollLine, PayrollRun, PayrollRunId, PeriodId, Side, VendorId, arap,
AccountId, AccountType, AssetId, AssetStatus, BillId, Class, ClassId, CloseTo, Currency,
CustomerId, DocumentLine, EntryId, EntryStatus, InvoiceId, Ledger, LedgerError, Money,
NewAsset, PayrollLine, PayrollRun, PayrollRunId, PeriodId, Side, VendorId, arap,
payroll::{PayrollAccounts, payroll_entry_plan, payroll_remit_plan},
reports,
};
@@ -955,14 +955,32 @@ impl Store {
/// re-runs the deterministic close logic and checks the closing entry
/// id against the journal.
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
// the Retained Earnings account creation below.
self.ledger.preview_close_period(id)?;
// Retained Earnings must exist before the close so its creation (if
// needed) is journaled as account.added; the close's own ensure
// then no-ops and replay stays deterministic.
self.ensure_control_account("3999", "Retained Earnings", AccountType::Equity)?;
let closing_entry = self.ledger.close_period(id)?;
if *to == CloseTo::RetainedEarnings {
// Retained Earnings must exist before the close so its creation
// (if needed) is journaled as account.added; the close's own
// ensure then no-ops and replay stays deterministic.
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 {
Some(eid) => {
let e = self
@@ -978,11 +996,21 @@ impl Store {
}
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(
"period.closed",
json!({
"id": id,
"closing_entry": closing_entry,
"close_to": close_to,
"entry": entry_detail,
}),
)?;
@@ -2738,15 +2766,44 @@ fn replay_line_into_ledger(
let id = get_u64(p, "id")?;
let want = p.get("closing_entry").and_then(|v| v.as_u64());
// Deterministic: the close re-derives the closing entry from
// the replayed state, so it must match the journaled id. The
// journaled entry detail is for the projection; the ledger
// re-derives the entry itself.
let got = ledger.close_period(id)?;
// the replayed state and the journaled target (absent: Retained
// Earnings), so it must match the journal's id and every line.
let to = match p.get("close_to").and_then(|v| v.get("members")) {
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 {
return Err(StoreError::ReplayMismatch(format!(
"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" => {
ledger.mark_cleared(
+69
View File
@@ -457,3 +457,72 @@ fn receipt_corrections_are_journaled_and_survive_rebuild() {
check(&store);
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
every report, `[company] fiscal_year_start` ("MM-DD", default "01-01";
the year's end is derived, never stored) defines fiscal-year-to-date for the
CPA package, and `[books] receipt_threshold` sets the receipt policy unless
CPA package and the balance sheet's net income line, `[[company.members]]`
(`name`, `share` as "50%" or "0.5", `capital_account` code; shares must sum
to 100%) drives the statement of members' equity, `[books]
accounting_basis` ("accrual" or "cash", default accrual) labels report
footers, and `[books] receipt_threshold` sets the receipt policy unless
`config.json` sets `receipt_policy`.
## Transports
@@ -85,8 +89,8 @@ below.
- `books.get_entry { entry_id }` → full entry with lines.
- `books.search_entries { account?, date_from?, date_to?, text?, limit?, offset? }`
→ entries with lines, newest first. `limit` caps at 100.
- `books.trial_balance {}` → canonical JSON (same shape as
`reports::snapshot` trial-balance section).
- `books.trial_balance { as_of? }` → `as_of`, lines, totals, `balanced`
(the trial balance as of the date, default today).
- `books.balance_sheet { as_of? }` → totals (`assets`, `liabilities`,
`equity`, `current_earnings`, `balanced`) plus `asset_accounts`,
`liability_accounts`, `equity_accounts` (`{code, name, amount}`), as of
@@ -192,12 +196,27 @@ posting.
## Tools — export
- `books.export { format: "csv" | "json" | "xlsx" | "pdf" | "iif", report:
"journal" | "trial_balance" | "profit_and_loss" | "balance_sheet" |
"cash_flow", start?, end?, as_of?, cash_accounts?, financing_accounts? }` →
`{ content, content_encoding? }`. CPA-ready output. `xlsx` and `pdf` are
base64-encoded; `pdf` covers the three financial statements; `iif` is
journal-only (QuickBooks Desktop). Cash flow needs `cash_accounts`.
- `books.export { format, report, start?, end?, as_of?, cash_accounts?,
financing_accounts? }` → `{ content, content_encoding? }`.
- Reports: `profit_and_loss`, `balance_sheet`, `cash_flow` (needs
`cash_accounts`), `trial_balance`, `members_equity` (statement of
changes in members' equity; members from `wanderbooks.toml`),
`general_ledger` (by account: beginning balance, postings with running
balance and split, ending balance), `general_journal` (alias
`journal`: every entry, line by line, in date order).
- Formats: `pdf` and `xlsx` (base64) for the five statements; `xlsx`,
`csv`, `json` for every report; `iif` for the journal (QuickBooks
Desktop).
- Dates: period reports take `start` (default: first entry) and `end`
(default: today, local time); `balance_sheet` and `trial_balance`
take `as_of`.
- Presentation (PDF and XLSX alike, QuickBooks-style): centered company,
title, and period; a column-header row; "$" on subtotals and totals;
negatives in parentheses with decimal points aligned; a rule above every
sum and a double rule under statement totals; a footer with the
accounting basis, preparation date, and journal seq. Multi-member LLCs
get "Members' equity" with members named; the balance sheet separates
retained earnings from the fiscal year's net income (loss).
## Tools — import
@@ -260,10 +279,13 @@ R-FUTURE-3 and rides the engine roadmap, not the MCP layer.
- `books.create_period { name, start, end }` → `{ period_id }`.
Dates are YYYY-MM-DD, inclusive.
- `books.close_period { period_id, confirmed? }` → without confirmation:
`{ status: "preview", lines: [...] }` showing the closing lines and posts
nothing; with `confirmed: true`: posts the closing entry (revenue debited
to zero, expenses credited to zero, net to `3999` Retained Earnings) and
marks the period closed. Closed periods reject new entries.
`{ status: "preview", lines: [...] }`: the exact closing entry (the close
run on a copy of the ledger), posting nothing; with `confirmed: true`:
posts it and marks the period closed. The entry zeroes revenue and
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,
closing_entry }] }`.
@@ -336,10 +358,9 @@ entries.
## Tools — CPA handoff
- `books.export_cpa_package { as_of?, cash_accounts, financing_accounts? }`
→ `{ file_name, content_base64 }`. A zip with `trial-balance.xlsx`,
`general-ledger.xlsx` (every posted line: date, entry, description,
account, class, debit, credit), `profit-and-loss.pdf` (fiscal year to
date, per-account detail), `balance-sheet.pdf` (per-account detail), and
`cash-flow.pdf` (fiscal year to date, indirect method, reconciled to
`cash_accounts`). What the CPA needs; they never
log into the system.
→ `{ file_name, content_base64 }`. A zip with the full set, each
statement as PDF and XLSX: `profit-and-loss`, `balance-sheet`,
`cash-flow` (reconciled to `cash_accounts`), `members-equity`
(multi-member LLCs), `trial-balance`; plus `general-ledger.xlsx` and
`general-journal.xlsx`. Period reports cover the fiscal year to date.
What the CPA needs; they never log into the system.
+3
View File
@@ -21,6 +21,9 @@ No other statuses.
| 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 |
| 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` |
| 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 |
+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
to the whole, and a range's net income equals the change in current
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
core reports as deterministic JSON (decimal strings, sorted lines, fixed
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
remain future.)
- **R-? — Periods and close.** Was R-FUTURE-2.
`ledger-core::periods`: `create_period`, `close_period` (posts closing
entry to `3999` Retained Earnings), closed periods reject new entries.
`ledger-core::periods`: `create_period`, `close_period_to(id, to)`; closed
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.
`ledger-core::arap`: customers, vendors, invoices, bills, payments,
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
lines by account code (it followed HashMap order, so line order could
change between runs).
- 2026-10-11: The canonical report set. Core gained the general ledger by
account (running balances; QuickBooks' "split" = the opposite side of the
entry, "-Split-" when several accounts), the trial balance as of a date,
and the statement of changes in members' equity (capital roll-forward per
member, allocation by share to the cent, ties to balance-sheet equity).
The entry-ordered listing is now correctly called the general journal.
Reports render QuickBooks-style (Stephen's reference): Inter (bundled,
OFL) instead of the typst-assets serif fonts, centered title block, a
column-header row, multi-column statements (trial balance Debit/Credit,
one column per member), "$" on sums, rules above sums, a double rule
under totals, zero as 0.00, entity terms (Members' equity, Net income
(loss), standard cash-flow wording) and an "Accrual basis · Prepared …"
footer. "Today" is local time, not UTC (evening exports were dated
tomorrow).
- 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.
- This is the trust layer for humans (Stephen, Kat, the CPA), not the
primary interface. Read-only at first; editing stays with the agent.
- Design references (Stephen, 2026-10-11; screenshots shared in session,
described here):
- **Reports with a date range and a column interval** (QuickBooks
Desktop's P&L: From/To plus Columns = Year/Quarter/Month/...): any
statement split into one column per interval plus TOTAL, to see the
books over time. The multi-column statement layout in
`ledger-import::report` already renders this shape; the GUI (and the
exports) need a per-interval builder over the period-scoped core
statements.
- **Financial dashboard** (Coupler.io's QuickBooks dashboard): P&L,
balance sheet, and cash flow overview tables with Last month / Month to
date / Year to date columns, each beside charts — income vs. COGS +
expenses vs. earnings bars; stacked assets vs. liabilities + equity;
cash at end of period; net cash change.
- **Business overview** (QuickBooks Online home): cards for cash balance
with a trend, expenses by category (donut), profit and loss (income
vs. expenses), invoices (unpaid, overdue, paid), sales trend, and bank
account balances.
- **Revenue dashboard** (Coupler.io): KPI tiles (paid, overdue, open
invoices this month), an invoice table (number, customer, dates,
currency, home and foreign amounts, balance, totals), expenses by
category by month, invoices by status per month.
- **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