From dbdc8e671ff500c719b6e8637a8b95b39675c722 Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Sun, 11 Oct 2026 21:01:31 -0700 Subject: [PATCH] =?UTF-8?q?feat:=20entity-aware=20owner=20equity=20?= =?UTF-8?q?=E2=80=94=20chart=20templates,=20draws,=20distributions,=20divi?= =?UTF-8?q?dends?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every business has owners, and how owners put money in and take it out depends on what the business is. wanderbooks now knows the four common US shapes: - books.chart_template {entity_type, owners}: a starter chart for a sole proprietorship (owner's capital and draws), partnership or multi-member LLC (capital and distributions per partner, guaranteed payments), S corporation (stock, APIC, shareholder distributions, officer compensation, retained earnings) or C corporation (dividends, income tax expense), plus common income and expense accounts with tax categories, and the wanderbooks.toml settings that go with it (tax form, members with equal-share placeholders that sum to exactly 100, close target). A proposal for the agent to review with the human. - Closing: CloseTo is now RetainedEarnings { sweep }, Account { into, sweep } (a sole proprietor's capital) or Members([MemberClose { capital, share, distributions }]). Sweeps zero owner-pay equity accounts into their target at close: draws into owner's capital, dividends or distributions into retained earnings, each partner's distributions into their own capital. Validation (equity, distinct, shares sum to 1) runs in the pre-close preview, before anything is journaled. The journal records the target; journals with no target or the old "retained_earnings" string still replay. - Members' equity: a partner's distributions account is part of their capital; its activity reports as distributions. - Config: members' distribution_account; [books] close_into and close_sweep for single-owner businesses (rejected alongside members). The cash flow names members on their distribution accounts too. Tests: a sole proprietor (net 2,000 and 500 of draws into capital: 6,500, the cash); a corporation (dividends 300 into retained earnings: 600); a partnership with distribution accounts (Bob 100 + 25 - 20 = 105, swept at close, February begins there); bad sweep accounts; templates round-trip through the chart parser, unique codes, equity per entity, every tax category valid for the entity's return; stdio: template -> settings -> import -> postings -> close with the exact closing lines. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MtYEaR6M6Ybdhm6X98BWNu --- crates/ledger-core/src/ledger.rs | 303 ++++++++++------- crates/ledger-core/src/lib.rs | 2 +- crates/ledger-core/src/periods.rs | 39 ++- crates/ledger-core/src/reports.rs | 45 ++- crates/ledger-core/tests/statements.rs | 227 ++++++++++++- crates/ledger-import/src/lib.rs | 2 + crates/ledger-import/src/report.rs | 11 +- crates/ledger-import/src/templates.rs | 432 +++++++++++++++++++++++++ crates/ledger-mcp/src/company.rs | 34 +- crates/ledger-mcp/src/server.rs | 92 +++++- crates/ledger-mcp/src/types.rs | 25 ++ crates/ledger-mcp/tests/mcp_stdio.rs | 114 ++++++- crates/ledger-store/src/store.rs | 79 +++-- crates/ledger-store/tests/store.rs | 13 +- docs/mcp-interface.md | 17 +- docs/requirements.md | 7 +- plan/STATUS.md | 8 + 17 files changed, 1277 insertions(+), 173 deletions(-) create mode 100644 crates/ledger-import/src/templates.rs 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.