diff --git a/crates/ledger-core/src/ledger.rs b/crates/ledger-core/src/ledger.rs
index 2921115..5f3dcba 100644
--- a/crates/ledger-core/src/ledger.rs
+++ b/crates/ledger-core/src/ledger.rs
@@ -634,108 +634,113 @@ impl Ledger {
/// Close a period into Retained Earnings: see [`Self::close_period_to`].
pub fn close_period(&mut self, id: PeriodId) -> Result, LedgerError> {
- self.close_period_to(id, &periods::CloseTo::RetainedEarnings)
+ self.close_period_to(id, &periods::CloseTo::RetainedEarnings { sweep: vec![] })
}
/// Close a period. This:
/// 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).
+ /// inside it, if a later period is already closed (closing out of
+ /// order would sweep the same income twice), or if `to` is invalid.
/// 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.
+ /// dated after it stay open for their own period), puts the net in
+ /// `to`'s target, and zeroes `to`'s sweep accounts (draws,
+ /// distributions, dividends) into their target too:
+ /// - `RetainedEarnings`: account `3999`, created if missing.
+ /// - `Account`: one equity account (a sole proprietor's capital).
+ /// - `Members`: each member's capital account gets their share of
+ /// the net, to the cent (the last absorbs rounding), and their own
+ /// distribution account.
/// 3. Marks the period closed.
///
/// Returns the closing entry's id, or `None` if there was nothing to
- /// close (no revenue/expense balance at period end).
+ /// close.
pub fn close_period_to(
&mut self,
id: PeriodId,
to: &periods::CloseTo,
) -> Result , LedgerError> {
- self.preview_close_period(id)?;
- if let periods::CloseTo::Members(members) = to {
- self.validate_close_members(members)?;
- }
+ self.preview_close_period_to(id, to)?;
let period = self.periods.get(&id).expect("previewed").clone();
+ let (closings, net) = self.income_closings(period.end);
- // 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 {
- if !matches!(
- account.account_type,
- AccountType::Revenue | AccountType::Expense
- ) {
- continue;
+ // (account, amount to credit; negative debits): the net to its
+ // targets, and each sweep account's balance into its target.
+ let mut equity: Vec<(AccountId, Decimal)> = Vec::new();
+ let mut sweep_lines: Vec<(AccountId, AccountId, Decimal)> = Vec::new();
+ let mut sweep = |from: AccountId, into: AccountId, this: &Self| {
+ // An equity balance (credit-normal) is −movement; zeroing it
+ // moves exactly that much into the target.
+ let balance = -this.movement_through(from, period.end);
+ if !balance.is_zero() {
+ sweep_lines.push((from, into, balance));
}
- let movement = self.movement_through(account.id, period.end);
- if movement.is_zero() {
- continue;
+ };
+ match to {
+ periods::CloseTo::RetainedEarnings { sweep: accounts } => {
+ let has_work = !closings.is_empty()
+ || accounts
+ .iter()
+ .any(|a| !self.movement_through(*a, period.end).is_zero());
+ if has_work {
+ let re = self.retained_earnings_account()?;
+ equity.push((re, net));
+ for a in accounts {
+ sweep(*a, re, self);
+ }
+ }
+ }
+ periods::CloseTo::Account {
+ into,
+ sweep: accounts,
+ } => {
+ equity.push((*into, net));
+ for a in accounts {
+ sweep(*a, *into, self);
+ }
+ }
+ periods::CloseTo::Members(members) => {
+ let shares: Vec = members.iter().map(|m| m.share).collect();
+ for (m, part) in members.iter().zip(periods::allocate(net, &shares)) {
+ equity.push((m.capital, part));
+ if let Some(d) = m.distributions {
+ sweep(d, m.capital, self);
+ }
+ }
}
- // 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() && sweep_lines.is_empty() {
None
} else {
- // 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 = 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))?;
- }
- // Profit credits equity; a loss debits it.
- for (account, share) in targets {
- if share.is_zero() {
- continue;
+ let post = |this: &mut Self, account, credit: Decimal| -> Result<(), LedgerError> {
+ if credit.is_zero() {
+ return Ok(());
}
- let (side, amount) = if share > Decimal::ZERO {
- (Side::Credit, share)
+ let (side, amount) = if credit > Decimal::ZERO {
+ (Side::Credit, credit)
} else {
- (Side::Debit, -share)
+ (Side::Debit, -credit)
};
- self.add_line(entry, account, side, Money::new(amount, self.base_currency))?;
+ this.add_line(entry, account, side, Money::new(amount, this.base_currency))
+ };
+ for (account, side, amount) in closings {
+ let credit = if side == Side::Credit {
+ amount
+ } else {
+ -amount
+ };
+ post(self, account, credit)?;
+ }
+ for (account, share) in equity {
+ post(self, account, share)?;
+ }
+ // A sweep zeroes the source (opposite its balance) and moves
+ // the balance into the target.
+ for (from, into, balance) in sweep_lines {
+ post(self, from, -balance)?;
+ post(self, into, balance)?;
}
self.post_entry(entry)?;
Some(entry)
@@ -747,24 +752,88 @@ impl Ledger {
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}"
- )));
+ /// Revenue and expense balances as of `end` as closing lines (code
+ /// order), and the net income they carry (revenue debits minus expense
+ /// credits).
+ fn income_closings(&self, end: NaiveDate) -> (Vec<(AccountId, Side, Decimal)>, Decimal) {
+ let mut closings = Vec::new();
+ let mut accounts: Vec<_> = self.chart.all().collect();
+ accounts.sort_by(|a, b| a.code.cmp(&b.code));
+ for account in accounts {
+ if !matches!(
+ account.account_type,
+ AccountType::Revenue | AccountType::Expense
+ ) {
+ continue;
+ }
+ let movement = self.movement_through(account.id, end);
+ if movement.is_zero() {
+ continue;
+ }
+ // To zero the account: post the opposite of its net movement.
+ closings.push(if movement > Decimal::ZERO {
+ (account.id, Side::Credit, movement)
+ } else {
+ (account.id, Side::Debit, -movement)
+ });
+ }
+ let net = closings
+ .iter()
+ .map(|(_, side, amount)| match side {
+ Side::Debit => *amount,
+ Side::Credit => -*amount,
+ })
+ .sum();
+ (closings, net)
+ }
+
+ fn retained_earnings_account(&mut self) -> Result {
+ Ok(
+ 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,
+ )?,
+ },
+ )
+ }
+
+ /// Validate a close target: every account an equity account, targets
+ /// and sweeps distinct, members' shares positive and summing to 1.
+ fn validate_close_to(&self, to: &periods::CloseTo) -> Result<(), LedgerError> {
+ let mut accounts: Vec = Vec::new();
+ match to {
+ periods::CloseTo::RetainedEarnings { sweep } => accounts.extend(sweep),
+ periods::CloseTo::Account { into, sweep } => {
+ accounts.push(*into);
+ accounts.extend(sweep);
+ }
+ periods::CloseTo::Members(members) => {
+ let total: Decimal = members.iter().map(|m| m.share).sum();
+ if members.is_empty()
+ || total != Decimal::ONE
+ || members.iter().any(|m| m.share <= Decimal::ZERO)
+ {
+ return Err(LedgerError::InvalidMembers(format!(
+ "member shares must be positive and sum to 1, got {total}"
+ )));
+ }
+ for m in members {
+ accounts.push(m.capital);
+ accounts.extend(m.distributions);
+ }
+ }
}
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) {
+ for a in accounts {
+ 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",
+ "close account {} must be a distinct equity account",
account.code
)));
}
@@ -787,16 +856,23 @@ impl Ledger {
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
- /// (period end) is not inside another closed period — and returns the id
- /// the closing entry will receive, or `None` when the close posts no
- /// entry (no revenue/expense movement). [`Self::close_period`] repeats
- /// these checks before mutating; this exists so stores can journal the
- /// outcome before mutating, keeping a failed write from leaving a
- /// journal line behind.
+ /// [`Self::preview_close_period_to`] closing into Retained Earnings.
pub fn preview_close_period(&self, id: PeriodId) -> Result, LedgerError> {
+ self.preview_close_period_to(id, &periods::CloseTo::RetainedEarnings { sweep: vec![] })
+ }
+
+ /// Pure pre-close check for journaling stores: verifies that
+ /// [`Self::close_period_to`] would succeed — the period exists and is
+ /// open, no draft entries are dated inside it, no later period is
+ /// closed, the closing date isn't inside another closed period, and the
+ /// target is valid — and returns the id the closing entry will receive,
+ /// or `None` when the close posts no entry. Stores call this before
+ /// journaling so a failed close leaves no journal line behind.
+ pub fn preview_close_period_to(
+ &self,
+ id: PeriodId,
+ to: &periods::CloseTo,
+ ) -> Result , LedgerError> {
let period = self
.periods
.get(&id)
@@ -825,16 +901,23 @@ impl Ledger {
{
return Err(LedgerError::CloseOutOfOrder(id, later.id));
}
- // The close posts an entry exactly when some revenue or expense
- // 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.movement_through(a.id, period.end).is_zero()
- });
- Ok(any_movement.then_some(self.next_entry_id))
+ self.validate_close_to(to)?;
+ // An entry posts exactly when there is income to close or a sweep
+ // account with a balance. Account creation (Retained Earnings) does
+ // not consume entry ids.
+ let (closings, _) = self.income_closings(period.end);
+ let sweeps: Vec = match to {
+ periods::CloseTo::RetainedEarnings { sweep }
+ | periods::CloseTo::Account { sweep, .. } => sweep.clone(),
+ periods::CloseTo::Members(members) => {
+ members.iter().filter_map(|m| m.distributions).collect()
+ }
+ };
+ let any = !closings.is_empty()
+ || sweeps
+ .iter()
+ .any(|a| !self.movement_through(*a, period.end).is_zero());
+ Ok(any.then_some(self.next_entry_id))
}
// ------------------------------------------------------------------
diff --git a/crates/ledger-core/src/lib.rs b/crates/ledger-core/src/lib.rs
index d6a831f..03f9c6a 100644
--- a/crates/ledger-core/src/lib.rs
+++ b/crates/ledger-core/src/lib.rs
@@ -44,7 +44,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::{CloseTo, Period, PeriodId, PeriodStatus, RETAINED_EARNINGS_CODE};
+pub use periods::{CloseTo, MemberClose, Period, PeriodId, PeriodStatus, RETAINED_EARNINGS_CODE};
pub use reports::{
BalanceSheet, BalanceSheetStatement, CashFlowLine, CashFlowParams, CashFlowStatement,
ClassProfitAndLoss, GeneralLedger, LedgerAccount, LedgerPosting, MemberCapital,
diff --git a/crates/ledger-core/src/periods.rs b/crates/ledger-core/src/periods.rs
index 6cf1c97..e081d5c 100644
--- a/crates/ledger-core/src/periods.rs
+++ b/crates/ledger-core/src/periods.rs
@@ -29,16 +29,41 @@ pub enum PeriodStatus {
Closed,
}
-/// Where a period close puts the period's net income.
+/// Where a period close puts the period's net income, and which owner
+/// accounts (draws, distributions, dividends) it zeroes into equity too.
#[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
+ /// The Retained Earnings account ([`RETAINED_EARNINGS_CODE`], created if
+ /// missing): the corporate convention. `sweep` lists dividend or
+ /// distribution accounts to close into it.
+ RetainedEarnings {
+ /// Equity accounts zeroed into Retained Earnings.
+ sweep: Vec,
+ },
+ /// One equity account, e.g. a sole proprietor's capital, with `sweep`
+ /// (e.g. the owner's draws) zeroed into it.
+ Account {
+ /// The equity account receiving the net.
+ into: crate::AccountId,
+ /// Equity accounts zeroed into it.
+ sweep: Vec,
+ },
+ /// 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)>),
+ /// their allocated earnings and absorbs their distributions.
+ Members(Vec),
+}
+
+/// One member in a partnership close.
+#[derive(Clone, Debug, PartialEq)]
+pub struct MemberClose {
+ /// The member's capital account.
+ pub capital: crate::AccountId,
+ /// Their share of net income.
+ pub share: rust_decimal::Decimal,
+ /// Their distributions account, zeroed into their capital, if they
+ /// have one.
+ pub distributions: Option,
}
/// Split `amount` by `shares` to the cent; the last share absorbs rounding,
diff --git a/crates/ledger-core/src/reports.rs b/crates/ledger-core/src/reports.rs
index bfe8ed1..dcc4f4d 100644
--- a/crates/ledger-core/src/reports.rs
+++ b/crates/ledger-core/src/reports.rs
@@ -671,6 +671,10 @@ pub struct MemberCapital {
/// Their capital account (equity): contributions credit it,
/// distributions debit it.
pub capital_account: crate::AccountId,
+ /// Their distributions account (contra-equity), if distributions are
+ /// recorded there rather than directly against capital. Part of their
+ /// capital on every statement; zeroed into capital at close.
+ pub distribution_account: Option,
/// Share of income, e.g. 0.5. The shares must sum to exactly 1.
pub share: Decimal,
}
@@ -738,15 +742,17 @@ pub fn members_equity(
}
let mut seen = BTreeSet::new();
for m in members {
- let account = ledger
- .chart()
- .get(m.capital_account)
- .ok_or(LedgerError::UnknownAccount(m.capital_account))?;
- if account.account_type != AccountType::Equity || !seen.insert(m.capital_account) {
- return Err(LedgerError::InvalidMembers(format!(
- "{}: capital account {} must be a distinct equity account",
- m.name, account.code
- )));
+ for id in std::iter::once(m.capital_account).chain(m.distribution_account) {
+ let account = ledger
+ .chart()
+ .get(id)
+ .ok_or(LedgerError::UnknownAccount(id))?;
+ if account.account_type != AccountType::Equity || !seen.insert(id) {
+ return Err(LedgerError::InvalidMembers(format!(
+ "{}: account {} must be a distinct equity account",
+ m.name, account.code
+ )));
+ }
}
}
let day_before = start.pred_opt().ok_or(LedgerError::InvalidDateRange {
@@ -788,11 +794,28 @@ pub fn members_equity(
}
(credits, debits)
};
+ // A member's capital is their capital account plus their distributions
+ // account (a debit balance, so it reduces capital).
let capital: Vec = members
.iter()
- .map(|m| capital_before(m.capital_account))
+ .map(|m| {
+ capital_before(m.capital_account)
+ + m.distribution_account.map_or(Decimal::ZERO, capital_before)
+ })
+ .collect();
+ // (contributions, distributions): credits to capital are
+ // contributions; debits to capital and net debits to the distributions
+ // account are distributions.
+ let flow: Vec<(Decimal, Decimal)> = members
+ .iter()
+ .map(|m| {
+ let (credits, debits) = flows(m.capital_account);
+ let (dc, dd) = m
+ .distribution_account
+ .map_or((Decimal::ZERO, Decimal::ZERO), flows);
+ (credits, debits + dd - dc)
+ })
.collect();
- let flow: Vec<(Decimal, Decimal)> = members.iter().map(|m| flows(m.capital_account)).collect();
// Everything in equity that isn't a capital account is partnership
// earnings not yet distributed: allocate it by share.
diff --git a/crates/ledger-core/tests/statements.rs b/crates/ledger-core/tests/statements.rs
index 4066eee..6b0c969 100644
--- a/crates/ledger-core/tests/statements.rs
+++ b/crates/ledger-core/tests/statements.rs
@@ -413,11 +413,13 @@ fn partnership(close_january: bool) -> (Ledger, Vec) {
MemberCapital {
name: "Alice".into(),
capital_account: alice,
+ distribution_account: None,
share: half,
},
MemberCapital {
name: "Bob".into(),
capital_account: bob,
+ distribution_account: None,
share: half,
},
];
@@ -603,7 +605,11 @@ fn a_partnership_closes_into_members_capital() {
let to = ledger_core::CloseTo::Members(
members
.iter()
- .map(|m| (m.capital_account, m.share))
+ .map(|m| ledger_core::MemberClose {
+ capital: m.capital_account,
+ share: m.share,
+ distributions: None,
+ })
.collect(),
);
let jan = l.create_period("2026-01", d(1, 1), d(1, 31)).unwrap();
@@ -659,9 +665,226 @@ fn a_partnership_closes_into_members_capital() {
// 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"))]);
+ let bad = ledger_core::CloseTo::Members(vec![ledger_core::MemberClose {
+ capital: members[0].capital_account,
+ share: cents("0.5"),
+ distributions: None,
+ }]);
assert!(matches!(
l.close_period_to(feb, &bad),
Err(LedgerError::InvalidMembers(_))
));
}
+
+fn entry_lines(l: &Ledger, id: ledger_core::EntryId) -> Vec<(String, Side, Decimal)> {
+ l.get_entry(id)
+ .unwrap()
+ .lines()
+ .iter()
+ .map(|x| {
+ (
+ l.chart().get(x.account).unwrap().code.clone(),
+ x.side,
+ x.amount.amount,
+ )
+ })
+ .collect()
+}
+
+#[test]
+fn a_sole_proprietor_closes_net_income_and_draws_into_capital() {
+ let mut l = Ledger::new(Currency::USD);
+ let cash = l
+ .add_account("1000", "Cash", AccountType::Asset, None, false)
+ .unwrap();
+ let capital = l
+ .add_account("3000", "Owner's Capital", AccountType::Equity, None, false)
+ .unwrap();
+ let draws = l
+ .add_account("3100", "Owner's Draws", AccountType::Equity, None, false)
+ .unwrap();
+ let sales = l
+ .add_account("4000", "Sales", AccountType::Revenue, None, false)
+ .unwrap();
+ let rent = l
+ .add_account("6000", "Rent", AccountType::Expense, None, false)
+ .unwrap();
+ post(&mut l, d(1, 2), "Owner invests", cash, capital, 5_000);
+ post(&mut l, d(1, 10), "Sales", cash, sales, 3_000);
+ post(&mut l, d(1, 15), "Rent", rent, cash, 1_000);
+ post(&mut l, d(1, 20), "Owner draw", draws, cash, 500);
+ let jan = l.create_period("2026-01", d(1, 1), d(1, 31)).unwrap();
+ let to = ledger_core::CloseTo::Account {
+ into: capital,
+ sweep: vec![draws],
+ };
+ let closing = l.close_period_to(jan, &to).unwrap().unwrap();
+ // Net 2,000 to capital; the 500 of draws zeroed into capital.
+ assert_eq!(
+ entry_lines(&l, closing),
+ [
+ ("4000".to_string(), Side::Debit, dec(3_000)),
+ ("6000".to_string(), Side::Credit, dec(1_000)),
+ ("3000".to_string(), Side::Credit, dec(2_000)),
+ ("3100".to_string(), Side::Credit, dec(500)),
+ ("3000".to_string(), Side::Debit, dec(500)),
+ ]
+ );
+ let bs = balance_sheet_as_of(&l, d(1, 31)).unwrap();
+ // 5,000 + 2,000 − 500 = 6,500, which is exactly the cash.
+ assert_eq!(bs.equity, vec![line("3000", "Owner's Capital", 6_500)]);
+ assert_eq!(bs.total_assets, dec(6_500));
+ assert_eq!(bs.current_earnings, Decimal::ZERO);
+}
+
+#[test]
+fn a_corporation_closes_dividends_into_retained_earnings() {
+ let mut l = Ledger::new(Currency::USD);
+ let cash = l
+ .add_account("1000", "Cash", AccountType::Asset, None, false)
+ .unwrap();
+ let stock = l
+ .add_account("3000", "Common Stock", AccountType::Equity, None, false)
+ .unwrap();
+ let dividends = l
+ .add_account("3200", "Dividends Paid", AccountType::Equity, None, false)
+ .unwrap();
+ let sales = l
+ .add_account("4000", "Sales", AccountType::Revenue, None, false)
+ .unwrap();
+ post(&mut l, d(1, 2), "Stock issued", cash, stock, 1_000);
+ post(&mut l, d(1, 10), "Sales", cash, sales, 900);
+ post(&mut l, d(1, 25), "Dividend", dividends, cash, 300);
+ let jan = l.create_period("2026-01", d(1, 1), d(1, 31)).unwrap();
+ let to = ledger_core::CloseTo::RetainedEarnings {
+ sweep: vec![dividends],
+ };
+ l.close_period_to(jan, &to).unwrap();
+ let bs = balance_sheet_as_of(&l, d(1, 31)).unwrap();
+ // Retained earnings: 900 earned − 300 paid out.
+ assert_eq!(
+ bs.equity,
+ vec![
+ line("3000", "Common Stock", 1_000),
+ line("3999", "Retained Earnings", 600)
+ ]
+ );
+ assert!(bs.balanced());
+}
+
+#[test]
+fn partner_distribution_accounts_roll_into_capital() {
+ let mut l = Ledger::new(Currency::USD);
+ let cash = l
+ .add_account("1000", "Cash", AccountType::Asset, None, false)
+ .unwrap();
+ let cap_a = l
+ .add_account("3000", "Capital — Alice", AccountType::Equity, None, false)
+ .unwrap();
+ let cap_b = l
+ .add_account("3001", "Capital — Bob", AccountType::Equity, None, false)
+ .unwrap();
+ let dist_a = l
+ .add_account(
+ "3100",
+ "Distributions — Alice",
+ AccountType::Equity,
+ None,
+ false,
+ )
+ .unwrap();
+ let dist_b = l
+ .add_account(
+ "3101",
+ "Distributions — Bob",
+ AccountType::Equity,
+ None,
+ false,
+ )
+ .unwrap();
+ let sales = l
+ .add_account("4000", "Sales", AccountType::Revenue, None, false)
+ .unwrap();
+ post(&mut l, d(1, 2), "Alice invests", cash, cap_a, 100);
+ post(&mut l, d(1, 2), "Bob invests", cash, cap_b, 100);
+ post(&mut l, d(1, 10), "Sales", cash, sales, 50);
+ post(&mut l, d(1, 20), "Bob's distribution", dist_b, cash, 20);
+ let half = cents("0.5");
+ let members = vec![
+ MemberCapital {
+ name: "Alice".into(),
+ capital_account: cap_a,
+ distribution_account: Some(dist_a),
+ share: half,
+ },
+ MemberCapital {
+ name: "Bob".into(),
+ capital_account: cap_b,
+ distribution_account: Some(dist_b),
+ share: half,
+ },
+ ];
+ // January: Bob's distribution comes from his distributions account.
+ let jan_me = members_equity(&l, d(1, 1), d(1, 31), &members).unwrap();
+ let row = |c: &ledger_core::MemberEquityColumn| {
+ (c.contributions, c.net_income, c.distributions, c.ending)
+ };
+ assert_eq!(
+ row(&jan_me.members[0]),
+ (dec(100), dec(25), dec(0), dec(125))
+ );
+ assert_eq!(
+ row(&jan_me.members[1]),
+ (dec(100), dec(25), dec(20), dec(105))
+ );
+ assert_eq!(
+ jan_me.total.ending,
+ balance_sheet_as_of(&l, d(1, 31)).unwrap().total_equity
+ );
+
+ // The close sweeps Bob's distributions into his capital.
+ let jan = l.create_period("2026-01", d(1, 1), d(1, 31)).unwrap();
+ let to = ledger_core::CloseTo::Members(
+ members
+ .iter()
+ .map(|m| ledger_core::MemberClose {
+ capital: m.capital_account,
+ share: m.share,
+ distributions: m.distribution_account,
+ })
+ .collect(),
+ );
+ l.close_period_to(jan, &to).unwrap();
+ let bs = balance_sheet_as_of(&l, d(1, 31)).unwrap();
+ assert_eq!(
+ bs.equity,
+ vec![
+ line("3000", "Capital — Alice", 125),
+ line("3001", "Capital — Bob", 105)
+ ]
+ );
+ // February starts there.
+ let feb_me = members_equity(&l, d(2, 1), d(2, 28), &members).unwrap();
+ assert_eq!(
+ (feb_me.members[0].beginning, feb_me.members[1].beginning),
+ (dec(125), dec(105))
+ );
+
+ // A sweep account must be equity.
+ let mut l2 = Ledger::new(Currency::USD);
+ let c = l2
+ .add_account("1000", "Cash", AccountType::Asset, None, false)
+ .unwrap();
+ let e = l2
+ .add_account("3000", "Capital", AccountType::Equity, None, false)
+ .unwrap();
+ let p = l2.create_period("2026-01", d(1, 1), d(1, 31)).unwrap();
+ let bad = ledger_core::CloseTo::Account {
+ into: e,
+ sweep: vec![c],
+ };
+ assert!(matches!(
+ l2.close_period_to(p, &bad),
+ Err(LedgerError::InvalidMembers(_))
+ ));
+}
diff --git a/crates/ledger-import/src/lib.rs b/crates/ledger-import/src/lib.rs
index 1c629c6..d94f4b5 100644
--- a/crates/ledger-import/src/lib.rs
+++ b/crates/ledger-import/src/lib.rs
@@ -14,6 +14,7 @@ pub mod chart_csv;
pub mod iif;
pub mod pdf;
pub mod report;
+pub mod templates;
pub mod xlsx;
pub use bank_csv::{BankTransaction, parse_bank_csv};
@@ -21,4 +22,5 @@ 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, YearEarnings, format_amount};
+pub use templates::{ChartTemplate, EntityType, chart_template};
pub use xlsx::{XlsxReport, report_to_xlsx};
diff --git a/crates/ledger-import/src/report.rs b/crates/ledger-import/src/report.rs
index 7f056f5..61ddb00 100644
--- a/crates/ledger-import/src/report.rs
+++ b/crates/ledger-import/src/report.rs
@@ -53,6 +53,8 @@ pub struct Member {
pub name: String,
/// Code of their capital account.
pub capital_code: String,
+ /// Code of their distributions account, if they have one.
+ pub distribution_code: Option,
}
/// What a row is, which decides how renderers style it.
@@ -276,9 +278,10 @@ fn flow_lines<'a>(
members: &'a [Member],
) -> impl Iterator- + 'a {
lines.iter().map(move |l| {
- let member = members
- .iter()
- .find(|m| l.account.as_deref() == Some(m.capital_code.as_str()));
+ let member = members.iter().find(|m| {
+ let code = l.account.as_deref();
+ code == Some(m.capital_code.as_str()) || code == m.distribution_code.as_deref()
+ });
let label = match member {
Some(m) if l.amount >= Decimal::ZERO => format!("Member contributions — {}", m.name),
Some(m) => format!("Member distributions — {}", m.name),
@@ -666,10 +669,12 @@ mod tests {
Member {
name: "Alice".into(),
capital_code: "3000".into(),
+ distribution_code: None,
},
Member {
name: "Bob".into(),
capital_code: "3001".into(),
+ distribution_code: None,
},
];
let st = balance_sheet(&bs, e, &members);
diff --git a/crates/ledger-import/src/templates.rs b/crates/ledger-import/src/templates.rs
new file mode 100644
index 0000000..078a34e
--- /dev/null
+++ b/crates/ledger-import/src/templates.rs
@@ -0,0 +1,432 @@
+//! Starter charts of accounts by entity type.
+//!
+//! A new business shouldn't start from a blank chart, and the right chart
+//! depends on what the business *is*: a sole proprietor has an owner's
+//! capital and draws; a partnership (or multi-member LLC) has a capital and
+//! distributions account per partner and pays partners guaranteed payments;
+//! an S corporation pays officer wages and makes shareholder distributions;
+//! a C corporation pays dividends and income tax. Each template is a
+//! proposal for the agent to review with the human before importing (via
+//! `books.import_chart_csv`), with tax categories already assigned, plus
+//! the `wanderbooks.toml` settings that go with it (tax form, owners, and
+//! where period closes go).
+
+use crate::chart_csv::ChartRow;
+use rust_decimal::Decimal;
+
+/// The kinds of business a template exists for.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum EntityType {
+ /// Sole proprietorship or single-member LLC (Schedule C).
+ SoleProprietorship,
+ /// Partnership or multi-member LLC (Form 1065).
+ Partnership,
+ /// S corporation, including an LLC electing S status (Form 1120-S).
+ SCorporation,
+ /// C corporation (Form 1120).
+ CCorporation,
+}
+
+impl EntityType {
+ /// Parse "sole_proprietorship", "partnership", "s_corporation",
+ /// "c_corporation" and common spellings ("llc" alone is ambiguous).
+ pub fn parse(s: &str) -> Option
{
+ let k: String = s
+ .to_lowercase()
+ .chars()
+ .filter(|c| c.is_ascii_alphanumeric())
+ .collect();
+ match k.as_str() {
+ "soleproprietorship" | "soleprop" | "singlememberllc" | "smllc" => {
+ Some(EntityType::SoleProprietorship)
+ }
+ "partnership" | "multimemberllc" | "mmllc" => Some(EntityType::Partnership),
+ "scorporation" | "scorp" | "s" => Some(EntityType::SCorporation),
+ "ccorporation" | "ccorp" | "c" | "corporation" => Some(EntityType::CCorporation),
+ _ => None,
+ }
+ }
+}
+
+/// A starter chart plus the settings that go with it.
+#[derive(Clone, Debug)]
+pub struct ChartTemplate {
+ /// Accounts, parents first.
+ pub rows: Vec,
+ /// The `wanderbooks.toml` additions: tax form, owners, close settings.
+ pub settings_toml: String,
+ /// What the human should decide or know, in plain words.
+ pub notes: Vec,
+}
+
+impl ChartTemplate {
+ /// The accounts as chart CSV with the tax_category column.
+ pub fn csv(&self) -> String {
+ let mut out = String::from("code,name,type,parent_code,tax_category\n");
+ for r in &self.rows {
+ let field = |s: &str| {
+ if s.contains([',', '"']) {
+ format!("\"{}\"", s.replace('"', "\"\""))
+ } else {
+ s.to_string()
+ }
+ };
+ out.push_str(&format!(
+ "{},{},{},{},{}\n",
+ r.code,
+ field(&r.name),
+ r.account_type,
+ r.parent_code.as_deref().unwrap_or(""),
+ r.tax_category.as_deref().unwrap_or("")
+ ));
+ }
+ out
+ }
+}
+
+fn acct(code: &str, name: &str, t: &str, tax: Option<&str>) -> ChartRow {
+ ChartRow {
+ code: code.to_string(),
+ name: name.to_string(),
+ account_type: t.to_string(),
+ parent_code: None,
+ tax_category: tax.map(str::to_string),
+ }
+}
+
+/// Equal shares for `n` owners as percents to two places, the last
+/// absorbing the remainder so they sum to exactly 100.
+fn equal_shares(n: usize) -> Vec {
+ let each = (Decimal::from(100) / Decimal::from(n as u64)).round_dp(2);
+ let mut v = vec![each; n];
+ if let Some((last, rest)) = v.split_last_mut() {
+ *last = Decimal::from(100) - rest.iter().sum::();
+ }
+ v
+}
+
+/// Build the template for `entity` with these owners (names; at least two
+/// for a partnership, which gets a capital and distributions account each).
+pub fn chart_template(entity: EntityType, owners: &[String]) -> Result {
+ use EntityType::*;
+ if entity == Partnership && owners.len() < 2 {
+ return Err("a partnership template needs at least two owners' names".to_string());
+ }
+ if owners.len() > 90 {
+ return Err("at most 90 owners".to_string());
+ }
+ let owed_to = match entity {
+ SoleProprietorship => "Due to Owner",
+ Partnership => "Due to Members",
+ SCorporation | CCorporation => "Due to Shareholders",
+ };
+ let mut rows = vec![
+ acct("1000", "Business Checking", "asset", None),
+ acct("1100", "Savings", "asset", None),
+ acct("1200", "Accounts Receivable", "asset", None),
+ acct("1300", "Prepaid Expenses", "asset", None),
+ acct("1500", "Equipment", "asset", None),
+ acct("1590", "Accumulated Depreciation", "asset", None),
+ acct("2000", "Accounts Payable", "liability", None),
+ acct("2100", "Credit Card", "liability", None),
+ acct("2200", "Sales Tax Payable", "liability", None),
+ acct("2300", "Payroll Liabilities", "liability", None),
+ acct("2400", "Deferred Revenue", "liability", None),
+ acct("2500", owed_to, "liability", None),
+ acct("2700", "Loans Payable", "liability", None),
+ ];
+ let mut settings = String::new();
+ let mut notes = vec![
+ "Rename, add, or drop accounts before importing; codes are a convention, not a rule."
+ .to_string(),
+ format!(
+ "{owed_to}: business expenses an owner paid personally, until the business reimburses them."
+ ),
+ ];
+ match entity {
+ SoleProprietorship => {
+ rows.push(acct("3000", "Owner's Capital", "equity", None));
+ rows.push(acct("3100", "Owner's Draws", "equity", None));
+ settings.push_str("[company]\ntax_form = \"schedule_c\"\n\n[books]\n# Net income and draws close into the owner's capital.\nclose_into = \"3000\"\nclose_sweep = [\"3100\"]\n");
+ notes
+ .push("Money you take out is a draw (3100), not a wage or an expense.".to_string());
+ }
+ Partnership => {
+ let shares = equal_shares(owners.len());
+ settings.push_str("[company]\ntax_form = \"1065\"\n\n# Each partner's share drives allocations and closes. Equal shares are a\n# placeholder: use the operating agreement's numbers.\n");
+ for (i, name) in owners.iter().enumerate() {
+ let cap = format!("{}", 3000 + i);
+ let dist = format!("{}", 3100 + i);
+ rows.push(acct(&cap, &format!("Capital — {name}"), "equity", None));
+ rows.push(acct(
+ &dist,
+ &format!("Distributions — {name}"),
+ "equity",
+ None,
+ ));
+ settings.push_str(&format!(
+ "\n[[company.members]]\nname = \"{name}\"\nshare = \"{}%\"\ncapital_account = \"{cap}\"\ndistribution_account = \"{dist}\"\n",
+ shares[i]
+ ));
+ }
+ notes.push("A partner paid for work gets guaranteed payments (7200), not wages; profit taken out is a distribution.".to_string());
+ notes.push(
+ "Shares default to equal; set them from the operating agreement.".to_string(),
+ );
+ }
+ SCorporation | CCorporation => {
+ rows.push(acct("3000", "Common Stock", "equity", None));
+ rows.push(acct("3100", "Additional Paid-in Capital", "equity", None));
+ if entity == SCorporation {
+ rows.push(acct("3200", "Shareholder Distributions", "equity", None));
+ settings.push_str("[company]\ntax_form = \"1120s\"\n\n[books]\n# Net income and distributions close into Retained Earnings (3999).\nclose_sweep = [\"3200\"]\n");
+ notes.push("Owner-employees must take reasonable W-2 wages (officer compensation, 7200) before distributions.".to_string());
+ } else {
+ rows.push(acct("3200", "Dividends Paid", "equity", None));
+ settings.push_str("[company]\ntax_form = \"1120\"\n\n[books]\n# Net income and dividends close into Retained Earnings (3999).\nclose_sweep = [\"3200\"]\n");
+ notes.push("The corporation pays its own income tax (7900, not deductible); dividends are not expenses.".to_string());
+ }
+ rows.push(acct("3999", "Retained Earnings", "equity", None));
+ }
+ }
+ rows.extend([
+ acct("4000", "Sales", "revenue", Some("gross_receipts")),
+ acct(
+ "4100",
+ "Refunds and Allowances",
+ "revenue",
+ Some("returns_allowances"),
+ ),
+ acct(
+ "4900",
+ "Interest Income",
+ "revenue",
+ Some("interest_income"),
+ ),
+ acct("4950", "Other Income", "revenue", Some("other_income")),
+ acct(
+ "5000",
+ "Cost of Goods Sold",
+ "expense",
+ Some("cost_of_goods_sold"),
+ ),
+ acct("6000", "Advertising", "expense", Some("advertising")),
+ acct("6050", "Bank Fees", "expense", Some("other_deductions")),
+ acct(
+ "6100",
+ "Platform and Payment Processing Fees",
+ "expense",
+ Some("commissions_fees"),
+ ),
+ acct("6150", "Contract Labor", "expense", Some("contract_labor")),
+ acct("6200", "Depreciation", "expense", Some("depreciation")),
+ acct(
+ "6250",
+ "Dues and Memberships",
+ "expense",
+ Some("other_deductions"),
+ ),
+ acct("6300", "Insurance", "expense", Some("insurance")),
+ acct(
+ "6350",
+ "Interest Expense",
+ "expense",
+ Some("interest_expense"),
+ ),
+ acct(
+ "6400",
+ "Legal and Professional Fees",
+ "expense",
+ Some("legal_professional"),
+ ),
+ acct("6450", "Meals", "expense", Some("meals")),
+ acct("6500", "Office Expense", "expense", Some("office_expense")),
+ acct(
+ "6550",
+ "Organizational and Start-up Costs",
+ "expense",
+ Some("organization_startup"),
+ ),
+ acct("6600", "Rent", "expense", Some("rent_property")),
+ acct(
+ "6650",
+ "Repairs and Maintenance",
+ "expense",
+ Some("repairs"),
+ ),
+ acct(
+ "6700",
+ "Software and Subscriptions",
+ "expense",
+ Some("other_deductions"),
+ ),
+ acct("6750", "Supplies", "expense", Some("supplies")),
+ acct(
+ "6800",
+ "Taxes and Licenses",
+ "expense",
+ Some("taxes_licenses"),
+ ),
+ acct("6850", "Travel", "expense", Some("travel")),
+ acct("6900", "Utilities", "expense", Some("utilities")),
+ acct(
+ "6950",
+ "Penalties and Nondeductible Expenses",
+ "expense",
+ Some("nondeductible"),
+ ),
+ acct(
+ "7000",
+ "Salaries and Wages",
+ "expense",
+ Some("salaries_wages"),
+ ),
+ acct("7050", "Payroll Taxes", "expense", Some("taxes_licenses")),
+ acct(
+ "7100",
+ "Employee Benefits",
+ "expense",
+ Some("employee_benefits"),
+ ),
+ ]);
+ match entity {
+ Partnership => rows.push(acct(
+ "7200",
+ "Guaranteed Payments to Partners",
+ "expense",
+ Some("guaranteed_payments"),
+ )),
+ SCorporation | CCorporation => rows.push(acct(
+ "7200",
+ "Officer Compensation",
+ "expense",
+ Some("officer_compensation"),
+ )),
+ SoleProprietorship => {}
+ }
+ if entity == CCorporation {
+ rows.push(acct(
+ "7900",
+ "Income Tax Expense",
+ "expense",
+ Some("nondeductible"),
+ ));
+ }
+ Ok(ChartTemplate {
+ rows,
+ settings_toml: settings,
+ notes,
+ })
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::chart_csv::parse_chart_csv;
+
+ #[test]
+ fn templates_round_trip_through_the_chart_parser() {
+ for e in [
+ EntityType::SoleProprietorship,
+ EntityType::Partnership,
+ EntityType::SCorporation,
+ EntityType::CCorporation,
+ ] {
+ let t = chart_template(e, &["Ann Lee".into(), "Bo, Jr.".into(), "Cy".into()]).unwrap();
+ let parsed = parse_chart_csv(&t.csv()).unwrap();
+ assert_eq!(parsed, t.rows, "{e:?}");
+ // Codes are unique.
+ let mut codes: Vec<&str> = t.rows.iter().map(|r| r.code.as_str()).collect();
+ codes.sort_unstable();
+ codes.dedup();
+ assert_eq!(codes.len(), t.rows.len(), "{e:?}");
+ }
+ }
+
+ #[test]
+ fn equity_fits_the_entity() {
+ let names = |t: &ChartTemplate| -> Vec {
+ t.rows
+ .iter()
+ .filter(|r| r.account_type == "equity")
+ .map(|r| r.name.clone())
+ .collect()
+ };
+ let sole = chart_template(EntityType::SoleProprietorship, &[]).unwrap();
+ assert_eq!(names(&sole), ["Owner's Capital", "Owner's Draws"]);
+ assert!(sole.settings_toml.contains("close_into = \"3000\""));
+
+ let p = chart_template(
+ EntityType::Partnership,
+ &["A".into(), "B".into(), "C".into()],
+ )
+ .unwrap();
+ assert_eq!(
+ names(&p),
+ [
+ "Capital — A",
+ "Distributions — A",
+ "Capital — B",
+ "Distributions — B",
+ "Capital — C",
+ "Distributions — C"
+ ]
+ );
+ // Three equal shares that sum to exactly 100.
+ assert!(p.settings_toml.contains("share = \"33.33%\""));
+ assert!(p.settings_toml.contains("share = \"33.34%\""));
+ assert!(
+ p.rows
+ .iter()
+ .any(|r| r.tax_category.as_deref() == Some("guaranteed_payments"))
+ );
+ assert!(chart_template(EntityType::Partnership, &["solo".into()]).is_err());
+
+ let s = chart_template(EntityType::SCorporation, &[]).unwrap();
+ assert!(names(&s).contains(&"Shareholder Distributions".to_string()));
+ assert!(
+ s.rows
+ .iter()
+ .any(|r| r.tax_category.as_deref() == Some("officer_compensation"))
+ );
+ let c = chart_template(EntityType::CCorporation, &[]).unwrap();
+ assert!(names(&c).contains(&"Dividends Paid".to_string()));
+ assert!(c.rows.iter().any(|r| r.name == "Income Tax Expense"));
+ }
+
+ #[test]
+ fn every_category_is_valid_for_the_entitys_return() {
+ use ledger_core::{AccountType, TaxForm, tax::check_category};
+ for (e, form) in [
+ (EntityType::SoleProprietorship, TaxForm::ScheduleC),
+ (EntityType::Partnership, TaxForm::Form1065),
+ (EntityType::SCorporation, TaxForm::Form1120S),
+ (EntityType::CCorporation, TaxForm::Form1120),
+ ] {
+ for r in chart_template(e, &["A".into(), "B".into()]).unwrap().rows {
+ let Some(cat) = &r.tax_category else { continue };
+ let t = if r.account_type == "revenue" {
+ AccountType::Revenue
+ } else {
+ AccountType::Expense
+ };
+ check_category(cat, t, Some(form))
+ .unwrap_or_else(|err| panic!("{e:?} {}: {err}", r.code));
+ }
+ }
+ }
+
+ #[test]
+ fn entity_types_parse() {
+ assert_eq!(
+ EntityType::parse("multi-member LLC"),
+ Some(EntityType::Partnership)
+ );
+ assert_eq!(EntityType::parse("S corp"), Some(EntityType::SCorporation));
+ assert_eq!(
+ EntityType::parse("sole_proprietorship"),
+ Some(EntityType::SoleProprietorship)
+ );
+ assert_eq!(EntityType::parse("LLC"), None, "ambiguous");
+ }
+}
diff --git a/crates/ledger-mcp/src/company.rs b/crates/ledger-mcp/src/company.rs
index 657c5e7..edce28f 100644
--- a/crates/ledger-mcp/src/company.rs
+++ b/crates/ledger-mcp/src/company.rs
@@ -28,6 +28,13 @@ pub struct Company {
/// The income tax return the business files, if known: from
/// `tax_form`, else from `tax_classification`.
pub tax_form: Option,
+ /// `[books] close_into`: the equity account a period close puts net
+ /// income in (a sole proprietor's capital, say). Unset: Retained
+ /// Earnings, or members' capital accounts when there are members.
+ pub close_into: Option,
+ /// `[books] close_sweep`: equity accounts (draws, dividends,
+ /// distributions) a close zeroes into `close_into`.
+ pub close_sweep: Vec,
}
/// One member of a multi-member LLC.
@@ -39,6 +46,9 @@ pub struct Member {
pub share: Decimal,
/// Code of their capital account in the chart.
pub capital_account: String,
+ /// Code of their distributions account, if distributions are recorded
+ /// separately from capital (zeroed into capital at close).
+ pub distribution_account: Option,
}
impl Default for Company {
@@ -49,6 +59,8 @@ impl Default for Company {
members: Vec::new(),
basis: "Accrual".to_string(),
tax_form: None,
+ close_into: None,
+ close_sweep: Vec::new(),
}
}
}
@@ -102,12 +114,16 @@ struct MemberToml {
name: String,
share: String,
capital_account: Option,
+ distribution_account: Option,
}
#[derive(Default, Deserialize)]
struct BooksSection {
receipt_threshold: Option,
accounting_basis: Option,
+ close_into: Option,
+ #[serde(default)]
+ close_sweep: Vec,
}
/// The return a federal tax classification files: "partnership" → 1065,
@@ -174,6 +190,10 @@ pub fn parse(text: &str) -> Result {
name: m.name.trim().to_string(),
share,
capital_account: capital_account.trim().to_string(),
+ distribution_account: m
+ .distribution_account
+ .map(|d| d.trim().to_string())
+ .filter(|d| !d.is_empty()),
});
}
if !company.members.is_empty() {
@@ -190,6 +210,14 @@ pub fn parse(text: &str) -> Result {
(None, Some(c)) => form_for_classification(c),
(None, None) => None,
};
+ if !company.members.is_empty() && raw.books.close_into.is_some() {
+ return Err(
+ "close_into is for single-owner businesses; with [[company.members]] a close goes to each member's capital account"
+ .to_string(),
+ );
+ }
+ company.close_into = raw.books.close_into.map(|c| c.trim().to_string());
+ company.close_sweep = raw.books.close_sweep;
if let Some(basis) = raw.books.accounting_basis {
company.basis = match basis.trim().to_lowercase().as_str() {
"accrual" => "Accrual".to_string(),
@@ -291,12 +319,14 @@ mod tests {
Member {
name: "Stephen Waits".into(),
share: half,
- capital_account: "3000".into()
+ capital_account: "3000".into(),
+ distribution_account: None,
},
Member {
name: "Katherine Pendergast".into(),
share: half,
- capital_account: "3001".into()
+ capital_account: "3001".into(),
+ distribution_account: None,
},
]
);
diff --git a/crates/ledger-mcp/src/server.rs b/crates/ledger-mcp/src/server.rs
index 18e872e..c1c9d48 100644
--- a/crates/ledger-mcp/src/server.rs
+++ b/crates/ledger-mcp/src/server.rs
@@ -82,6 +82,7 @@ impl BooksServer {
.map(|m| ledger_import::Member {
name: m.name.clone(),
capital_code: m.capital_account.clone(),
+ distribution_code: m.distribution_account.clone(),
})
.collect()
}
@@ -108,15 +109,69 @@ impl BooksServer {
m.name, m.capital_account
)
})?;
+ let distribution_account = match &m.distribution_account {
+ Some(code) => Some(
+ ledger
+ .chart()
+ .find_by_code(code)
+ .ok_or_else(|| {
+ format!("member {}: unknown distribution account {code}", m.name)
+ })?
+ .id,
+ ),
+ None => None,
+ };
Ok(ledger_core::MemberCapital {
name: m.name.clone(),
capital_account: account.id,
+ distribution_account,
share: m.share,
})
})
.collect()
}
+ /// Where a period close goes, from wanderbooks.toml: members' capital
+ /// accounts by share (with their distribution accounts swept in) for a
+ /// partnership; else `close_into` (Retained Earnings when unset or
+ /// `3999`) with `close_sweep` zeroed into it.
+ fn close_target(&self, ledger: &Ledger) -> Result {
+ let code = |c: &str| -> Result {
+ ledger
+ .chart()
+ .find_by_code(c)
+ .map(|a| a.id)
+ .ok_or_else(|| format!("wanderbooks.toml names unknown account {c}"))
+ };
+ if !self.company.members.is_empty() {
+ return Ok(ledger_core::CloseTo::Members(
+ self.member_capitals(ledger)?
+ .into_iter()
+ .map(|m| ledger_core::MemberClose {
+ capital: m.capital_account,
+ share: m.share,
+ distributions: m.distribution_account,
+ })
+ .collect(),
+ ));
+ }
+ let sweep = self
+ .company
+ .close_sweep
+ .iter()
+ .map(|c| code(c))
+ .collect::, _>>()?;
+ Ok(match self.company.close_into.as_deref() {
+ None | Some(ledger_core::RETAINED_EARNINGS_CODE) => {
+ ledger_core::CloseTo::RetainedEarnings { sweep }
+ }
+ Some(c) => ledger_core::CloseTo::Account {
+ into: code(c)?,
+ sweep,
+ },
+ })
+ }
+
/// 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
@@ -1361,6 +1416,30 @@ impl BooksServer {
Ok(Json(ImportBankCsvOut { imported, count }))
}
+ #[tool(
+ name = "books.chart_template",
+ description = "A starter chart of accounts for a kind of business (sole proprietorship, partnership or multi-member LLC, S corporation, C corporation): the right equity and owner-pay accounts (draws; per-partner capital and distributions plus guaranteed payments; officer compensation and distributions; dividends and income tax), common income and expense accounts with tax categories assigned, and the wanderbooks.toml settings that go with it. A proposal: review it with the human, adjust, then books.import_chart_csv.",
+ annotations(read_only_hint = true)
+ )]
+ async fn chart_template(
+ &self,
+ params: Parameters,
+ ) -> Result, String> {
+ let p = params.0;
+ let entity = ledger_import::EntityType::parse(&p.entity_type).ok_or_else(|| {
+ format!(
+ "entity_type {:?}: expected sole_proprietorship, partnership, s_corporation, or c_corporation (an LLC is one of these for tax purposes)",
+ p.entity_type
+ )
+ })?;
+ let t = ledger_import::chart_template(entity, &p.owners)?;
+ Ok(Json(ChartTemplateOut {
+ csv: t.csv(),
+ settings_toml: t.settings_toml,
+ notes: t.notes,
+ }))
+ }
+
#[tool(
name = "books.tax_categories",
description = "The tax categories an account can map to (where it lands on the income tax return), filtered to the business's return when wanderbooks.toml says which (tax_form or tax_classification). Each has a default deductible percent (meals 50, nondeductible 0) and guidance. Categories are stable keys, not form line numbers.",
@@ -2072,18 +2151,7 @@ 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 to = self.close_target(store.ledger())?;
let describe = |ledger: &Ledger, eid: Option| -> Vec {
let Some(entry) = eid.and_then(|e| ledger.get_entry(e)) else {
return Vec::new();
diff --git a/crates/ledger-mcp/src/types.rs b/crates/ledger-mcp/src/types.rs
index b1fc7b6..3cf2ac3 100644
--- a/crates/ledger-mcp/src/types.rs
+++ b/crates/ledger-mcp/src/types.rs
@@ -54,6 +54,31 @@ pub struct ChartAccount {
pub deductible_percent: Option,
}
+/// Parameters for `books.chart_template`.
+#[derive(Debug, Deserialize, JsonSchema)]
+pub struct ChartTemplateParams {
+ /// "sole_proprietorship" (incl. single-member LLC), "partnership"
+ /// (incl. multi-member LLC), "s_corporation", or "c_corporation".
+ pub entity_type: String,
+ /// Owners' names: one capital and distributions account each for a
+ /// partnership (at least two). Ignored otherwise.
+ #[serde(default)]
+ pub owners: Vec,
+}
+
+/// Result of `books.chart_template`.
+#[derive(Debug, Serialize, JsonSchema)]
+pub struct ChartTemplateOut {
+ /// Chart CSV (code,name,type,parent_code,tax_category) for
+ /// `books.import_chart_csv`, after review.
+ pub csv: String,
+ /// The `wanderbooks.toml` settings that go with it: tax form, owners,
+ /// where period closes go.
+ pub settings_toml: String,
+ /// What to decide or know.
+ pub notes: Vec,
+}
+
/// Parameters for `books.set_tax_category`.
#[derive(Debug, Deserialize, JsonSchema)]
pub struct SetTaxCategoryParams {
diff --git a/crates/ledger-mcp/tests/mcp_stdio.rs b/crates/ledger-mcp/tests/mcp_stdio.rs
index 0dcc18b..b44aa69 100644
--- a/crates/ledger-mcp/tests/mcp_stdio.rs
+++ b/crates/ledger-mcp/tests/mcp_stdio.rs
@@ -139,7 +139,7 @@ fn mcp_end_to_end_over_stdio() {
// Tool listing: 23 narrow tools, chart is read-only.
let list = mcp.request("tools/list", json!({}));
let tools = list["result"]["tools"].as_array().unwrap();
- assert_eq!(tools.len(), 52, "expected 52 tools, got {}", tools.len());
+ assert_eq!(tools.len(), 53, "expected 53 tools, got {}", tools.len());
let chart = tools.iter().find(|t| t["name"] == "books.chart").unwrap();
assert_eq!(chart["annotations"]["readOnlyHint"], true);
assert!(
@@ -1120,3 +1120,115 @@ fn mcp_period_statements_over_stdio() {
.all(|a| a["code"] != "6100")
);
}
+
+#[test]
+fn mcp_partnership_from_template_over_stdio() {
+ let init = |mcp: &mut Mcp| {
+ mcp.request(
+ "initialize",
+ json!({
+ "protocolVersion": "2025-06-18",
+ "capabilities": {},
+ "clientInfo": {"name": "smoke-test", "version": "0.1.0"},
+ }),
+ );
+ mcp.notify("notifications/initialized");
+ };
+ // 1. Ask for a template (any server will do).
+ let dir = test_dir("template");
+ let mut scratch = Mcp::spawn(&dir.join("scratch"));
+ init(&mut scratch);
+ let t = scratch.call(
+ "books.chart_template",
+ json!({"entity_type": "multi-member LLC", "owners": ["Ann", "Bo"]}),
+ );
+ let settings = t["settings_toml"].as_str().unwrap();
+ assert!(settings.contains("distribution_account = \"3101\""));
+ let err = scratch.call_err("books.chart_template", json!({"entity_type": "LLC"}));
+ assert!(err.contains("entity_type"), "{err}");
+
+ // 2. Its settings become the company config; its chart, the chart.
+ let toml = dir.join("wanderbooks.toml");
+ std::fs::write(
+ &toml,
+ format!("{settings}\n[books]\nreceipt_threshold = \"10000\"\n"),
+ )
+ .unwrap();
+ let mut mcp = Mcp::spawn_with(
+ &dir.join("books-data"),
+ &[std::ffi::OsStr::new("--books-toml"), toml.as_os_str()],
+ );
+ init(&mut mcp);
+ mcp.call("books.import_chart_csv", json!({"csv_text": t["csv"]}));
+ let post = |mcp: &mut Mcp, date: &str, dr: &str, cr: &str, amount: &str| {
+ let e = mcp.call(
+ "books.draft_entry",
+ json!({"date": date, "description": "x"}),
+ );
+ let id = e["entry_id"].as_u64().unwrap();
+ mcp.call(
+ "books.add_line",
+ json!({"entry_id": id, "account": dr, "side": "debit", "amount": amount}),
+ );
+ mcp.call(
+ "books.add_line",
+ json!({"entry_id": id, "account": cr, "side": "credit", "amount": amount}),
+ );
+ mcp.call(
+ "books.post_entry",
+ json!({"entry_id": id, "confirmed": true}),
+ );
+ };
+ post(&mut mcp, "2026-01-02", "1000", "3000", "1000.00");
+ post(&mut mcp, "2026-01-02", "1000", "3001", "1000.00");
+ post(&mut mcp, "2026-01-10", "1000", "4000", "400.00");
+ post(&mut mcp, "2026-01-20", "3101", "1000", "50.00");
+
+ // 3. Members' equity sees Bo's distribution in his distributions account.
+ 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["members"][1]["distributions"], "50.00");
+ assert_eq!(me["members"][1]["ending"], "1150.00");
+
+ // 4. Closing January: 200 each to capital, Bo's distributions swept in.
+ let jan = mcp.call(
+ "books.create_period",
+ json!({"name": "2026-01", "start": "2026-01-01", "end": "2026-01-31"}),
+ );
+ let closed = mcp.call(
+ "books.close_period",
+ json!({"period_id": jan["period_id"], "confirmed": true}),
+ );
+ assert_eq!(
+ closed["lines"],
+ json!([
+ "Debit 4000 400.00",
+ "Credit 3000 200.00",
+ "Credit 3001 200.00",
+ "Credit 3101 50.00",
+ "Debit 3001 50.00"
+ ])
+ );
+ let bs = mcp.call("books.balance_sheet", json!({"as_of": "2026-01-31"}));
+ let equity: Vec<(String, String)> = bs["equity_accounts"]
+ .as_array()
+ .unwrap()
+ .iter()
+ .map(|a| {
+ (
+ a["code"].as_str().unwrap().to_string(),
+ a["amount"].as_str().unwrap().to_string(),
+ )
+ })
+ .collect();
+ assert_eq!(
+ equity,
+ [
+ ("3000".into(), "1200.00".into()),
+ ("3001".into(), "1150.00".into())
+ ]
+ );
+}
diff --git a/crates/ledger-store/src/store.rs b/crates/ledger-store/src/store.rs
index 4bc2d98..66343fc 100644
--- a/crates/ledger-store/src/store.rs
+++ b/crates/ledger-store/src/store.rs
@@ -955,7 +955,7 @@ 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, StoreError> {
- self.close_period_to(id, &CloseTo::RetainedEarnings)
+ self.close_period_to(id, &CloseTo::RetainedEarnings { sweep: vec![] })
}
/// Close a period with its net income going to `to`: Retained Earnings,
@@ -969,8 +969,8 @@ impl Store {
) -> Result , StoreError> {
// Pure pre-validation: a doomed close journals nothing, not even
// the Retained Earnings account creation below.
- self.ledger.preview_close_period(id)?;
- if *to == CloseTo::RetainedEarnings {
+ self.ledger.preview_close_period_to(id, to)?;
+ if matches!(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.
@@ -997,11 +997,17 @@ impl Store {
None => None,
};
let close_to = match to {
- CloseTo::RetainedEarnings => json!("retained_earnings"),
+ CloseTo::RetainedEarnings { sweep } if sweep.is_empty() => json!("retained_earnings"),
+ CloseTo::RetainedEarnings { sweep } => json!({"retained_earnings": {"sweep": sweep}}),
+ CloseTo::Account { into, sweep } => json!({"account": {"into": into, "sweep": sweep}}),
CloseTo::Members(members) => json!({
"members": members
.iter()
- .map(|(account, share)| json!({"account": account, "share": share.to_string()}))
+ .map(|m| json!({
+ "account": m.capital,
+ "share": m.share.to_string(),
+ "distributions": m.distributions,
+ }))
.collect::>(),
}),
};
@@ -2805,22 +2811,7 @@ fn replay_line_into_ledger(
// Deterministic: the close re-derives the closing entry from
// 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::>()?,
- ),
- };
+ let to = close_to_from_journal(p.get("close_to"))?;
let got = ledger.close_period_to(id, &to)?;
if got != want {
return Err(StoreError::ReplayMismatch(format!(
@@ -2997,6 +2988,52 @@ fn apply_document_payment(
/// of the journal line: INSERTs for new facts, targeted UPDATEs/DELETEs
/// for lifecycle changes (receipt linking, disposal, period close,
/// cleared flags, document payments, fx rate updates).
+/// The close target recorded in a `period.closed` line. Absent or
+/// `"retained_earnings"`: Retained Earnings with no sweeps (every journal
+/// written before targets existed).
+fn close_to_from_journal(v: Option<&Value>) -> Result {
+ let corrupt = |what: &str| StoreError::JournalCorrupt(format!("close_to: {what}"));
+ let ids = |v: &Value| -> Result, StoreError> {
+ v.as_array()
+ .ok_or_else(|| corrupt("sweep"))?
+ .iter()
+ .map(|x| x.as_u64().ok_or_else(|| corrupt("sweep id")))
+ .collect()
+ };
+ let Some(v) = v.filter(|v| !v.is_null() && v.as_str() != Some("retained_earnings")) else {
+ return Ok(CloseTo::RetainedEarnings { sweep: vec![] });
+ };
+ if let Some(re) = v.get("retained_earnings") {
+ return Ok(CloseTo::RetainedEarnings {
+ sweep: ids(&re["sweep"])?,
+ });
+ }
+ if let Some(a) = v.get("account") {
+ return Ok(CloseTo::Account {
+ into: get_u64(a, "into")?,
+ sweep: ids(&a["sweep"])?,
+ });
+ }
+ let members = v
+ .get("members")
+ .and_then(|m| m.as_array())
+ .ok_or_else(|| corrupt("unknown target"))?;
+ Ok(CloseTo::Members(
+ members
+ .iter()
+ .map(|m| {
+ Ok(ledger_core::MemberClose {
+ capital: get_u64(m, "account")?,
+ share: get_str(m, "share")?
+ .parse()
+ .map_err(|_| corrupt("member share"))?,
+ distributions: m.get("distributions").and_then(|d| d.as_u64()),
+ })
+ })
+ .collect::>()?,
+ ))
+}
+
fn apply_line_to_sqlite(conn: &Connection, line: &JournalLine) -> Result<(), StoreError> {
let p = &line.payload;
match line.line_type.as_str() {
diff --git a/crates/ledger-store/tests/store.rs b/crates/ledger-store/tests/store.rs
index 64dc043..2eed25b 100644
--- a/crates/ledger-store/tests/store.rs
+++ b/crates/ledger-store/tests/store.rs
@@ -504,7 +504,18 @@ fn a_close_into_members_capital_replays_exactly() {
store
.close_period_to(
jan,
- &ledger_core::CloseTo::Members(vec![(alice, half), (bob, half)]),
+ &ledger_core::CloseTo::Members(vec![
+ ledger_core::MemberClose {
+ capital: alice,
+ share: half,
+ distributions: None,
+ },
+ ledger_core::MemberClose {
+ capital: bob,
+ share: half,
+ distributions: None,
+ },
+ ]),
)
.unwrap();
// 100.01 splits 50.01 / 50.00 into the capital accounts.
diff --git a/docs/mcp-interface.md b/docs/mcp-interface.md
index bc4aac7..70d2056 100644
--- a/docs/mcp-interface.md
+++ b/docs/mcp-interface.md
@@ -32,7 +32,10 @@ CPA package and the balance sheet's net income line, `[[company.members]]`
to 100%) drives the statement of members' equity, `[books]
accounting_basis` ("accrual" or "cash", default accrual) labels report
footers, and `[books] receipt_threshold` sets the receipt policy unless
-`config.json` sets `receipt_policy`.
+`config.json` sets `receipt_policy`. Members may name a
+`distribution_account`; single-owner businesses may set `[books] close_into`
+(the equity account a close puts net income in; default Retained Earnings)
+and `close_sweep` (draw, distribution, or dividend accounts closed into it).
## Transports
@@ -218,6 +221,18 @@ posting.
get "Members' equity" with members named; the balance sheet separates
retained earnings from the fiscal year's net income (loss).
+## Tools — chart templates
+
+- `books.chart_template { entity_type, owners? }` → `{ csv, settings_toml,
+ notes }`: a starter chart for a sole proprietorship (incl. single-member
+ LLC), partnership (incl. multi-member LLC), S corporation, or C
+ corporation — the equity and owner-pay accounts that entity needs (owner's
+ capital and draws; per-partner capital and distributions plus guaranteed
+ payments; officer compensation and shareholder distributions; dividends
+ and income tax expense), common income and expense accounts with tax
+ categories, and the `wanderbooks.toml` settings (tax form, members, close
+ target). A proposal: review with the human, then `books.import_chart_csv`.
+
## Tools — tax categories
- `books.tax_categories {}` → `{ form?, categories: [{ key, label, kind,
diff --git a/docs/requirements.md b/docs/requirements.md
index f143888..af4b4ec 100644
--- a/docs/requirements.md
+++ b/docs/requirements.md
@@ -126,7 +126,12 @@ requirement conflict, the requirement wins.
`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
+ partnership convention), or `CloseTo::Account` (any equity account, e.g.
+ a sole proprietor's capital). Every target can sweep owner-pay equity
+ accounts — draws, distributions, dividends — into it: a member's
+ distributions account into their own capital. The members' equity
+ statement counts a member's distributions account as part of their
+ capital. 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.
diff --git a/plan/STATUS.md b/plan/STATUS.md
index 6eefdf5..8eb1f24 100644
--- a/plan/STATUS.md
+++ b/plan/STATUS.md
@@ -221,4 +221,12 @@ interface is MCP, not a cloned GUI. The first storage + MCP slice is done
deductible percent; the tax summary shows book vs. tax by category and
every uncategorized account. tax_form comes from config or the tax
classification. Schema v6.
+- 2026-10-11: Owner equity for every entity type. Chart templates for sole
+ proprietorships, partnerships/multi-member LLCs, S corps, and C corps
+ (equity and owner-pay accounts, tax categories, matching config). Closes
+ sweep draws, distributions, and dividends into their target; partners'
+ distribution accounts count as capital on the members' equity statement
+ and close into it. CloseTo became RetainedEarnings { sweep } / Account
+ { into, sweep } / Members([MemberClose]); journals without a target, or
+ with the old plain "retained_earnings", still replay.