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")]
|
||||
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(periods::RETAINED_EARNINGS_CODE) {
|
||||
Some(a) => a.id,
|
||||
None => self.add_account(
|
||||
periods::RETAINED_EARNINGS_CODE,
|
||||
"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,7 +43,7 @@ pub use journal::{EntryId, EntryStatus, JournalEntry, JournalLine};
|
||||
pub use ledger::{Ledger, ReconciliationReport, UnclearedLine};
|
||||
pub use money::{Currency, Money};
|
||||
pub use payroll::{PayrollLine, PayrollRun, PayrollRunId};
|
||||
pub use periods::{Period, PeriodId, PeriodStatus, RETAINED_EARNINGS_CODE};
|
||||
pub use periods::{CloseTo, Period, PeriodId, PeriodStatus, RETAINED_EARNINGS_CODE};
|
||||
pub use reports::{
|
||||
BalanceSheet, BalanceSheetStatement, CashFlowLine, CashFlowParams, CashFlowStatement,
|
||||
ClassProfitAndLoss, GeneralLedger, LedgerAccount, LedgerPosting, MemberCapital,
|
||||
|
||||
@@ -29,6 +29,35 @@ 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";
|
||||
|
||||
@@ -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;
|
||||
@@ -711,20 +712,6 @@ pub struct MembersEquityStatement {
|
||||
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]`.
|
||||
///
|
||||
/// Each member's capital account supplies contributions (credits) and
|
||||
@@ -783,11 +770,14 @@ pub fn members_equity(
|
||||
}
|
||||
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)
|
||||
.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 {
|
||||
|
||||
@@ -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();
|
||||
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 iif::{IifEntry, journal_to_iif};
|
||||
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};
|
||||
@@ -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
|
||||
/// net income (loss): it shows as its own equity line, and earnings from
|
||||
/// before the fiscal year — the Retained Earnings account plus any not yet
|
||||
/// closed — show as "Retained earnings". With `members`, equity is
|
||||
/// "Members' equity".
|
||||
/// 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,
|
||||
net_income_ytd: Decimal,
|
||||
earnings: YearEarnings,
|
||||
members: &[Member],
|
||||
) -> Statement {
|
||||
let equity_name = if members.is_empty() {
|
||||
@@ -203,21 +218,39 @@ pub fn balance_sheet(
|
||||
));
|
||||
rows.push(spacer());
|
||||
rows.push(section(equity_name));
|
||||
let (retained, other): (Vec<_>, Vec<_>) = bs
|
||||
.equity
|
||||
.iter()
|
||||
.partition(|l| l.code == RETAINED_EARNINGS_CODE);
|
||||
rows.extend(
|
||||
other
|
||||
if members.is_empty() {
|
||||
let (retained, other): (Vec<_>, Vec<_>) = bs
|
||||
.equity
|
||||
.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 - net_income_ytd;
|
||||
if !retained.is_zero() {
|
||||
rows.push(one(RowKind::Line, "Retained earnings", retained));
|
||||
.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,
|
||||
"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(
|
||||
RowKind::Subtotal,
|
||||
format!("Total {}", equity_name.to_lowercase()),
|
||||
@@ -474,50 +507,105 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn balance_sheet_splits_retained_earnings_from_this_year() {
|
||||
let line = |code: &str, name: &str, a: &str| StatementLine {
|
||||
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![line("1000", "Cash", "2500")],
|
||||
assets: vec![sl("1000", "Cash", "2500")],
|
||||
liabilities: vec![],
|
||||
equity: vec![
|
||||
line("3000", "Capital — Alice", "1000"),
|
||||
line("3999", "Retained Earnings", "1000"),
|
||||
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 members = [Member {
|
||||
name: "Alice".into(),
|
||||
capital_code: "3000".into(),
|
||||
}];
|
||||
let st = balance_sheet(&bs, dec("300"), &members);
|
||||
let eq: Vec<(&str, Option<Decimal>)> = st
|
||||
.rows
|
||||
.iter()
|
||||
.skip_while(|r| r.label != "Members' equity")
|
||||
.map(|r| (r.label.as_str(), r.amounts.first().copied().flatten()))
|
||||
.collect();
|
||||
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!(
|
||||
eq,
|
||||
equity_rows(&st, "Equity"),
|
||||
[
|
||||
("Members' equity", None),
|
||||
("3000 Capital — Alice", Some(dec("1000"))),
|
||||
("Retained earnings", Some(dec("1200"))),
|
||||
("Net income (loss)", Some(dec("300"))),
|
||||
("Total members' equity", Some(dec("2500"))),
|
||||
("", None),
|
||||
("Total liabilities and members' equity", Some(dec("2500"))),
|
||||
("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")),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -117,14 +117,31 @@ impl BooksServer {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Net income (loss) for the fiscal year containing `as_of`, through it.
|
||||
fn net_income_ytd(&self, ledger: &Ledger, as_of: NaiveDate) -> Result<Decimal, String> {
|
||||
/// 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);
|
||||
Ok(
|
||||
ledger_core::profit_and_loss_for_period(ledger, fy_start, as_of, None)
|
||||
.map_err(|e| e.to_string())?
|
||||
.net_income(),
|
||||
)
|
||||
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> {
|
||||
@@ -1529,8 +1546,8 @@ impl BooksServer {
|
||||
)?),
|
||||
"balance_sheet" => {
|
||||
let bs = bs_statement(ledger, p.as_of.as_deref())?;
|
||||
let ytd = self.net_income_ytd(ledger, bs.as_of)?;
|
||||
report::balance_sheet(&bs, ytd, &members)
|
||||
let earnings = self.year_earnings(ledger, bs.as_of)?;
|
||||
report::balance_sheet(&bs, earnings, &members)
|
||||
}
|
||||
"cash_flow" => report::cash_flow(
|
||||
&cash_flow_statement(
|
||||
@@ -1952,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,
|
||||
@@ -1964,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,
|
||||
@@ -2644,7 +2661,7 @@ impl BooksServer {
|
||||
"balance-sheet",
|
||||
report::balance_sheet(
|
||||
&bs_statement(ledger, Some(&as_of_s))?,
|
||||
self.net_income_ytd(ledger, as_of)?,
|
||||
self.year_earnings(ledger, as_of)?,
|
||||
&members,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1022,4 +1022,37 @@ fn mcp_period_statements_over_stdio() {
|
||||
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();
|
||||
}
|
||||
Reference in new issue
Block a user