feat: entity-aware owner equity — chart templates, draws, distributions, dividends
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MtYEaR6M6Ybdhm6X98BWNu
This commit is contained in:
17 files changed
+1277
-173
No files matched your search
+193
-110
@@ -634,108 +634,113 @@ impl Ledger {
|
|||||||
|
|
||||||
/// Close a period into Retained Earnings: see [`Self::close_period_to`].
|
/// Close a period into Retained Earnings: see [`Self::close_period_to`].
|
||||||
pub fn close_period(&mut self, id: PeriodId) -> Result<Option<EntryId>, LedgerError> {
|
pub fn close_period(&mut self, id: PeriodId) -> Result<Option<EntryId>, LedgerError> {
|
||||||
self.close_period_to(id, &periods::CloseTo::RetainedEarnings)
|
self.close_period_to(id, &periods::CloseTo::RetainedEarnings { sweep: vec![] })
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Close a period. This:
|
/// Close a period. This:
|
||||||
/// 1. Rejects if the period is already closed, if any draft is dated
|
/// 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
|
/// inside it, if a later period is already closed (closing out of
|
||||||
/// order would sweep the same income twice).
|
/// 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
|
/// 2. Posts a closing entry dated at period end that zeroes every revenue
|
||||||
/// and expense account's balance *as of the period end* (entries
|
/// and expense account's balance *as of the period end* (entries
|
||||||
/// dated after it stay open for their own period): revenue debited,
|
/// dated after it stay open for their own period), puts the net in
|
||||||
/// expense credited, and the net to `to` — Retained Earnings (account
|
/// `to`'s target, and zeroes `to`'s sweep accounts (draws,
|
||||||
/// `3999`, created if missing), or members' capital accounts by
|
/// distributions, dividends) into their target too:
|
||||||
/// share for a partnership, to the cent with the last member
|
/// - `RetainedEarnings`: account `3999`, created if missing.
|
||||||
/// absorbing rounding.
|
/// - `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.
|
/// 3. Marks the period closed.
|
||||||
///
|
///
|
||||||
/// Returns the closing entry's id, or `None` if there was nothing to
|
/// Returns the closing entry's id, or `None` if there was nothing to
|
||||||
/// close (no revenue/expense balance at period end).
|
/// close.
|
||||||
pub fn close_period_to(
|
pub fn close_period_to(
|
||||||
&mut self,
|
&mut self,
|
||||||
id: PeriodId,
|
id: PeriodId,
|
||||||
to: &periods::CloseTo,
|
to: &periods::CloseTo,
|
||||||
) -> Result<Option<EntryId>, LedgerError> {
|
) -> Result<Option<EntryId>, LedgerError> {
|
||||||
self.preview_close_period(id)?;
|
self.preview_close_period_to(id, to)?;
|
||||||
if let periods::CloseTo::Members(members) = to {
|
|
||||||
self.validate_close_members(members)?;
|
|
||||||
}
|
|
||||||
let period = self.periods.get(&id).expect("previewed").clone();
|
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.
|
// (account, amount to credit; negative debits): the net to its
|
||||||
let mut closings: Vec<(AccountId, Side, Decimal)> = Vec::new();
|
// targets, and each sweep account's balance into its target.
|
||||||
let mut accounts: Vec<_> = self.chart.all().collect();
|
let mut equity: Vec<(AccountId, Decimal)> = Vec::new();
|
||||||
accounts.sort_by(|a, b| a.code.cmp(&b.code));
|
let mut sweep_lines: Vec<(AccountId, AccountId, Decimal)> = Vec::new();
|
||||||
for account in accounts {
|
let mut sweep = |from: AccountId, into: AccountId, this: &Self| {
|
||||||
if !matches!(
|
// An equity balance (credit-normal) is −movement; zeroing it
|
||||||
account.account_type,
|
// moves exactly that much into the target.
|
||||||
AccountType::Revenue | AccountType::Expense
|
let balance = -this.movement_through(from, period.end);
|
||||||
) {
|
if !balance.is_zero() {
|
||||||
continue;
|
sweep_lines.push((from, into, balance));
|
||||||
}
|
}
|
||||||
let movement = self.movement_through(account.id, period.end);
|
};
|
||||||
if movement.is_zero() {
|
match to {
|
||||||
continue;
|
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<Decimal> = 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
|
None
|
||||||
} else {
|
} 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<Decimal> = members.iter().map(|(_, s)| *s).collect();
|
|
||||||
members
|
|
||||||
.iter()
|
|
||||||
.map(|(a, _)| *a)
|
|
||||||
.zip(periods::allocate(net, &shares))
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let entry = self.create_entry(period.end, &format!("Close period {}", period.name));
|
let entry = self.create_entry(period.end, &format!("Close period {}", period.name));
|
||||||
for (account, side, amount) in closings {
|
let post = |this: &mut Self, account, credit: Decimal| -> Result<(), LedgerError> {
|
||||||
self.add_line(entry, account, side, Money::new(amount, self.base_currency))?;
|
if credit.is_zero() {
|
||||||
}
|
return Ok(());
|
||||||
// Profit credits equity; a loss debits it.
|
|
||||||
for (account, share) in targets {
|
|
||||||
if share.is_zero() {
|
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
let (side, amount) = if share > Decimal::ZERO {
|
let (side, amount) = if credit > Decimal::ZERO {
|
||||||
(Side::Credit, share)
|
(Side::Credit, credit)
|
||||||
} else {
|
} 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)?;
|
self.post_entry(entry)?;
|
||||||
Some(entry)
|
Some(entry)
|
||||||
@@ -747,24 +752,88 @@ impl Ledger {
|
|||||||
Ok(closing_entry)
|
Ok(closing_entry)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Members for a close: shares positive and summing to 1, capital
|
/// Revenue and expense balances as of `end` as closing lines (code
|
||||||
/// accounts distinct equity accounts.
|
/// order), and the net income they carry (revenue debits minus expense
|
||||||
fn validate_close_members(&self, members: &[(AccountId, Decimal)]) -> Result<(), LedgerError> {
|
/// credits).
|
||||||
let total: Decimal = members.iter().map(|(_, s)| *s).sum();
|
fn income_closings(&self, end: NaiveDate) -> (Vec<(AccountId, Side, Decimal)>, Decimal) {
|
||||||
if members.is_empty()
|
let mut closings = Vec::new();
|
||||||
|| total != Decimal::ONE
|
let mut accounts: Vec<_> = self.chart.all().collect();
|
||||||
|| members.iter().any(|(_, s)| *s <= Decimal::ZERO)
|
accounts.sort_by(|a, b| a.code.cmp(&b.code));
|
||||||
{
|
for account in accounts {
|
||||||
return Err(LedgerError::InvalidMembers(format!(
|
if !matches!(
|
||||||
"member shares must be positive and sum to 1, got {total}"
|
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<AccountId, LedgerError> {
|
||||||
|
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<AccountId> = 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();
|
let mut seen = std::collections::BTreeSet::new();
|
||||||
for (a, _) in members {
|
for a in accounts {
|
||||||
let account = self.chart.get(*a).ok_or(LedgerError::UnknownAccount(*a))?;
|
let account = self.chart.get(a).ok_or(LedgerError::UnknownAccount(a))?;
|
||||||
if account.account_type != AccountType::Equity || !seen.insert(*a) {
|
if account.account_type != AccountType::Equity || !seen.insert(a) {
|
||||||
return Err(LedgerError::InvalidMembers(format!(
|
return Err(LedgerError::InvalidMembers(format!(
|
||||||
"capital account {} must be a distinct equity account",
|
"close account {} must be a distinct equity account",
|
||||||
account.code
|
account.code
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
@@ -787,16 +856,23 @@ impl Ledger {
|
|||||||
total
|
total
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Pure pre-close check for journaling stores: verifies that
|
/// [`Self::preview_close_period_to`] closing into Retained Earnings.
|
||||||
/// [`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.
|
|
||||||
pub fn preview_close_period(&self, id: PeriodId) -> Result<Option<EntryId>, LedgerError> {
|
pub fn preview_close_period(&self, id: PeriodId) -> Result<Option<EntryId>, 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<Option<EntryId>, LedgerError> {
|
||||||
let period = self
|
let period = self
|
||||||
.periods
|
.periods
|
||||||
.get(&id)
|
.get(&id)
|
||||||
@@ -825,16 +901,23 @@ impl Ledger {
|
|||||||
{
|
{
|
||||||
return Err(LedgerError::CloseOutOfOrder(id, later.id));
|
return Err(LedgerError::CloseOutOfOrder(id, later.id));
|
||||||
}
|
}
|
||||||
// The close posts an entry exactly when some revenue or expense
|
self.validate_close_to(to)?;
|
||||||
// account has a balance at period end — the same predicate
|
// An entry posts exactly when there is income to close or a sweep
|
||||||
// close_period_to uses to decide. The entry id is the next one the
|
// account with a balance. Account creation (Retained Earnings) does
|
||||||
// allocator hands out; account creation (e.g. Retained Earnings)
|
// not consume entry ids.
|
||||||
// does not consume entry ids.
|
let (closings, _) = self.income_closings(period.end);
|
||||||
let any_movement = self.chart.all().any(|a| {
|
let sweeps: Vec<AccountId> = match to {
|
||||||
matches!(a.account_type, AccountType::Revenue | AccountType::Expense)
|
periods::CloseTo::RetainedEarnings { sweep }
|
||||||
&& !self.movement_through(a.id, period.end).is_zero()
|
| periods::CloseTo::Account { sweep, .. } => sweep.clone(),
|
||||||
});
|
periods::CloseTo::Members(members) => {
|
||||||
Ok(any_movement.then_some(self.next_entry_id))
|
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))
|
||||||
}
|
}
|
||||||
|
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ pub use journal::{EntryId, EntryStatus, JournalEntry, JournalLine};
|
|||||||
pub use ledger::{Ledger, ReconciliationReport, UnclearedLine};
|
pub use ledger::{Ledger, ReconciliationReport, UnclearedLine};
|
||||||
pub use money::{Currency, Money};
|
pub use money::{Currency, Money};
|
||||||
pub use payroll::{PayrollLine, PayrollRun, PayrollRunId};
|
pub use payroll::{PayrollLine, PayrollRun, PayrollRunId};
|
||||||
pub use periods::{CloseTo, Period, PeriodId, PeriodStatus, RETAINED_EARNINGS_CODE};
|
pub use periods::{CloseTo, MemberClose, Period, PeriodId, PeriodStatus, RETAINED_EARNINGS_CODE};
|
||||||
pub use reports::{
|
pub use reports::{
|
||||||
BalanceSheet, BalanceSheetStatement, CashFlowLine, CashFlowParams, CashFlowStatement,
|
BalanceSheet, BalanceSheetStatement, CashFlowLine, CashFlowParams, CashFlowStatement,
|
||||||
ClassProfitAndLoss, GeneralLedger, LedgerAccount, LedgerPosting, MemberCapital,
|
ClassProfitAndLoss, GeneralLedger, LedgerAccount, LedgerPosting, MemberCapital,
|
||||||
|
|||||||
@@ -29,16 +29,41 @@ pub enum PeriodStatus {
|
|||||||
Closed,
|
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)]
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
pub enum CloseTo {
|
pub enum CloseTo {
|
||||||
/// One Retained Earnings account ([`RETAINED_EARNINGS_CODE`]): the
|
/// The Retained Earnings account ([`RETAINED_EARNINGS_CODE`], created if
|
||||||
/// corporate convention.
|
/// missing): the corporate convention. `sweep` lists dividend or
|
||||||
RetainedEarnings,
|
/// distribution accounts to close into it.
|
||||||
/// Members' capital accounts, by share (shares sum to 1): the
|
RetainedEarnings {
|
||||||
|
/// Equity accounts zeroed into Retained Earnings.
|
||||||
|
sweep: Vec<crate::AccountId>,
|
||||||
|
},
|
||||||
|
/// 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<crate::AccountId>,
|
||||||
|
},
|
||||||
|
/// Members' capital accounts by share (shares sum to 1): the
|
||||||
/// partnership convention, where each member's capital account carries
|
/// partnership convention, where each member's capital account carries
|
||||||
/// their allocated earnings.
|
/// their allocated earnings and absorbs their distributions.
|
||||||
Members(Vec<(crate::AccountId, rust_decimal::Decimal)>),
|
Members(Vec<MemberClose>),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<crate::AccountId>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Split `amount` by `shares` to the cent; the last share absorbs rounding,
|
/// Split `amount` by `shares` to the cent; the last share absorbs rounding,
|
||||||
|
|||||||
@@ -671,6 +671,10 @@ pub struct MemberCapital {
|
|||||||
/// Their capital account (equity): contributions credit it,
|
/// Their capital account (equity): contributions credit it,
|
||||||
/// distributions debit it.
|
/// distributions debit it.
|
||||||
pub capital_account: crate::AccountId,
|
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<crate::AccountId>,
|
||||||
/// Share of income, e.g. 0.5. The shares must sum to exactly 1.
|
/// Share of income, e.g. 0.5. The shares must sum to exactly 1.
|
||||||
pub share: Decimal,
|
pub share: Decimal,
|
||||||
}
|
}
|
||||||
@@ -738,15 +742,17 @@ pub fn members_equity(
|
|||||||
}
|
}
|
||||||
let mut seen = BTreeSet::new();
|
let mut seen = BTreeSet::new();
|
||||||
for m in members {
|
for m in members {
|
||||||
let account = ledger
|
for id in std::iter::once(m.capital_account).chain(m.distribution_account) {
|
||||||
.chart()
|
let account = ledger
|
||||||
.get(m.capital_account)
|
.chart()
|
||||||
.ok_or(LedgerError::UnknownAccount(m.capital_account))?;
|
.get(id)
|
||||||
if account.account_type != AccountType::Equity || !seen.insert(m.capital_account) {
|
.ok_or(LedgerError::UnknownAccount(id))?;
|
||||||
return Err(LedgerError::InvalidMembers(format!(
|
if account.account_type != AccountType::Equity || !seen.insert(id) {
|
||||||
"{}: capital account {} must be a distinct equity account",
|
return Err(LedgerError::InvalidMembers(format!(
|
||||||
m.name, account.code
|
"{}: account {} must be a distinct equity account",
|
||||||
)));
|
m.name, account.code
|
||||||
|
)));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let day_before = start.pred_opt().ok_or(LedgerError::InvalidDateRange {
|
let day_before = start.pred_opt().ok_or(LedgerError::InvalidDateRange {
|
||||||
@@ -788,11 +794,28 @@ pub fn members_equity(
|
|||||||
}
|
}
|
||||||
(credits, debits)
|
(credits, debits)
|
||||||
};
|
};
|
||||||
|
// A member's capital is their capital account plus their distributions
|
||||||
|
// account (a debit balance, so it reduces capital).
|
||||||
let capital: Vec<Decimal> = members
|
let capital: Vec<Decimal> = members
|
||||||
.iter()
|
.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();
|
.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
|
// Everything in equity that isn't a capital account is partnership
|
||||||
// earnings not yet distributed: allocate it by share.
|
// earnings not yet distributed: allocate it by share.
|
||||||
|
|||||||
@@ -413,11 +413,13 @@ fn partnership(close_january: bool) -> (Ledger, Vec<MemberCapital>) {
|
|||||||
MemberCapital {
|
MemberCapital {
|
||||||
name: "Alice".into(),
|
name: "Alice".into(),
|
||||||
capital_account: alice,
|
capital_account: alice,
|
||||||
|
distribution_account: None,
|
||||||
share: half,
|
share: half,
|
||||||
},
|
},
|
||||||
MemberCapital {
|
MemberCapital {
|
||||||
name: "Bob".into(),
|
name: "Bob".into(),
|
||||||
capital_account: bob,
|
capital_account: bob,
|
||||||
|
distribution_account: None,
|
||||||
share: half,
|
share: half,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@@ -603,7 +605,11 @@ fn a_partnership_closes_into_members_capital() {
|
|||||||
let to = ledger_core::CloseTo::Members(
|
let to = ledger_core::CloseTo::Members(
|
||||||
members
|
members
|
||||||
.iter()
|
.iter()
|
||||||
.map(|m| (m.capital_account, m.share))
|
.map(|m| ledger_core::MemberClose {
|
||||||
|
capital: m.capital_account,
|
||||||
|
share: m.share,
|
||||||
|
distributions: None,
|
||||||
|
})
|
||||||
.collect(),
|
.collect(),
|
||||||
);
|
);
|
||||||
let jan = l.create_period("2026-01", d(1, 1), d(1, 31)).unwrap();
|
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.
|
// Shares must still add up.
|
||||||
let feb = l.create_period("2026-02", d(2, 1), d(2, 28)).unwrap();
|
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!(
|
assert!(matches!(
|
||||||
l.close_period_to(feb, &bad),
|
l.close_period_to(feb, &bad),
|
||||||
Err(LedgerError::InvalidMembers(_))
|
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(_))
|
||||||
|
));
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ pub mod chart_csv;
|
|||||||
pub mod iif;
|
pub mod iif;
|
||||||
pub mod pdf;
|
pub mod pdf;
|
||||||
pub mod report;
|
pub mod report;
|
||||||
|
pub mod templates;
|
||||||
pub mod xlsx;
|
pub mod xlsx;
|
||||||
|
|
||||||
pub use bank_csv::{BankTransaction, parse_bank_csv};
|
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 iif::{IifEntry, journal_to_iif};
|
||||||
pub use pdf::{PdfError, statement_pdf};
|
pub use pdf::{PdfError, statement_pdf};
|
||||||
pub use report::{Member, ReportMeta, Row, RowKind, Statement, YearEarnings, format_amount};
|
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};
|
pub use xlsx::{XlsxReport, report_to_xlsx};
|
||||||
@@ -53,6 +53,8 @@ pub struct Member {
|
|||||||
pub name: String,
|
pub name: String,
|
||||||
/// Code of their capital account.
|
/// Code of their capital account.
|
||||||
pub capital_code: String,
|
pub capital_code: String,
|
||||||
|
/// Code of their distributions account, if they have one.
|
||||||
|
pub distribution_code: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// What a row is, which decides how renderers style it.
|
/// What a row is, which decides how renderers style it.
|
||||||
@@ -276,9 +278,10 @@ fn flow_lines<'a>(
|
|||||||
members: &'a [Member],
|
members: &'a [Member],
|
||||||
) -> impl Iterator<Item = Row> + 'a {
|
) -> impl Iterator<Item = Row> + 'a {
|
||||||
lines.iter().map(move |l| {
|
lines.iter().map(move |l| {
|
||||||
let member = members
|
let member = members.iter().find(|m| {
|
||||||
.iter()
|
let code = l.account.as_deref();
|
||||||
.find(|m| l.account.as_deref() == Some(m.capital_code.as_str()));
|
code == Some(m.capital_code.as_str()) || code == m.distribution_code.as_deref()
|
||||||
|
});
|
||||||
let label = match member {
|
let label = match member {
|
||||||
Some(m) if l.amount >= Decimal::ZERO => format!("Member contributions — {}", m.name),
|
Some(m) if l.amount >= Decimal::ZERO => format!("Member contributions — {}", m.name),
|
||||||
Some(m) => format!("Member distributions — {}", m.name),
|
Some(m) => format!("Member distributions — {}", m.name),
|
||||||
@@ -666,10 +669,12 @@ mod tests {
|
|||||||
Member {
|
Member {
|
||||||
name: "Alice".into(),
|
name: "Alice".into(),
|
||||||
capital_code: "3000".into(),
|
capital_code: "3000".into(),
|
||||||
|
distribution_code: None,
|
||||||
},
|
},
|
||||||
Member {
|
Member {
|
||||||
name: "Bob".into(),
|
name: "Bob".into(),
|
||||||
capital_code: "3001".into(),
|
capital_code: "3001".into(),
|
||||||
|
distribution_code: None,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
let st = balance_sheet(&bs, e, &members);
|
let st = balance_sheet(&bs, e, &members);
|
||||||
|
|||||||
@@ -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<EntityType> {
|
||||||
|
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<ChartRow>,
|
||||||
|
/// 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<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<Decimal> {
|
||||||
|
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::<Decimal>();
|
||||||
|
}
|
||||||
|
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<ChartTemplate, String> {
|
||||||
|
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<String> {
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,6 +28,13 @@ pub struct Company {
|
|||||||
/// The income tax return the business files, if known: from
|
/// The income tax return the business files, if known: from
|
||||||
/// `tax_form`, else from `tax_classification`.
|
/// `tax_form`, else from `tax_classification`.
|
||||||
pub tax_form: Option<ledger_core::TaxForm>,
|
pub tax_form: Option<ledger_core::TaxForm>,
|
||||||
|
/// `[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<String>,
|
||||||
|
/// `[books] close_sweep`: equity accounts (draws, dividends,
|
||||||
|
/// distributions) a close zeroes into `close_into`.
|
||||||
|
pub close_sweep: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One member of a multi-member LLC.
|
/// One member of a multi-member LLC.
|
||||||
@@ -39,6 +46,9 @@ pub struct Member {
|
|||||||
pub share: Decimal,
|
pub share: Decimal,
|
||||||
/// Code of their capital account in the chart.
|
/// Code of their capital account in the chart.
|
||||||
pub capital_account: String,
|
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<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for Company {
|
impl Default for Company {
|
||||||
@@ -49,6 +59,8 @@ impl Default for Company {
|
|||||||
members: Vec::new(),
|
members: Vec::new(),
|
||||||
basis: "Accrual".to_string(),
|
basis: "Accrual".to_string(),
|
||||||
tax_form: None,
|
tax_form: None,
|
||||||
|
close_into: None,
|
||||||
|
close_sweep: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -102,12 +114,16 @@ struct MemberToml {
|
|||||||
name: String,
|
name: String,
|
||||||
share: String,
|
share: String,
|
||||||
capital_account: Option<String>,
|
capital_account: Option<String>,
|
||||||
|
distribution_account: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Default, Deserialize)]
|
#[derive(Default, Deserialize)]
|
||||||
struct BooksSection {
|
struct BooksSection {
|
||||||
receipt_threshold: Option<String>,
|
receipt_threshold: Option<String>,
|
||||||
accounting_basis: Option<String>,
|
accounting_basis: Option<String>,
|
||||||
|
close_into: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
close_sweep: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The return a federal tax classification files: "partnership" → 1065,
|
/// The return a federal tax classification files: "partnership" → 1065,
|
||||||
@@ -174,6 +190,10 @@ pub fn parse(text: &str) -> Result<BooksSettings, String> {
|
|||||||
name: m.name.trim().to_string(),
|
name: m.name.trim().to_string(),
|
||||||
share,
|
share,
|
||||||
capital_account: capital_account.trim().to_string(),
|
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() {
|
if !company.members.is_empty() {
|
||||||
@@ -190,6 +210,14 @@ pub fn parse(text: &str) -> Result<BooksSettings, String> {
|
|||||||
(None, Some(c)) => form_for_classification(c),
|
(None, Some(c)) => form_for_classification(c),
|
||||||
(None, None) => None,
|
(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 {
|
if let Some(basis) = raw.books.accounting_basis {
|
||||||
company.basis = match basis.trim().to_lowercase().as_str() {
|
company.basis = match basis.trim().to_lowercase().as_str() {
|
||||||
"accrual" => "Accrual".to_string(),
|
"accrual" => "Accrual".to_string(),
|
||||||
@@ -291,12 +319,14 @@ mod tests {
|
|||||||
Member {
|
Member {
|
||||||
name: "Stephen Waits".into(),
|
name: "Stephen Waits".into(),
|
||||||
share: half,
|
share: half,
|
||||||
capital_account: "3000".into()
|
capital_account: "3000".into(),
|
||||||
|
distribution_account: None,
|
||||||
},
|
},
|
||||||
Member {
|
Member {
|
||||||
name: "Katherine Pendergast".into(),
|
name: "Katherine Pendergast".into(),
|
||||||
share: half,
|
share: half,
|
||||||
capital_account: "3001".into()
|
capital_account: "3001".into(),
|
||||||
|
distribution_account: None,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ impl BooksServer {
|
|||||||
.map(|m| ledger_import::Member {
|
.map(|m| ledger_import::Member {
|
||||||
name: m.name.clone(),
|
name: m.name.clone(),
|
||||||
capital_code: m.capital_account.clone(),
|
capital_code: m.capital_account.clone(),
|
||||||
|
distribution_code: m.distribution_account.clone(),
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
@@ -108,15 +109,69 @@ impl BooksServer {
|
|||||||
m.name, m.capital_account
|
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 {
|
Ok(ledger_core::MemberCapital {
|
||||||
name: m.name.clone(),
|
name: m.name.clone(),
|
||||||
capital_account: account.id,
|
capital_account: account.id,
|
||||||
|
distribution_account,
|
||||||
share: m.share,
|
share: m.share,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.collect()
|
.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<ledger_core::CloseTo, String> {
|
||||||
|
let code = |c: &str| -> Result<AccountId, String> {
|
||||||
|
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::<Result<Vec<_>, _>>()?;
|
||||||
|
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:
|
/// The fiscal year's earnings through `as_of`, for the balance sheet:
|
||||||
/// net income (loss) year to date (closing entries excluded), and the
|
/// net income (loss) year to date (closing entries excluded), and the
|
||||||
/// part of it still unclosed (closing entries included, so months
|
/// part of it still unclosed (closing entries included, so months
|
||||||
@@ -1361,6 +1416,30 @@ impl BooksServer {
|
|||||||
Ok(Json(ImportBankCsvOut { imported, count }))
|
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<ChartTemplateParams>,
|
||||||
|
) -> Result<Json<ChartTemplateOut>, 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(
|
#[tool(
|
||||||
name = "books.tax_categories",
|
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.",
|
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() {
|
if store.ledger().get_period(p.period_id).is_none() {
|
||||||
return Err(format!("unknown period {}", p.period_id));
|
return Err(format!("unknown period {}", p.period_id));
|
||||||
}
|
}
|
||||||
// A partnership closes into its members' capital accounts by share;
|
let to = self.close_target(store.ledger())?;
|
||||||
// everyone else into Retained Earnings.
|
|
||||||
let to = if self.company.members.is_empty() {
|
|
||||||
ledger_core::CloseTo::RetainedEarnings
|
|
||||||
} else {
|
|
||||||
ledger_core::CloseTo::Members(
|
|
||||||
self.member_capitals(store.ledger())?
|
|
||||||
.into_iter()
|
|
||||||
.map(|m| (m.capital_account, m.share))
|
|
||||||
.collect(),
|
|
||||||
)
|
|
||||||
};
|
|
||||||
let describe = |ledger: &Ledger, eid: Option<ledger_core::EntryId>| -> Vec<String> {
|
let describe = |ledger: &Ledger, eid: Option<ledger_core::EntryId>| -> Vec<String> {
|
||||||
let Some(entry) = eid.and_then(|e| ledger.get_entry(e)) else {
|
let Some(entry) = eid.and_then(|e| ledger.get_entry(e)) else {
|
||||||
return Vec::new();
|
return Vec::new();
|
||||||
|
|||||||
@@ -54,6 +54,31 @@ pub struct ChartAccount {
|
|||||||
pub deductible_percent: Option<String>,
|
pub deductible_percent: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<String>,
|
||||||
|
}
|
||||||
|
|
||||||
/// Parameters for `books.set_tax_category`.
|
/// Parameters for `books.set_tax_category`.
|
||||||
#[derive(Debug, Deserialize, JsonSchema)]
|
#[derive(Debug, Deserialize, JsonSchema)]
|
||||||
pub struct SetTaxCategoryParams {
|
pub struct SetTaxCategoryParams {
|
||||||
|
|||||||
@@ -139,7 +139,7 @@ fn mcp_end_to_end_over_stdio() {
|
|||||||
// Tool listing: 23 narrow tools, chart is read-only.
|
// Tool listing: 23 narrow tools, chart is read-only.
|
||||||
let list = mcp.request("tools/list", json!({}));
|
let list = mcp.request("tools/list", json!({}));
|
||||||
let tools = list["result"]["tools"].as_array().unwrap();
|
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();
|
let chart = tools.iter().find(|t| t["name"] == "books.chart").unwrap();
|
||||||
assert_eq!(chart["annotations"]["readOnlyHint"], true);
|
assert_eq!(chart["annotations"]["readOnlyHint"], true);
|
||||||
assert!(
|
assert!(
|
||||||
@@ -1120,3 +1120,115 @@ fn mcp_period_statements_over_stdio() {
|
|||||||
.all(|a| a["code"] != "6100")
|
.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())
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -955,7 +955,7 @@ impl Store {
|
|||||||
/// re-runs the deterministic close logic and checks the closing entry
|
/// re-runs the deterministic close logic and checks the closing entry
|
||||||
/// id against the journal.
|
/// id against the journal.
|
||||||
pub fn close_period(&mut self, id: PeriodId) -> Result<Option<EntryId>, StoreError> {
|
pub fn close_period(&mut self, id: PeriodId) -> Result<Option<EntryId>, StoreError> {
|
||||||
self.close_period_to(id, &CloseTo::RetainedEarnings)
|
self.close_period_to(id, &CloseTo::RetainedEarnings { sweep: vec![] })
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Close a period with its net income going to `to`: Retained Earnings,
|
/// Close a period with its net income going to `to`: Retained Earnings,
|
||||||
@@ -969,8 +969,8 @@ impl Store {
|
|||||||
) -> Result<Option<EntryId>, StoreError> {
|
) -> Result<Option<EntryId>, StoreError> {
|
||||||
// Pure pre-validation: a doomed close journals nothing, not even
|
// Pure pre-validation: a doomed close journals nothing, not even
|
||||||
// the Retained Earnings account creation below.
|
// the Retained Earnings account creation below.
|
||||||
self.ledger.preview_close_period(id)?;
|
self.ledger.preview_close_period_to(id, to)?;
|
||||||
if *to == CloseTo::RetainedEarnings {
|
if matches!(to, CloseTo::RetainedEarnings { .. }) {
|
||||||
// Retained Earnings must exist before the close so its creation
|
// Retained Earnings must exist before the close so its creation
|
||||||
// (if needed) is journaled as account.added; the close's own
|
// (if needed) is journaled as account.added; the close's own
|
||||||
// ensure then no-ops and replay stays deterministic.
|
// ensure then no-ops and replay stays deterministic.
|
||||||
@@ -997,11 +997,17 @@ impl Store {
|
|||||||
None => None,
|
None => None,
|
||||||
};
|
};
|
||||||
let close_to = match to {
|
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!({
|
CloseTo::Members(members) => json!({
|
||||||
"members": members
|
"members": members
|
||||||
.iter()
|
.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::<Vec<_>>(),
|
.collect::<Vec<_>>(),
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
@@ -2805,22 +2811,7 @@ fn replay_line_into_ledger(
|
|||||||
// Deterministic: the close re-derives the closing entry from
|
// Deterministic: the close re-derives the closing entry from
|
||||||
// the replayed state and the journaled target (absent: Retained
|
// the replayed state and the journaled target (absent: Retained
|
||||||
// Earnings), so it must match the journal's id and every line.
|
// 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")) {
|
let to = close_to_from_journal(p.get("close_to"))?;
|
||||||
None => ledger_core::CloseTo::RetainedEarnings,
|
|
||||||
Some(members) => ledger_core::CloseTo::Members(
|
|
||||||
members
|
|
||||||
.as_array()
|
|
||||||
.ok_or_else(|| StoreError::JournalCorrupt("close_to.members".into()))?
|
|
||||||
.iter()
|
|
||||||
.map(|m| {
|
|
||||||
let share = get_str(m, "share")?.parse().map_err(|_| {
|
|
||||||
StoreError::JournalCorrupt("close_to member share".into())
|
|
||||||
})?;
|
|
||||||
Ok((get_u64(m, "account")?, share))
|
|
||||||
})
|
|
||||||
.collect::<Result<_, StoreError>>()?,
|
|
||||||
),
|
|
||||||
};
|
|
||||||
let got = ledger.close_period_to(id, &to)?;
|
let got = ledger.close_period_to(id, &to)?;
|
||||||
if got != want {
|
if got != want {
|
||||||
return Err(StoreError::ReplayMismatch(format!(
|
return Err(StoreError::ReplayMismatch(format!(
|
||||||
@@ -2997,6 +2988,52 @@ fn apply_document_payment(
|
|||||||
/// of the journal line: INSERTs for new facts, targeted UPDATEs/DELETEs
|
/// of the journal line: INSERTs for new facts, targeted UPDATEs/DELETEs
|
||||||
/// for lifecycle changes (receipt linking, disposal, period close,
|
/// for lifecycle changes (receipt linking, disposal, period close,
|
||||||
/// cleared flags, document payments, fx rate updates).
|
/// 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<CloseTo, StoreError> {
|
||||||
|
let corrupt = |what: &str| StoreError::JournalCorrupt(format!("close_to: {what}"));
|
||||||
|
let ids = |v: &Value| -> Result<Vec<AccountId>, 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::<Result<_, StoreError>>()?,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
fn apply_line_to_sqlite(conn: &Connection, line: &JournalLine) -> Result<(), StoreError> {
|
fn apply_line_to_sqlite(conn: &Connection, line: &JournalLine) -> Result<(), StoreError> {
|
||||||
let p = &line.payload;
|
let p = &line.payload;
|
||||||
match line.line_type.as_str() {
|
match line.line_type.as_str() {
|
||||||
|
|||||||
@@ -504,7 +504,18 @@ fn a_close_into_members_capital_replays_exactly() {
|
|||||||
store
|
store
|
||||||
.close_period_to(
|
.close_period_to(
|
||||||
jan,
|
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();
|
.unwrap();
|
||||||
// 100.01 splits 50.01 / 50.00 into the capital accounts.
|
// 100.01 splits 50.01 / 50.00 into the capital accounts.
|
||||||
|
|||||||
+16
-1
@@ -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]
|
to 100%) drives the statement of members' equity, `[books]
|
||||||
accounting_basis` ("accrual" or "cash", default accrual) labels report
|
accounting_basis` ("accrual" or "cash", default accrual) labels report
|
||||||
footers, and `[books] receipt_threshold` sets the receipt policy unless
|
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
|
## Transports
|
||||||
|
|
||||||
@@ -218,6 +221,18 @@ posting.
|
|||||||
get "Members' equity" with members named; the balance sheet separates
|
get "Members' equity" with members named; the balance sheet separates
|
||||||
retained earnings from the fiscal year's net income (loss).
|
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
|
## Tools — tax categories
|
||||||
|
|
||||||
- `books.tax_categories {}` → `{ form?, categories: [{ key, label, kind,
|
- `books.tax_categories {}` → `{ form?, categories: [{ key, label, kind,
|
||||||
|
|||||||
@@ -126,7 +126,12 @@ requirement conflict, the requirement wins.
|
|||||||
`to`: `CloseTo::RetainedEarnings` (`3999`, the corporate convention;
|
`to`: `CloseTo::RetainedEarnings` (`3999`, the corporate convention;
|
||||||
`close_period` is shorthand) or `CloseTo::Members` (each member's capital
|
`close_period` is shorthand) or `CloseTo::Members` (each member's capital
|
||||||
account by share, to the cent, the last absorbing rounding: the
|
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
|
journal records the target, and replay re-derives the closing entry and
|
||||||
checks every line against the journal.
|
checks every line against the journal.
|
||||||
- **R-? — A/R and A/P workflows.** Was R-FUTURE-5.
|
- **R-? — A/R and A/P workflows.** Was R-FUTURE-5.
|
||||||
|
|||||||
@@ -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
|
deductible percent; the tax summary shows book vs. tax by category and
|
||||||
every uncategorized account. tax_form comes from config or the tax
|
every uncategorized account. tax_form comes from config or the tax
|
||||||
classification. Schema v6.
|
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.
|
||||||
|
|
||||||
Reference in new issue
Block a user