P2 slice 6: the viewer

`wanderbooks gui [BOOK]` finds the data root, reads settings from the
journal, the vault of the book, presence through ops/, Save a copy /
Open on every document, Save the W-9, the Export menu, and colour
schemes. One builder for the CPA package and the 1099 worksheet
(ledger-package), shared by the server and the viewer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
This commit is contained in:
swaitsandClaude Opus 5.5 committed 2026-10-20 11:14:21 -06:00
1 parent b26c099a68
commit 5bc0633cdd
77 files changed
+5833 -2427

No files matched your search

+281
View File
@@ -0,0 +1,281 @@
//! The financial statements as the server's report tools and the package
//! make them: from the books' settings, a date range, and account codes.
//!
//! ```text
//! GOAL: a statement in the package is the one the report tool shows for
//! the same range, with the same refusals.
//! SOURCE: ledger-mcp server.rs (books.profit_and_loss, books.balance_sheet,
//! books.cash_flow, books.export_cpa_package); ASC 230-10-20
//! (financing and investing activities).
//! INVARIANTS:
//! 1. A range defaults its end to `today` and its start to the first
//! posted entry (never after the end); an end before its start is
//! refused.
//! 2. The cash flow statement needs at least one cash account, and
//! every code it is given must be in the chart; account lists a call
//! leaves empty default to the settings' roles.
//! 3. The year's earnings split net income year to date from the part
//! not yet closed into equity (closes are cumulative).
//! ```
use chrono::NaiveDate;
use ledger_core::settings::{Company, Member};
use ledger_core::{AccountId, Ledger, PeriodStatus};
use ledger_import::ReportMeta;
use rust_decimal::Decimal;
use crate::text::parse_date;
/// The books' settings the package and its reports read: the company, and
/// the account roles (`books.cash_accounts`, `books.financing_accounts`,
/// `books.investing_accounts`) as chart codes.
#[derive(Clone, Copy, Debug)]
pub struct Settings<'a> {
/// The company: name, basis, fiscal year, members, tax form.
pub company: &'a Company,
/// The cash accounts' codes.
pub cash_accounts: &'a [String],
/// The financing accounts' codes.
pub financing_accounts: &'a [String],
/// The investing accounts' codes.
pub investing_accounts: &'a [String],
}
/// Resolve a statement's date range: `end` defaults to `today` and `start`
/// to the first posted entry (where "from the beginning" really begins, so
/// no statement says "0001-01-01"), or to `end` for empty books. An end
/// before its start is refused (INVARIANT 1).
pub fn statement_range(
ledger: &Ledger,
start: Option<&str>,
end: Option<&str>,
today: NaiveDate,
) -> Result<(NaiveDate, NaiveDate), String> {
let end = match end {
Some(s) => parse_date(s)?,
None => today,
};
let start = match start {
Some(s) => parse_date(s)?,
None => ledger
.posted_entries()
.map(|e| e.date())
.min()
.map_or(end, |first| first.min(end)),
};
if end < start {
return Err("end is before start".to_string());
}
Ok((start, end))
}
/// Profit and loss for a range (see [`statement_range`]), optionally one
/// class.
pub fn pnl_statement(
ledger: &Ledger,
start: Option<&str>,
end: Option<&str>,
class_id: Option<ledger_core::ClassId>,
today: NaiveDate,
) -> Result<ledger_core::ProfitAndLossStatement, String> {
let (start, end) = statement_range(ledger, start, end, today)?;
ledger_core::profit_and_loss_for_period(ledger, start, end, class_id).map_err(|e| e.to_string())
}
/// Balance sheet as of a date (default `today`).
pub fn bs_statement(
ledger: &Ledger,
as_of: Option<&str>,
today: NaiveDate,
) -> Result<ledger_core::BalanceSheetStatement, String> {
let as_of = match as_of {
Some(s) => parse_date(s)?,
None => today,
};
ledger_core::balance_sheet_as_of(ledger, as_of).map_err(|e| e.to_string())
}
/// Build the cash flow statement from account codes, as the exports need it:
/// `start` defaults to the beginning of time, `end` to `today`, and at
/// least one cash account is required (the statement reconciles to them).
pub fn cash_flow_statement(
ledger: &Ledger,
start: Option<&str>,
end: Option<&str>,
cash_codes: &[String],
financing_codes: &[String],
investing_codes: &[String],
today: NaiveDate,
) -> Result<ledger_core::reports::CashFlowStatement, String> {
let resolve = |codes: &[String], what: &str| -> Result<Vec<AccountId>, String> {
codes
.iter()
.map(|code| {
ledger
.chart()
.find_by_code(code)
.map(|a| a.id)
.ok_or_else(|| format!("unknown {what} account code: {code}"))
})
.collect()
};
let cash_accounts = resolve(cash_codes, "cash")?;
if cash_accounts.is_empty() {
return Err(
"cash_accounts is required for the cash flow statement (pass it, or set the books' cash accounts setting: books.settings shows it)"
.to_string(),
);
}
let financing_accounts = resolve(financing_codes, "financing")?;
let investing_accounts = resolve(investing_codes, "investing")?;
let (start, end) = statement_range(ledger, start, end, today)?;
ledger_core::cash_flow(
ledger,
&ledger_core::CashFlowParams {
start,
end,
cash_accounts,
financing_accounts,
investing_accounts,
},
)
.map_err(|e| e.to_string())
}
/// The cash flow statement from account codes, each list defaulting to
/// the settings' roles when the call passes none.
#[allow(clippy::too_many_arguments)]
pub fn cash_flow_for(
ledger: &Ledger,
settings: &Settings<'_>,
start: Option<&str>,
end: Option<&str>,
cash: &[String],
financing: &[String],
investing: &[String],
today: NaiveDate,
) -> Result<ledger_core::reports::CashFlowStatement, String> {
let or = |given: &'_ [String], default: &'_ [String]| -> Vec<String> {
if given.is_empty() {
default.to_vec()
} else {
given.to_vec()
}
};
cash_flow_statement(
ledger,
start,
end,
&or(cash, settings.cash_accounts),
&or(financing, settings.financing_accounts),
&or(investing, settings.investing_accounts),
today,
)
}
/// Header and provenance for a rendered report: the company's name and
/// basis, the day it was prepared, and the journal state it reflects.
pub fn report_meta(company: &Company, prepared_on: NaiveDate, journal_seq: u64) -> ReportMeta {
ReportMeta {
company: company.name.clone(),
basis: company.basis.clone(),
prepared_on,
journal_seq,
}
}
/// Members as reports name them (empty unless a multi-member LLC).
pub fn report_members(company: &Company) -> Vec<ledger_import::Member> {
company
.members
.iter()
.map(|m| ledger_import::Member {
name: m.name.clone(),
capital_code: m.capital_account.clone(),
distribution_code: m.distribution_account.clone(),
})
.collect()
}
/// Members with their capital (and distribution) accounts resolved in the
/// chart, for the statement of members' equity and the K-1 worksheet; a
/// code the chart lacks is refused, naming the member.
pub fn member_capitals(
ledger: &Ledger,
members: &[Member],
) -> Result<Vec<ledger_core::MemberCapital>, String> {
members
.iter()
.map(|m| {
let account = ledger
.chart()
.find_by_code(&m.capital_account)
.ok_or_else(|| {
format!(
"member {}: unknown 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 {
name: m.name.clone(),
capital_account: account.id,
distribution_account,
share: m.share,
})
})
.collect()
}
/// 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 not yet closed into equity: the income after the last
/// close (closes are cumulative), from no earlier than the year's start.
pub fn year_earnings(
ledger: &Ledger,
company: &Company,
as_of: NaiveDate,
) -> Result<ledger_import::YearEarnings, String> {
let (fy_start, _) = company.fiscal_year_containing(as_of);
let net_income_ytd = ledger_core::profit_and_loss_for_period(ledger, fy_start, as_of, None)
.map_err(|e| e.to_string())?
.net_income();
// A close takes everything unclosed through its end, earlier years
// included, so this year's unclosed part is the income from the day
// after the last close (never before the year's start) through
// as_of. (Counting the closing entries dated this year instead
// charged this year with closing last year's income: found by
// year_earnings_split_closed_from_unclosed, 2026-10-14.)
let last_close = ledger
.list_periods()
.iter()
.filter(|p| p.status == PeriodStatus::Closed && p.end <= as_of)
.map(|p| p.end)
.max();
let from = match last_close.and_then(|e| e.succ_opt()) {
Some(after) if after > fy_start => after,
_ => fy_start,
};
let unclosed = if from <= as_of {
ledger_core::profit_and_loss_for_period(ledger, from, as_of, None)
.map_err(|e| e.to_string())?
.net_income()
} else {
Decimal::ZERO
};
Ok(ledger_import::YearEarnings {
net_income_ytd,
unclosed,
})
}