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:
swaitsandClaude Opus 5.5 committed 2026-10-11 21:01:31 -07:00
1 parent f858fb4cfe
commit dbdc8e671f
17 files changed
+1277 -173

No files matched your search

+32 -2
View File
@@ -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<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.
@@ -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<String>,
}
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<String>,
distribution_account: Option<String>,
}
#[derive(Default, Deserialize)]
struct BooksSection {
receipt_threshold: 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,
@@ -174,6 +190,10 @@ pub fn parse(text: &str) -> Result<BooksSettings, String> {
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<BooksSettings, String> {
(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,
},
]
);
+80 -12
View File
@@ -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<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:
/// 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<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(
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<ledger_core::EntryId>| -> Vec<String> {
let Some(entry) = eid.and_then(|e| ledger.get_entry(e)) else {
return Vec::new();
+25
View File
@@ -54,6 +54,31 @@ pub struct ChartAccount {
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`.
#[derive(Debug, Deserialize, JsonSchema)]
pub struct SetTaxCategoryParams {