Compare commits
4
Commits
f4cb01b3f9
...
8164abb2b8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8164abb2b8
|
||
|
|
f591264533
|
||
|
|
8f623ba810
|
||
|
|
02b3b9c053
|
No files matched your search
Generated
-1
@@ -1894,7 +1894,6 @@ dependencies = [
|
||||
"thiserror",
|
||||
"typst",
|
||||
"typst-as-lib",
|
||||
"typst-assets",
|
||||
"typst-layout",
|
||||
"typst-pdf",
|
||||
]
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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(_))
|
||||
));
|
||||
}
|
||||
@@ -14,7 +14,6 @@ thiserror = "2"
|
||||
rust_xlsxwriter = "0.90"
|
||||
typst-as-lib = { version = "0.17", default-features = false }
|
||||
typst-pdf = "0.15.1"
|
||||
typst-assets = { version = "0.15.1", features = ["fonts"] }
|
||||
typst = "0.15.1"
|
||||
serde_json = "1"
|
||||
typst-layout = "0.15.1"
|
||||
Binary file not shown.
@@ -0,0 +1,92 @@
|
||||
Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION AND CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
Binary file not shown.
Binary file not shown.
@@ -20,5 +20,5 @@ pub use bank_csv::{BankTransaction, parse_bank_csv};
|
||||
pub use chart_csv::{ChartRow, parse_chart_csv};
|
||||
pub use iif::{IifEntry, journal_to_iif};
|
||||
pub use pdf::{PdfError, statement_pdf};
|
||||
pub use report::{ReportMeta, Row, RowKind, Statement, format_amount};
|
||||
pub use report::{Member, ReportMeta, Row, RowKind, Statement, YearEarnings, format_amount};
|
||||
pub use xlsx::{XlsxReport, report_to_xlsx};
|
||||
@@ -4,9 +4,10 @@
|
||||
//! rows to `templates/statement.typ` as JSON data (`sys.inputs.data`) and
|
||||
//! renders the PDF. Amounts arrive already split by
|
||||
//! [`crate::report::amount_parts`] — Typst never does arithmetic on money,
|
||||
//! and labels are data, never markup. Fonts are bundled (`typst-assets`),
|
||||
//! so output doesn't depend on what's installed, and it is deterministic:
|
||||
//! the same statement renders to the same bytes.
|
||||
//! and labels are data, never markup. The font (Inter, SIL OFL 1.1; see
|
||||
//! `fonts/Inter-LICENSE.txt`) is bundled, so output doesn't depend on
|
||||
//! what's installed, and it is deterministic: the same statement and
|
||||
//! report metadata render to the same bytes.
|
||||
|
||||
use crate::report::{ReportMeta, RowKind, Statement, amount_parts};
|
||||
use std::sync::OnceLock;
|
||||
@@ -27,6 +28,11 @@ pub enum PdfError {
|
||||
}
|
||||
|
||||
static TEMPLATE: &str = include_str!("../templates/statement.typ");
|
||||
static INTER: [&[u8]; 3] = [
|
||||
include_bytes!("../fonts/Inter-Regular.ttf"),
|
||||
include_bytes!("../fonts/Inter-SemiBold.ttf"),
|
||||
include_bytes!("../fonts/Inter-Bold.ttf"),
|
||||
];
|
||||
|
||||
/// The engine, built once: parsing the bundled fonts is the expensive part.
|
||||
fn engine() -> &'static TypstEngine<TypstTemplateMainFile> {
|
||||
@@ -34,15 +40,17 @@ fn engine() -> &'static TypstEngine<TypstTemplateMainFile> {
|
||||
ENGINE.get_or_init(|| {
|
||||
TypstEngine::builder()
|
||||
.main_file(TEMPLATE)
|
||||
.fonts(typst_assets::fonts())
|
||||
.fonts(INTER)
|
||||
.build()
|
||||
})
|
||||
}
|
||||
|
||||
/// The template's input: the statement with every amount formatted.
|
||||
/// The template's input: the statement with every amount split for
|
||||
/// alignment.
|
||||
pub fn template_data(st: &Statement, meta: &ReportMeta) -> serde_json::Value {
|
||||
let kind = |k: RowKind| match k {
|
||||
RowKind::Section => "section",
|
||||
RowKind::Subheading => "subheading",
|
||||
RowKind::Line => "line",
|
||||
RowKind::Subtotal => "subtotal",
|
||||
RowKind::Total => "total",
|
||||
@@ -52,20 +60,17 @@ pub fn template_data(st: &Statement, meta: &ReportMeta) -> serde_json::Value {
|
||||
"company": meta.company,
|
||||
"title": st.title,
|
||||
"period": st.period,
|
||||
"footer": format!("Prepared from journal through seq {}", meta.journal_seq),
|
||||
"footer": meta.footer(),
|
||||
"columns": st.columns,
|
||||
"rows": st.rows.iter().map(|r| serde_json::json!({
|
||||
"kind": kind(r.kind),
|
||||
"label": r.label,
|
||||
"rule": r.rule_above,
|
||||
"amount": r.amount.map(|a| {
|
||||
"currency": r.currency,
|
||||
"amounts": r.amounts.iter().map(|a| a.map(|a| {
|
||||
let p = amount_parts(a);
|
||||
serde_json::json!({
|
||||
"digits": p.digits,
|
||||
"negative": p.negative,
|
||||
"zero": p.zero,
|
||||
"currency": r.currency,
|
||||
})
|
||||
}),
|
||||
serde_json::json!({ "digits": p.digits, "negative": p.negative })
|
||||
})).collect::<Vec<_>>(),
|
||||
})).collect::<Vec<_>>(),
|
||||
})
|
||||
}
|
||||
@@ -101,6 +106,8 @@ mod tests {
|
||||
fn meta() -> ReportMeta {
|
||||
ReportMeta {
|
||||
company: "Test Co".to_string(),
|
||||
basis: "Accrual".to_string(),
|
||||
prepared_on: NaiveDate::from_ymd_opt(2026, 10, 11).unwrap(),
|
||||
journal_seq: 42,
|
||||
}
|
||||
}
|
||||
@@ -127,6 +134,7 @@ mod tests {
|
||||
/// The exact data the template typesets: hand-checked, like a golden.
|
||||
#[test]
|
||||
fn template_data_is_exact() {
|
||||
let amt = |digits: &str, negative: bool| serde_json::json!([{"digits": digits, "negative": negative}]);
|
||||
let data = template_data(&profit_and_loss(&pl()), &meta());
|
||||
assert_eq!(
|
||||
data,
|
||||
@@ -134,22 +142,18 @@ mod tests {
|
||||
"company": "Test Co",
|
||||
"title": "Profit and Loss",
|
||||
"period": "October 4 – October 11, 2026",
|
||||
"footer": "Prepared from journal through seq 42",
|
||||
"footer": "Accrual basis · Prepared Sunday, October 11, 2026 · Journal seq 42",
|
||||
"columns": ["Total"],
|
||||
"rows": [
|
||||
{"kind": "section", "label": "Revenue", "rule": false, "amount": null},
|
||||
{"kind": "subtotal", "label": "Total revenue", "rule": false, "amount":
|
||||
{"digits": "", "negative": false, "zero": true, "currency": true}},
|
||||
{"kind": "spacer", "label": "", "rule": false, "amount": null},
|
||||
{"kind": "section", "label": "Expenses", "rule": false, "amount": null},
|
||||
{"kind": "line", "label": "6000 Organizational Costs", "rule": false, "amount":
|
||||
{"digits": "59.00", "negative": false, "zero": false, "currency": true}},
|
||||
{"kind": "line", "label": "6400 Memberships & Licenses", "rule": false, "amount":
|
||||
{"digits": "106.38", "negative": false, "zero": false, "currency": false}},
|
||||
{"kind": "subtotal", "label": "Total expenses", "rule": true, "amount":
|
||||
{"digits": "165.38", "negative": false, "zero": false, "currency": true}},
|
||||
{"kind": "spacer", "label": "", "rule": false, "amount": null},
|
||||
{"kind": "total", "label": "Net income", "rule": true, "amount":
|
||||
{"digits": "165.38", "negative": true, "zero": false, "currency": true}},
|
||||
{"kind": "section", "label": "Revenue", "rule": false, "currency": false, "amounts": []},
|
||||
{"kind": "subtotal", "label": "Total revenue", "rule": false, "currency": true, "amounts": amt("0.00", false)},
|
||||
{"kind": "spacer", "label": "", "rule": false, "currency": false, "amounts": []},
|
||||
{"kind": "section", "label": "Expenses", "rule": false, "currency": false, "amounts": []},
|
||||
{"kind": "line", "label": "6000 Organizational Costs", "rule": false, "currency": false, "amounts": amt("59.00", false)},
|
||||
{"kind": "line", "label": "6400 Memberships & Licenses", "rule": false, "currency": false, "amounts": amt("106.38", false)},
|
||||
{"kind": "subtotal", "label": "Total expenses", "rule": true, "currency": true, "amounts": amt("165.38", false)},
|
||||
{"kind": "spacer", "label": "", "rule": false, "currency": false, "amounts": []},
|
||||
{"kind": "total", "label": "Net income (loss)", "rule": true, "currency": true, "amounts": amt("165.38", true)},
|
||||
],
|
||||
})
|
||||
);
|
||||
@@ -172,7 +176,7 @@ mod tests {
|
||||
.map(|i| Row {
|
||||
kind: RowKind::Line,
|
||||
label: format!("#{i} *not bold* $x$ <label> @ref [link] \\ #panic()"),
|
||||
amount: Some(Decimal::new(i, 0)),
|
||||
amounts: vec![Some(Decimal::new(i, 0))],
|
||||
currency: false,
|
||||
rule_above: false,
|
||||
})
|
||||
|
||||
+424
-188
@@ -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
@@ -3,27 +3,29 @@
|
||||
//! Money is converted to f64 ONLY at this presentation boundary — never in
|
||||
//! a money path. All accounting math stays `Decimal`; the conversion happens
|
||||
//! when writing a cell, rounded to cents. Cells stay numeric (so a CPA can
|
||||
//! sum them) with an accounting number format: thousands separators,
|
||||
//! negatives in parentheses.
|
||||
//! sum them) with accounting number formats: thousands separators,
|
||||
//! negatives in parentheses with the closing parenthesis hanging (so
|
||||
//! decimal points align), "$" on subtotals and totals.
|
||||
|
||||
use crate::report::{ReportMeta, RowKind, Statement};
|
||||
use chrono::NaiveDate;
|
||||
use ledger_core::Ledger;
|
||||
use ledger_core::reports::TrialBalance;
|
||||
use ledger_core::reports::GeneralLedger;
|
||||
use rust_decimal::Decimal;
|
||||
use rust_xlsxwriter::{Format, FormatBorder, Workbook, Worksheet};
|
||||
use rust_xlsxwriter::{Format, FormatAlign, FormatBorder, Workbook, Worksheet};
|
||||
use thiserror::Error;
|
||||
|
||||
/// Which report to render.
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum XlsxReport<'a> {
|
||||
/// Trial balance lines.
|
||||
TrialBalance(&'a TrialBalance),
|
||||
/// A laid-out financial statement (P&L, balance sheet, cash flow); see
|
||||
/// [`crate::report`].
|
||||
/// A laid-out statement (P&L, balance sheet, cash flow, trial balance,
|
||||
/// members' equity); see [`crate::report`].
|
||||
Statement(&'a Statement),
|
||||
/// General ledger: every posted journal line. Needs the ledger for
|
||||
/// account names and class names.
|
||||
GeneralLedger(&'a Ledger),
|
||||
/// The general ledger: by account, with running balances and splits.
|
||||
GeneralLedger(&'a GeneralLedger),
|
||||
/// The general journal: every posted entry in `[start, end]`, line by
|
||||
/// line, in date order.
|
||||
GeneralJournal(&'a Ledger, NaiveDate, NaiveDate),
|
||||
}
|
||||
|
||||
/// XLSX errors.
|
||||
@@ -40,179 +42,332 @@ impl From<rust_xlsxwriter::XlsxError> for XlsxError {
|
||||
}
|
||||
}
|
||||
|
||||
/// Excel's Accounting format, with and without the currency symbol: "$"
|
||||
/// pinned left, digits right, negatives in parentheses with the closing
|
||||
/// parenthesis hanging (`_)` reserves its width on positives, so decimal
|
||||
/// points line up), zero as a dash.
|
||||
const ACCOUNTING: &str = r#"_(* #,##0.00_);_(* (#,##0.00);_(* "-"??_);_(@_)"#;
|
||||
const ACCOUNTING_USD: &str = r#"_($* #,##0.00_);_($* (#,##0.00);_($* "-"??_);_(@_)"#;
|
||||
/// Accounting number formats: the `_)` reserves a closing parenthesis's
|
||||
/// width on positives so decimal points line up with negatives.
|
||||
const PLAIN: &str = "#,##0.00_);(#,##0.00)";
|
||||
const DOLLARS: &str = "$#,##0.00_);($#,##0.00)";
|
||||
|
||||
/// Company name, title, and period across the top; returns the next row.
|
||||
fn header(
|
||||
sheet: &mut Worksheet,
|
||||
meta: &ReportMeta,
|
||||
title: &str,
|
||||
period: Option<&str>,
|
||||
) -> Result<u32, XlsxError> {
|
||||
let bold = Format::new().set_bold();
|
||||
sheet.write_string_with_format(0, 0, &meta.company, &bold.clone().set_font_size(14))?;
|
||||
sheet.write_string_with_format(1, 0, title, &bold)?;
|
||||
let mut row = 2;
|
||||
if let Some(period) = period {
|
||||
sheet.write_string(row, 0, period)?;
|
||||
row += 1;
|
||||
}
|
||||
Ok(row + 1)
|
||||
struct Formats {
|
||||
bold: Format,
|
||||
company: Format,
|
||||
centered: Format,
|
||||
header: Format,
|
||||
header_right: Format,
|
||||
plain: Format,
|
||||
dollars_sum: Format,
|
||||
dollars_total: Format,
|
||||
line_label: Format,
|
||||
sub_label: Format,
|
||||
note: Format,
|
||||
}
|
||||
|
||||
/// "Prepared from journal through seq N" under the last row.
|
||||
fn footer(sheet: &mut Worksheet, meta: &ReportMeta, row: u32) -> Result<(), XlsxError> {
|
||||
let note = Format::new().set_italic().set_font_color("#666666");
|
||||
sheet.write_string_with_format(
|
||||
impl Formats {
|
||||
fn new() -> Self {
|
||||
let bold = Format::new().set_bold();
|
||||
let ruled = |f: Format| f.set_border_top(FormatBorder::Thin);
|
||||
Self {
|
||||
company: Format::new()
|
||||
.set_font_size(14)
|
||||
.set_align(FormatAlign::Center),
|
||||
centered: Format::new().set_align(FormatAlign::Center),
|
||||
header: bold
|
||||
.clone()
|
||||
.set_font_size(9)
|
||||
.set_border_bottom(FormatBorder::Thin),
|
||||
header_right: bold
|
||||
.clone()
|
||||
.set_font_size(9)
|
||||
.set_border_bottom(FormatBorder::Thin)
|
||||
.set_align(FormatAlign::Right),
|
||||
plain: Format::new().set_num_format(PLAIN),
|
||||
dollars_sum: ruled(bold.clone().set_num_format(DOLLARS)),
|
||||
dollars_total: ruled(bold.clone().set_num_format(DOLLARS))
|
||||
.set_border_bottom(FormatBorder::Double),
|
||||
line_label: Format::new().set_indent(1),
|
||||
sub_label: Format::new().set_italic().set_indent(1),
|
||||
note: Format::new()
|
||||
.set_italic()
|
||||
.set_font_size(9)
|
||||
.set_font_color("#666666")
|
||||
.set_align(FormatAlign::Center),
|
||||
bold,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Company, title, and period centered over `width` columns; returns the
|
||||
/// next free row.
|
||||
fn header(
|
||||
sheet: &mut Worksheet,
|
||||
f: &Formats,
|
||||
meta: &ReportMeta,
|
||||
title: &str,
|
||||
period: &str,
|
||||
width: u16,
|
||||
) -> Result<u32, XlsxError> {
|
||||
let last = width.saturating_sub(1);
|
||||
sheet.merge_range(0, 0, 0, last, &meta.company, &f.company)?;
|
||||
sheet.merge_range(
|
||||
1,
|
||||
0,
|
||||
1,
|
||||
last,
|
||||
title,
|
||||
&f.bold.clone().set_align(FormatAlign::Center),
|
||||
)?;
|
||||
sheet.merge_range(2, 0, 2, last, period, &f.centered)?;
|
||||
Ok(4)
|
||||
}
|
||||
|
||||
/// Basis, preparation date, and journal seq under the last row.
|
||||
fn footer(
|
||||
sheet: &mut Worksheet,
|
||||
f: &Formats,
|
||||
meta: &ReportMeta,
|
||||
row: u32,
|
||||
width: u16,
|
||||
) -> Result<(), XlsxError> {
|
||||
sheet.merge_range(
|
||||
row + 1,
|
||||
0,
|
||||
format!("Prepared from journal through seq {}", meta.journal_seq),
|
||||
¬e,
|
||||
row + 1,
|
||||
width.saturating_sub(1),
|
||||
&meta.footer(),
|
||||
&f.note,
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn number(
|
||||
sheet: &mut Worksheet,
|
||||
row: u32,
|
||||
col: u16,
|
||||
d: Decimal,
|
||||
fmt: &Format,
|
||||
) -> Result<(), XlsxError> {
|
||||
sheet.write_number_with_format(row, col, dec_to_f64(d), fmt)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Render a report to XLSX bytes.
|
||||
pub fn report_to_xlsx(report: &XlsxReport<'_>, meta: &ReportMeta) -> Result<Vec<u8>, XlsxError> {
|
||||
let mut workbook = Workbook::new();
|
||||
let sheet = workbook.add_worksheet();
|
||||
let bold = Format::new().set_bold();
|
||||
let money = Format::new().set_num_format(ACCOUNTING);
|
||||
let usd = Format::new().set_num_format(ACCOUNTING_USD);
|
||||
// Totals are marked by rules, not bold: bold figures are wider and would
|
||||
// break the decimal column.
|
||||
let usd_bold = usd.clone();
|
||||
|
||||
let f = Formats::new();
|
||||
match report {
|
||||
XlsxReport::TrialBalance(tb) => {
|
||||
sheet.set_name("Trial Balance")?;
|
||||
let mut row = header(sheet, meta, "Trial Balance", None)?;
|
||||
for (i, h) in ["Code", "Account", "Debit", "Credit"].iter().enumerate() {
|
||||
sheet.write_string_with_format(row, i as u16, *h, &bold)?;
|
||||
}
|
||||
row += 1;
|
||||
for line in &tb.lines {
|
||||
sheet.write_string(row, 0, &line.code)?;
|
||||
sheet.write_string(row, 1, &line.name)?;
|
||||
sheet.write_number_with_format(row, 2, dec_to_f64(line.debit), &money)?;
|
||||
sheet.write_number_with_format(row, 3, dec_to_f64(line.credit), &money)?;
|
||||
row += 1;
|
||||
}
|
||||
let total = usd_bold
|
||||
.clone()
|
||||
.set_border_top(FormatBorder::Thin)
|
||||
.set_border_bottom(FormatBorder::Double);
|
||||
sheet.write_string_with_format(row, 1, "Total", &bold)?;
|
||||
sheet.write_number_with_format(row, 2, dec_to_f64(tb.total_debits), &total)?;
|
||||
sheet.write_number_with_format(row, 3, dec_to_f64(tb.total_credits), &total)?;
|
||||
sheet.set_column_width(1, 40)?;
|
||||
sheet.set_column_width(2, 14)?;
|
||||
sheet.set_column_width(3, 14)?;
|
||||
footer(sheet, meta, row + 1)?;
|
||||
}
|
||||
XlsxReport::Statement(st) => {
|
||||
sheet.set_name(&st.title)?;
|
||||
let mut row = header(sheet, meta, &st.title, Some(&st.period))?;
|
||||
let line_label = Format::new().set_indent(1);
|
||||
let subtotal = usd_bold.clone().set_border_top(FormatBorder::Thin);
|
||||
let total = usd_bold
|
||||
.clone()
|
||||
.set_border_top(FormatBorder::Thin)
|
||||
.set_border_bottom(FormatBorder::Double);
|
||||
for r in &st.rows {
|
||||
let amount = r.amount.map(dec_to_f64);
|
||||
match r.kind {
|
||||
RowKind::Spacer => {}
|
||||
RowKind::Section => {
|
||||
sheet.write_string_with_format(row, 0, &r.label, &bold)?;
|
||||
}
|
||||
RowKind::Line => {
|
||||
sheet.write_string_with_format(row, 0, &r.label, &line_label)?;
|
||||
if let Some(a) = amount {
|
||||
let fmt = if r.currency { &usd } else { &money };
|
||||
sheet.write_number_with_format(row, 1, a, fmt)?;
|
||||
}
|
||||
}
|
||||
RowKind::Subtotal => {
|
||||
sheet.write_string_with_format(row, 0, &r.label, &bold)?;
|
||||
if let Some(a) = amount {
|
||||
let fmt = if r.rule_above { &subtotal } else { &usd_bold };
|
||||
sheet.write_number_with_format(row, 1, a, fmt)?;
|
||||
}
|
||||
}
|
||||
RowKind::Total => {
|
||||
sheet.write_string_with_format(row, 0, &r.label, &bold)?;
|
||||
if let Some(a) = amount {
|
||||
sheet.write_number_with_format(row, 1, a, &total)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
row += 1;
|
||||
}
|
||||
sheet.set_column_width(0, 60)?;
|
||||
sheet.set_column_width(1, 16)?;
|
||||
footer(sheet, meta, row)?;
|
||||
}
|
||||
XlsxReport::GeneralLedger(ledger) => {
|
||||
sheet.set_name("General Ledger")?;
|
||||
let mut row = header(sheet, meta, "General Ledger", None)?;
|
||||
for (i, h) in [
|
||||
"Date",
|
||||
"Entry",
|
||||
"Description",
|
||||
"Account Code",
|
||||
"Account Name",
|
||||
"Class",
|
||||
"Debit",
|
||||
"Credit",
|
||||
]
|
||||
.iter()
|
||||
.enumerate()
|
||||
{
|
||||
sheet.write_string_with_format(row, i as u16, *h, &bold)?;
|
||||
}
|
||||
row += 1;
|
||||
for entry in ledger.posted_entries() {
|
||||
for line in entry.lines() {
|
||||
let account = ledger.chart().get(line.account);
|
||||
let (code, name) = match account {
|
||||
Some(a) => (a.code.as_str(), a.name.as_str()),
|
||||
None => ("?", "?"),
|
||||
};
|
||||
let class_name = line
|
||||
.class_id
|
||||
.and_then(|cid| ledger.get_class(cid))
|
||||
.map(|c| c.name.as_str())
|
||||
.unwrap_or("");
|
||||
let (debit, credit) = match line.side {
|
||||
ledger_core::Side::Debit => (line.amount.amount, Decimal::ZERO),
|
||||
ledger_core::Side::Credit => (Decimal::ZERO, line.amount.amount),
|
||||
};
|
||||
sheet.write_string(row, 0, entry.date().to_string())?;
|
||||
sheet.write_number(row, 1, entry.id() as f64)?;
|
||||
sheet.write_string(row, 2, entry.description())?;
|
||||
sheet.write_string(row, 3, code)?;
|
||||
sheet.write_string(row, 4, name)?;
|
||||
sheet.write_string(row, 5, class_name)?;
|
||||
sheet.write_number_with_format(row, 6, dec_to_f64(debit), &money)?;
|
||||
sheet.write_number_with_format(row, 7, dec_to_f64(credit), &money)?;
|
||||
row += 1;
|
||||
}
|
||||
}
|
||||
sheet.set_column_width(2, 50)?;
|
||||
sheet.set_column_width(4, 32)?;
|
||||
footer(sheet, meta, row)?;
|
||||
XlsxReport::Statement(st) => statement(sheet, &f, meta, st)?,
|
||||
XlsxReport::GeneralLedger(gl) => general_ledger(sheet, &f, meta, gl)?,
|
||||
XlsxReport::GeneralJournal(ledger, start, end) => {
|
||||
general_journal(sheet, &f, meta, ledger, *start, *end)?
|
||||
}
|
||||
}
|
||||
|
||||
Ok(workbook.save_to_buffer()?)
|
||||
}
|
||||
|
||||
fn statement(
|
||||
sheet: &mut Worksheet,
|
||||
f: &Formats,
|
||||
meta: &ReportMeta,
|
||||
st: &Statement,
|
||||
) -> Result<(), XlsxError> {
|
||||
// Excel sheet names: at most 31 characters, no apostrophes at the ends.
|
||||
let name: String = st.title.replace('\'', "").chars().take(31).collect();
|
||||
sheet.set_name(name.trim())?;
|
||||
let width = 1 + st.columns.len() as u16;
|
||||
let mut row = header(sheet, f, meta, &st.title, &st.period, width)?;
|
||||
sheet.write_string_with_format(row, 0, "", &f.header)?;
|
||||
for (i, c) in st.columns.iter().enumerate() {
|
||||
sheet.write_string_with_format(row, 1 + i as u16, c.to_uppercase(), &f.header_right)?;
|
||||
}
|
||||
row += 1;
|
||||
let dollars_unruled = f.bold.clone().set_num_format(DOLLARS);
|
||||
for r in &st.rows {
|
||||
let (label_fmt, amount_fmt) = match r.kind {
|
||||
RowKind::Spacer => {
|
||||
row += 1;
|
||||
continue;
|
||||
}
|
||||
RowKind::Section => (&f.bold, &f.plain),
|
||||
RowKind::Subheading => (&f.sub_label, &f.plain),
|
||||
RowKind::Line => (&f.line_label, &f.plain),
|
||||
RowKind::Subtotal if r.rule_above => (&f.bold, &f.dollars_sum),
|
||||
RowKind::Subtotal => (&f.bold, &dollars_unruled),
|
||||
RowKind::Total => (&f.bold, &f.dollars_total),
|
||||
};
|
||||
sheet.write_string_with_format(row, 0, &r.label, label_fmt)?;
|
||||
for (i, a) in r.amounts.iter().enumerate() {
|
||||
match a {
|
||||
Some(d) => number(sheet, row, 1 + i as u16, *d, amount_fmt)?,
|
||||
None if r.rule_above => {
|
||||
sheet.write_blank(row, 1 + i as u16, amount_fmt)?;
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
row += 1;
|
||||
}
|
||||
sheet.set_column_width(0, 58)?;
|
||||
for i in 0..st.columns.len() as u16 {
|
||||
sheet.set_column_width(1 + i, 16)?;
|
||||
}
|
||||
footer(sheet, f, meta, row, width)
|
||||
}
|
||||
|
||||
fn general_ledger(
|
||||
sheet: &mut Worksheet,
|
||||
f: &Formats,
|
||||
meta: &ReportMeta,
|
||||
gl: &GeneralLedger,
|
||||
) -> Result<(), XlsxError> {
|
||||
sheet.set_name("General Ledger")?;
|
||||
let period = crate::report::period_label(gl.start, gl.end);
|
||||
let cols = [
|
||||
"Date",
|
||||
"Entry",
|
||||
"Description",
|
||||
"Split",
|
||||
"Debit",
|
||||
"Credit",
|
||||
"Balance",
|
||||
];
|
||||
let mut row = header(sheet, f, meta, "General Ledger", &period, cols.len() as u16)?;
|
||||
for (i, h) in cols.iter().enumerate() {
|
||||
let fmt = if i >= 4 { &f.header_right } else { &f.header };
|
||||
sheet.write_string_with_format(row, i as u16, h.to_uppercase(), fmt)?;
|
||||
}
|
||||
row += 1;
|
||||
for a in &gl.accounts {
|
||||
let name = format!("{} {}", a.code, a.name);
|
||||
sheet.write_string_with_format(row, 0, &name, &f.bold)?;
|
||||
row += 1;
|
||||
sheet.write_string_with_format(row, 2, "Beginning balance", &f.line_label)?;
|
||||
number(sheet, row, 6, a.beginning_balance, &f.plain)?;
|
||||
row += 1;
|
||||
for p in &a.postings {
|
||||
sheet.write_string(row, 0, p.date.to_string())?;
|
||||
sheet.write_number(row, 1, p.entry_id as f64)?;
|
||||
sheet.write_string(row, 2, &p.description)?;
|
||||
sheet.write_string(row, 3, &p.split)?;
|
||||
if !p.debit.is_zero() {
|
||||
number(sheet, row, 4, p.debit, &f.plain)?;
|
||||
}
|
||||
if !p.credit.is_zero() {
|
||||
number(sheet, row, 5, p.credit, &f.plain)?;
|
||||
}
|
||||
number(sheet, row, 6, p.balance, &f.plain)?;
|
||||
row += 1;
|
||||
}
|
||||
sheet.write_string_with_format(row, 0, format!("Total for {name}"), &f.bold)?;
|
||||
for c in 1..4 {
|
||||
sheet.write_blank(row, c, &f.dollars_sum)?;
|
||||
}
|
||||
number(sheet, row, 4, a.total_debits, &f.dollars_sum)?;
|
||||
number(sheet, row, 5, a.total_credits, &f.dollars_sum)?;
|
||||
number(sheet, row, 6, a.ending_balance, &f.dollars_sum)?;
|
||||
row += 2;
|
||||
}
|
||||
for (c, w) in [
|
||||
(0, 12.0),
|
||||
(1, 7.0),
|
||||
(2, 46.0),
|
||||
(3, 34.0),
|
||||
(4, 13.0),
|
||||
(5, 13.0),
|
||||
(6, 13.0),
|
||||
] {
|
||||
sheet.set_column_width(c, w)?;
|
||||
}
|
||||
footer(sheet, f, meta, row, cols.len() as u16)
|
||||
}
|
||||
|
||||
fn general_journal(
|
||||
sheet: &mut Worksheet,
|
||||
f: &Formats,
|
||||
meta: &ReportMeta,
|
||||
ledger: &Ledger,
|
||||
start: NaiveDate,
|
||||
end: NaiveDate,
|
||||
) -> Result<(), XlsxError> {
|
||||
sheet.set_name("General Journal")?;
|
||||
let period = crate::report::period_label(start, end);
|
||||
let cols = [
|
||||
"Date",
|
||||
"Entry",
|
||||
"Description",
|
||||
"Account",
|
||||
"Class",
|
||||
"Debit",
|
||||
"Credit",
|
||||
];
|
||||
let mut row = header(
|
||||
sheet,
|
||||
f,
|
||||
meta,
|
||||
"General Journal",
|
||||
&period,
|
||||
cols.len() as u16,
|
||||
)?;
|
||||
for (i, h) in cols.iter().enumerate() {
|
||||
let fmt = if i >= 5 { &f.header_right } else { &f.header };
|
||||
sheet.write_string_with_format(row, i as u16, h.to_uppercase(), fmt)?;
|
||||
}
|
||||
row += 1;
|
||||
let mut entries: Vec<_> = ledger
|
||||
.posted_entries()
|
||||
.filter(|e| e.date() >= start && e.date() <= end)
|
||||
.collect();
|
||||
entries.sort_by_key(|e| (e.date(), e.id()));
|
||||
let (mut debits, mut credits) = (Decimal::ZERO, Decimal::ZERO);
|
||||
for entry in entries {
|
||||
for line in entry.lines() {
|
||||
let account = ledger
|
||||
.chart()
|
||||
.get(line.account)
|
||||
.map(|a| format!("{} {}", a.code, a.name))
|
||||
.unwrap_or_else(|| "?".to_string());
|
||||
let class = line
|
||||
.class_id
|
||||
.and_then(|cid| ledger.get_class(cid))
|
||||
.map(|c| c.name.as_str())
|
||||
.unwrap_or("");
|
||||
sheet.write_string(row, 0, entry.date().to_string())?;
|
||||
sheet.write_number(row, 1, entry.id() as f64)?;
|
||||
sheet.write_string(row, 2, entry.description())?;
|
||||
sheet.write_string(row, 3, &account)?;
|
||||
sheet.write_string(row, 4, class)?;
|
||||
match line.side {
|
||||
ledger_core::Side::Debit => {
|
||||
number(sheet, row, 5, line.amount.amount, &f.plain)?;
|
||||
debits += line.amount.amount;
|
||||
}
|
||||
ledger_core::Side::Credit => {
|
||||
number(sheet, row, 6, line.amount.amount, &f.plain)?;
|
||||
credits += line.amount.amount;
|
||||
}
|
||||
}
|
||||
row += 1;
|
||||
}
|
||||
}
|
||||
sheet.write_string_with_format(row, 0, "Total", &f.bold)?;
|
||||
for c in 1..5 {
|
||||
sheet.write_blank(row, c, &f.dollars_total)?;
|
||||
}
|
||||
number(sheet, row, 5, debits, &f.dollars_total)?;
|
||||
number(sheet, row, 6, credits, &f.dollars_total)?;
|
||||
for (c, w) in [
|
||||
(0, 12.0),
|
||||
(1, 7.0),
|
||||
(2, 50.0),
|
||||
(3, 38.0),
|
||||
(4, 12.0),
|
||||
(5, 13.0),
|
||||
(6, 13.0),
|
||||
] {
|
||||
sheet.set_column_width(c, w)?;
|
||||
}
|
||||
footer(sheet, f, meta, row + 1, cols.len() as u16)
|
||||
}
|
||||
|
||||
/// Decimal to f64 at the presentation boundary. Rounded to cents first.
|
||||
fn dec_to_f64(d: Decimal) -> f64 {
|
||||
use rust_decimal::prelude::ToPrimitive;
|
||||
@@ -222,7 +377,8 @@ fn dec_to_f64(d: Decimal) -> f64 {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use ledger_core::reports::TrialBalanceLine;
|
||||
use crate::report;
|
||||
use ledger_core::reports::{TrialBalance, TrialBalanceLine};
|
||||
|
||||
#[test]
|
||||
fn xlsx_is_a_zip() {
|
||||
@@ -238,9 +394,12 @@ mod tests {
|
||||
};
|
||||
let meta = ReportMeta {
|
||||
company: "Test Co".to_string(),
|
||||
basis: "Accrual".to_string(),
|
||||
prepared_on: NaiveDate::from_ymd_opt(2026, 10, 11).unwrap(),
|
||||
journal_seq: 7,
|
||||
};
|
||||
let bytes = report_to_xlsx(&XlsxReport::TrialBalance(&tb), &meta).unwrap();
|
||||
let st = report::trial_balance(&tb, NaiveDate::from_ymd_opt(2026, 10, 11).unwrap());
|
||||
let bytes = report_to_xlsx(&XlsxReport::Statement(&st), &meta).unwrap();
|
||||
// XLSX is a ZIP: starts with PK.
|
||||
assert_eq!(&bytes[0..2], b"PK");
|
||||
assert!(bytes.len() > 1000);
|
||||
|
||||
@@ -1,111 +1,114 @@
|
||||
// A financial statement: P&L, balance sheet, or cash flow.
|
||||
// A financial statement: P&L, balance sheet, cash flow, trial balance, or
|
||||
// members' equity, in US small-business (QuickBooks-style) presentation.
|
||||
//
|
||||
// Everything arrives as data in sys.inputs.data (JSON), already laid out by
|
||||
// ledger-import::report, with amounts already split into exact parts. The
|
||||
// template only typesets: no arithmetic happens here, and no label is ever
|
||||
// parsed as markup.
|
||||
//
|
||||
// Shape: { company, title, period, footer,
|
||||
// rows: [{ kind: "section" | "line" | "subtotal" | "total"
|
||||
// | "spacer", label, rule (single rule above),
|
||||
// amount: null | { digits: "1,234.56", negative, zero,
|
||||
// currency } }] }
|
||||
// Shape: { company, title, period, footer, columns: ["Total"] | [...],
|
||||
// rows: [{ kind: "section" | "subheading" | "line" | "subtotal"
|
||||
// | "total" | "spacer",
|
||||
// label, rule (rule above), currency ("$" on amounts),
|
||||
// amounts: [null | { digits: "1,234.56", negative }] }] }
|
||||
//
|
||||
// Amounts follow US accounting presentation: tabular figures, "$" pinned
|
||||
// to the left of the amount column on the first line of each section and on
|
||||
// totals, negatives in parentheses with the closing parenthesis hanging
|
||||
// (its width is reserved on every row, so decimal points always line up),
|
||||
// and zero as a dash in the ones position. Amounts are never bold: bold
|
||||
// figures are wider, which would knock totals out of the decimal column —
|
||||
// the rules mark subtotals and totals instead.
|
||||
// Amounts: Inter's tabular figures; negatives in parentheses with the
|
||||
// closing parenthesis hanging (its width is reserved on every row, so
|
||||
// decimal points always line up); "$" attached on subtotals and totals.
|
||||
|
||||
#let d = json(bytes(sys.inputs.data))
|
||||
#let ncols = d.columns.len()
|
||||
#let ink = luma(25)
|
||||
#let quiet = luma(110)
|
||||
#let hair = 0.5pt + luma(160)
|
||||
#let firm = 0.6pt + ink
|
||||
|
||||
#set document(title: d.company + " — " + d.title, author: d.company)
|
||||
#set text(
|
||||
font: "Libertinus Serif",
|
||||
size: 10.5pt,
|
||||
number-type: "lining",
|
||||
number-width: "tabular",
|
||||
)
|
||||
#set text(font: "Inter", size: 9pt, fill: ink, number-width: "tabular")
|
||||
#set page(
|
||||
paper: "us-letter",
|
||||
margin: (x: 1in, top: 0.9in, bottom: 0.8in),
|
||||
margin: (x: 0.75in, top: 0.75in, bottom: 0.75in),
|
||||
header: context {
|
||||
if counter(page).get().first() > 1 {
|
||||
set text(8.5pt, fill: luma(110))
|
||||
[#d.company — #d.title #h(1fr) #d.period]
|
||||
set text(7.5pt, fill: quiet)
|
||||
[#d.company · #d.title #h(1fr) #d.period]
|
||||
}
|
||||
},
|
||||
footer: context {
|
||||
set text(8pt, fill: luma(110))
|
||||
[#d.footer #h(1fr) Page #counter(page).display() of #counter(page).final().first()]
|
||||
set text(7.5pt, fill: quiet)
|
||||
grid(
|
||||
columns: (1fr, auto, 1fr),
|
||||
[],
|
||||
d.footer,
|
||||
align(right)[Page #counter(page).display() of #counter(page).final().first()],
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
#align(center)[
|
||||
#text(15pt, weight: "bold", d.company) \
|
||||
#text(15pt, fill: luma(45), d.company) \
|
||||
#v(4pt)
|
||||
#text(10.5pt, weight: "bold", d.title) \
|
||||
#v(2pt)
|
||||
#text(12.5pt, d.title) \
|
||||
#v(1pt)
|
||||
#text(10pt, style: "italic", d.period)
|
||||
#d.period
|
||||
]
|
||||
#v(16pt)
|
||||
#v(12pt)
|
||||
|
||||
// Two lines under a statement total, the accounting double rule, set
|
||||
// clear of the descenders.
|
||||
#let double-rule(body) = stack(
|
||||
body,
|
||||
v(2.4pt),
|
||||
line(length: 100%, stroke: 0.5pt),
|
||||
v(1.4pt),
|
||||
line(length: 100%, stroke: 0.5pt),
|
||||
)
|
||||
|
||||
#let amount(r) = {
|
||||
let a = r.amount
|
||||
#let amount(a, currency) = {
|
||||
if a == none { return [] }
|
||||
let open = if a.negative { [(] } else { [] }
|
||||
let close = if a.negative { [)] } else { hide[)] }
|
||||
let body = if a.zero { [–#hide[.00]] } else { [#open#a.digits] }
|
||||
grid(
|
||||
columns: (auto, 1fr),
|
||||
align: (left, right),
|
||||
if a.currency [\$] else [],
|
||||
[#body#close],
|
||||
let sign = if currency [\$] else []
|
||||
[#open#sign#a.digits#close]
|
||||
}
|
||||
|
||||
#let full(body, ..args) = table.cell(colspan: ncols + 1, ..args, body)
|
||||
|
||||
#let row-cells(r) = {
|
||||
let bold = r.kind == "subtotal" or r.kind == "total"
|
||||
let stroke = if r.rule { (top: if r.kind == "total" { firm } else { hair }) } else { none }
|
||||
let label = if r.kind == "line" { pad(left: 1.4em, r.label) } else { r.label }
|
||||
let label = if bold { text(weight: "bold", label) } else { label }
|
||||
(
|
||||
table.cell(stroke: stroke, label),
|
||||
..r.amounts.map(a => table.cell(stroke: stroke, amount(a, r.currency))),
|
||||
)
|
||||
}
|
||||
|
||||
#table(
|
||||
columns: (1fr, 9em),
|
||||
columns: (1fr,) + (8em,) * ncols,
|
||||
stroke: none,
|
||||
inset: (x: 0pt, y: 3.2pt),
|
||||
align: (left + horizon, right + horizon),
|
||||
inset: (x: 4pt, y: 3.6pt),
|
||||
align: (left + horizon,) + (right + horizon,) * ncols,
|
||||
table.header(
|
||||
table.cell(stroke: (top: firm, bottom: hair), []),
|
||||
..d.columns.map(c => table.cell(
|
||||
stroke: (top: firm, bottom: hair),
|
||||
text(7.5pt, weight: "bold", tracking: 0.04em, upper(c)),
|
||||
)),
|
||||
),
|
||||
..d
|
||||
.rows
|
||||
.map(r => {
|
||||
if r.kind == "spacer" {
|
||||
(table.cell(colspan: 2, v(3pt)),)
|
||||
(full(v(2pt), inset: 2pt),)
|
||||
} else if r.kind == "section" {
|
||||
(table.cell(colspan: 2, text(weight: "bold", r.label)),)
|
||||
} else if r.kind == "line" {
|
||||
(pad(left: 1.4em, r.label), amount(r))
|
||||
} else if r.kind == "subtotal" {
|
||||
(
|
||||
text(weight: "bold", r.label),
|
||||
table.cell(
|
||||
stroke: if r.rule { (top: 0.5pt) } else { none },
|
||||
amount(r),
|
||||
(full(text(weight: "semibold", r.label)),)
|
||||
} else if r.kind == "subheading" {
|
||||
(full(pad(left: 1.4em, emph(r.label))),)
|
||||
} else if r.kind == "total" {
|
||||
// The double rule under a statement total, full width.
|
||||
let double = full(
|
||||
inset: (x: 0pt, y: 0pt),
|
||||
stack(
|
||||
spacing: 1.6pt,
|
||||
line(length: 100%, stroke: firm),
|
||||
line(length: 100%, stroke: firm),
|
||||
),
|
||||
)
|
||||
row-cells(r) + (double,)
|
||||
} else {
|
||||
(
|
||||
align(top, text(weight: "bold", r.label)),
|
||||
table.cell(
|
||||
stroke: (top: 0.5pt),
|
||||
double-rule(amount(r)),
|
||||
),
|
||||
)
|
||||
row-cells(r)
|
||||
}
|
||||
})
|
||||
.flatten(),
|
||||
|
||||
@@ -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
@@ -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 ¶ms.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" => {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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.
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in new issue
Block a user