//! 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, today: NaiveDate, ) -> Result { 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 { 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 { let resolve = |codes: &[String], what: &str| -> Result, 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 { let or = |given: &'_ [String], default: &'_ [String]| -> Vec { 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 { 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, 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 { 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, }) }