fix: period close sweeps only through the period end; partnerships close into members' capital
The close zeroed every revenue and expense account's all-time movement, so closing a period after later entries were posted swept those too: close January in March and February's and March's income landed in January's closing entry. The preview had the same bug and never showed where the net went. Now: - close_period_to(id, to) zeroes balances as of the period end; entries dated later stay open for their own period. Periods must close in order (LedgerError::CloseOutOfOrder): a later close has already swept the earlier period. - CloseTo::Members: for a partnership (multi-member LLC) the net goes to each member's capital account by share, to the cent, the last member absorbing rounding, which is what K-1 capital accounts track. CloseTo::RetainedEarnings (3999) stays the default; close_period is shorthand for it. books.close_period picks Members when wanderbooks.toml lists members. - The journal records the close target; replay re-derives the closing entry and now compares every line with the journal, not just the id, so a change to closing math can never silently rewrite history. - The tool's preview is the real close run on a clone of the ledger (Ledger now derives Clone), so preview and post can't disagree. - Members' equity: closing entries into a capital account are income, not contributions. Balance sheet for a partnership: capital accounts (with allocated earnings), then only unallocated earnings — "Undistributed earnings, prior years" if any, and this year's unclosed net income (loss). periods::allocate is shared by the close and the statement. Tests: January closed after February is posted sweeps exactly Dr 4000 2,500 / Cr 6100 800 / Cr 3999 1,700 (mutation-checked: the old code gives 3,500/120/800/2,580); out-of-order close refused; a partnership close splits 666.67 into 333.34/333.33 with no 3999, and the members' equity statement reads correctly before and after; store replay of a members close (reopen, rebuild, verify); partnership and corporate balance sheet layouts; stdio close preview equals the posted lines. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MtYEaR6M6Ybdhm6X98BWNu
This commit is contained in:
15 files changed
+713
-217
No files matched your search
@@ -166,6 +166,10 @@ pub enum LedgerError {
|
|||||||
#[error("cannot close period {0}: draft entries exist with dates in the period")]
|
#[error("cannot close period {0}: draft entries exist with dates in the period")]
|
||||||
DraftsInPeriod(u64),
|
DraftsInPeriod(u64),
|
||||||
|
|
||||||
|
/// A period can't close while a later period is already closed.
|
||||||
|
#[error("period {0} can't close: later period {1} is already closed (close in order)")]
|
||||||
|
CloseOutOfOrder(u64, u64),
|
||||||
|
|
||||||
/// Entries cannot be created or posted in a closed period.
|
/// Entries cannot be created or posted in a closed period.
|
||||||
#[error("period {0} is closed; entries dated {1} are rejected")]
|
#[error("period {0} is closed; entries dated {1} are rejected")]
|
||||||
ClosedPeriod(u64, String),
|
ClosedPeriod(u64, String),
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ pub struct ReconciliationReport {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The books. Single-currency (see R-5); owns id allocation.
|
/// The books. Single-currency (see R-5); owns id allocation.
|
||||||
#[derive(Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct Ledger {
|
pub struct Ledger {
|
||||||
base_currency: Currency,
|
base_currency: Currency,
|
||||||
chart: ChartOfAccounts,
|
chart: ChartOfAccounts,
|
||||||
@@ -587,114 +587,161 @@ impl Ledger {
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Close a period into Retained Earnings: see [`Self::close_period_to`].
|
||||||
|
pub fn close_period(&mut self, id: PeriodId) -> Result<Option<EntryId>, LedgerError> {
|
||||||
|
self.close_period_to(id, &periods::CloseTo::RetainedEarnings)
|
||||||
|
}
|
||||||
|
|
||||||
/// Close a period. This:
|
/// Close a period. This:
|
||||||
/// 1. Rejects if the period is already closed.
|
/// 1. Rejects if the period is already closed, if any draft is dated
|
||||||
/// 2. Rejects if any draft entries have dates inside the period.
|
/// inside it, or if a later period is already closed (closing out of
|
||||||
/// 3. Posts a closing entry dated at period end: revenue accounts are
|
/// order would sweep the same income twice).
|
||||||
/// debited to zero, expense accounts are credited to zero, and the
|
/// 2. Posts a closing entry dated at period end that zeroes every revenue
|
||||||
/// net goes to Retained Earnings (account `3999`, created if missing).
|
/// and expense account's balance *as of the period end* (entries
|
||||||
/// 4. Marks the period closed.
|
/// dated after it stay open for their own period): revenue debited,
|
||||||
|
/// expense credited, and the net to `to` — Retained Earnings (account
|
||||||
|
/// `3999`, created if missing), or members' capital accounts by
|
||||||
|
/// share for a partnership, to the cent with the last member
|
||||||
|
/// absorbing rounding.
|
||||||
|
/// 3. Marks the period closed.
|
||||||
///
|
///
|
||||||
/// Returns the closing entry's id, or `None` if there was nothing to
|
/// Returns the closing entry's id, or `None` if there was nothing to
|
||||||
/// close (no revenue/expense movement).
|
/// close (no revenue/expense balance at period end).
|
||||||
pub fn close_period(&mut self, id: PeriodId) -> Result<Option<EntryId>, LedgerError> {
|
pub fn close_period_to(
|
||||||
let period = self
|
&mut self,
|
||||||
.periods
|
id: PeriodId,
|
||||||
.get(&id)
|
to: &periods::CloseTo,
|
||||||
.ok_or(LedgerError::UnknownPeriod(id))?
|
) -> Result<Option<EntryId>, LedgerError> {
|
||||||
.clone();
|
self.preview_close_period(id)?;
|
||||||
if period.status == PeriodStatus::Closed {
|
if let periods::CloseTo::Members(members) = to {
|
||||||
return Err(LedgerError::PeriodAlreadyClosed(id));
|
self.validate_close_members(members)?;
|
||||||
}
|
|
||||||
// No drafts may have dates inside the period.
|
|
||||||
let has_drafts = self
|
|
||||||
.entries
|
|
||||||
.values()
|
|
||||||
.any(|e| e.status() == EntryStatus::Draft && period.contains(e.date()));
|
|
||||||
if has_drafts {
|
|
||||||
return Err(LedgerError::DraftsInPeriod(id));
|
|
||||||
}
|
}
|
||||||
|
let period = self.periods.get(&id).expect("previewed").clone();
|
||||||
|
|
||||||
// Collect revenue and expense accounts with non-zero movement.
|
// Revenue and expense balances as of the period end.
|
||||||
let mut closings: Vec<(AccountId, Side, Decimal)> = Vec::new();
|
let mut closings: Vec<(AccountId, Side, Decimal)> = Vec::new();
|
||||||
let mut accounts: Vec<_> = self.chart.all().collect();
|
let mut accounts: Vec<_> = self.chart.all().collect();
|
||||||
accounts.sort_by(|a, b| a.code.cmp(&b.code));
|
accounts.sort_by(|a, b| a.code.cmp(&b.code));
|
||||||
for account in accounts {
|
for account in accounts {
|
||||||
match account.account_type {
|
if !matches!(
|
||||||
AccountType::Revenue | AccountType::Expense => {
|
account.account_type,
|
||||||
let movement = self.account_movement(account.id);
|
AccountType::Revenue | AccountType::Expense
|
||||||
if movement.is_zero() {
|
) {
|
||||||
continue;
|
continue;
|
||||||
}
|
|
||||||
// To zero the account: post the opposite of its net movement.
|
|
||||||
let (side, amount) = if movement > Decimal::ZERO {
|
|
||||||
(Side::Credit, movement)
|
|
||||||
} else {
|
|
||||||
(Side::Debit, -movement)
|
|
||||||
};
|
|
||||||
closings.push((account.id, side, amount));
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
}
|
||||||
|
let movement = self.movement_through(account.id, period.end);
|
||||||
|
if movement.is_zero() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// To zero the account: post the opposite of its net movement.
|
||||||
|
let (side, amount) = if movement > Decimal::ZERO {
|
||||||
|
(Side::Credit, movement)
|
||||||
|
} else {
|
||||||
|
(Side::Debit, -movement)
|
||||||
|
};
|
||||||
|
closings.push((account.id, side, amount));
|
||||||
}
|
}
|
||||||
|
|
||||||
let closing_entry = if closings.is_empty() {
|
let closing_entry = if closings.is_empty() {
|
||||||
None
|
None
|
||||||
} else {
|
} else {
|
||||||
// Ensure Retained Earnings exists.
|
// Net income: the closing debits (revenue) minus credits (expense).
|
||||||
let re_id = match self.chart.find_by_code(periods::RETAINED_EARNINGS_CODE) {
|
let net: Decimal = closings
|
||||||
Some(a) => a.id,
|
.iter()
|
||||||
None => self.add_account(
|
.map(|(_, side, amount)| match side {
|
||||||
periods::RETAINED_EARNINGS_CODE,
|
Side::Debit => *amount,
|
||||||
"Retained Earnings",
|
Side::Credit => -*amount,
|
||||||
AccountType::Equity,
|
})
|
||||||
None,
|
.sum();
|
||||||
false,
|
let targets: Vec<(AccountId, Decimal)> = match to {
|
||||||
)?,
|
periods::CloseTo::RetainedEarnings => {
|
||||||
};
|
let re_id = match self.chart.find_by_code(periods::RETAINED_EARNINGS_CODE) {
|
||||||
// Sum the closing lines to find the net for RE.
|
Some(a) => a.id,
|
||||||
let mut debits = Decimal::ZERO;
|
None => self.add_account(
|
||||||
let mut credits = Decimal::ZERO;
|
periods::RETAINED_EARNINGS_CODE,
|
||||||
for (_, side, amount) in &closings {
|
"Retained Earnings",
|
||||||
match side {
|
AccountType::Equity,
|
||||||
Side::Debit => debits += *amount,
|
None,
|
||||||
Side::Credit => credits += *amount,
|
false,
|
||||||
|
)?,
|
||||||
|
};
|
||||||
|
vec![(re_id, net)]
|
||||||
}
|
}
|
||||||
}
|
periods::CloseTo::Members(members) => {
|
||||||
|
let shares: Vec<Decimal> = members.iter().map(|(_, s)| *s).collect();
|
||||||
|
members
|
||||||
|
.iter()
|
||||||
|
.map(|(a, _)| *a)
|
||||||
|
.zip(periods::allocate(net, &shares))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
};
|
||||||
let entry = self.create_entry(period.end, &format!("Close period {}", period.name));
|
let entry = self.create_entry(period.end, &format!("Close period {}", period.name));
|
||||||
for (account, side, amount) in closings {
|
for (account, side, amount) in closings {
|
||||||
self.add_line(entry, account, side, Money::new(amount, self.base_currency))?;
|
self.add_line(entry, account, side, Money::new(amount, self.base_currency))?;
|
||||||
}
|
}
|
||||||
// Balance with Retained Earnings.
|
// Profit credits equity; a loss debits it.
|
||||||
if debits > credits {
|
for (account, share) in targets {
|
||||||
// Profit: credit RE.
|
if share.is_zero() {
|
||||||
self.add_line(
|
continue;
|
||||||
entry,
|
}
|
||||||
re_id,
|
let (side, amount) = if share > Decimal::ZERO {
|
||||||
Side::Credit,
|
(Side::Credit, share)
|
||||||
Money::new(debits - credits, self.base_currency),
|
} else {
|
||||||
)?;
|
(Side::Debit, -share)
|
||||||
} else if credits > debits {
|
};
|
||||||
// Loss: debit RE.
|
self.add_line(entry, account, side, Money::new(amount, self.base_currency))?;
|
||||||
self.add_line(
|
|
||||||
entry,
|
|
||||||
re_id,
|
|
||||||
Side::Debit,
|
|
||||||
Money::new(credits - debits, self.base_currency),
|
|
||||||
)?;
|
|
||||||
}
|
}
|
||||||
self.post_entry(entry)?;
|
self.post_entry(entry)?;
|
||||||
Some(entry)
|
Some(entry)
|
||||||
};
|
};
|
||||||
|
|
||||||
self.periods.get_mut(&id).expect("period exists").status = PeriodStatus::Closed;
|
let p = self.periods.get_mut(&id).expect("period exists");
|
||||||
self.periods
|
p.status = PeriodStatus::Closed;
|
||||||
.get_mut(&id)
|
p.closing_entry = closing_entry;
|
||||||
.expect("period exists")
|
|
||||||
.closing_entry = closing_entry;
|
|
||||||
Ok(closing_entry)
|
Ok(closing_entry)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Members for a close: shares positive and summing to 1, capital
|
||||||
|
/// accounts distinct equity accounts.
|
||||||
|
fn validate_close_members(&self, members: &[(AccountId, Decimal)]) -> Result<(), LedgerError> {
|
||||||
|
let total: Decimal = members.iter().map(|(_, s)| *s).sum();
|
||||||
|
if members.is_empty()
|
||||||
|
|| total != Decimal::ONE
|
||||||
|
|| members.iter().any(|(_, s)| *s <= Decimal::ZERO)
|
||||||
|
{
|
||||||
|
return Err(LedgerError::InvalidMembers(format!(
|
||||||
|
"member shares must be positive and sum to 1, got {total}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
let mut seen = std::collections::BTreeSet::new();
|
||||||
|
for (a, _) in members {
|
||||||
|
let account = self.chart.get(*a).ok_or(LedgerError::UnknownAccount(*a))?;
|
||||||
|
if account.account_type != AccountType::Equity || !seen.insert(*a) {
|
||||||
|
return Err(LedgerError::InvalidMembers(format!(
|
||||||
|
"capital account {} must be a distinct equity account",
|
||||||
|
account.code
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Debits minus credits on an account across posted entries dated on or
|
||||||
|
/// before `end`.
|
||||||
|
fn movement_through(&self, account: AccountId, end: NaiveDate) -> Decimal {
|
||||||
|
let mut total = Decimal::ZERO;
|
||||||
|
for entry in self.posted_entries().filter(|e| e.date() <= end) {
|
||||||
|
for line in entry.lines().iter().filter(|l| l.account == account) {
|
||||||
|
match line.side {
|
||||||
|
Side::Debit => total += line.amount.amount,
|
||||||
|
Side::Credit => total -= line.amount.amount,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
total
|
||||||
|
}
|
||||||
|
|
||||||
/// Pure pre-close check for journaling stores: verifies that
|
/// Pure pre-close check for journaling stores: verifies that
|
||||||
/// [`Self::close_period`] would succeed — the period exists and is open,
|
/// [`Self::close_period`] would succeed — the period exists and is open,
|
||||||
/// no draft entries are dated inside it, and the closing entry's date
|
/// no draft entries are dated inside it, and the closing entry's date
|
||||||
@@ -724,14 +771,23 @@ impl Ledger {
|
|||||||
{
|
{
|
||||||
return Err(LedgerError::ClosedPeriod(other, period.end.to_string()));
|
return Err(LedgerError::ClosedPeriod(other, period.end.to_string()));
|
||||||
}
|
}
|
||||||
|
// Periods close in order: a later closed period already swept this
|
||||||
|
// one's income, so closing it now would sweep it again.
|
||||||
|
if let Some(later) = self
|
||||||
|
.periods
|
||||||
|
.values()
|
||||||
|
.find(|p| p.status == PeriodStatus::Closed && p.end > period.end)
|
||||||
|
{
|
||||||
|
return Err(LedgerError::CloseOutOfOrder(id, later.id));
|
||||||
|
}
|
||||||
// The close posts an entry exactly when some revenue or expense
|
// The close posts an entry exactly when some revenue or expense
|
||||||
// account has non-zero movement — the same predicate close_period
|
// account has a balance at period end — the same predicate
|
||||||
// uses to decide. The entry id is the next one the allocator hands
|
// close_period_to uses to decide. The entry id is the next one the
|
||||||
// out; account creation (e.g. Retained Earnings) does not consume
|
// allocator hands out; account creation (e.g. Retained Earnings)
|
||||||
// entry ids.
|
// does not consume entry ids.
|
||||||
let any_movement = self.chart.all().any(|a| {
|
let any_movement = self.chart.all().any(|a| {
|
||||||
matches!(a.account_type, AccountType::Revenue | AccountType::Expense)
|
matches!(a.account_type, AccountType::Revenue | AccountType::Expense)
|
||||||
&& !self.account_movement(a.id).is_zero()
|
&& !self.movement_through(a.id, period.end).is_zero()
|
||||||
});
|
});
|
||||||
Ok(any_movement.then_some(self.next_entry_id))
|
Ok(any_movement.then_some(self.next_entry_id))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ pub use journal::{EntryId, EntryStatus, JournalEntry, JournalLine};
|
|||||||
pub use ledger::{Ledger, ReconciliationReport, UnclearedLine};
|
pub use ledger::{Ledger, ReconciliationReport, UnclearedLine};
|
||||||
pub use money::{Currency, Money};
|
pub use money::{Currency, Money};
|
||||||
pub use payroll::{PayrollLine, PayrollRun, PayrollRunId};
|
pub use payroll::{PayrollLine, PayrollRun, PayrollRunId};
|
||||||
pub use periods::{Period, PeriodId, PeriodStatus, RETAINED_EARNINGS_CODE};
|
pub use periods::{CloseTo, Period, PeriodId, PeriodStatus, RETAINED_EARNINGS_CODE};
|
||||||
pub use reports::{
|
pub use reports::{
|
||||||
BalanceSheet, BalanceSheetStatement, CashFlowLine, CashFlowParams, CashFlowStatement,
|
BalanceSheet, BalanceSheetStatement, CashFlowLine, CashFlowParams, CashFlowStatement,
|
||||||
ClassProfitAndLoss, GeneralLedger, LedgerAccount, LedgerPosting, MemberCapital,
|
ClassProfitAndLoss, GeneralLedger, LedgerAccount, LedgerPosting, MemberCapital,
|
||||||
|
|||||||
@@ -29,6 +29,35 @@ pub enum PeriodStatus {
|
|||||||
Closed,
|
Closed,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Where a period close puts the period's net income.
|
||||||
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
|
pub enum CloseTo {
|
||||||
|
/// One Retained Earnings account ([`RETAINED_EARNINGS_CODE`]): the
|
||||||
|
/// corporate convention.
|
||||||
|
RetainedEarnings,
|
||||||
|
/// Members' capital accounts, by share (shares sum to 1): the
|
||||||
|
/// partnership convention, where each member's capital account carries
|
||||||
|
/// their allocated earnings.
|
||||||
|
Members(Vec<(crate::AccountId, rust_decimal::Decimal)>),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Split `amount` by `shares` to the cent; the last share absorbs rounding,
|
||||||
|
/// so the parts always sum to `amount` exactly.
|
||||||
|
pub fn allocate(
|
||||||
|
amount: rust_decimal::Decimal,
|
||||||
|
shares: &[rust_decimal::Decimal],
|
||||||
|
) -> Vec<rust_decimal::Decimal> {
|
||||||
|
use rust_decimal::RoundingStrategy::MidpointAwayFromZero;
|
||||||
|
let mut parts: Vec<_> = shares
|
||||||
|
.iter()
|
||||||
|
.map(|s| (amount * s).round_dp_with_strategy(2, MidpointAwayFromZero))
|
||||||
|
.collect();
|
||||||
|
if let Some((last, rest)) = parts.split_last_mut() {
|
||||||
|
*last = amount - rest.iter().copied().sum::<rust_decimal::Decimal>();
|
||||||
|
}
|
||||||
|
parts
|
||||||
|
}
|
||||||
|
|
||||||
/// Code of the Retained Earnings account that closing entries credit (or
|
/// Code of the Retained Earnings account that closing entries credit (or
|
||||||
/// debit), created on the first close if the chart lacks it.
|
/// debit), created on the first close if the chart lacks it.
|
||||||
pub const RETAINED_EARNINGS_CODE: &str = "3999";
|
pub const RETAINED_EARNINGS_CODE: &str = "3999";
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
//! (`docs/oracle.md`) diffs: the same fixture books through two engines must
|
//! (`docs/oracle.md`) diffs: the same fixture books through two engines must
|
||||||
//! produce byte-identical [`snapshot`] output.
|
//! produce byte-identical [`snapshot`] output.
|
||||||
|
|
||||||
|
use crate::periods::allocate;
|
||||||
use crate::{AccountType, Class, ClassId, JournalEntry, JournalLine, Ledger, LedgerError};
|
use crate::{AccountType, Class, ClassId, JournalEntry, JournalLine, Ledger, LedgerError};
|
||||||
use chrono::NaiveDate;
|
use chrono::NaiveDate;
|
||||||
use rust_decimal::Decimal;
|
use rust_decimal::Decimal;
|
||||||
@@ -711,20 +712,6 @@ pub struct MembersEquityStatement {
|
|||||||
pub total: MemberEquityColumn,
|
pub total: MemberEquityColumn,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Split `amount` by `shares` to the cent; the last share absorbs rounding,
|
|
||||||
/// so the parts always sum to `amount` exactly.
|
|
||||||
fn allocate(amount: Decimal, shares: &[Decimal]) -> Vec<Decimal> {
|
|
||||||
use rust_decimal::RoundingStrategy::MidpointAwayFromZero;
|
|
||||||
let mut parts: Vec<Decimal> = 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().sum::<Decimal>();
|
|
||||||
}
|
|
||||||
parts
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Build the statement of changes in members' equity for `[start, end]`.
|
/// Build the statement of changes in members' equity for `[start, end]`.
|
||||||
///
|
///
|
||||||
/// Each member's capital account supplies contributions (credits) and
|
/// Each member's capital account supplies contributions (credits) and
|
||||||
@@ -783,11 +770,14 @@ pub fn members_equity(
|
|||||||
}
|
}
|
||||||
m
|
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 flows = |id: crate::AccountId| -> (Decimal, Decimal) {
|
||||||
let (mut credits, mut debits) = (Decimal::ZERO, Decimal::ZERO);
|
let (mut credits, mut debits) = (Decimal::ZERO, Decimal::ZERO);
|
||||||
for e in ledger
|
for e in ledger
|
||||||
.posted_entries()
|
.posted_entries()
|
||||||
.filter(|e| e.date() >= start && e.date() <= end)
|
.filter(|e| e.date() >= start && e.date() <= end && !closings.contains(&e.id()))
|
||||||
{
|
{
|
||||||
for l in e.lines().iter().filter(|l| l.account == id) {
|
for l in e.lines().iter().filter(|l| l.account == id) {
|
||||||
match l.side {
|
match l.side {
|
||||||
|
|||||||
@@ -533,3 +533,135 @@ fn cash_flow_lines_name_their_account() {
|
|||||||
let accounts: Vec<Option<&str>> = cf.financing.iter().map(|f| f.account.as_deref()).collect();
|
let accounts: Vec<Option<&str>> = cf.financing.iter().map(|f| f.account.as_deref()).collect();
|
||||||
assert_eq!(accounts, [Some("3000"), Some("3001")]);
|
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(_))
|
||||||
|
));
|
||||||
|
}
|
||||||
@@ -20,5 +20,5 @@ pub use bank_csv::{BankTransaction, parse_bank_csv};
|
|||||||
pub use chart_csv::{ChartRow, parse_chart_csv};
|
pub use chart_csv::{ChartRow, parse_chart_csv};
|
||||||
pub use iif::{IifEntry, journal_to_iif};
|
pub use iif::{IifEntry, journal_to_iif};
|
||||||
pub use pdf::{PdfError, statement_pdf};
|
pub use pdf::{PdfError, statement_pdf};
|
||||||
pub use report::{Member, ReportMeta, Row, RowKind, Statement, format_amount};
|
pub use report::{Member, ReportMeta, Row, RowKind, Statement, YearEarnings, format_amount};
|
||||||
pub use xlsx::{XlsxReport, report_to_xlsx};
|
pub use xlsx::{XlsxReport, report_to_xlsx};
|
||||||
@@ -175,14 +175,29 @@ pub fn profit_and_loss(pl: &ProfitAndLossStatement) -> Statement {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Lay out a balance sheet. `net_income_ytd` is the fiscal year to date's
|
/// The fiscal year's earnings, as the balance sheet presents them.
|
||||||
/// net income (loss): it shows as its own equity line, and earnings from
|
#[derive(Clone, Copy, Debug)]
|
||||||
/// before the fiscal year — the Retained Earnings account plus any not yet
|
pub struct YearEarnings {
|
||||||
/// closed — show as "Retained earnings". With `members`, equity is
|
/// Net income (loss) for the fiscal year through the as-of date.
|
||||||
/// "Members' equity".
|
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(
|
pub fn balance_sheet(
|
||||||
bs: &BalanceSheetStatement,
|
bs: &BalanceSheetStatement,
|
||||||
net_income_ytd: Decimal,
|
earnings: YearEarnings,
|
||||||
members: &[Member],
|
members: &[Member],
|
||||||
) -> Statement {
|
) -> Statement {
|
||||||
let equity_name = if members.is_empty() {
|
let equity_name = if members.is_empty() {
|
||||||
@@ -203,21 +218,39 @@ pub fn balance_sheet(
|
|||||||
));
|
));
|
||||||
rows.push(spacer());
|
rows.push(spacer());
|
||||||
rows.push(section(equity_name));
|
rows.push(section(equity_name));
|
||||||
let (retained, other): (Vec<_>, Vec<_>) = bs
|
if members.is_empty() {
|
||||||
.equity
|
let (retained, other): (Vec<_>, Vec<_>) = bs
|
||||||
.iter()
|
.equity
|
||||||
.partition(|l| l.code == RETAINED_EARNINGS_CODE);
|
|
||||||
rows.extend(
|
|
||||||
other
|
|
||||||
.iter()
|
.iter()
|
||||||
.map(|l| one(RowKind::Line, format!("{} {}", l.code, l.name), l.amount)),
|
.partition(|l| l.code == RETAINED_EARNINGS_CODE);
|
||||||
);
|
rows.extend(
|
||||||
let retained: Decimal =
|
other
|
||||||
retained.iter().map(|l| l.amount).sum::<Decimal>() + bs.current_earnings - net_income_ytd;
|
.iter()
|
||||||
if !retained.is_zero() {
|
.map(|l| one(RowKind::Line, format!("{} {}", l.code, l.name), l.amount)),
|
||||||
rows.push(one(RowKind::Line, "Retained earnings", retained));
|
);
|
||||||
|
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,
|
||||||
|
"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::Line, "Net income (loss)", net_income_ytd));
|
|
||||||
rows.push(one(
|
rows.push(one(
|
||||||
RowKind::Subtotal,
|
RowKind::Subtotal,
|
||||||
format!("Total {}", equity_name.to_lowercase()),
|
format!("Total {}", equity_name.to_lowercase()),
|
||||||
@@ -474,50 +507,105 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
fn sl(code: &str, name: &str, a: &str) -> StatementLine {
|
||||||
fn balance_sheet_splits_retained_earnings_from_this_year() {
|
StatementLine {
|
||||||
let line = |code: &str, name: &str, a: &str| StatementLine {
|
|
||||||
code: code.into(),
|
code: code.into(),
|
||||||
name: name.into(),
|
name: name.into(),
|
||||||
amount: dec(a),
|
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
|
// Prior years closed 1,000 to Retained Earnings; 200 more unclosed
|
||||||
// from last year; this year's net income is 300 (current earnings
|
// from last year; this year's net income is 300 (current earnings
|
||||||
// 500 = 200 + 300).
|
// 500 = 200 + 300).
|
||||||
let bs = BalanceSheetStatement {
|
let bs = BalanceSheetStatement {
|
||||||
as_of: NaiveDate::from_ymd_opt(2027, 3, 31).unwrap(),
|
as_of: NaiveDate::from_ymd_opt(2027, 3, 31).unwrap(),
|
||||||
assets: vec![line("1000", "Cash", "2500")],
|
assets: vec![sl("1000", "Cash", "2500")],
|
||||||
liabilities: vec![],
|
liabilities: vec![],
|
||||||
equity: vec![
|
equity: vec![
|
||||||
line("3000", "Capital — Alice", "1000"),
|
sl("3000", "Common Stock", "1000"),
|
||||||
line("3999", "Retained Earnings", "1000"),
|
sl("3999", "Retained Earnings", "1000"),
|
||||||
],
|
],
|
||||||
current_earnings: dec("500"),
|
current_earnings: dec("500"),
|
||||||
total_assets: dec("2500"),
|
total_assets: dec("2500"),
|
||||||
total_liabilities: dec("0"),
|
total_liabilities: dec("0"),
|
||||||
total_equity: dec("2500"),
|
total_equity: dec("2500"),
|
||||||
};
|
};
|
||||||
let members = [Member {
|
let e = YearEarnings {
|
||||||
name: "Alice".into(),
|
net_income_ytd: dec("300"),
|
||||||
capital_code: "3000".into(),
|
unclosed: dec("300"),
|
||||||
}];
|
};
|
||||||
let st = balance_sheet(&bs, dec("300"), &members);
|
let st = balance_sheet(&bs, e, &[]);
|
||||||
let eq: Vec<(&str, Option<Decimal>)> = st
|
let some = |s: &str| Some(dec(s));
|
||||||
.rows
|
|
||||||
.iter()
|
|
||||||
.skip_while(|r| r.label != "Members' equity")
|
|
||||||
.map(|r| (r.label.as_str(), r.amounts.first().copied().flatten()))
|
|
||||||
.collect();
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
eq,
|
equity_rows(&st, "Equity"),
|
||||||
[
|
[
|
||||||
("Members' equity", None),
|
("Equity".into(), None),
|
||||||
("3000 Capital — Alice", Some(dec("1000"))),
|
("3000 Common Stock".into(), some("1000")),
|
||||||
("Retained earnings", Some(dec("1200"))),
|
("Retained earnings".into(), some("1200")),
|
||||||
("Net income (loss)", Some(dec("300"))),
|
("Net income (loss)".into(), some("300")),
|
||||||
("Total members' equity", Some(dec("2500"))),
|
("Total equity".into(), some("2500")),
|
||||||
("", None),
|
("".into(), None),
|
||||||
("Total liabilities and members' equity", Some(dec("2500"))),
|
("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")),
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -117,14 +117,31 @@ impl BooksServer {
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Net income (loss) for the fiscal year containing `as_of`, through it.
|
/// The fiscal year's earnings through `as_of`, for the balance sheet:
|
||||||
fn net_income_ytd(&self, ledger: &Ledger, as_of: NaiveDate) -> Result<Decimal, String> {
|
/// 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 (fy_start, _) = self.company.fiscal_year_containing(as_of);
|
||||||
Ok(
|
let net_income_ytd = ledger_core::profit_and_loss_for_period(ledger, fy_start, as_of, None)
|
||||||
ledger_core::profit_and_loss_for_period(ledger, fy_start, as_of, None)
|
.map_err(|e| e.to_string())?
|
||||||
.map_err(|e| e.to_string())?
|
.net_income();
|
||||||
.net_income(),
|
let mut unclosed = Decimal::ZERO;
|
||||||
)
|
for a in ledger.chart().all().filter(|a| !a.placeholder) {
|
||||||
|
match a.account_type {
|
||||||
|
AccountType::Revenue => unclosed += movement_in(ledger, a.id, fy_start, as_of),
|
||||||
|
AccountType::Expense => unclosed -= movement_in(ledger, a.id, fy_start, as_of),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(ledger_import::YearEarnings {
|
||||||
|
net_income_ytd,
|
||||||
|
unclosed,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn lock(&self) -> Result<std::sync::MutexGuard<'_, Store>, String> {
|
fn lock(&self) -> Result<std::sync::MutexGuard<'_, Store>, String> {
|
||||||
@@ -1529,8 +1546,8 @@ impl BooksServer {
|
|||||||
)?),
|
)?),
|
||||||
"balance_sheet" => {
|
"balance_sheet" => {
|
||||||
let bs = bs_statement(ledger, p.as_of.as_deref())?;
|
let bs = bs_statement(ledger, p.as_of.as_deref())?;
|
||||||
let ytd = self.net_income_ytd(ledger, bs.as_of)?;
|
let earnings = self.year_earnings(ledger, bs.as_of)?;
|
||||||
report::balance_sheet(&bs, ytd, &members)
|
report::balance_sheet(&bs, earnings, &members)
|
||||||
}
|
}
|
||||||
"cash_flow" => report::cash_flow(
|
"cash_flow" => report::cash_flow(
|
||||||
&cash_flow_statement(
|
&cash_flow_statement(
|
||||||
@@ -1952,7 +1969,7 @@ impl BooksServer {
|
|||||||
|
|
||||||
#[tool(
|
#[tool(
|
||||||
name = "books.close_period",
|
name = "books.close_period",
|
||||||
description = "Close an accounting period. Without confirmed=true, returns a preview of the closing entry (revenue/expense zeroed to Retained Earnings) and closes nothing. With confirmed=true, posts the closing entry and marks the period closed. Closed periods reject new entries."
|
description = "Close an accounting period. The closing entry, dated at period end, zeroes revenue and expense balances as of that date into Retained Earnings, or, for a multi-member LLC (members in wanderbooks.toml), into each member's capital account by share. Periods close in order. Without confirmed=true, returns the exact closing entry it would post and closes nothing. With confirmed=true, posts it and marks the period closed. Closed periods reject new entries."
|
||||||
)]
|
)]
|
||||||
async fn close_period(
|
async fn close_period(
|
||||||
&self,
|
&self,
|
||||||
@@ -1964,53 +1981,53 @@ impl BooksServer {
|
|||||||
if store.ledger().get_period(p.period_id).is_none() {
|
if store.ledger().get_period(p.period_id).is_none() {
|
||||||
return Err(format!("unknown period {}", p.period_id));
|
return Err(format!("unknown period {}", p.period_id));
|
||||||
}
|
}
|
||||||
|
// A partnership closes into its members' capital accounts by share;
|
||||||
|
// everyone else into Retained Earnings.
|
||||||
|
let to = if self.company.members.is_empty() {
|
||||||
|
ledger_core::CloseTo::RetainedEarnings
|
||||||
|
} else {
|
||||||
|
ledger_core::CloseTo::Members(
|
||||||
|
self.member_capitals(store.ledger())?
|
||||||
|
.into_iter()
|
||||||
|
.map(|m| (m.capital_account, m.share))
|
||||||
|
.collect(),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
let describe = |ledger: &Ledger, eid: Option<ledger_core::EntryId>| -> Vec<String> {
|
||||||
|
let Some(entry) = eid.and_then(|e| ledger.get_entry(e)) else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
entry
|
||||||
|
.lines()
|
||||||
|
.iter()
|
||||||
|
.map(|line| {
|
||||||
|
let code = ledger
|
||||||
|
.chart()
|
||||||
|
.get(line.account)
|
||||||
|
.map(|a| a.code.clone())
|
||||||
|
.unwrap_or_default();
|
||||||
|
format!("{:?} {} {}", line.side, code, money(line.amount.amount))
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
};
|
||||||
if !p.confirmed {
|
if !p.confirmed {
|
||||||
// Preview: compute what the closing entry would look like
|
// Preview: run the real close on a copy of the ledger, so the
|
||||||
// without mutating. We do this by inspecting revenue/expense
|
// preview is exactly what confirming would post.
|
||||||
// balances directly.
|
let mut scratch = store.ledger().clone();
|
||||||
let mut lines = Vec::new();
|
let closing = scratch
|
||||||
let mut accounts: Vec<_> = store.ledger().chart().all().collect();
|
.close_period_to(p.period_id, &to)
|
||||||
accounts.sort_by(|a, b| a.code.cmp(&b.code));
|
.map_err(|e| e.to_string())?;
|
||||||
for account in accounts {
|
|
||||||
match account.account_type {
|
|
||||||
AccountType::Revenue | AccountType::Expense => {
|
|
||||||
let movement = store.ledger().account_movement(account.id);
|
|
||||||
if movement.is_zero() {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let (side, amount) = if movement > Decimal::ZERO {
|
|
||||||
("credit", movement)
|
|
||||||
} else {
|
|
||||||
("debit", -movement)
|
|
||||||
};
|
|
||||||
lines.push(format!("{} {} {} to zero", side, account.code, amount));
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return Ok(Json(ClosePeriodOut {
|
return Ok(Json(ClosePeriodOut {
|
||||||
status: "preview".to_string(),
|
status: "preview".to_string(),
|
||||||
period_id: p.period_id,
|
period_id: p.period_id,
|
||||||
closing_entry: None,
|
closing_entry: None,
|
||||||
lines,
|
lines: describe(&scratch, closing),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
let closing = store.close_period(p.period_id).map_err(|e| e.to_string())?;
|
let closing = store
|
||||||
// Build line descriptions from the posted closing entry.
|
.close_period_to(p.period_id, &to)
|
||||||
let mut lines = Vec::new();
|
.map_err(|e| e.to_string())?;
|
||||||
if let Some(eid) = closing
|
let lines = describe(store.ledger(), closing);
|
||||||
&& let Some(entry) = store.ledger().get_entry(eid)
|
|
||||||
{
|
|
||||||
for line in entry.lines() {
|
|
||||||
let code = store
|
|
||||||
.ledger()
|
|
||||||
.chart()
|
|
||||||
.get(line.account)
|
|
||||||
.map(|a| a.code.clone())
|
|
||||||
.unwrap_or_default();
|
|
||||||
lines.push(format!("{:?} {} {}", line.side, code, line.amount.amount));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(Json(ClosePeriodOut {
|
Ok(Json(ClosePeriodOut {
|
||||||
status: "closed".to_string(),
|
status: "closed".to_string(),
|
||||||
period_id: p.period_id,
|
period_id: p.period_id,
|
||||||
@@ -2644,7 +2661,7 @@ impl BooksServer {
|
|||||||
"balance-sheet",
|
"balance-sheet",
|
||||||
report::balance_sheet(
|
report::balance_sheet(
|
||||||
&bs_statement(ledger, Some(&as_of_s))?,
|
&bs_statement(ledger, Some(&as_of_s))?,
|
||||||
self.net_income_ytd(ledger, as_of)?,
|
self.year_earnings(ledger, as_of)?,
|
||||||
&members,
|
&members,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1022,4 +1022,37 @@ fn mcp_period_statements_over_stdio() {
|
|||||||
json!({"format": "pdf", "report": "members_equity", "end": "2026-02-28"}),
|
json!({"format": "pdf", "report": "members_equity", "end": "2026-02-28"}),
|
||||||
);
|
);
|
||||||
assert_eq!(pdf["content_encoding"], "base64");
|
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 crate::schema;
|
||||||
use chrono::{Months, NaiveDate};
|
use chrono::{Months, NaiveDate};
|
||||||
use ledger_core::{
|
use ledger_core::{
|
||||||
AccountId, AccountType, AssetId, AssetStatus, BillId, Class, ClassId, Currency, CustomerId,
|
AccountId, AccountType, AssetId, AssetStatus, BillId, Class, ClassId, CloseTo, Currency,
|
||||||
DocumentLine, EntryId, EntryStatus, InvoiceId, Ledger, LedgerError, Money, NewAsset,
|
CustomerId, DocumentLine, EntryId, EntryStatus, InvoiceId, Ledger, LedgerError, Money,
|
||||||
PayrollLine, PayrollRun, PayrollRunId, PeriodId, Side, VendorId, arap,
|
NewAsset, PayrollLine, PayrollRun, PayrollRunId, PeriodId, Side, VendorId, arap,
|
||||||
payroll::{PayrollAccounts, payroll_entry_plan, payroll_remit_plan},
|
payroll::{PayrollAccounts, payroll_entry_plan, payroll_remit_plan},
|
||||||
reports,
|
reports,
|
||||||
};
|
};
|
||||||
@@ -955,14 +955,32 @@ impl Store {
|
|||||||
/// re-runs the deterministic close logic and checks the closing entry
|
/// re-runs the deterministic close logic and checks the closing entry
|
||||||
/// id against the journal.
|
/// id against the journal.
|
||||||
pub fn close_period(&mut self, id: PeriodId) -> Result<Option<EntryId>, StoreError> {
|
pub fn close_period(&mut self, id: PeriodId) -> Result<Option<EntryId>, StoreError> {
|
||||||
|
self.close_period_to(id, &CloseTo::RetainedEarnings)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Close a period with its net income going to `to`: Retained Earnings,
|
||||||
|
/// or members' capital accounts by share (see
|
||||||
|
/// [`ledger_core::Ledger::close_period_to`]). The target rides in the
|
||||||
|
/// journal so replay re-derives the identical closing entry.
|
||||||
|
pub fn close_period_to(
|
||||||
|
&mut self,
|
||||||
|
id: PeriodId,
|
||||||
|
to: &CloseTo,
|
||||||
|
) -> Result<Option<EntryId>, StoreError> {
|
||||||
// Pure pre-validation: a doomed close journals nothing, not even
|
// Pure pre-validation: a doomed close journals nothing, not even
|
||||||
// the Retained Earnings account creation below.
|
// the Retained Earnings account creation below.
|
||||||
self.ledger.preview_close_period(id)?;
|
self.ledger.preview_close_period(id)?;
|
||||||
// Retained Earnings must exist before the close so its creation (if
|
if *to == CloseTo::RetainedEarnings {
|
||||||
// needed) is journaled as account.added; the close's own ensure
|
// Retained Earnings must exist before the close so its creation
|
||||||
// then no-ops and replay stays deterministic.
|
// (if needed) is journaled as account.added; the close's own
|
||||||
self.ensure_control_account("3999", "Retained Earnings", AccountType::Equity)?;
|
// ensure then no-ops and replay stays deterministic.
|
||||||
let closing_entry = self.ledger.close_period(id)?;
|
self.ensure_control_account(
|
||||||
|
ledger_core::RETAINED_EARNINGS_CODE,
|
||||||
|
"Retained Earnings",
|
||||||
|
AccountType::Equity,
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
let closing_entry = self.ledger.close_period_to(id, to)?;
|
||||||
let entry_detail = match closing_entry {
|
let entry_detail = match closing_entry {
|
||||||
Some(eid) => {
|
Some(eid) => {
|
||||||
let e = self
|
let e = self
|
||||||
@@ -978,11 +996,21 @@ impl Store {
|
|||||||
}
|
}
|
||||||
None => None,
|
None => None,
|
||||||
};
|
};
|
||||||
|
let close_to = match to {
|
||||||
|
CloseTo::RetainedEarnings => json!("retained_earnings"),
|
||||||
|
CloseTo::Members(members) => json!({
|
||||||
|
"members": members
|
||||||
|
.iter()
|
||||||
|
.map(|(account, share)| json!({"account": account, "share": share.to_string()}))
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
}),
|
||||||
|
};
|
||||||
let line = self.append_journal(
|
let line = self.append_journal(
|
||||||
"period.closed",
|
"period.closed",
|
||||||
json!({
|
json!({
|
||||||
"id": id,
|
"id": id,
|
||||||
"closing_entry": closing_entry,
|
"closing_entry": closing_entry,
|
||||||
|
"close_to": close_to,
|
||||||
"entry": entry_detail,
|
"entry": entry_detail,
|
||||||
}),
|
}),
|
||||||
)?;
|
)?;
|
||||||
@@ -2738,15 +2766,44 @@ fn replay_line_into_ledger(
|
|||||||
let id = get_u64(p, "id")?;
|
let id = get_u64(p, "id")?;
|
||||||
let want = p.get("closing_entry").and_then(|v| v.as_u64());
|
let want = p.get("closing_entry").and_then(|v| v.as_u64());
|
||||||
// Deterministic: the close re-derives the closing entry from
|
// Deterministic: the close re-derives the closing entry from
|
||||||
// the replayed state, so it must match the journaled id. The
|
// the replayed state and the journaled target (absent: Retained
|
||||||
// journaled entry detail is for the projection; the ledger
|
// Earnings), so it must match the journal's id and every line.
|
||||||
// re-derives the entry itself.
|
let to = match p.get("close_to").and_then(|v| v.get("members")) {
|
||||||
let got = ledger.close_period(id)?;
|
None => ledger_core::CloseTo::RetainedEarnings,
|
||||||
|
Some(members) => ledger_core::CloseTo::Members(
|
||||||
|
members
|
||||||
|
.as_array()
|
||||||
|
.ok_or_else(|| StoreError::JournalCorrupt("close_to.members".into()))?
|
||||||
|
.iter()
|
||||||
|
.map(|m| {
|
||||||
|
let share = get_str(m, "share")?.parse().map_err(|_| {
|
||||||
|
StoreError::JournalCorrupt("close_to member share".into())
|
||||||
|
})?;
|
||||||
|
Ok((get_u64(m, "account")?, share))
|
||||||
|
})
|
||||||
|
.collect::<Result<_, StoreError>>()?,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
let got = ledger.close_period_to(id, &to)?;
|
||||||
if got != want {
|
if got != want {
|
||||||
return Err(StoreError::ReplayMismatch(format!(
|
return Err(StoreError::ReplayMismatch(format!(
|
||||||
"closing entry: journal says {want:?}, replay got {got:?}"
|
"closing entry: journal says {want:?}, replay got {got:?}"
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
|
if let Some(eid) = got {
|
||||||
|
let replayed: Vec<Value> = ledger
|
||||||
|
.get_entry(eid)
|
||||||
|
.expect("close posted it")
|
||||||
|
.lines()
|
||||||
|
.iter()
|
||||||
|
.map(journal_line_json)
|
||||||
|
.collect();
|
||||||
|
if p["entry"]["lines"].as_array() != Some(&replayed) {
|
||||||
|
return Err(StoreError::ReplayMismatch(format!(
|
||||||
|
"closing entry {eid}: replayed lines differ from the journal"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
"line.cleared" => {
|
"line.cleared" => {
|
||||||
ledger.mark_cleared(
|
ledger.mark_cleared(
|
||||||
|
|||||||
@@ -457,3 +457,72 @@ fn receipt_corrections_are_journaled_and_survive_rebuild() {
|
|||||||
check(&store);
|
check(&store);
|
||||||
store.verify().unwrap();
|
store.verify().unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_close_into_members_capital_replays_exactly() {
|
||||||
|
let dir = test_dir("members-close");
|
||||||
|
let mut store = Store::create(&dir, Currency::USD).unwrap();
|
||||||
|
let cash = store
|
||||||
|
.add_account("1000", "Cash", AccountType::Asset, None, false)
|
||||||
|
.unwrap();
|
||||||
|
let alice = store
|
||||||
|
.add_account("3000", "Capital — Alice", AccountType::Equity, None, false)
|
||||||
|
.unwrap();
|
||||||
|
let bob = store
|
||||||
|
.add_account("3001", "Capital — Bob", AccountType::Equity, None, false)
|
||||||
|
.unwrap();
|
||||||
|
let revenue = store
|
||||||
|
.add_account("4000", "Revenue", AccountType::Revenue, None, false)
|
||||||
|
.unwrap();
|
||||||
|
let jan15 = NaiveDate::from_ymd_opt(2026, 1, 15).unwrap();
|
||||||
|
let e = store.create_entry(jan15, "Sale").unwrap();
|
||||||
|
store
|
||||||
|
.add_line(
|
||||||
|
e,
|
||||||
|
cash,
|
||||||
|
Side::Debit,
|
||||||
|
Money::new(Decimal::new(10001, 2), Currency::USD),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
store
|
||||||
|
.add_line(
|
||||||
|
e,
|
||||||
|
revenue,
|
||||||
|
Side::Credit,
|
||||||
|
Money::new(Decimal::new(10001, 2), Currency::USD),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
store.post_entry(e).unwrap();
|
||||||
|
let jan = store
|
||||||
|
.create_period(
|
||||||
|
"2026-01",
|
||||||
|
NaiveDate::from_ymd_opt(2026, 1, 1).unwrap(),
|
||||||
|
NaiveDate::from_ymd_opt(2026, 1, 31).unwrap(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let half = Decimal::new(5, 1);
|
||||||
|
store
|
||||||
|
.close_period_to(
|
||||||
|
jan,
|
||||||
|
&ledger_core::CloseTo::Members(vec![(alice, half), (bob, half)]),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
// 100.01 splits 50.01 / 50.00 into the capital accounts.
|
||||||
|
let balances = |s: &Store| {
|
||||||
|
(
|
||||||
|
s.ledger().account_balance(alice).unwrap(),
|
||||||
|
s.ledger().account_balance(bob).unwrap(),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
let want = (Decimal::new(5001, 2), Decimal::new(5000, 2));
|
||||||
|
assert_eq!(balances(&store), want);
|
||||||
|
let journal = std::fs::read_to_string(dir.join("journal.jsonl")).unwrap();
|
||||||
|
assert!(journal.contains("\"close_to\":{\"members\""));
|
||||||
|
|
||||||
|
drop(store);
|
||||||
|
let mut store = Store::open(&dir).unwrap();
|
||||||
|
assert_eq!(balances(&store), want);
|
||||||
|
store.rebuild().unwrap();
|
||||||
|
assert_eq!(balances(&store), want);
|
||||||
|
store.verify().unwrap();
|
||||||
|
}
|
||||||
@@ -279,10 +279,13 @@ R-FUTURE-3 and rides the engine roadmap, not the MCP layer.
|
|||||||
- `books.create_period { name, start, end }` → `{ period_id }`.
|
- `books.create_period { name, start, end }` → `{ period_id }`.
|
||||||
Dates are YYYY-MM-DD, inclusive.
|
Dates are YYYY-MM-DD, inclusive.
|
||||||
- `books.close_period { period_id, confirmed? }` → without confirmation:
|
- `books.close_period { period_id, confirmed? }` → without confirmation:
|
||||||
`{ status: "preview", lines: [...] }` showing the closing lines and posts
|
`{ status: "preview", lines: [...] }`: the exact closing entry (the close
|
||||||
nothing; with `confirmed: true`: posts the closing entry (revenue debited
|
run on a copy of the ledger), posting nothing; with `confirmed: true`:
|
||||||
to zero, expenses credited to zero, net to `3999` Retained Earnings) and
|
posts it and marks the period closed. The entry zeroes revenue and
|
||||||
marks the period closed. Closed periods reject new entries.
|
expense balances as of the period end; the net goes to `3999` Retained
|
||||||
|
Earnings, or, with `[[company.members]]` in `wanderbooks.toml`, to each
|
||||||
|
member's capital account by share. Periods close in order. Closed periods
|
||||||
|
reject new entries.
|
||||||
- `books.list_periods {}` → `{ periods: [{ id, name, start, end, status,
|
- `books.list_periods {}` → `{ periods: [{ id, name, start, end, status,
|
||||||
closing_entry }] }`.
|
closing_entry }] }`.
|
||||||
|
|
||||||
|
|||||||
+10
-2
@@ -111,8 +111,16 @@ requirement conflict, the requirement wins.
|
|||||||
Reports work in base currency. (Full per-account currencies and translation
|
Reports work in base currency. (Full per-account currencies and translation
|
||||||
remain future.)
|
remain future.)
|
||||||
- **R-? — Periods and close.** Was R-FUTURE-2.
|
- **R-? — Periods and close.** Was R-FUTURE-2.
|
||||||
`ledger-core::periods`: `create_period`, `close_period` (posts closing
|
`ledger-core::periods`: `create_period`, `close_period_to(id, to)`; closed
|
||||||
entry to `3999` Retained Earnings), closed periods reject new entries.
|
periods reject new entries. The closing entry, dated at period end,
|
||||||
|
zeroes each revenue and expense account's balance **as of the period
|
||||||
|
end** (later-dated entries belong to later periods) and puts the net in
|
||||||
|
`to`: `CloseTo::RetainedEarnings` (`3999`, the corporate convention;
|
||||||
|
`close_period` is shorthand) or `CloseTo::Members` (each member's capital
|
||||||
|
account by share, to the cent, the last absorbing rounding: the
|
||||||
|
partnership convention). Periods close in order (`CloseOutOfOrder`). The
|
||||||
|
journal records the target, and replay re-derives the closing entry and
|
||||||
|
checks every line against the journal.
|
||||||
- **R-? — A/R and A/P workflows.** Was R-FUTURE-5.
|
- **R-? — A/R and A/P workflows.** Was R-FUTURE-5.
|
||||||
`ledger-core::arap`: customers, vendors, invoices, bills, payments,
|
`ledger-core::arap`: customers, vendors, invoices, bills, payments,
|
||||||
AR/AP aging. MCP tools for the full workflow.
|
AR/AP aging. MCP tools for the full workflow.
|
||||||
|
|||||||
@@ -204,4 +204,14 @@ interface is MCP, not a cloned GUI. The first storage + MCP slice is done
|
|||||||
(loss), standard cash-flow wording) and an "Accrual basis · Prepared …"
|
(loss), standard cash-flow wording) and an "Accrual basis · Prepared …"
|
||||||
footer. "Today" is local time, not UTC (evening exports were dated
|
footer. "Today" is local time, not UTC (evening exports were dated
|
||||||
tomorrow).
|
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.
|
||||||
|
|
||||||
Reference in new issue
Block a user