//! The helpers the tool groups share: resolving a name or a code to an //! id, reading a tool's amounts and dates, shaping a party, a document or //! a statement for output, and the journal's own readings. They live here //! so that no group file reaches into another group's (plan/served.md 8.2 //! U0a, Build step 2); `server.rs` re-exports them and keeps the request //! machinery. use super::*; use ledger_core::InstrumentId; /// The book's id, as the commands given to agents name it: its folder's /// name in the data root. pub(crate) fn book_name(store: &Store) -> String { store .data_dir() .file_name() .map(|n| n.to_string_lossy().to_string()) .unwrap_or_default() } /// A filed file's name and type: those given, else the upload's own. pub(crate) fn named( file_name: Option, mime_type: Option, file: &files::TakenFile, ) -> Result<(String, String), String> { let name = file_name.unwrap_or_else(|| file.file_name.clone()); let mime = mime_type.unwrap_or_else(|| file.mime.clone()); if let Some(why) = ledger_store::uploads::file_name_problem(&name) .or_else(|| ledger_store::uploads::mime_problem(&mime)) { return Err(why); } Ok((name, mime)) } /// The settings as ledger_package reads them: the company, and the /// `[books]` cash, financing, and investing roles. pub(crate) fn package_settings(settings: &Settings) -> ledger_package::Settings<'_> { ledger_package::Settings { company: &settings.company, cash_accounts: &settings.roles.cash, financing_accounts: &settings.roles.financing, investing_accounts: &settings.roles.investing, } } /// A decimal string, exactly `-?digits[.digits]` (INVARIANT 3). pub(crate) fn parse_amount(s: &str) -> Result { ledger_config::decimal(s) .ok_or_else(|| format!("bad amount {s:?}: expected a decimal string like \"123.45\"")) } pub(crate) fn parse_side(s: &str) -> Result { match s.to_lowercase().as_str() { "debit" => Ok(Side::Debit), "credit" => Ok(Side::Credit), _ => Err(format!("bad side {s:?}: expected \"debit\" or \"credit\"")), } } /// Every account code a live convention names: (convention id, code). pub(crate) fn convention_codes(ledger: &Ledger) -> Vec<(String, String)> { let mut out = Vec::new(); for c in ledger .conventions() .conventions() .filter(|c| c.retired.is_none()) { if let Some(t) = c.template() { for l in t.lines { if ledger_core::conventions::slot(&l.account).is_none() { out.push((c.id.clone(), l.account)); } } } if let Some(r) = c.approval_rule() { for a in r.accounts { out.push((c.id.clone(), a)); } } } out.dedup(); out } pub(crate) fn settings_problems(ledger: &Ledger, cfg: &Settings) -> Vec { let mut out = Vec::new(); // Named as the books keep them. let how = " with books.set_setting"; // The cash flow roles: codes the chart lacks here; the rest is the // engine's own check, the one the statement refuses on (rules.md B-14 // rule 2), so the two never disagree. let lists = [ ( ledger_core::RoleList::Cash, "books.cash_accounts", &cfg.roles.cash, ), ( ledger_core::RoleList::Financing, "books.financing_accounts", &cfg.roles.financing, ), ( ledger_core::RoleList::Investing, "books.investing_accounts", &cfg.roles.investing, ), ]; let mut ids: [Vec; 3] = Default::default(); for ((_, setting, codes), ids) in lists.iter().zip(&mut ids) { for code in codes.iter() { match ledger.chart().find_by_code(code) { Some(a) => ids.push(a.id), None => out.push(format!( "{setting} names {code}, which isn't in the chart: add that account, or change {setting}{how}" )), } } } let named = |l: ledger_core::RoleList| { lists .iter() .find(|(list, _, _)| *list == l) .map_or(String::new(), |(_, setting, _)| setting.to_string()) }; for p in ledger_core::role_problems(ledger, &ids[0], &ids[1], &ids[2]) { out.push(format!("{}{how}", p.message(named))); } let mut check = |setting: &str, code: &str, want: &[AccountType]| { match ledger.chart().find_by_code(code) { None => out.push(format!( "{setting} names {code}, which isn't in the chart: add that account, or change {setting}{how}" )), Some(a) if !want.contains(&a.account_type) => out.push(format!( "{setting} names {code} \"{}\", a {:?} account, but needs {}: change {setting}{how}", a.name, a.account_type, want.iter().map(|t| format!("{t:?}")).collect::>().join(" or "), )), Some(_) => {} } }; use AccountType::*; let c = &cfg.company; for m in &c.members { check( &format!("{} {} capital_account", "company.members", m.name), &m.capital_account, &[Equity], ); if let Some(d) = &m.distribution_account { check( &format!("{} {} distribution_account", "company.members", m.name), d, &[Equity], ); } } if let Some(code) = &c.close_into { check("books.close_into", code, &[Equity]); } for code in &c.close_sweep { check("books.close_sweep", code, &[Equity]); } // Conventions naming an account no longer in the chart (renumbered or // never there): a template can't draft, a rule covers nothing. for (id, code) in convention_codes(ledger) { if ledger.chart().find_by_code(&code).is_none() { out.push(format!( "convention {id} names account {code}, which isn't in the chart: revise it with books.set_convention" )); } } out } /// An asset named by a string that is its current tag or its id. A tag /// wins: tags like "1001" are allowed, and the label is what the human /// reads. pub(crate) fn resolve_asset_ref(store: &Store, r: &str) -> Result { if let Some(a) = store.ledger().asset_by_tag(r) { return Ok(a.id); } match r.parse::() { Ok(id) if store.ledger().get_asset(id).is_some() => Ok(id), _ => Err(format!("no asset has the tag or id {r:?}")), } } /// Parse a calendar-month period "YYYY-MM". pub(crate) fn parse_period_mcp(period: &str) -> Result<(i32, u32), String> { let err = || format!("bad period {period:?}: expected YYYY-MM"); let mut parts = period.split('-'); let year: i32 = parts.next().ok_or_else(err)?.parse().map_err(|_| err())?; let month: u32 = parts.next().ok_or_else(err)?.parse().map_err(|_| err())?; // A month of the calendar the books keep (ledger_core::calendar). if parts.next().is_some() || !(1..=9999).contains(&year) || !(1..=12).contains(&month) { return Err(err()); } Ok((year, month)) } /// Last calendar day of a month: refused, never a panic, for one chrono /// can't reach (P0 review round 3, S1). pub(crate) fn last_day_of_month(year: i32, month: u32) -> Result { NaiveDate::from_ymd_opt(year, month, 1) .and_then(|first| first.checked_add_months(chrono::Months::new(1))) .and_then(|next| next.pred_opt()) .ok_or_else(|| format!("bad period {year:04}-{month:02}: no such month")) } /// Resolve an account reference: an account code, or a numeric id. A /// number that is one account's code and another's id is refused /// (INVARIANT 6). pub(crate) fn resolve_account(store: &Store, account: &str) -> Result { let chart = store.ledger().chart(); let by_code = chart.find_by_code(account).map(|a| a.id); let by_id = account .parse::() .ok() .filter(|id| chart.get(*id).is_some()); match (by_code, by_id) { (Some(c), Some(i)) if c != i => Err(format!( "account {account:?} is ambiguous: it is the code of account {c} and the id of account {i}; pass the code of the one you mean" )), (Some(c), _) => Ok(c), (None, Some(i)) => Ok(i), (None, None) => Err(format!( "unknown account {account:?}: use an id or code from books.chart" )), } } /// "invoice" or "bill" and an id, as a document. pub(crate) fn doc_ref(kind: &str, id: u64) -> Result { match kind.trim().to_ascii_lowercase().as_str() { "invoice" => Ok(ledger_core::DocRef::Invoice(id)), "bill" => Ok(ledger_core::DocRef::Bill(id)), other => Err(format!("bad kind {other:?}: expected invoice or bill")), } } /// The control check as `books.control_check` answers it. pub(crate) fn control_check_out( ledger: &Ledger, as_of: chrono::NaiveDate, checks: &[ledger_core::ControlCheck], ) -> ControlCheckOut { let accounts: Vec = checks .iter() .map(|c| { let acct = ledger.chart().get(c.account); ControlCheckRowOut { role: c.role.clone(), code: acct.map(|a| a.code.clone()).unwrap_or_default(), name: acct.map(|a| a.name.clone()).unwrap_or_default(), balance: money(c.balance), documents_total: money(c.documents_total), unattached: c .unattached .iter() .map(|u| UnattachedLineOut { entry_id: u.entry, line: u.line, date: u.date.to_string(), amount: money(u.amount), }) .collect(), difference: money(c.difference), attention: c .attention .iter() .map(|a| { let (kind, id) = match a.doc { ledger_core::DocRef::Invoice(id) => ("invoice", id), ledger_core::DocRef::Bill(id) => ("bill", id), }; DocAttentionOut { kind: kind.to_string(), id, status: a.status.key().to_string(), total: money(a.total), outstanding: money(a.outstanding), } }) .collect(), } }) .collect(); let agrees = checks .iter() .all(|c| c.difference.is_zero() && c.unattached.is_empty() && c.attention.is_empty()); ControlCheckOut { as_of: as_of.to_string(), agrees, accounts, } } /// Resolve a party by id or by name: the name ignoring case and /// surrounding space, among parties not archived (an archived party goes /// by id). A number that is one party's id and another's name is refused /// (INVARIANT 6). pub(crate) fn resolve_party(store: &Store, party: &str) -> Result { let ledger = store.ledger(); let by_id = party .trim() .parse::() .ok() .filter(|id| ledger.get_party(*id).is_some()); let by_name = ledger.party_by_name(party).map(|p| p.id); match (by_id, by_name) { (Some(i), Some(n)) if i != n => Err(format!( "party {party:?} is ambiguous: it is party {i}'s id and party {n}'s name; pass the id" )), (Some(i), _) | (None, Some(i)) => Ok(i), (None, None) => Err(format!( "unknown party {party:?}: use an id or name from books.parties (an archived party goes by id), or add it with books.add_party" )), } } /// A stored attachment's metadata, by id. pub(crate) fn find_attachment(store: &Store, id: u64) -> Result { store .list_attachments(None) .map_err(|e| e.to_string())? .into_iter() .find(|a| a.id == id) .ok_or_else(|| format!("unknown attachment {id}")) } /// An attachment for a listing, with its links. pub(crate) fn receipt_out( store: &Store, a: ledger_store::Attachment, ) -> Result { Ok(ReceiptOut { links: links_out(store, a.id)?, attachment_id: a.id, sha256: a.blob_sha, entry_id: a.entry_id, category: a.category, file_name: a.file_name, mime_type: a.mime_type, size_bytes: a.size_bytes, description: a.description, withdrawn: a.withdrawn_at.is_some(), withdrawn_reason: a.withdrawn_reason, }) } /// An account's code and name. pub(crate) fn code_name(store: &Store, id: AccountId) -> (String, String) { store .ledger() .chart() .get(id) .map(|a| (a.code.clone(), a.name.clone())) .unwrap_or_default() } /// The latest statement date an account is reconciled through /// (reconciliations not voided), if any. pub(crate) fn reconciled_through( store: &Store, account: AccountId, ) -> Result, String> { Ok(store .reconciliations(Some(account)) .map_err(|e| e.to_string())? .into_iter() .filter(|r| r.voided.is_none()) .map(|r| r.statement_date) .max()) } /// The last four digits of every TIN the books know of, from the journal /// (each party's TIN and W-9 facts), never the vault: the filing scan's /// probe bound (tin_files.rs INVARIANT 4). pub(crate) fn known_tin_last4(store: &Store) -> Vec { store .ledger() .parties() .flat_map(|p| { [ p.tin.as_ref().map(|t| t.last4.as_str()), p.w9.as_ref().map(|w| w.tin_last4.as_str()), ] }) .flatten() .filter_map(|l| l.parse().ok()) .collect() } /// An optional party text from a tool: trimmed, and "" clears it. pub(crate) fn optional_text(s: &str) -> Option { let t = s.trim(); (!t.is_empty()).then(|| t.to_string()) } /// The sum of `values`, each computed or refused: refused (as text) when /// one is, or when the sum is past exact arithmetic. Figures an earlier /// version journaled are as journaled (rules.md B-28). pub(crate) fn checked_sum( values: impl IntoIterator>, what: &str, ) -> Result { values.into_iter().try_fold(Decimal::ZERO, |sum, v| { let v = v.map_err(|e| e.to_string())?; sum.checked_add(v).ok_or_else(|| { ledger_core::LedgerError::TooLargeToCompute(what.to_string()).to_string() }) }) } /// The `party_required` refusal for an engine-posted entry (payroll, a /// remittance, a disposal) that names no counterparty under `[books] /// parties = "require"`: `what` says what to pass. pub(crate) fn party_required(what: &str) -> String { serde_json::json!({ "code": "party_required", "message": format!( "these books require a counterparty on every entry (their parties setting is \"require\"): {what} (add the party with books.add_party first if it's new)" ), }) .to_string() } /// The chart's ids for these account codes (codes it lacks are skipped). pub(crate) fn cash_ids(ledger: &Ledger, codes: &[String]) -> Vec { codes .iter() .filter_map(|c| ledger.chart().find_by_code(c).map(|a| a.id)) .collect() } /// What party shares resolve with ([`ledger_core::shares::resolve`]): the /// money accounts over the `[books] cash_accounts` and /// `financing_accounts` (with the cards), and the members. pub(crate) fn share_context( ledger: &Ledger, settings: &Settings, ) -> ( ledger_core::parties::MoneyAccounts, Vec, ) { ( ledger_core::parties::money_accounts( ledger, &cash_ids(ledger, &settings.roles.cash), &cash_ids(ledger, &settings.roles.financing), ), settings.company.party_members(ledger), ) } /// `parties` params as shares: each party resolved, `amount` or `percent` /// parsed (not both), neither meaning derived. pub(crate) fn parse_shares( store: &Store, params: &[PartyShareParam], ) -> Result, String> { params .iter() .map(|p| { let party = resolve_party(store, &p.party)?; let share = match (&p.amount, &p.percent) { (Some(_), Some(_)) => { return Err(format!( "party {}: give an amount or a percent, not both", p.party )); } (Some(a), None) => ledger_core::Share::Amount(parse_amount(a)?), (None, Some(pc)) => ledger_core::Share::Percent(parse_amount(pc)?), (None, None) => ledger_core::Share::Derived, }; Ok(ledger_core::PartyShare { party, share }) }) .collect() } /// Whether an entry's lines are final enough to check shares against: /// posted, or a draft with lines whose debits equal its credits. pub(crate) fn lines_settled(entry: &JournalEntry) -> bool { if entry.status() == ledger_core::EntryStatus::Posted { return true; } // A draft's lines are as journaled, of any size (rules.md B-28): sides // past exact arithmetic are not settled lines. let side = |s: Side| -> Option { entry .lines() .iter() .filter(|l| l.side == s) .try_fold(Decimal::ZERO, |sum, l| sum.checked_add(l.amount.amount)) }; match (side(Side::Debit), side(Side::Credit)) { (Some(debits), Some(credits)) => !entry.lines().is_empty() && debits == credits, _ => false, } } /// Fix a posted entry's derived split among several parties: journal the /// exact amounts they resolve to now, so a later change to /// `[[company.members]]` (a new income split, a member archived) never /// re-splits posted history. /// /// ```text /// GOAL: posted is forever, the split between its parties included. /// SOURCE: AGENTS.md ("Posted is forever"); docs/parties-and-attachments.md /// "Shares"; ledger_core::shares INVARIANT 10 (derived several: /// the members' capital lines, else their income shares, both read /// from the settings). /// INVARIANTS: /// 1. Only a posted entry naming two or more parties with derived shares /// changes: its shares become Amount(|part|) for each party, in /// order, journaled as entry.parties_set (the reversal chain too), so /// each party's resolved part is exactly what it was. /// 2. Nothing changes when the shares don't resolve, or a part is zero /// (an amount must be positive): the entry keeps its derived shares. /// ``` pub(crate) fn fix_derived_shares( store: &mut Store, settings: &Settings, entry_id: u64, ) -> Result<(), String> { let ledger = store.ledger(); let Some(entry) = ledger.get_entry(entry_id) else { return Ok(()); }; let shares = ledger.entry_parties(entry_id); if entry.status() != ledger_core::EntryStatus::Posted || shares.len() < 2 || !shares .iter() .any(|s| matches!(s.share, ledger_core::Share::Derived)) { return Ok(()); } let (money, members) = share_context(ledger, settings); let Ok(resolved) = ledger_core::shares::resolve(ledger, entry_id, shares, &money, &members) else { return Ok(()); }; if resolved.parts.iter().any(|p| p.amount.is_zero()) { return Ok(()); } let fixed: Vec = resolved .parts .iter() .map(|p| ledger_core::PartyShare { party: p.party, share: ledger_core::Share::Amount(p.amount.abs()), }) .collect(); store .set_entry_parties(entry_id, &fixed) .map_err(|e| e.to_string()) } /// Resolve a class (a project) by id or name, trimmed, the name ignoring /// capitals as the duplicate check does; a number that is one class's id /// and another's name is refused (INVARIANT 6). New names can't be digits /// only, so only old books can hold such a pair. pub(crate) fn resolve_class(store: &Store, class: &str) -> Result { let class = class.trim(); let by_id = class .parse::() .ok() .filter(|id| store.ledger().get_class(*id).is_some()); let by_name = store.ledger().find_class_by_name(class).map(|c| c.id); match (by_id, by_name) { (Some(i), Some(n)) if i != n => { let name = store .ledger() .get_class(i) .map(|c| c.name.clone()) .unwrap_or_default(); Err(format!( "project {class:?} is ambiguous: it is project {i}'s id and project {n}'s name; pass {n} for project {n}, or {name:?} (project {i}'s name) for project {i}" )) } (Some(i), _) | (None, Some(i)) => Ok(i), (None, None) => Err(format!( "unknown project {class:?}: use an id or name from books.projects" )), } } /// A tool description that ends with the one sentence for its kind of /// dates (rules.md B-30), so no tool says other than the resolver does. pub(crate) fn described(what: &str, kind: ledger_core::calendar::PeriodKind) -> String { format!("{what} {}", kind.describe()) } /// The warning for a revenue or expense account created without a tax /// category (rules.md B-32). pub(crate) fn uncategorized_warning(code: &str, name: &str) -> String { format!( "{} {} has no tax category: the tax summary will list it as uncategorized. Propose one from books.tax_categories and set it with books.set_tax_category once the human agrees.", code.trim(), name.trim() ) } /// The warnings for the revenue and expense accounts an operation created /// (control accounts: Revenue, General Expenses, payroll's expenses) /// without a tax category: every account from `first_new` on. pub(crate) fn created_uncategorized( ledger: &Ledger, first_new: ledger_core::AccountId, ) -> Vec { let mut new: Vec<&ledger_core::Account> = ledger .chart() .all() .filter(|a| a.id >= first_new && a.tax_category.is_none()) .filter(|a| ledger_core::tax::needs_category(a.account_type, a.placeholder)) .collect(); new.sort_by_key(|a| a.id); new.into_iter() .map(|a| uncategorized_warning(&a.code, &a.name)) .collect() } /// Everything an attachment is linked to, for a tool's output. pub(crate) fn links_out(store: &Store, attachment_id: u64) -> Result, String> { Ok(store .attachment_links(attachment_id) .map_err(|e| e.to_string())? .into_iter() .map(|s| subject_out(store, s)) .collect()) } /// A subject for a tool's output, with human words for it. pub(crate) fn subject_out(store: &Store, sub: ledger_store::Subject) -> SubjectOut { use ledger_store::Subject as S; let l = store.ledger(); let label = match sub { S::Entry(i) => l .get_entry(i) .map(|e| format!("entry #{i} {}", e.description())), S::Account(i) => l .chart() .get(i) .map(|a| format!("account {} {}", a.code, a.name)), S::Asset(i) => l.get_asset(i).map(|a| match &a.tag { Some(t) => format!("asset {t} {}", a.description), None => format!("asset {i} {}", a.description), }), S::Party(i) => l.get_party(i).map(|p| format!("party {i} {}", p.label())), S::Project(i) => u32::try_from(i) .ok() .and_then(|c| l.get_class(c)) .map(|c| format!("project {i} {}", c.name)), S::Period(i) => l .list_periods() .iter() .find(|p| p.id == i) .map(|p| format!("period {}", p.name)), S::PayrollRun(i) => l .get_payroll_run(i) .map(|r| format!("payroll run {i} of {}", r.date)), S::Schedule(i) => l .get_schedule(i) .map(|s| format!("schedule {i} {}", s.description)), S::Reconciliation(i) => store.reconciliations(None).ok().and_then(|rs| { rs.into_iter().find(|r| r.id == i).map(|r| { let code = l .chart() .get(r.account_id) .map(|a| a.code.clone()) .unwrap_or_default(); format!("reconciliation {i} of {code} through {}", r.statement_date) }) }), S::Invoice(_) | S::Bill(_) | S::Company => None, } .unwrap_or_else(|| sub.describe()); SubjectOut { kind: sub.kind().to_string(), id: (sub != S::Company).then(|| sub.id()), label, } } /// Resolve a payment method by id or label (INVARIANT 5). pub(crate) fn resolve_instrument(store: &Store, r: &str) -> Result { let ledger = store.ledger(); let by_id = r .trim() .parse::() .ok() .filter(|id| ledger.get_instrument(*id).is_some()); let by_label = ledger.instrument_by_label(r).map(|i| i.id); match (by_id, by_label) { (Some(i), Some(l)) if i != l => Err(format!( "payment method {r:?} is ambiguous: it is method {i}'s id and method {l}'s label; pass the id" )), (Some(i), _) | (None, Some(i)) => Ok(i), (None, None) => Err(format!( "unknown payment method {r:?}: use an id or label from books.instruments (books.add_instrument adds one)" )), } } /// A recorded send on the wire (plan P4b S5). pub(crate) fn sent_out(s: &ledger_core::arap::Sent) -> SentOut { SentOut { on: s.on.to_string(), by: s.by.clone(), via: s.via.key(), to: s.to.clone(), kind: s.kind.key().to_string(), reference: s.reference.clone(), note: s.note.clone(), } } /// The day an invoice last went out, when a send is recorded. pub(crate) fn last_sent(sends: &[ledger_core::arap::Sent]) -> Option { sends.iter().map(|s| s.on).max().map(|d| d.to_string()) } /// An annotation as a tool answers with it (plan P6 step 5). pub(crate) fn annotation_out(a: &ledger_core::Annotation) -> AnnotationOut { AnnotationOut { receipt_number: a.fields.receipt_number, payee: a.fields.payee.clone(), amount: a.fields.amount.map(money), date: a.fields.date.map(|d| d.to_string()), instrument: a.fields.instrument.clone(), category: a.fields.category.clone(), source: a.fields.source.clone(), reimbursable_to: a.fields.reimbursable_to.clone(), note: a.note.clone(), } } /// A file name's stem, for a page picture's or a text file's name: what is /// before the last dot, or "attachment" when there is nothing. pub(crate) fn stem(file_name: &str) -> String { let stem = file_name .rsplit_once('.') .map_or(file_name, |(stem, _)| stem) .trim(); if stem.is_empty() { "attachment".to_string() } else { stem.chars().take(80).collect() } } /// The largest character boundary at or below `at` in `text`. pub(crate) fn floor_char_boundary(text: &str, at: usize) -> usize { let mut cut = at.min(text.len()); while !text.is_char_boundary(cut) { cut -= 1; } cut }